mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
Compare commits
169
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b92c5313b | ||
|
|
d76f236f59 | ||
|
|
50c4dfce03 | ||
|
|
746446be86 | ||
|
|
df8cf73385 | ||
|
|
97e165e8c2 | ||
|
|
eb4d537437 | ||
|
|
31e4596860 | ||
|
|
b95b8c3569 | ||
|
|
e4549f5fa1 | ||
|
|
e2c3f31c26 | ||
|
|
2766affa65 | ||
|
|
4d5328d8a0 | ||
|
|
97e700a1ab | ||
|
|
5fadc21455 | ||
|
|
64ec3265b7 | ||
|
|
9514bc501a | ||
|
|
35f4f9178b | ||
|
|
e4d97f7aba | ||
|
|
cc94dfc458 | ||
|
|
79915d6c7a | ||
|
|
4e72b1f38d | ||
|
|
ed19ca2ece | ||
|
|
481f12c892 | ||
|
|
544b442f4e | ||
|
|
705c93053f | ||
|
|
ffc61bdbd2 | ||
|
|
3de80dacdd | ||
|
|
95c8a2a915 | ||
|
|
92e5bb45c9 | ||
|
|
2afad4fa0d | ||
|
|
4e51f906fe | ||
|
|
548bf39136 | ||
|
|
acc820e263 | ||
|
|
4b5d02f17b | ||
|
|
fd0eb0a9a0 | ||
|
|
01ccc6475d | ||
|
|
a9738c6311 | ||
|
|
767c3db1ae | ||
|
|
018f30a3d8 | ||
|
|
762639cab5 | ||
|
|
94c7fba536 | ||
|
|
ddc6811c34 | ||
|
|
04dba5ef76 | ||
|
|
0448842f60 | ||
|
|
b9085af44c | ||
|
|
44e2eb7ae8 | ||
|
|
f43bc877f0 | ||
|
|
d9af47323a | ||
|
|
02404ef562 | ||
|
|
d05fb6fc07 | ||
|
|
2bbff7e477 | ||
|
|
e94b365495 | ||
|
|
d62bff5ace | ||
|
|
29335f7ddf | ||
|
|
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 |
@@ -40,6 +40,11 @@ runs:
|
|||||||
path: ~/.gradle/caches
|
path: ~/.gradle/caches
|
||||||
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
restore-keys: gradle-v2-
|
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
|
- id: signing
|
||||||
if: inputs.sign == 'true'
|
if: inputs.sign == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -50,5 +50,5 @@ jobs:
|
|||||||
- uses: actions/cache@v6
|
- uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: .integration/music
|
path: .integration/music
|
||||||
key: integration-music-v1
|
key: integration-music-v3
|
||||||
- run: nix develop .#integration --command nu tools/integration-test.nu
|
- run: nix develop .#integration --command nu tools/integration-test.nu
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ jobs:
|
|||||||
echo "value=false" >> "$GITHUB_OUTPUT"
|
echo "value=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
- name: Check release notes exist
|
- name: Check release notes exist
|
||||||
run: test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
run: |
|
||||||
|
test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||||
|
test -f "docs/releases/$GITHUB_REF_NAME.md"
|
||||||
- name: Write Play service account
|
- name: Write Play service account
|
||||||
if: steps.stable.outputs.value == 'true'
|
if: steps.stable.outputs.value == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -123,7 +125,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt")
|
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md")
|
||||||
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
||||||
args+=(--prerelease)
|
args+=(--prerelease)
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ See [signing/README.md](signing/README.md) for the fingerprints and the commands
|
|||||||
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
||||||
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
|
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
|
||||||
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
|
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
|
||||||
|
- **ReplayGain:** Loudness normalization from the tags your server reports, with Auto, track and album modes and a preamp.
|
||||||
- **Up Next:** Add items into the currently playing queue without rewriting it.
|
- **Up Next:** Add items into the currently playing queue without rewriting it.
|
||||||
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
||||||
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ android {
|
|||||||
applicationId = "com.subtracks"
|
applicationId = "com.subtracks"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 13
|
versionCode = 14
|
||||||
versionName = "3.0.0"
|
versionName = "3.1.0"
|
||||||
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("debug").res.srcDir("src/nightly/res")
|
getByName("debug").res.directories.add("src/nightly/res")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -181,6 +181,8 @@ val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
|||||||
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
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")) {
|
if (name.endsWith("UnitTest")) {
|
||||||
filter {
|
filter {
|
||||||
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||||
|
|||||||
+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
+2092
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -56,7 +56,7 @@ class SubsonicSourceIntegrationTest(
|
|||||||
assertNotNull(kosmo.starred)
|
assertNotNull(kosmo.starred)
|
||||||
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
|
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)
|
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 }
|
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
|
||||||
.map { it.name }
|
.map { it.name }
|
||||||
.sorted()
|
.sorted()
|
||||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
|
assertEquals(listOf("Kosmonaut", "Retroconnaissance"), ugressAlbums)
|
||||||
|
|
||||||
val bradAlbums =
|
val bradAlbums =
|
||||||
albums
|
albums
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ class SubsonicWriteIntegrationTest(
|
|||||||
.albums()
|
.albums()
|
||||||
.toList()
|
.toList()
|
||||||
.flatten()
|
.flatten()
|
||||||
.first { it.name == "Retroconnaissance EP" }
|
.first { it.name == "Retroconnaissance" }
|
||||||
|
|
||||||
source.setStar(StarType.Album, album.id, true)
|
source.setStar(StarType.Album, album.id, true)
|
||||||
try {
|
try {
|
||||||
@@ -94,7 +94,7 @@ class SubsonicWriteIntegrationTest(
|
|||||||
.songs()
|
.songs()
|
||||||
.toList()
|
.toList()
|
||||||
.flatten()
|
.flatten()
|
||||||
.first { it.album == "Retroconnaissance EP" }
|
.first { it.album == "Retroconnaissance" }
|
||||||
|
|
||||||
source.setStar(StarType.Song, song.id, true)
|
source.setStar(StarType.Song, song.id, true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-11
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
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 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.model.Source
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -18,12 +14,7 @@ class MultiSourceSyncIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
TestServers.all.forEachIndexed { index, server ->
|
TestServers.all.forEachIndexed { index, server ->
|
||||||
val id = index + 1L
|
val id = index + 1L
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
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 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.model.Source
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||||
@@ -28,12 +24,7 @@ class PruneSyncIntegrationTest {
|
|||||||
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
||||||
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
||||||
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
|
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)")
|
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val REMOVED_ALBUM_DIR = "197"
|
const val REMOVED_ALBUM_DIR = "7GZYpfn8wypaTR00LWeNI4"
|
||||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
|
const val REMOVED_ALBUM_NAME = "Retroconnaissance"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-11
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
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 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.model.Source
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
|
|||||||
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
servers.forEach { server ->
|
servers.forEach { server ->
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
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 android.content.Context
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
|
import androidx.room3.RoomDatabase
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
|||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(driver)
|
.setDriver(driver)
|
||||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||||
.addCallback(SqliteLifecycleLogging)
|
.addSubtracksDatabaseCallbacks()
|
||||||
.build()
|
.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() {
|
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
|
||||||
override suspend fun onCreate(connection: SQLiteConnection) {
|
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||||
Log.i("sql", "created fresh database")
|
Log.i("sql", "created fresh database")
|
||||||
|
|||||||
@@ -15,31 +15,13 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
// downloads are visible even though they lead no albums of their own.
|
// downloads are visible even though they lead no albums of their own.
|
||||||
// Starts from songs so status totals include every song the artist is credited on.
|
// Starts from songs so status totals include every song the artist is credited on.
|
||||||
internal const val ARTIST_DOWNLOAD_ROWS =
|
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
"SELECT credits.artistId AS id, credits.songId AS songId, song_downloads.status AS status " +
|
||||||
"FROM songs " +
|
"FROM (" + CREDITED_SONG_ROWS + ") credits " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN song_downloads ON song_downloads.sourceId = :sourceId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"AND song_downloads.songId = credits.songId"
|
||||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT album_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
|
||||||
"FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = :sourceId " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
|
||||||
"FROM songs " +
|
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
|
||||||
"FROM songs " +
|
|
||||||
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = :sourceId"
|
|
||||||
|
|
||||||
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
|
// 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 =
|
internal const val DOWNLOADED_ARTIST_ROWS =
|
||||||
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
|
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
@@ -74,6 +56,27 @@ interface DownloadDao {
|
|||||||
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
||||||
fun downloads(sourceId: Long): Flow<List<SongDownload>>
|
fun downloads(sourceId: Long): Flow<List<SongDownload>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
||||||
|
suspend fun downloadsOnce(sourceId: Long): List<SongDownload>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT d.* FROM song_downloads d " +
|
||||||
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||||
|
)
|
||||||
|
suspend fun completedDownloads(sourceId: Long): List<SongDownload>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT d.songId FROM song_downloads d " +
|
||||||
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
|
"WHERE d.sourceId = :sourceId AND d.songId IN (:songIds)",
|
||||||
|
)
|
||||||
|
suspend fun queueVisibleSongIds(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: Collection<String>,
|
||||||
|
): List<String>
|
||||||
|
|
||||||
@Query("SELECT * FROM song_downloads WHERE status IN ('Queued', 'Running') AND (engineId IS NOT NULL OR sourceId = :sourceId)")
|
@Query("SELECT * FROM song_downloads WHERE status IN ('Queued', 'Running') AND (engineId IS NOT NULL OR sourceId = :sourceId)")
|
||||||
fun activeDownloads(sourceId: Long): Flow<List<SongDownload>>
|
fun activeDownloads(sourceId: Long): Flow<List<SongDownload>>
|
||||||
|
|
||||||
@@ -179,13 +182,16 @@ interface DownloadDao {
|
|||||||
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
"SELECT d.sourceId AS sourceId, s.id AS songRow, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||||
|
"al.artistId AS albumArtistId, " +
|
||||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
|
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||||
|
"aar.coverArt AS albumArtistCoverArt " +
|
||||||
"FROM song_downloads d " +
|
"FROM song_downloads d " +
|
||||||
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
|
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
|
||||||
|
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
|
||||||
)
|
)
|
||||||
suspend fun artwork(): List<DownloadArtwork>
|
suspend fun artwork(): List<DownloadArtwork>
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,21 @@ import androidx.room3.Transaction
|
|||||||
import androidx.room3.Upsert
|
import androidx.room3.Upsert
|
||||||
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
|
import com.subtracks.data.model.AlbumSearch
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
|
import com.subtracks.data.model.ArtistSearch
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.PlaylistSearch
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -35,25 +42,6 @@ internal const val DOWNLOADED_SONG =
|
|||||||
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
|
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
|
||||||
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
|
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
|
||||||
|
|
||||||
internal const val ALBUMS_FILTER =
|
|
||||||
"FROM albums WHERE sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
|
||||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
|
|
||||||
"AND (:search = '' " +
|
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
|
||||||
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
|
||||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
|
|
||||||
|
|
||||||
internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
|
||||||
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
|
||||||
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id"
|
|
||||||
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED =
|
|
||||||
"albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC"
|
|
||||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
||||||
@@ -69,61 +57,129 @@ internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName C
|
|||||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
|
|
||||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
private const val ALBUM_NAME_SORT_KEY_MODE =
|
||||||
"SELECT albums.id FROM albums " +
|
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
|
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.id FROM album_artists " +
|
|
||||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = artists.id " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
|
|
||||||
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId FROM song_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
|
|
||||||
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL"
|
|
||||||
|
|
||||||
internal const val ARTIST_CREDITED =
|
internal const val ARTIST_CREDITED =
|
||||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER =
|
// May repeat a (artistId, songId) pair when several credit paths reach it; consumers dedupe.
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
internal const val CREDITED_SONG_ROWS =
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"SELECT songs.artistId AS artistId, songs.id AS songId, songs.albumId AS albumId, " +
|
||||||
"AND $ARTIST_CREDITED " +
|
"songs.playCount AS playCount, songs.played AS played " +
|
||||||
"AND (:downloadedFilter = 0 OR EXISTS (" +
|
"FROM songs WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||||
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
|
"UNION ALL " +
|
||||||
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
|
"SELECT song_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||||
"AND (:search = '' " +
|
"FROM song_artists JOIN songs " +
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
"WHERE song_artists.sourceId = :sourceId " +
|
||||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
"UNION ALL " +
|
||||||
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
"SELECT albums.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||||
|
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||||
|
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT album_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId"
|
||||||
|
|
||||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
internal val ALBUMS_FOR_ARTIST =
|
||||||
// an artist matches the albums its detail screen lists.
|
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
||||||
internal const val ARTISTS_SELECT =
|
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
|
||||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
|
||||||
") ELSE artists.albumCount END AS albumCount, " +
|
|
||||||
"artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName, " +
|
|
||||||
"artists.credited "
|
|
||||||
|
|
||||||
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
internal val ALBUM_CREDIT_ALBUM_IDS =
|
||||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, sortName COLLATE NOCASE, id"
|
"WHERE artistId = :artistId AND creditType = 'album'"
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
|
||||||
|
internal val DOWNLOADED_ARTIST_ALBUMS =
|
||||||
|
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
||||||
|
|
||||||
|
private const val ARTIST_COVER_RANKS_TAIL =
|
||||||
|
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||||
|
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
|
"FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||||
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
|
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
|
||||||
|
"deduped AS (SELECT artistId, coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
||||||
|
|
||||||
|
private val ARTIST_COVER_RANKS =
|
||||||
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
|
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||||
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
|
private val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||||
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
|
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||||
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
|
internal val ARTIST_ART_UPDATE_SQL =
|
||||||
|
"WITH $ARTIST_COVER_RANKS, " +
|
||||||
|
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
|
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
|
") GROUP BY artistId), " +
|
||||||
|
"downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS)), " +
|
||||||
|
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||||
|
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
|
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||||
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
|
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||||
|
"downloaded_deduped AS (SELECT artistId, coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
|
||||||
|
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
|
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
|
") GROUP BY artistId), " +
|
||||||
|
"desired AS (SELECT artists.rowid AS rowid, " +
|
||||||
|
"COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), '') AS coverArts, " +
|
||||||
|
"COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||||
|
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
|
||||||
|
"FROM artists WHERE artists.sourceId = :sourceId) " +
|
||||||
|
"UPDATE artists SET coverArts = desired.coverArts, downloadedCoverArts = desired.downloadedCoverArts " +
|
||||||
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
|
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
||||||
|
|
||||||
|
internal val ARTIST_COVER_ARTS_SQL =
|
||||||
|
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||||
|
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
||||||
|
|
||||||
|
internal val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||||
|
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||||
|
"WHERE artistId IN (:artistIds)), " +
|
||||||
|
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||||
|
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
|
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||||
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
|
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||||
|
"downloaded_deduped AS (SELECT artistId, coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
|
||||||
|
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
|
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
|
") GROUP BY artistId), " +
|
||||||
|
"desired AS (SELECT artists.rowid AS rowid, " +
|
||||||
|
"COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||||
|
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
|
||||||
|
"FROM artists WHERE artists.sourceId = :sourceId AND artists.id IN (:artistIds)) " +
|
||||||
|
"UPDATE artists SET downloadedCoverArts = desired.downloadedCoverArts " +
|
||||||
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
|
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
||||||
|
|
||||||
|
internal const val ARTIST_COLUMNS_TAIL =
|
||||||
|
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, " +
|
||||||
|
"artists.downloadedCoverArts, artists.playCount, artists.played, artists.songCount, " +
|
||||||
|
"artists.sortName, artists.credited"
|
||||||
|
|
||||||
|
internal const val ARTIST_COLUMNS =
|
||||||
|
"artists.sourceId, artists.id, artists.name, artists.albumCount, $ARTIST_COLUMNS_TAIL"
|
||||||
|
|
||||||
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
||||||
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id"
|
||||||
@@ -131,26 +187,13 @@ internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, s
|
|||||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
|
|
||||||
internal const val PLAYLISTS_FILTER =
|
private const val ARTIST_NAME_SORT_KEY_MODE =
|
||||||
"FROM playlists WHERE sourceId = :sourceId " +
|
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
|
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
|
||||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId " +
|
|
||||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')) " +
|
|
||||||
"AND (:search = '' " +
|
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
|
||||||
"WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
|
||||||
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
|
|
||||||
|
|
||||||
// With the Downloaded filter on, count the songs that actually have a download so the number under
|
private const val SONG_NAME_SORT_KEY_MODE =
|
||||||
// a playlist matches the songs its detail screen lists.
|
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
|
||||||
internal const val PLAYLISTS_SELECT =
|
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
|
||||||
"SELECT playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt, " +
|
|
||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
|
||||||
"SELECT COUNT(DISTINCT ps.songId) FROM playlist_songs ps " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
|
||||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
|
||||||
") ELSE playlists.songCount END AS songCount, " +
|
|
||||||
"playlists.created, playlists.changed, playlists.duration "
|
|
||||||
|
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||||
@@ -179,6 +222,39 @@ internal const val SONG_SEARCH_FILTER =
|
|||||||
"(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " +
|
"(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " +
|
||||||
"WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))"
|
"WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))"
|
||||||
|
|
||||||
|
internal val ARTIST_AGGREGATES_SQL =
|
||||||
|
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
||||||
|
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
||||||
|
"totals AS (" +
|
||||||
|
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||||
|
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||||
|
"FROM credited GROUP BY artistId" +
|
||||||
|
"), album_totals AS (" +
|
||||||
|
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||||
|
"FROM credited_albums GROUP BY artistId" +
|
||||||
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||||
|
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
||||||
|
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
||||||
|
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||||
|
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
||||||
|
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||||
|
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||||
|
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||||
|
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||||
|
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
||||||
|
"WHERE artists.sourceId = :sourceId) c " +
|
||||||
|
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||||
|
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
||||||
|
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
||||||
|
"OR artists.credited != c.credited)"
|
||||||
|
|
||||||
|
internal val ALBUMS_FOR_ARTIST_DOWNLOADED =
|
||||||
|
"SELECT * FROM ($DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST) ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||||
interface LibraryDao {
|
interface LibraryDao {
|
||||||
@@ -211,62 +287,46 @@ interface LibraryDao {
|
|||||||
)
|
)
|
||||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||||
|
|
||||||
// The credited albums are the union of the lead id and the relation table, so a shared album
|
@RawQuery
|
||||||
// counts once per artist rather than twice.
|
suspend fun recomputeArtistAggregates(query: RoomRawQuery): Int
|
||||||
@Query(
|
|
||||||
"WITH credited AS (" +
|
|
||||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, " +
|
|
||||||
"albums.playCount AS playCount, albums.played AS played " +
|
|
||||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT album_artists.artistId, albums.id, albums.playCount, albums.played " +
|
|
||||||
"FROM album_artists JOIN albums " +
|
|
||||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId" +
|
|
||||||
"), totals AS (" +
|
|
||||||
"SELECT artistId AS id, SUM(playCount) AS playCount, MAX(played) AS played " +
|
|
||||||
"FROM credited GROUP BY artistId" +
|
|
||||||
") " +
|
|
||||||
"UPDATE artists SET playCount = c.playCount, played = c.played " +
|
|
||||||
"FROM (SELECT artists.id AS id, COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
|
||||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
|
||||||
"WHERE artists.sourceId = :sourceId) c " +
|
|
||||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
|
||||||
"AND (artists.playCount != c.playCount OR artists.played IS NOT c.played)",
|
|
||||||
)
|
|
||||||
suspend fun recomputeArtistPlayData(sourceId: Long)
|
|
||||||
|
|
||||||
// Stored so the download badge query can report an artist's total without walking every song.
|
suspend fun recomputeArtistAggregates(sourceId: Long) {
|
||||||
@Query(
|
recomputeArtistAggregates(rawQuery(ARTIST_AGGREGATES_SQL, mapOf("sourceId" to sourceId)))
|
||||||
"WITH credited AS (" +
|
}
|
||||||
"SELECT artistId, songId FROM song_artists WHERE sourceId = :sourceId " +
|
|
||||||
"UNION " +
|
@RawQuery
|
||||||
"SELECT album_artists.artistId AS artistId, songs.id AS songId " +
|
suspend fun recomputeArtistArt(query: RoomRawQuery): Int
|
||||||
"FROM album_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
suspend fun recomputeArtistArt(sourceId: Long) {
|
||||||
"WHERE album_artists.sourceId = :sourceId" +
|
recomputeArtistArt(rawQuery(ARTIST_ART_UPDATE_SQL, mapOf("sourceId" to sourceId)))
|
||||||
"), counts AS (" +
|
}
|
||||||
"SELECT artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY artistId" +
|
|
||||||
") " +
|
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||||
"UPDATE artists SET songCount = c.songCount, credited = c.credited " +
|
fun artistCoverArts(query: RoomRawQuery): Flow<List<String>>
|
||||||
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " +
|
|
||||||
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
fun artistCoverArts(
|
||||||
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
sourceId: Long,
|
||||||
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
artistId: String,
|
||||||
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
): Flow<List<String>> = artistCoverArts(rawQuery(ARTIST_COVER_ARTS_SQL, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||||
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
|
||||||
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
@RawQuery
|
||||||
"FROM artists LEFT JOIN counts ON counts.id = artists.id " +
|
suspend fun recomputeDownloadedArtistArt(query: RoomRawQuery): Int
|
||||||
"WHERE artists.sourceId = :sourceId) c " +
|
|
||||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
suspend fun recomputeDownloadedArtistArt(
|
||||||
"AND (artists.songCount != c.songCount OR artists.credited != c.credited)",
|
sourceId: Long,
|
||||||
)
|
artistIds: List<String>,
|
||||||
suspend fun recomputeArtistSongCounts(sourceId: Long)
|
) {
|
||||||
|
recomputeDownloadedArtistArt(downloadedArtistArtQuery(sourceId, artistIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
||||||
|
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun recomputePlayData(sourceId: Long) {
|
suspend fun recomputeAggregates(sourceId: Long) {
|
||||||
recomputeAlbumPlayData(sourceId)
|
recomputeAlbumPlayData(sourceId)
|
||||||
recomputeArtistPlayData(sourceId)
|
recomputeArtistAggregates(sourceId)
|
||||||
|
recomputeArtistArt(sourceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -293,11 +353,14 @@ interface LibraryDao {
|
|||||||
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
||||||
"WHERE sourceId = :sourceId AND (" +
|
"WHERE sourceId = :sourceId AND (" +
|
||||||
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
|
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
|
||||||
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId))",
|
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId) " +
|
||||||
|
"OR id = (SELECT artistId FROM songs WHERE sourceId = :sourceId AND id = :songId) " +
|
||||||
|
"OR id IN (SELECT artistId FROM song_artists WHERE sourceId = :sourceId AND songId = :songId))",
|
||||||
)
|
)
|
||||||
suspend fun bumpArtistPlay(
|
suspend fun bumpArtistPlay(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String?,
|
||||||
|
songId: String,
|
||||||
at: Long,
|
at: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -307,6 +370,12 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): String?
|
): String?
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT artistId FROM ($CREDITED_SONG_ROWS) WHERE songId IN (:songIds)")
|
||||||
|
suspend fun creditedArtistIdsForSongs(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: List<String>,
|
||||||
|
): List<String>
|
||||||
|
|
||||||
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
|
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
|
||||||
// sync then overwrites with the server's own number.
|
// sync then overwrites with the server's own number.
|
||||||
@Transaction
|
@Transaction
|
||||||
@@ -316,9 +385,9 @@ interface LibraryDao {
|
|||||||
at: Long,
|
at: Long,
|
||||||
) {
|
) {
|
||||||
bumpSongPlay(sourceId, songId, at)
|
bumpSongPlay(sourceId, songId, at)
|
||||||
val albumId = albumIdForSong(sourceId, songId) ?: return
|
val albumId = albumIdForSong(sourceId, songId)
|
||||||
bumpAlbumPlay(sourceId, albumId, at)
|
if (albumId != null) bumpAlbumPlay(sourceId, albumId, at)
|
||||||
bumpArtistPlay(sourceId, albumId, at)
|
bumpArtistPlay(sourceId, albumId, songId, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Albums, not songs: these flows re-run on every write to their table, and songs are written
|
// Albums, not songs: these flows re-run on every write to their table, and songs are written
|
||||||
@@ -339,7 +408,7 @@ interface LibraryDao {
|
|||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyPlayedArtists(
|
fun recentlyPlayedArtists(
|
||||||
@@ -357,7 +426,7 @@ interface LibraryDao {
|
|||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun mostPlayedArtists(
|
fun mostPlayedArtists(
|
||||||
@@ -397,22 +466,24 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
||||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchAlbums(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<Artist>>
|
): Flow<List<Artist>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -429,12 +500,13 @@ interface LibraryDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
||||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
"ORDER BY $SONG_SEARCH_ORDER_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchSongs(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<AlbumSongItem>>
|
): Flow<List<AlbumSongItem>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -470,7 +542,7 @@ interface LibraryDao {
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
id: String,
|
id: String,
|
||||||
starred: Long?,
|
starred: Long?,
|
||||||
)
|
): Int
|
||||||
|
|
||||||
@Query("SELECT id FROM artists WHERE sourceId = :sourceId")
|
@Query("SELECT id FROM artists WHERE sourceId = :sourceId")
|
||||||
suspend fun artistIds(sourceId: Long): List<String>
|
suspend fun artistIds(sourceId: Long): List<String>
|
||||||
@@ -557,7 +629,7 @@ interface LibraryDao {
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
position: Long,
|
position: Long,
|
||||||
)
|
): Int
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"DELETE FROM playlist_songs WHERE sourceId = :sourceId " +
|
"DELETE FROM playlist_songs WHERE sourceId = :sourceId " +
|
||||||
@@ -568,245 +640,35 @@ interface LibraryDao {
|
|||||||
@RawQuery(observedEntities = [Album::class])
|
@RawQuery(observedEntities = [Album::class])
|
||||||
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||||
|
|
||||||
|
// The fallback art reads albums and credits, but a successful sync writes sources after the
|
||||||
|
// library changes, so observing artists and sources avoids invalidating on every song/album write.
|
||||||
@RawQuery(observedEntities = [Artist::class, Source::class])
|
@RawQuery(observedEntities = [Artist::class, Source::class])
|
||||||
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@RawQuery(observedEntities = [Playlist::class])
|
@RawQuery(observedEntities = [Playlist::class])
|
||||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
|
// These observe every table the statements read; the sets match what Room inferred for the
|
||||||
fun albumsByName(
|
// old downloaded @Query methods, so download and library writes invalidate as before.
|
||||||
sourceId: Long,
|
@RawQuery(observedEntities = [Album::class, Song::class, SongDownload::class, AlbumSearch::class])
|
||||||
starredFilter: Int,
|
fun downloadedAlbumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
|
@RawQuery(
|
||||||
fun albumsByNameReversed(
|
observedEntities = [
|
||||||
sourceId: Long,
|
Album::class,
|
||||||
starredFilter: Int,
|
Song::class,
|
||||||
search: String,
|
SongDownload::class,
|
||||||
downloadedFilter: Int = 0,
|
AlbumArtistRef::class,
|
||||||
): PagingSource<Int, Album>
|
SongArtistRef::class,
|
||||||
|
Artist::class,
|
||||||
|
Source::class,
|
||||||
|
ArtistSearch::class,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
fun downloadedArtistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
|
@RawQuery(observedEntities = [PlaylistSong::class, SongDownload::class, Playlist::class, PlaylistSearch::class])
|
||||||
fun albumsByArtist(
|
fun downloadedPlaylistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
|
|
||||||
fun albumsByArtistReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
|
||||||
fun albumsByYear(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED")
|
|
||||||
fun albumsByYearReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED")
|
|
||||||
fun albumsByRecentlyAdded(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED")
|
|
||||||
fun albumsByRecentlyAddedReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED")
|
|
||||||
fun albumsByStarred(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED")
|
|
||||||
fun albumsByStarredReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT")
|
|
||||||
fun albumsByFrequent(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT_REVERSED")
|
|
||||||
fun albumsByFrequentReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT")
|
|
||||||
fun albumsByRecent(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT_REVERSED")
|
|
||||||
fun albumsByRecentReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
|
|
||||||
fun artistsByName(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
|
|
||||||
fun artistsByNameReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
|
||||||
fun artistsByAlbumCount(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED")
|
|
||||||
fun artistsByAlbumCountReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED")
|
|
||||||
fun artistsByStarred(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED")
|
|
||||||
fun artistsByStarredReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT")
|
|
||||||
fun artistsByFrequent(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT_REVERSED")
|
|
||||||
fun artistsByFrequentReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT")
|
|
||||||
fun artistsByRecent(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT_REVERSED")
|
|
||||||
fun artistsByRecentReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME")
|
|
||||||
fun playlistsByName(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED")
|
|
||||||
fun playlistsByNameReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED")
|
|
||||||
fun playlistsByAdded(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED")
|
|
||||||
fun playlistsByAddedReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED")
|
|
||||||
fun playlistsByUpdated(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED")
|
|
||||||
fun playlistsByUpdatedReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
||||||
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
@@ -818,13 +680,13 @@ interface LibraryDao {
|
|||||||
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY played DESC, sortName COLLATE NOCASE, id",
|
"ORDER BY played DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id",
|
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||||
@@ -883,7 +745,7 @@ interface LibraryDao {
|
|||||||
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT artists.* FROM artists " +
|
"SELECT $ARTIST_COLUMNS FROM artists " +
|
||||||
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
|
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
|
||||||
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
|
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
|
||||||
"WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
|
"WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
|
||||||
@@ -982,18 +844,33 @@ interface LibraryDao {
|
|||||||
albumId: String,
|
albumId: String,
|
||||||
): Album?
|
): Album?
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||||
suspend fun artistOnce(
|
suspend fun artistOnce(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Artist?
|
): Artist?
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||||
fun artist(
|
fun artist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<Artist?>
|
): Flow<Artist?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
|
||||||
|
fun artistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<ArtistBio?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
|
||||||
|
suspend fun artistBioOnce(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): ArtistBio?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertArtistBio(item: ArtistBio)
|
||||||
|
|
||||||
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
|
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT COALESCE(" +
|
"SELECT COALESCE(" +
|
||||||
@@ -1025,28 +902,21 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Flow<List<String>>
|
): Flow<List<String>>
|
||||||
|
|
||||||
@Query(
|
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||||
"SELECT * FROM (" +
|
fun albumsForArtist(query: RoomRawQuery): Flow<List<Album>>
|
||||||
"SELECT albums.* FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM album_artists " +
|
|
||||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM song_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
|
||||||
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
fun albumsForArtist(
|
fun albumsForArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>> = albumsForArtist(rawQuery(ALBUMS_FOR_ARTIST, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||||
|
|
||||||
|
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||||
|
fun albumCreditAlbumIds(query: RoomRawQuery): Flow<List<String>>
|
||||||
|
|
||||||
|
fun albumCreditAlbumIds(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>> = albumCreditAlbumIds(rawQuery(ALBUM_CREDIT_ALBUM_IDS, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||||
|
|
||||||
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
||||||
fun discs(
|
fun discs(
|
||||||
@@ -1083,35 +953,16 @@ interface LibraryDao {
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
): PagingSource<Int, PlaylistSongItem>
|
): PagingSource<Int, PlaylistSongItem>
|
||||||
|
|
||||||
@Query(
|
@RawQuery(
|
||||||
"SELECT * FROM (" +
|
observedEntities = [Album::class, Song::class, SongDownload::class, AlbumArtistRef::class, SongArtistRef::class],
|
||||||
"SELECT albums.* FROM albums " +
|
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM album_artists " +
|
|
||||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM song_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
|
||||||
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
|
||||||
)
|
)
|
||||||
|
fun albumsForArtistDownloaded(query: RoomRawQuery): Flow<List<Album>>
|
||||||
|
|
||||||
fun albumsForArtistDownloaded(
|
fun albumsForArtistDownloaded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>> =
|
||||||
|
albumsForArtistDownloaded(
|
||||||
|
rawQuery(ALBUMS_FOR_ARTIST_DOWNLOADED, mapOf("sourceId" to sourceId, "artistId" to artistId)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,49 +5,163 @@ import androidx.sqlite.SQLiteStatement
|
|||||||
import com.subtracks.data.prefs.AlbumSort
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
|
|
||||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
// The default filters intentionally omit song_downloads: the unfiltered lists do not need the
|
||||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
// join, and their paging sources do not observe download writes (including progress updates).
|
||||||
internal const val ALBUMS_FILTER_BASE =
|
internal const val ALBUMS_FILTER = """
|
||||||
"FROM albums WHERE sourceId = ? " +
|
FROM albums WHERE sourceId = :sourceId
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"AND (? = '' " +
|
AND (:search = ''
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||||
"OR instr(lower(albumArtist), lower(?)) > 0 " +
|
OR instr(lower(albumArtist), lower(:search)) > 0
|
||||||
"OR instr(lower(sortName), lower(?)) > 0 " +
|
OR instr(lower(sortName), lower(:search)) > 0
|
||||||
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
|
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
|
||||||
|
"""
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER_BASE =
|
internal const val ALBUMS_FILTER_DOWNLOADED = """
|
||||||
"FROM artists WHERE sourceId = ? " +
|
FROM albums WHERE sourceId = :sourceId
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
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 songs
|
||||||
"AND (? = '' " +
|
JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')
|
||||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
AND (:search = ''
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||||
"OR instr(lower(sortName), lower(?)) > 0))) "
|
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 =
|
internal const val ARTISTS_FILTER = """
|
||||||
"FROM playlists WHERE sourceId = ? " +
|
FROM artists WHERE sourceId = :sourceId
|
||||||
"AND (? = '' " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
AND $ARTIST_CREDITED
|
||||||
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
AND (:search = ''
|
||||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
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,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
): RoomRawQuery {
|
downloaded: Boolean,
|
||||||
val order = albumOrder(sort, descending)
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
): RoomRawQuery =
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
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(
|
internal fun rediscoverAlbumsQuery(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -55,93 +169,126 @@ internal fun rediscoverAlbumsQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
cutoff: Long,
|
cutoff: Long,
|
||||||
): RoomRawQuery =
|
): RoomRawQuery =
|
||||||
RoomRawQuery(
|
rawQuery(
|
||||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
sql =
|
||||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
|
||||||
) { stmt ->
|
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
bindings =
|
||||||
stmt.bindLong(13, cutoff)
|
mapOf(
|
||||||
}
|
"sourceId" to sourceId,
|
||||||
|
"starredFilter" to starredFilter,
|
||||||
|
"search" to search,
|
||||||
|
"cutoff" to cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private fun SQLiteStatement.bindAlbumFilters(
|
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
|
||||||
sourceId: Long,
|
// index, so the SQL can keep its names and only the first-appearance order needs binding.
|
||||||
starredFilter: Int,
|
internal fun rawQuery(
|
||||||
search: String,
|
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)
|
when (value) {
|
||||||
bindInt(2, starredFilter)
|
null -> bindNull(index)
|
||||||
bindInt(3, starredFilter)
|
is Int -> bindLong(index, value.toLong())
|
||||||
bindInt(4, starredFilter)
|
is Long -> bindLong(index, value)
|
||||||
bindText(5, search)
|
is String -> bindText(index, value)
|
||||||
bindText(6, search)
|
else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
|
||||||
bindText(7, search)
|
|
||||||
bindText(8, search)
|
|
||||||
bindText(9, search)
|
|
||||||
bindText(10, search)
|
|
||||||
bindText(11, search)
|
|
||||||
bindText(12, search)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun artistsDefaultQuery(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
sort: ArtistSort,
|
|
||||||
descending: Boolean,
|
|
||||||
): RoomRawQuery {
|
|
||||||
val order = artistOrder(sort, descending)
|
|
||||||
return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
|
||||||
stmt.bindLong(1, sourceId)
|
|
||||||
stmt.bindInt(2, starredFilter)
|
|
||||||
stmt.bindInt(3, starredFilter)
|
|
||||||
stmt.bindInt(4, starredFilter)
|
|
||||||
stmt.bindText(5, search)
|
|
||||||
stmt.bindText(6, search)
|
|
||||||
stmt.bindText(7, search)
|
|
||||||
stmt.bindText(8, search)
|
|
||||||
stmt.bindText(9, search)
|
|
||||||
stmt.bindText(10, search)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun playlistsDefaultQuery(
|
internal fun albumNameSortKey(mode: SortNameMode): String =
|
||||||
sourceId: Long,
|
when (mode) {
|
||||||
search: String,
|
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||||
sort: PlaylistSort,
|
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||||
descending: Boolean,
|
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||||
): RoomRawQuery {
|
}
|
||||||
val order = playlistOrder(sort, descending)
|
|
||||||
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
internal fun albumArtistSortKey(mode: SortNameMode): String =
|
||||||
stmt.bindLong(1, sourceId)
|
when (mode) {
|
||||||
stmt.bindText(2, search)
|
SortNameMode.SortName -> {
|
||||||
stmt.bindText(3, search)
|
"albumArtistSort COLLATE NOCASE"
|
||||||
stmt.bindText(4, search)
|
}
|
||||||
stmt.bindText(5, search)
|
|
||||||
stmt.bindText(6, search)
|
SortNameMode.Name -> {
|
||||||
|
"albumArtist COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
SortNameMode.NonLatin -> {
|
||||||
|
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistNameSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||||
|
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||||
|
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun albumOrder(
|
private fun albumOrder(
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
AlbumSort.Name -> {
|
||||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
val name = albumNameSortKey(mode)
|
||||||
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
}
|
||||||
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
|
||||||
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
AlbumSort.Artist -> {
|
||||||
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
val artist = albumArtistSortKey(mode)
|
||||||
|
val name = albumNameSortKey(mode)
|
||||||
|
if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id"
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Year -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Added -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Starred -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Frequent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Recent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun artistOrder(
|
private fun artistOrder(
|
||||||
sort: ArtistSort,
|
sort: ArtistSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
ArtistSort.Name -> {
|
ArtistSort.Name -> {
|
||||||
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
|
val name = artistNameSortKey(mode)
|
||||||
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
}
|
}
|
||||||
|
|
||||||
ArtistSort.AlbumCount -> {
|
ArtistSort.AlbumCount -> {
|
||||||
|
|||||||
@@ -579,6 +579,90 @@ val MIGRATION_32_33 =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recomputes the artist totals from the same credit unions the sync uses, so badges, the artist
|
||||||
|
// page and the shuffle queue agree for libraries that predate the change.
|
||||||
|
val MIGRATION_33_34 =
|
||||||
|
object : Migration(33, 34) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"WITH credited_songs AS (" +
|
||||||
|
"SELECT songs.`sourceId` AS sourceId, songs.`artistId` AS artistId, " +
|
||||||
|
"songs.`id` AS songId, songs.`playCount` AS playCount, songs.`played` AS played " +
|
||||||
|
"FROM songs WHERE songs.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.`sourceId`, albums.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM albums JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = albums.`sourceId` AND songs.`albumId` = albums.`id` " +
|
||||||
|
"WHERE albums.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.`sourceId`, album_artists.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = album_artists.`sourceId` AND songs.`albumId` = album_artists.`albumId`" +
|
||||||
|
"), credited_albums AS (" +
|
||||||
|
"SELECT albums.`sourceId` AS sourceId, albums.`artistId` AS artistId, " +
|
||||||
|
"albums.`id` AS albumId " +
|
||||||
|
"FROM albums WHERE albums.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.`sourceId`, album_artists.`artistId`, albums.`id` " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.`sourceId` = album_artists.`sourceId` AND albums.`id` = album_artists.`albumId` " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.`sourceId`, songs.`artistId`, songs.`albumId` " +
|
||||||
|
"FROM songs JOIN albums " +
|
||||||
|
"ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId` " +
|
||||||
|
"WHERE songs.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`albumId` " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||||
|
"JOIN albums ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId`" +
|
||||||
|
"), totals AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||||
|
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||||
|
"FROM credited_songs GROUP BY sourceId, artistId" +
|
||||||
|
"), album_totals AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||||
|
"FROM credited_albums GROUP BY sourceId, artistId" +
|
||||||
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||||
|
"playCount = c.playCount, played = c.played " +
|
||||||
|
"FROM (SELECT artists.`sourceId` AS sourceId, artists.`id` AS id, " +
|
||||||
|
"COALESCE(totals.songCount, 0) AS songCount, " +
|
||||||
|
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||||
|
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
||||||
|
"FROM artists LEFT JOIN totals " +
|
||||||
|
"ON totals.sourceId = artists.`sourceId` AND totals.id = artists.`id` " +
|
||||||
|
"LEFT JOIN album_totals " +
|
||||||
|
"ON album_totals.sourceId = artists.`sourceId` AND album_totals.id = artists.`id` " +
|
||||||
|
"WHERE EXISTS (SELECT 1 FROM album_artists WHERE album_artists.`sourceId` = artists.`sourceId`) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM song_artists WHERE song_artists.`sourceId` = artists.`sourceId`)" +
|
||||||
|
") c " +
|
||||||
|
"WHERE artists.`sourceId` = c.sourceId AND artists.`id` = c.id " +
|
||||||
|
"AND (artists.`songCount` != c.songCount OR artists.`albumCount` != c.albumCount " +
|
||||||
|
"OR artists.`playCount` != c.playCount OR artists.`played` IS NOT c.played)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_34_35 =
|
||||||
|
object : Migration(34, 35) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `artist_bios` (" +
|
||||||
|
"`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `artistId`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||||
private suspend fun createSearchIndex(
|
private suspend fun createSearchIndex(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
@@ -611,6 +695,111 @@ private suspend fun createSearchIndex(
|
|||||||
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_35_36 =
|
||||||
|
object : Migration(35, 36) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
|
||||||
|
optimizeArtistSearchTriggers(connection)
|
||||||
|
backfillArtistArt(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_36_37 =
|
||||||
|
object : Migration(36, 37) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " +
|
||||||
|
"ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_37_38 =
|
||||||
|
object : Migration(37, 38) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `owner` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `shared` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_38_39 =
|
||||||
|
object : Migration(38, 39) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originKind` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originRefId` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originOccurrence` INTEGER")
|
||||||
|
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originEntryId` INTEGER")
|
||||||
|
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originKind` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originRefId` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originOccurrence` INTEGER")
|
||||||
|
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originEntryId` INTEGER")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `pending_sync_origins` (`sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, " +
|
||||||
|
"`refId` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `kind`, `refId`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_pending_sync_origins_sourceId` ON `pending_sync_origins` (`sourceId`)")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE `song_downloads_new` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, " +
|
||||||
|
"`engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, " +
|
||||||
|
"`downloadedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL("INSERT INTO `song_downloads_new` SELECT * FROM `song_downloads`")
|
||||||
|
connection.execSQL("DROP TABLE `song_downloads`")
|
||||||
|
connection.execSQL("ALTER TABLE `song_downloads_new` RENAME TO `song_downloads`")
|
||||||
|
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `song_downloads` (`sourceId`)")
|
||||||
|
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `song_downloads` (`engineId`)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||||
|
val triggers = mutableListOf<String>()
|
||||||
|
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||||
|
statement.bindText(1, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||||
|
statement.bindText(2, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||||
|
while (statement.step()) triggers += statement.getText(0)
|
||||||
|
}
|
||||||
|
return triggers.size == 2 && triggers.all { it.contains("UPDATE OF `name`, `sortName`") }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE " +
|
||||||
|
"BEFORE UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||||
|
"DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE " +
|
||||||
|
"AFTER UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||||
|
"INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) " +
|
||||||
|
"VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
|
||||||
|
val sourceIds = mutableListOf<Long>()
|
||||||
|
connection.prepare("SELECT id FROM sources").use { statement ->
|
||||||
|
while (statement.step()) sourceIds += statement.getLong(0)
|
||||||
|
}
|
||||||
|
sourceIds.forEach { sourceId ->
|
||||||
|
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val MIGRATIONS: Array<Migration> =
|
val MIGRATIONS: Array<Migration> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MIGRATION_1_2,
|
MIGRATION_1_2,
|
||||||
@@ -645,4 +834,10 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_30_31,
|
MIGRATION_30_31,
|
||||||
MIGRATION_31_32,
|
MIGRATION_31_32,
|
||||||
MIGRATION_32_33,
|
MIGRATION_32_33,
|
||||||
|
MIGRATION_33_34,
|
||||||
|
MIGRATION_34_35,
|
||||||
|
MIGRATION_35_36,
|
||||||
|
MIGRATION_36_37,
|
||||||
|
MIGRATION_37_38,
|
||||||
|
MIGRATION_38_39,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.room3.Insert
|
|||||||
import androidx.room3.OnConflictStrategy
|
import androidx.room3.OnConflictStrategy
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.PendingSyncOrigin
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
@@ -20,23 +21,15 @@ 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 ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
||||||
|
|
||||||
private const val ARTIST_SONG_IDS =
|
private const val ARTIST_SONG_IDS =
|
||||||
"(SELECT id FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
|
||||||
"UNION " +
|
|
||||||
"SELECT songId FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.id FROM albums " +
|
|
||||||
"JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.id FROM album_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId)"
|
|
||||||
|
|
||||||
private const val ARTIST_SONGS_SELECT =
|
private const val ARTIST_SONGS_FROM =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||||
|
|
||||||
|
private const val ARTIST_SONGS_SELECT = "SELECT songs.*, albums.coverArt AS coverArt $ARTIST_SONGS_FROM"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_ORDER =
|
private const val ARTIST_SONGS_ORDER =
|
||||||
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName 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"
|
||||||
|
|
||||||
@@ -47,6 +40,12 @@ private const val SONG_SQL =
|
|||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface QueueDao {
|
interface QueueDao {
|
||||||
|
@Query("SELECT * FROM pending_sync_origins WHERE sourceId = :sourceId")
|
||||||
|
suspend fun pendingSyncOrigins(sourceId: Long): List<PendingSyncOrigin>
|
||||||
|
|
||||||
|
@Query("DELETE FROM pending_sync_origins WHERE sourceId = :sourceId")
|
||||||
|
suspend fun clearPendingSyncOrigins(sourceId: Long)
|
||||||
|
|
||||||
@Query("DELETE FROM queue_entries")
|
@Query("DELETE FROM queue_entries")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|
||||||
@@ -184,6 +183,17 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT songRank FROM (" +
|
||||||
|
"SELECT songs.id AS songId, ROW_NUMBER() OVER ($ARTIST_SONGS_ORDER) - 1 AS songRank $ARTIST_SONGS_FROM" +
|
||||||
|
") WHERE songId = :songId ORDER BY songRank LIMIT 1",
|
||||||
|
)
|
||||||
|
suspend fun artistRank(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
songId: String,
|
||||||
|
): Long?
|
||||||
|
|
||||||
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
|
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
|
||||||
suspend fun song(
|
suspend fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -224,14 +234,72 @@ interface QueueDao {
|
|||||||
): List<String>
|
): List<String>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)",
|
"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,
|
sourceId: Long,
|
||||||
ids: List<String>,
|
albumId: String,
|
||||||
): List<AlbumSongItem>
|
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 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 playlist_songs.songId = :songId AND playlist_songs.position < :position",
|
||||||
|
)
|
||||||
|
suspend fun playlistOccurrenceRank(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
songId: String,
|
||||||
|
position: Long,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT playlist_songs.position 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 playlist_songs.songId = :songId ORDER BY playlist_songs.position LIMIT 1 OFFSET :occurrence",
|
||||||
|
)
|
||||||
|
suspend fun playlistPositionForOccurrence(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
songId: String,
|
||||||
|
occurrence: Long,
|
||||||
|
): Long?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"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 playlist_songs.position <= :position",
|
||||||
|
)
|
||||||
|
suspend fun playlistRankAtPosition(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
position: Long,
|
||||||
|
): Long
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
||||||
suspend fun genreLength(
|
suspend fun genreLength(
|
||||||
@@ -254,17 +322,24 @@ interface QueueDao {
|
|||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM song_genres " +
|
"SELECT songRank FROM (" +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"SELECT songs.id AS songId, " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"ROW_NUMBER() OVER (ORDER BY songs.sortName COLLATE NOCASE, songs.id) - 1 AS songRank " +
|
||||||
"ORDER BY songs.sortName COLLATE NOCASE, 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" +
|
||||||
|
") WHERE songId = :songId ORDER BY songRank LIMIT 1",
|
||||||
)
|
)
|
||||||
suspend fun genreSongIds(
|
suspend fun genreRank(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
): List<String>
|
songId: String,
|
||||||
|
): Long?
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
@Query(
|
||||||
|
"SELECT COUNT(*) FROM song_downloads d " +
|
||||||
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed'",
|
||||||
|
)
|
||||||
suspend fun downloadedLength(sourceId: Long): Long
|
suspend fun downloadedLength(sourceId: Long): Long
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL")
|
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL")
|
||||||
@@ -282,12 +357,6 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
|
||||||
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
@@ -302,10 +371,27 @@ interface QueueDao {
|
|||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM song_downloads d " +
|
"SELECT songRank FROM (" +
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"SELECT songs.id AS songId, " +
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"ROW_NUMBER() OVER (ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id) - 1 AS songRank " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
"FROM songs WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL" +
|
||||||
|
") WHERE songId = :songId ORDER BY songRank LIMIT 1",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
suspend fun starredRank(
|
||||||
|
sourceId: Long,
|
||||||
|
songId: String,
|
||||||
|
): Long?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT songRank FROM (" +
|
||||||
|
"SELECT songs.id AS songId, " +
|
||||||
|
"ROW_NUMBER() OVER (ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id) - 1 AS songRank " +
|
||||||
|
"FROM song_downloads d JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed'" +
|
||||||
|
") WHERE songId = :songId ORDER BY songRank LIMIT 1",
|
||||||
|
)
|
||||||
|
suspend fun downloadedRank(
|
||||||
|
sourceId: Long,
|
||||||
|
songId: String,
|
||||||
|
): Long?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.subtracks.data.model.Album
|
|||||||
import com.subtracks.data.model.AlbumArtistRef
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.AlbumSearch
|
import com.subtracks.data.model.AlbumSearch
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.ArtistRefsConverter
|
import com.subtracks.data.model.ArtistRefsConverter
|
||||||
import com.subtracks.data.model.ArtistSearch
|
import com.subtracks.data.model.ArtistSearch
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
@@ -15,6 +16,7 @@ import com.subtracks.data.model.DownloadErrorConverter
|
|||||||
import com.subtracks.data.model.DownloadStatusConverter
|
import com.subtracks.data.model.DownloadStatusConverter
|
||||||
import com.subtracks.data.model.PendingAction
|
import com.subtracks.data.model.PendingAction
|
||||||
import com.subtracks.data.model.PendingActionKindConverter
|
import com.subtracks.data.model.PendingActionKindConverter
|
||||||
|
import com.subtracks.data.model.PendingSyncOrigin
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSearch
|
import com.subtracks.data.model.PlaylistSearch
|
||||||
@@ -27,6 +29,7 @@ import com.subtracks.data.model.SongDownload
|
|||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.model.SongSearch
|
import com.subtracks.data.model.SongSearch
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.model.StringListConverter
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
import com.subtracks.data.model.UpNextEntry
|
import com.subtracks.data.model.UpNextEntry
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
Source::class,
|
Source::class,
|
||||||
SubsonicSource::class,
|
SubsonicSource::class,
|
||||||
Artist::class,
|
Artist::class,
|
||||||
|
ArtistBio::class,
|
||||||
ArtistSearch::class,
|
ArtistSearch::class,
|
||||||
Album::class,
|
Album::class,
|
||||||
AlbumSearch::class,
|
AlbumSearch::class,
|
||||||
@@ -53,8 +57,9 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
ArtworkSeed::class,
|
ArtworkSeed::class,
|
||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
|
PendingSyncOrigin::class,
|
||||||
],
|
],
|
||||||
version = 33,
|
version = 39,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
@@ -63,6 +68,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
DownloadErrorConverter::class,
|
DownloadErrorConverter::class,
|
||||||
PendingActionKindConverter::class,
|
PendingActionKindConverter::class,
|
||||||
ArtistRefsConverter::class,
|
ArtistRefsConverter::class,
|
||||||
|
StringListConverter::class,
|
||||||
)
|
)
|
||||||
abstract class SubtracksDatabase : RoomDatabase() {
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDao(): LibraryDao
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ fun coverArtKey(
|
|||||||
coverArt: String,
|
coverArt: String,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): String = "$sourceId:$coverArt:$thumbnail"
|
): String = "$sourceId:$coverArt:$thumbnail"
|
||||||
|
|
||||||
|
fun artistImageKey(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): String = "$sourceId:artist-image:$artistId"
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ enum class DownloadError(
|
|||||||
childColumns = ["sourceId"],
|
childColumns = ["sourceId"],
|
||||||
onDelete = ForeignKey.CASCADE,
|
onDelete = ForeignKey.CASCADE,
|
||||||
),
|
),
|
||||||
ForeignKey(
|
|
||||||
entity = Song::class,
|
|
||||||
parentColumns = ["sourceId", "id"],
|
|
||||||
childColumns = ["sourceId", "songId"],
|
|
||||||
onDelete = ForeignKey.CASCADE,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
indices = [Index("sourceId"), Index("engineId")],
|
indices = [Index("sourceId"), Index("engineId")],
|
||||||
)
|
)
|
||||||
@@ -58,9 +52,14 @@ data class SongDownload(
|
|||||||
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
val progress: Float?
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
get() = fractionOf(bytes, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun fractionOf(
|
||||||
|
bytes: Long,
|
||||||
|
total: Long,
|
||||||
|
): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||||
|
|
||||||
enum class DownloadList { Album, Playlist, Artist }
|
enum class DownloadList { Album, Playlist, Artist }
|
||||||
|
|
||||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||||
@@ -99,20 +98,23 @@ data class DownloadedSong(
|
|||||||
val songSort: String = title,
|
val songSort: String = title,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
val progress: Float?
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
get() = fractionOf(bytes, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DownloadArtwork(
|
data class DownloadArtwork(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
|
val songRow: String?,
|
||||||
val albumId: String?,
|
val albumId: String?,
|
||||||
val artistId: String?,
|
val artistId: String?,
|
||||||
|
val albumArtistId: String?,
|
||||||
val albumRow: String?,
|
val albumRow: String?,
|
||||||
val artistRow: String?,
|
val artistRow: String?,
|
||||||
val albumCoverArt: String?,
|
val albumCoverArt: String?,
|
||||||
val artistCoverArt: String?,
|
val artistCoverArt: String?,
|
||||||
|
val albumArtistCoverArt: String?,
|
||||||
) {
|
) {
|
||||||
val hasMissingLibraryRow: Boolean
|
val hasMissingLibraryRow: Boolean
|
||||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
get() = songRow == null || (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SourceCoverArt(
|
data class SourceCoverArt(
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ data class ArtistRef(
|
|||||||
indices = [
|
indices = [
|
||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||||
|
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_artists_albumCount",
|
name = "index_artists_albumCount",
|
||||||
value = ["sourceId", "albumCount", "sortName", "id"],
|
value = ["sourceId", "albumCount", "sortName", "id"],
|
||||||
@@ -88,6 +89,9 @@ data class Artist(
|
|||||||
val albumCount: Long,
|
val albumCount: Long,
|
||||||
val starred: Long?,
|
val starred: Long?,
|
||||||
val coverArt: String? = null,
|
val coverArt: String? = null,
|
||||||
|
val artistImageUrl: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "''") val coverArts: List<String> = emptyList(),
|
||||||
|
@ColumnInfo(defaultValue = "''") val downloadedCoverArts: List<String> = emptyList(),
|
||||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||||
val played: Long? = null,
|
val played: Long? = null,
|
||||||
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
||||||
@@ -95,6 +99,24 @@ data class Artist(
|
|||||||
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
|
@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(
|
@Entity(
|
||||||
tableName = "albums",
|
tableName = "albums",
|
||||||
primaryKeys = ["sourceId", "id"],
|
primaryKeys = ["sourceId", "id"],
|
||||||
@@ -110,7 +132,9 @@ data class Artist(
|
|||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index("sourceId", "artistId"),
|
Index("sourceId", "artistId"),
|
||||||
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||||
|
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
|
||||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||||
|
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_year",
|
name = "index_albums_year",
|
||||||
value = ["sourceId", "year", "sortName", "id"],
|
value = ["sourceId", "year", "sortName", "id"],
|
||||||
@@ -167,6 +191,13 @@ data class Album(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class ArtistAlbums(
|
||||||
|
val albumCredits: List<Album> = emptyList(),
|
||||||
|
val appearances: List<Album> = emptyList(),
|
||||||
|
) {
|
||||||
|
val count: Int get() = albumCredits.size + appearances.size
|
||||||
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "discs",
|
tableName = "discs",
|
||||||
primaryKeys = ["sourceId", "albumId", "disc"],
|
primaryKeys = ["sourceId", "albumId", "disc"],
|
||||||
@@ -223,6 +254,8 @@ data class Playlist(
|
|||||||
val created: Long,
|
val created: Long,
|
||||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||||
|
val owner: String? = null,
|
||||||
|
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -415,6 +448,31 @@ data class DiscKey(
|
|||||||
|
|
||||||
enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred }
|
enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred }
|
||||||
|
|
||||||
|
data class QueueOrigin(
|
||||||
|
val kind: QueueKind,
|
||||||
|
val sourceId: Long,
|
||||||
|
val refId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "pending_sync_origins",
|
||||||
|
primaryKeys = ["sourceId", "kind", "refId"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId")],
|
||||||
|
)
|
||||||
|
data class PendingSyncOrigin(
|
||||||
|
val sourceId: Long,
|
||||||
|
val kind: QueueKind,
|
||||||
|
val refId: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "queue_entries",
|
tableName = "queue_entries",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
@@ -435,10 +493,16 @@ data class QueueEntry(
|
|||||||
val refId: String,
|
val refId: String,
|
||||||
val rangeStart: Long? = null,
|
val rangeStart: Long? = null,
|
||||||
val rangeEnd: Long? = null,
|
val rangeEnd: Long? = null,
|
||||||
|
val originKind: QueueKind? = null,
|
||||||
|
val originRefId: String? = null,
|
||||||
|
val originOccurrence: Long? = null,
|
||||||
|
val originEntryId: Long? = null,
|
||||||
) {
|
) {
|
||||||
val offset: Long get() = rangeStart ?: 0
|
val offset: Long get() = rangeStart ?: 0
|
||||||
|
|
||||||
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
||||||
|
|
||||||
|
val origin: QueueOrigin get() = QueueOrigin(originKind ?: kind, sourceId, originRefId ?: refId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -461,16 +525,45 @@ data class UpNextEntry(
|
|||||||
val refId: String,
|
val refId: String,
|
||||||
val rangeStart: Long? = null,
|
val rangeStart: Long? = null,
|
||||||
val rangeEnd: Long? = null,
|
val rangeEnd: Long? = null,
|
||||||
|
val originKind: QueueKind? = null,
|
||||||
|
val originRefId: String? = null,
|
||||||
|
val originOccurrence: Long? = null,
|
||||||
|
val originEntryId: Long? = null,
|
||||||
) {
|
) {
|
||||||
val offset: Long get() = rangeStart ?: 0
|
val offset: Long get() = rangeStart ?: 0
|
||||||
|
|
||||||
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
||||||
|
|
||||||
fun toQueueEntry(): QueueEntry = QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd)
|
fun toQueueEntry(): QueueEntry =
|
||||||
|
QueueEntry(
|
||||||
|
id,
|
||||||
|
position,
|
||||||
|
sourceId,
|
||||||
|
kind,
|
||||||
|
refId,
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
originKind,
|
||||||
|
originRefId,
|
||||||
|
originOccurrence,
|
||||||
|
originEntryId,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(entry: QueueEntry) =
|
fun from(entry: QueueEntry) =
|
||||||
UpNextEntry(entry.id, entry.position, entry.sourceId, entry.kind, entry.refId, entry.rangeStart, entry.rangeEnd)
|
UpNextEntry(
|
||||||
|
entry.id,
|
||||||
|
entry.position,
|
||||||
|
entry.sourceId,
|
||||||
|
entry.kind,
|
||||||
|
entry.refId,
|
||||||
|
entry.rangeStart,
|
||||||
|
entry.rangeEnd,
|
||||||
|
entry.originKind,
|
||||||
|
entry.originRefId,
|
||||||
|
entry.originOccurrence,
|
||||||
|
entry.originEntryId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,6 +660,16 @@ class ArtistRefsConverter {
|
|||||||
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class StringListConverter {
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun fromList(values: List<String>): String = values.joinToString(LIST_SEPARATOR)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val LIST_SEPARATOR = "\u001F"
|
||||||
|
|
||||||
object ArtistRefsCodec {
|
object ArtistRefsCodec {
|
||||||
fun encode(refs: List<ArtistRef>): String =
|
fun encode(refs: List<ArtistRef>): String =
|
||||||
buildString {
|
buildString {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
enum class SortNameMode { SortName, Name, NonLatin }
|
||||||
|
|
||||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||||
|
|
||||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||||
@@ -57,7 +59,9 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
|||||||
|
|
||||||
enum class SyncMode { Off, WifiOnly, Always }
|
enum class SyncMode { Off, WifiOnly, Always }
|
||||||
|
|
||||||
enum class ReplayGainMode { Off, Track, Album }
|
enum class SongTapBehavior { InOrder, KeepShuffle, Shuffle }
|
||||||
|
|
||||||
|
enum class ReplayGainMode { Off, Track, Album, Auto }
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
val cacheKey: String,
|
val cacheKey: String,
|
||||||
@@ -127,6 +131,19 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val syncModeKey = stringPreferencesKey("sync_mode")
|
private val syncModeKey = stringPreferencesKey("sync_mode")
|
||||||
|
|
||||||
|
fun songTapBehavior(): Flow<SongTapBehavior> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[songTapBehaviorKey]?.let { stored ->
|
||||||
|
SongTapBehavior.entries.firstOrNull { it.name == stored }
|
||||||
|
} ?: SongTapBehavior.InOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSongTapBehavior(behavior: SongTapBehavior) {
|
||||||
|
store.edit { prefs -> prefs[songTapBehaviorKey] = behavior.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val songTapBehaviorKey = stringPreferencesKey("song_tap_behavior")
|
||||||
|
|
||||||
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
||||||
|
|
||||||
suspend fun setLastSyncAt(value: Long) {
|
suspend fun setLastSyncAt(value: Long) {
|
||||||
@@ -154,7 +171,7 @@ class UserPreferences(
|
|||||||
|
|
||||||
fun replayGainMode(): Flow<ReplayGainMode> =
|
fun replayGainMode(): Flow<ReplayGainMode> =
|
||||||
store.data.map { prefs ->
|
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) {
|
suspend fun setReplayGainMode(mode: ReplayGainMode) {
|
||||||
@@ -182,6 +199,17 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
||||||
|
|
||||||
|
fun sortNameMode(): Flow<SortNameMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[sortNameModeKey]?.let { stored -> SortNameMode.entries.firstOrNull { it.name == stored } } ?: SortNameMode.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSortNameMode(mode: SortNameMode) {
|
||||||
|
store.edit { prefs -> prefs[sortNameModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sortNameModeKey = stringPreferencesKey("sort_name_mode")
|
||||||
|
|
||||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||||
|
|
||||||
suspend fun setOfflineMode(enabled: Boolean) {
|
suspend fun setOfflineMode(enabled: Boolean) {
|
||||||
|
|||||||
@@ -11,14 +11,20 @@ import com.subtracks.data.download.EngineDownload
|
|||||||
import com.subtracks.data.download.EngineRequest
|
import com.subtracks.data.download.EngineRequest
|
||||||
import com.subtracks.data.download.EngineStatus
|
import com.subtracks.data.download.EngineStatus
|
||||||
import com.subtracks.data.download.looksLikeAudio
|
import com.subtracks.data.download.looksLikeAudio
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.DownloadError
|
import com.subtracks.data.model.DownloadError
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
import com.subtracks.data.model.DownloadStatus
|
import com.subtracks.data.model.DownloadStatus
|
||||||
import com.subtracks.data.model.DownloadedSong
|
import com.subtracks.data.model.DownloadedSong
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
|
import com.subtracks.data.repo.QueueResetPoint
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -43,6 +49,20 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
private fun SongDownload.sameResetRelevantState(other: SongDownload): Boolean =
|
||||||
|
sourceId == other.sourceId && songId == other.songId && status == other.status && engineId == other.engineId &&
|
||||||
|
total == other.total && error == other.error && downloadedAt == other.downloadedAt
|
||||||
|
|
||||||
|
private fun sameResetRelevantEmission(
|
||||||
|
previous: Pair<Long?, Map<String, SongDownload>>?,
|
||||||
|
sourceId: Long?,
|
||||||
|
rows: Map<String, SongDownload>,
|
||||||
|
): Boolean {
|
||||||
|
if (previous == null || previous.first != sourceId || previous.second.size != rows.size) return false
|
||||||
|
return previous.second.all { (songId, row) -> rows[songId]?.let { row.sameResetRelevantState(it) } == true }
|
||||||
|
}
|
||||||
|
|
||||||
class DownloadRepository(
|
class DownloadRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
@@ -51,6 +71,7 @@ class DownloadRepository(
|
|||||||
private val downloadsDir: File,
|
private val downloadsDir: File,
|
||||||
private val artworkStore: ArtworkStore,
|
private val artworkStore: ArtworkStore,
|
||||||
private val artworkFetcher: ArtworkFetcher,
|
private val artworkFetcher: ArtworkFetcher,
|
||||||
|
private val queueRepository: QueueRepository,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher),
|
||||||
@@ -63,6 +84,12 @@ class DownloadRepository(
|
|||||||
private var started = false
|
private var started = false
|
||||||
|
|
||||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
||||||
|
private val completedBySource = mutableMapOf<Long, List<Pair<String, Long>>>()
|
||||||
|
private val completedRowsBySource = mutableMapOf<Long, Map<String, Long>>()
|
||||||
|
private val completedBaselineMutex = Mutex()
|
||||||
|
private val pendingResetPoints = mutableMapOf<Long, QueueResetPoint>()
|
||||||
|
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
||||||
|
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (started) return
|
if (started) return
|
||||||
@@ -70,9 +97,66 @@ class DownloadRepository(
|
|||||||
downloadsDir.mkdirs()
|
downloadsDir.mkdirs()
|
||||||
File(downloadsDir, NO_MEDIA).createNewFile()
|
File(downloadsDir, NO_MEDIA).createNewFile()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
downloadStates().collect { rows ->
|
var resetPoint: QueueResetPoint? = null
|
||||||
statesFlow.value = rows
|
var reconciled: Pair<Long?, Map<String, SongDownload>>? = null
|
||||||
loaded.complete(Unit)
|
while (true) {
|
||||||
|
try {
|
||||||
|
if (resetPoint == null) resetPoint = queueRepository.captureResetPoint()
|
||||||
|
downloadStates().collect { (sourceId, emittedRows) ->
|
||||||
|
statesFlow.value = emittedRows
|
||||||
|
val unchanged = sameResetRelevantEmission(reconciled, sourceId, emittedRows)
|
||||||
|
val pending =
|
||||||
|
unchanged && sourceId != null &&
|
||||||
|
completedBaselineMutex.withLock { sourceId in pendingResetPoints }
|
||||||
|
if (unchanged && !pending) {
|
||||||
|
loaded.complete(Unit)
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
var rows = emittedRows
|
||||||
|
if (sourceId != null) {
|
||||||
|
mutex.withLock {
|
||||||
|
rows = db.downloadDao().downloadsOnce(sourceId).associateBy { it.songId }
|
||||||
|
statesFlow.value = rows
|
||||||
|
val completed = completedDownloads(sourceId)
|
||||||
|
val completedRows = completedDownloadKeys(rows)
|
||||||
|
completedBaselineMutex.withLock {
|
||||||
|
val previous = completedBySource[sourceId]
|
||||||
|
val previousRows = completedRowsBySource[sourceId]
|
||||||
|
val pending = pendingResetPoints[sourceId]
|
||||||
|
when {
|
||||||
|
pending != null -> {
|
||||||
|
if (previous == null || completed != previous) {
|
||||||
|
queueRepository.resetChangedOrigins(
|
||||||
|
setOf(QueueOrigin(QueueKind.Downloaded, sourceId, "")),
|
||||||
|
pending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pendingResetPoints.remove(sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
previous != null && completed != previous && completedRows != previousRows -> {
|
||||||
|
queueRepository.resetChangedOrigins(
|
||||||
|
setOf(QueueOrigin(QueueKind.Downloaded, sourceId, "")),
|
||||||
|
resetPoint!!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completedBySource[sourceId] = completed
|
||||||
|
completedRowsBySource[sourceId] = completedRows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetPoint = queueRepository.captureResetPoint()
|
||||||
|
reconciled = sourceId to rows
|
||||||
|
loaded.complete(Unit)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Log.w("download", "state reconciliation failed; retrying", failure)
|
||||||
|
loaded.complete(Unit)
|
||||||
|
delay(POLL_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -90,6 +174,47 @@ class DownloadRepository(
|
|||||||
|
|
||||||
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
|
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
|
||||||
|
|
||||||
|
internal suspend fun captureForReset(sourceId: Long) =
|
||||||
|
completedBaselineMutex.withLock {
|
||||||
|
if (sourceId !in pendingResetPoints) pendingResetPoints[sourceId] = queueRepository.captureResetPoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun captureForReset(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: Collection<String>,
|
||||||
|
) {
|
||||||
|
if (songIds.isEmpty()) return
|
||||||
|
val queueVisible =
|
||||||
|
songIds.asSequence().chunked(DELETE_CHUNK).any { ids ->
|
||||||
|
db.downloadDao().queueVisibleSongIds(sourceId, ids).isNotEmpty()
|
||||||
|
}
|
||||||
|
if (queueVisible) {
|
||||||
|
captureForReset(sourceId)
|
||||||
|
} else {
|
||||||
|
completedBaselineMutex.withLock {
|
||||||
|
if (sourceId !in pendingResetPoints) completedBySource[sourceId] = completedDownloads(sourceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureCompletedBaselines(rows: List<SongDownload>) =
|
||||||
|
completedBaselineMutex.withLock {
|
||||||
|
rows.groupBy { it.sourceId }.forEach { (sourceId, sourceRows) ->
|
||||||
|
if (sourceId !in completedBySource && sourceId !in pendingResetPoints) {
|
||||||
|
completedBySource[sourceId] = completedDownloads(sourceId)
|
||||||
|
completedRowsBySource[sourceId] = completedDownloadKeys(sourceRows.associateBy { it.songId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun completedDownloads(sourceId: Long): List<Pair<String, Long>> =
|
||||||
|
db.downloadDao().completedDownloads(sourceId).map { it.songId to it.downloadedAt }
|
||||||
|
|
||||||
|
private fun completedDownloadKeys(rows: Map<String, SongDownload>): Map<String, Long> =
|
||||||
|
rows.values
|
||||||
|
.filter { it.status == DownloadStatus.Completed }
|
||||||
|
.associate { it.songId to it.downloadedAt }
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
fun activeDownloads(): Flow<List<SongDownload>> =
|
fun activeDownloads(): Flow<List<SongDownload>> =
|
||||||
sourceRepository.activeSourceId().flatMapLatest { sourceId ->
|
sourceRepository.activeSourceId().flatMapLatest { sourceId ->
|
||||||
@@ -201,6 +326,16 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun remove(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: Collection<String>,
|
||||||
|
) = withContext(dispatcher) {
|
||||||
|
val ids = songIds.toHashSet()
|
||||||
|
mutex.withLock {
|
||||||
|
removeRows(db.downloadDao().all().filter { it.sourceId == sourceId && it.songId in ids })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun cancel(
|
suspend fun cancel(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songIds: Collection<String>,
|
songIds: Collection<String>,
|
||||||
@@ -345,8 +480,12 @@ class DownloadRepository(
|
|||||||
val engineIds = rows.mapNotNull { it.engineId }
|
val engineIds = rows.mapNotNull { it.engineId }
|
||||||
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
||||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||||
|
captureForReset(sourceId, group.filter { it.status == DownloadStatus.Completed }.map { it.songId })
|
||||||
group.forEach { file(sourceId, it.songId).delete() }
|
group.forEach { file(sourceId, it.songId).delete() }
|
||||||
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
||||||
|
if (group.any { it.status == DownloadStatus.Completed }) {
|
||||||
|
refreshDownloadedArtistArt(sourceId, group.map { it.songId })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sweep(db.downloadDao().all())
|
sweep(db.downloadDao().all())
|
||||||
}
|
}
|
||||||
@@ -362,13 +501,11 @@ class DownloadRepository(
|
|||||||
suspend fun removeSource(sourceId: Long) =
|
suspend fun removeSource(sourceId: Long) =
|
||||||
withContext(dispatcher) {
|
withContext(dispatcher) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
db
|
val rows = db.downloadDao().all().filter { it.sourceId == sourceId }
|
||||||
.downloadDao()
|
captureForReset(sourceId, rows.filter { it.status == DownloadStatus.Completed }.map { it.songId })
|
||||||
.all()
|
rows.mapNotNull { it.engineId }.let(engine::cancel)
|
||||||
.filter { it.sourceId == sourceId }
|
|
||||||
.mapNotNull { it.engineId }
|
|
||||||
.let(engine::cancel)
|
|
||||||
db.downloadDao().deleteSource(sourceId)
|
db.downloadDao().deleteSource(sourceId)
|
||||||
|
db.libraryDao().clearDownloadedArtistArt(sourceId)
|
||||||
dir(sourceId).deleteRecursively()
|
dir(sourceId).deleteRecursively()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,6 +515,7 @@ class DownloadRepository(
|
|||||||
val promoted =
|
val promoted =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val rows = db.downloadDao().all()
|
val rows = db.downloadDao().all()
|
||||||
|
ensureCompletedBaselines(rows)
|
||||||
reconcileRows(rows)
|
reconcileRows(rows)
|
||||||
sweep(rows)
|
sweep(rows)
|
||||||
ensurePolling()
|
ensurePolling()
|
||||||
@@ -387,11 +525,16 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun downloadStates(): Flow<Map<String, SongDownload>> =
|
private fun downloadStates(): Flow<Pair<Long?, Map<String, SongDownload>>> =
|
||||||
sourceRepository
|
sourceRepository
|
||||||
.activeSourceId()
|
.activeSourceId()
|
||||||
.flatMapLatest { sourceId -> if (sourceId == null) flowOf(emptyList()) else db.downloadDao().downloads(sourceId) }
|
.flatMapLatest { sourceId ->
|
||||||
.map { rows -> rows.associateBy { it.songId } }
|
if (sourceId == null) {
|
||||||
|
flowOf(null to emptyMap())
|
||||||
|
} else {
|
||||||
|
db.downloadDao().downloads(sourceId).map { rows -> sourceId to rows.associateBy { it.songId } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun alreadyDownloadingOrDownloaded(
|
private suspend fun alreadyDownloadingOrDownloaded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -406,14 +549,18 @@ class DownloadRepository(
|
|||||||
private suspend fun fetchArtwork(
|
private suspend fun fetchArtwork(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
|
artistIds: MutableSet<String>,
|
||||||
) {
|
) {
|
||||||
val song = db.libraryDao().songOnce(sourceId, songId)
|
val song = db.libraryDao().songOnce(sourceId, songId)
|
||||||
|
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
|
||||||
|
val 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 =
|
val coverArt =
|
||||||
(
|
(
|
||||||
listOfNotNull(
|
listOfNotNull(album?.coverArt) +
|
||||||
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt },
|
db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||||
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
|
|
||||||
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
|
|
||||||
).distinct()
|
).distinct()
|
||||||
coverArt.forEach { cover ->
|
coverArt.forEach { cover ->
|
||||||
storeArtwork(sourceId, cover, thumbnail = false)
|
storeArtwork(sourceId, cover, thumbnail = false)
|
||||||
@@ -421,6 +568,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(
|
private suspend fun storeArtwork(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
coverArt: String,
|
coverArt: String,
|
||||||
@@ -455,11 +663,15 @@ class DownloadRepository(
|
|||||||
.mapValues { (sourceId, rows) ->
|
.mapValues { (sourceId, rows) ->
|
||||||
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
||||||
val covers =
|
val covers =
|
||||||
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
|
rows.flatMap { row ->
|
||||||
playlistCovers[sourceId].orEmpty()
|
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
|
||||||
covers
|
} + playlistCovers[sourceId].orEmpty()
|
||||||
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
|
||||||
.toSet()
|
(
|
||||||
|
covers.flatMap { cover ->
|
||||||
|
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
|
||||||
|
} + artistImages.map { artistImageKey(sourceId, it) }
|
||||||
|
).toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,9 +718,11 @@ class DownloadRepository(
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val row = db.downloadDao().find(sourceId, songId) ?: return false
|
val row = db.downloadDao().find(sourceId, songId) ?: return false
|
||||||
|
if (row.status == DownloadStatus.Completed) captureForReset(sourceId, listOf(songId))
|
||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(sourceId, songId).delete()
|
file(sourceId, songId).delete()
|
||||||
db.downloadDao().delete(sourceId, songId)
|
db.downloadDao().delete(sourceId, songId)
|
||||||
|
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,11 +751,12 @@ class DownloadRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songIds: List<String>,
|
songIds: List<String>,
|
||||||
) {
|
) {
|
||||||
|
val artistIds = mutableSetOf<String>()
|
||||||
try {
|
try {
|
||||||
withTimeoutOrNull(ART_TIMEOUT_MS) {
|
withTimeoutOrNull(ART_TIMEOUT_MS) {
|
||||||
songIds.forEach { songId ->
|
songIds.forEach { songId ->
|
||||||
// A cancel between the queue and this pass takes the row away; nothing to warm.
|
// 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) {
|
} catch (e: CancellationException) {
|
||||||
@@ -549,6 +764,7 @@ class DownloadRepository(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Artwork is best effort and must never disturb a download that is already running.
|
// Artwork is best effort and must never disturb a download that is already running.
|
||||||
}
|
}
|
||||||
|
fetchArtistBiosSafely(sourceId, artistIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun apply(
|
private suspend fun apply(
|
||||||
@@ -586,7 +802,15 @@ class DownloadRepository(
|
|||||||
row.downloadedAt
|
row.downloadedAt
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
val completedBefore = row.takeIf { it.status == DownloadStatus.Completed }?.let { it.songId to it.downloadedAt }
|
||||||
|
val completedAfter = updated.takeIf { it.status == DownloadStatus.Completed }?.let { it.songId to it.downloadedAt }
|
||||||
|
if (completedBefore != completedAfter) captureForReset(row.sourceId, listOf(row.songId))
|
||||||
if (updated != row) db.downloadDao().upsert(updated)
|
if (updated != row) db.downloadDao().upsert(updated)
|
||||||
|
if (updated.status != row.status &&
|
||||||
|
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
|
||||||
|
) {
|
||||||
|
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||||
|
}
|
||||||
if (updated.status != row.status) {
|
if (updated.status != row.status) {
|
||||||
when (updated.status) {
|
when (updated.status) {
|
||||||
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
||||||
@@ -601,12 +825,26 @@ class DownloadRepository(
|
|||||||
row: SongDownload,
|
row: SongDownload,
|
||||||
error: DownloadError,
|
error: DownloadError,
|
||||||
) {
|
) {
|
||||||
|
if (row.status == DownloadStatus.Completed) captureForReset(row.sourceId, listOf(row.songId))
|
||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(row.sourceId, row.songId).delete()
|
file(row.sourceId, row.songId).delete()
|
||||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||||
|
if (row.status == DownloadStatus.Completed) {
|
||||||
|
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||||
|
}
|
||||||
Log.w("download", "failed ${row.songId}: $error")
|
Log.w("download", "failed ${row.songId}: $error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshDownloadedArtistArt(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: Collection<String>,
|
||||||
|
) {
|
||||||
|
songIds.chunked(500).forEach { songs ->
|
||||||
|
val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct()
|
||||||
|
artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun ensurePolling() {
|
private fun ensurePolling() {
|
||||||
if (pollJob?.isActive == true) return
|
if (pollJob?.isActive == true) return
|
||||||
pollJob =
|
pollJob =
|
||||||
@@ -673,5 +911,6 @@ class DownloadRepository(
|
|||||||
const val IN_FLIGHT_LIMIT = 8
|
const val IN_FLIGHT_LIMIT = 8
|
||||||
const val DELETE_CHUNK = 900
|
const val DELETE_CHUNK = 900
|
||||||
const val ART_TIMEOUT_MS = 60_000L
|
const val ART_TIMEOUT_MS = 60_000L
|
||||||
|
const val BIO_TIMEOUT_MS = 30_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,40 +7,52 @@ import androidx.paging.PagingSource
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.db.albumsDefaultQuery
|
import com.subtracks.data.db.albumsPageQuery
|
||||||
import com.subtracks.data.db.artistsDefaultQuery
|
import com.subtracks.data.db.artistsPageQuery
|
||||||
import com.subtracks.data.db.playlistsDefaultQuery
|
import com.subtracks.data.db.playlistsPageQuery
|
||||||
import com.subtracks.data.db.rediscoverAlbumsQuery
|
import com.subtracks.data.db.rediscoverAlbumsQuery
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.Artist
|
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.Disc
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.prefs.AlbumSort
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LibraryRepository(
|
class LibraryRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val userPreferences: UserPreferences,
|
||||||
|
private val queueRepository: QueueRepository,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
) {
|
) {
|
||||||
@@ -107,12 +119,18 @@ class LibraryRepository(
|
|||||||
fun searchAlbums(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Album>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Artist>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchPlaylists(
|
fun searchPlaylists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -122,7 +140,10 @@ class LibraryRepository(
|
|||||||
fun searchSongs(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<AlbumSongItem>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||||
|
|
||||||
@@ -174,69 +195,20 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Album>> =
|
): Flow<PagingData<Album>> =
|
||||||
pager(40, enablePlaceholders = true) {
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
val dao = db.libraryDao()
|
pager(40, enablePlaceholders = true) {
|
||||||
if (!downloaded) {
|
val dao = db.libraryDao()
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
val query =
|
||||||
} else {
|
albumsPageQuery(
|
||||||
val downloadedFilter = 1
|
sourceId = sourceId,
|
||||||
when (sort) {
|
starredFilter = starred.ordinal,
|
||||||
AlbumSort.Name -> {
|
search = search,
|
||||||
if (descending) {
|
sort = sort,
|
||||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
descending = descending,
|
||||||
} else {
|
downloaded = downloaded,
|
||||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
sortNameMode = mode,
|
||||||
}
|
)
|
||||||
}
|
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,53 +220,20 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Artist>> =
|
): Flow<PagingData<Artist>> =
|
||||||
pager(60) {
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
val dao = db.libraryDao()
|
pager(60) {
|
||||||
if (!downloaded) {
|
val dao = db.libraryDao()
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
val query =
|
||||||
} else {
|
artistsPageQuery(
|
||||||
val downloadedFilter = 1
|
sourceId = sourceId,
|
||||||
when (sort) {
|
starredFilter = starred.ordinal,
|
||||||
ArtistSort.Name -> {
|
search = search,
|
||||||
if (descending) {
|
sort = sort,
|
||||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
descending = descending,
|
||||||
} else {
|
downloaded = downloaded,
|
||||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
sortNameMode = mode,
|
||||||
}
|
)
|
||||||
}
|
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,36 +246,15 @@ class LibraryRepository(
|
|||||||
): Flow<PagingData<Playlist>> =
|
): Flow<PagingData<Playlist>> =
|
||||||
pager(40) {
|
pager(40) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
|
playlistsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
search = search,
|
||||||
when (sort) {
|
sort = sort,
|
||||||
PlaylistSort.Name -> {
|
descending = descending,
|
||||||
if (descending) {
|
downloaded = downloaded,
|
||||||
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
|
)
|
||||||
} else {
|
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun album(
|
fun album(
|
||||||
@@ -354,6 +272,36 @@ class LibraryRepository(
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
|
): 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(
|
fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
@@ -376,12 +324,24 @@ class LibraryRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
downloaded: Boolean = false,
|
downloaded: Boolean = false,
|
||||||
): Flow<List<Album>> =
|
): Flow<ArtistAlbums> {
|
||||||
if (downloaded) {
|
val albums =
|
||||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
if (downloaded) {
|
||||||
} else {
|
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||||
db.libraryDao().albumsForArtist(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(
|
fun albumDiscs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -403,10 +363,7 @@ class LibraryRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Long {
|
): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
|
||||||
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
|
|
||||||
return if (index < 0) 0L else index.toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playlist(
|
fun playlist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -498,10 +455,19 @@ class LibraryRepository(
|
|||||||
starred: Long?,
|
starred: Long?,
|
||||||
) {
|
) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
when (type) {
|
if (type == StarType.Song) {
|
||||||
StarType.Song -> dao.setSongStar(sourceId, id, starred)
|
val previous = song(sourceId, id).first()?.starred
|
||||||
StarType.Album -> dao.setAlbumStar(sourceId, id, starred)
|
if (previous == starred) return
|
||||||
StarType.Artist -> dao.setArtistStar(sourceId, id, starred)
|
val point = queueRepository.captureResetPoint()
|
||||||
|
if (dao.setSongStar(sourceId, id, starred) > 0) {
|
||||||
|
queueRepository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Starred, sourceId, "")), point)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (type) {
|
||||||
|
StarType.Album -> dao.setAlbumStar(sourceId, id, starred)
|
||||||
|
StarType.Artist -> dao.setArtistStar(sourceId, id, starred)
|
||||||
|
StarType.Song -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,4 +486,12 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
|||||||
|
|
||||||
const val SEARCH_RESULT_LIMIT = 50
|
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
|
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
|
|||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.model.SubsonicConfig
|
import com.subtracks.data.model.SubsonicConfig
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
@@ -69,6 +70,9 @@ class SourceRepository(
|
|||||||
private val _offline = MutableStateFlow(false)
|
private val _offline = MutableStateFlow(false)
|
||||||
val offline: StateFlow<Boolean> = _offline
|
val offline: StateFlow<Boolean> = _offline
|
||||||
|
|
||||||
|
private val _activeSource = MutableStateFlow<Long?>(null)
|
||||||
|
val activeSource: StateFlow<Long?> = _activeSource
|
||||||
|
|
||||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
@@ -109,6 +113,7 @@ class SourceRepository(
|
|||||||
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
||||||
}
|
}
|
||||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||||
|
_activeSource.value = config?.id
|
||||||
_quality.value = quality
|
_quality.value = quality
|
||||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -143,6 +148,18 @@ class SourceRepository(
|
|||||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun artistImage(
|
||||||
|
artistId: String,
|
||||||
|
url: String?,
|
||||||
|
): CoverArtRef? {
|
||||||
|
val current = active ?: return null
|
||||||
|
if (url.isNullOrBlank()) return null
|
||||||
|
val cacheKey = artistImageKey(current.id, artistId)
|
||||||
|
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
||||||
|
if (_offline.value) return null
|
||||||
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
fun networkCoverArt(
|
fun networkCoverArt(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
coverArt: String,
|
coverArt: String,
|
||||||
@@ -261,6 +278,15 @@ class SourceRepository(
|
|||||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
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 =
|
private fun SubsonicConfig.toClient(): SubsonicClient =
|
||||||
client(
|
client(
|
||||||
address = address,
|
address = address,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface MusicSource {
|
|||||||
time: Long? = null,
|
time: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun artistBiography(artistId: String): String? = null
|
||||||
|
|
||||||
fun artists(): Flow<List<Artist>>
|
fun artists(): Flow<List<Artist>>
|
||||||
|
|
||||||
fun albums(): Flow<List<Album>>
|
fun albums(): Flow<List<Album>>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
open class SubsonicException(
|
open class SubsonicException(
|
||||||
val code: Int,
|
val code: Int,
|
||||||
@@ -55,11 +56,12 @@ class SubsonicClient(
|
|||||||
fun <T> stream(
|
fun <T> stream(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
|
timeoutMs: Long? = null,
|
||||||
body: (InputStream) -> T,
|
body: (InputStream) -> T,
|
||||||
): T {
|
): T {
|
||||||
val usedTokenAuth = useTokenAuth
|
val usedTokenAuth = useTokenAuth
|
||||||
return try {
|
return try {
|
||||||
execute(method, params, body)
|
execute(method, params, timeoutMs, body)
|
||||||
} catch (failure: SubsonicException) {
|
} catch (failure: SubsonicException) {
|
||||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
||||||
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
|
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")
|
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
|
||||||
disableTokenAuth()
|
disableTokenAuth()
|
||||||
execute(method, params, body)
|
execute(method, params, timeoutMs, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> execute(
|
private fun <T> execute(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String>,
|
params: Map<String, String>,
|
||||||
|
timeoutMs: Long?,
|
||||||
body: (InputStream) -> T,
|
body: (InputStream) -> T,
|
||||||
): T {
|
): T {
|
||||||
val request =
|
val request =
|
||||||
@@ -81,7 +84,9 @@ class SubsonicClient(
|
|||||||
.Builder()
|
.Builder()
|
||||||
.url(uri(method, params))
|
.url(uri(method, params))
|
||||||
.build()
|
.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) {
|
if (!response.isSuccessful) {
|
||||||
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
|
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>> =
|
override fun artists(): Flow<List<Artist>> =
|
||||||
entityBatches(
|
entityBatches(
|
||||||
method = "getArtists",
|
method = "getArtists",
|
||||||
@@ -365,6 +372,7 @@ class SubsonicSource(
|
|||||||
const val PAGE_SIZE = 500
|
const val PAGE_SIZE = 500
|
||||||
const val MAX_PAGES = 20_000
|
const val MAX_PAGES = 20_000
|
||||||
const val THUMBNAIL_SIZE = 384
|
const val THUMBNAIL_SIZE = 384
|
||||||
|
const val ARTIST_BIO_TIMEOUT_MS = 15_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ object SubsonicXml {
|
|||||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||||
starred = attrs.dateAttr("starred"),
|
starred = attrs.dateAttr("starred"),
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
|
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
|
||||||
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,6 +129,8 @@ object SubsonicXml {
|
|||||||
created = attrs.dateAttr("created") ?: 0L,
|
created = attrs.dateAttr("created") ?: 0L,
|
||||||
changed = attrs.dateAttr("changed") ?: 0L,
|
changed = attrs.dateAttr("changed") ?: 0L,
|
||||||
duration = attrs.longAttr("duration") ?: 0L,
|
duration = attrs.longAttr("duration") ?: 0L,
|
||||||
|
owner = attrs.attr("owner").ifEmpty { null },
|
||||||
|
shared = attrs.boolAttr("public"),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun playlistSong(
|
internal fun playlistSong(
|
||||||
@@ -165,6 +168,12 @@ object SubsonicXml {
|
|||||||
parse(input, handler)
|
parse(input, handler)
|
||||||
return handler.found
|
return handler.found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun artistBiography(input: InputStream): String? {
|
||||||
|
val handler = ArtistBiographyHandler()
|
||||||
|
parse(input, handler)
|
||||||
|
return handler.biography
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class AlbumDraft(
|
internal class AlbumDraft(
|
||||||
@@ -358,6 +367,8 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
|||||||
|
|
||||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||||
|
|
||||||
|
internal fun Attributes.boolAttr(name: String): Boolean = attr(name).let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||||
|
|
||||||
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
||||||
|
|
||||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||||
@@ -421,6 +432,40 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
|||||||
) = Unit
|
) = 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 class EntityHandler<T>(
|
||||||
private val entityTag: String,
|
private val entityTag: String,
|
||||||
private val create: (Attributes) -> T,
|
private val create: (Attributes) -> T,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.subtracks.data.sync
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
@@ -86,13 +87,18 @@ class SyncManager(
|
|||||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||||
Log.i("sync", "started (source ${source.id})")
|
Log.i("sync", "started (source ${source.id})")
|
||||||
serverActions.flush()
|
serverActions.flush()
|
||||||
val summary = SyncService(db, source).sync()
|
val resetPoint = queueRepository.captureResetPoint()
|
||||||
|
val service = SyncService(db, source)
|
||||||
|
val completed = service.sync()
|
||||||
|
val pending = db.queueDao().pendingSyncOrigins(source.id).map { QueueOrigin(it.kind, it.sourceId, it.refId) }
|
||||||
|
val changedOrigins = pending.toSet() + completed.changedOrigins
|
||||||
Log.i(
|
Log.i(
|
||||||
"sync",
|
"sync",
|
||||||
"finished in ${elapsedMs(startedAt)}ms: " +
|
"finished in ${elapsedMs(startedAt)}ms: " +
|
||||||
"${summary.artists} artists, ${summary.albums} albums, ${summary.songs} songs, " +
|
"${completed.artists} artists, ${completed.albums} albums, ${completed.songs} songs, " +
|
||||||
"${summary.playlists} playlists, ${summary.playlistSongs} playlist tracks, ${summary.pruned} pruned",
|
"${completed.playlists} playlists, ${completed.playlistSongs} playlist tracks, ${completed.pruned} pruned",
|
||||||
)
|
)
|
||||||
|
queueRepository.resetChangedOriginsAndClearPendingOrigins(changedOrigins, resetPoint, source.id)
|
||||||
SyncStatus.Success
|
SyncStatus.Success
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
@@ -112,7 +118,6 @@ class SyncManager(
|
|||||||
if (!silent) showMessage(message)
|
if (!silent) showMessage(message)
|
||||||
SyncStatus.Failed(message)
|
SyncStatus.Failed(message)
|
||||||
}
|
}
|
||||||
queueRepository.invalidateLibraryCache()
|
|
||||||
_status.value = result
|
_status.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import com.subtracks.data.model.Disc
|
|||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongArtistRef
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
@@ -30,24 +32,39 @@ class SyncService(
|
|||||||
private var playlistsSynced = 0
|
private var playlistsSynced = 0
|
||||||
private var playlistSongsSynced = 0
|
private var playlistSongsSynced = 0
|
||||||
private var pruned = 0
|
private var pruned = 0
|
||||||
|
private val committedOrigins = mutableSetOf<QueueOrigin>()
|
||||||
|
private var transactionOrigins: MutableSet<QueueOrigin>? = null
|
||||||
|
private val changedOrigins =
|
||||||
|
object : AbstractMutableSet<QueueOrigin>() {
|
||||||
|
override val size: Int get() = committedOrigins.size
|
||||||
|
|
||||||
private var creditsChanged = false
|
override fun iterator(): MutableIterator<QueueOrigin> = committedOrigins.iterator()
|
||||||
|
|
||||||
|
override fun add(element: QueueOrigin): Boolean = (transactionOrigins ?: committedOrigins).add(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> commitOrigins(block: suspend () -> T): T {
|
||||||
|
check(transactionOrigins == null)
|
||||||
|
val pending = mutableSetOf<QueueOrigin>()
|
||||||
|
transactionOrigins = pending
|
||||||
|
return try {
|
||||||
|
block().also { committedOrigins.addAll(pending) }
|
||||||
|
} finally {
|
||||||
|
transactionOrigins = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun sync(): SyncSummary {
|
suspend fun sync(): SyncSummary {
|
||||||
val sources = db.sourcesDao()
|
val sources = db.sourcesDao()
|
||||||
val wasStale = sources.creditsStale(source.id) ?: true
|
val wasStale = sources.creditsStale(source.id) ?: true
|
||||||
creditsChanged = false
|
val artistsChanged = syncArtists()
|
||||||
val artistsBefore = dbTotalChanges()
|
val albumsChanged = syncAlbums()
|
||||||
syncArtists()
|
val songsChanged = syncSongs()
|
||||||
val artistsChanged = dbTotalChanges() != artistsBefore
|
|
||||||
syncAlbums()
|
|
||||||
syncSongs()
|
|
||||||
optimize()
|
optimize()
|
||||||
if (creditsChanged || artistsChanged || wasStale) {
|
if (artistsChanged || albumsChanged || songsChanged || wasStale) {
|
||||||
recomputeArtistSongCounts()
|
recomputeAggregates()
|
||||||
sources.setCreditsStale(source.id, false)
|
sources.setCreditsStale(source.id, false)
|
||||||
}
|
}
|
||||||
aggregatePlayData()
|
|
||||||
syncPlaylists()
|
syncPlaylists()
|
||||||
syncPlaylistSongs()
|
syncPlaylistSongs()
|
||||||
return SyncSummary(
|
return SyncSummary(
|
||||||
@@ -57,32 +74,55 @@ class SyncService(
|
|||||||
playlists = playlistsSynced,
|
playlists = playlistsSynced,
|
||||||
playlistSongs = playlistSongsSynced,
|
playlistSongs = playlistSongsSynced,
|
||||||
pruned = pruned,
|
pruned = pruned,
|
||||||
|
changedOrigins = changedOrigins.toSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncArtists() {
|
private suspend fun syncArtists(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.artists().collect { batch ->
|
source.artists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
changed =
|
||||||
|
write(
|
||||||
|
"artists",
|
||||||
|
primaryKey,
|
||||||
|
batch,
|
||||||
|
preserved = setOf("albumCount"),
|
||||||
|
markStale = true,
|
||||||
|
onChanged = { changedOrigins += origin(QueueKind.Artist, it.id) },
|
||||||
|
) { it.columns() } ||
|
||||||
|
changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
artistsSynced += batch.size
|
artistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
val prunedBefore = pruned
|
||||||
|
pruned +=
|
||||||
|
pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, {
|
||||||
|
it.forEach { id -> changedOrigins += origin(QueueKind.Artist, id) }
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
library.deleteArtists(source.id, it)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncAlbums() {
|
private suspend fun syncAlbums(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seenAlbums = HashedIds()
|
val seenAlbums = HashedIds()
|
||||||
val seenDiscs = HashedIds()
|
val seenDiscs = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.albums().collect { batch ->
|
source.albums().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
val discs =
|
val discs =
|
||||||
batch.flatMap { album ->
|
batch.flatMap { album ->
|
||||||
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
|
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)) }
|
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||||
albumsSynced += batch.size
|
albumsSynced += batch.size
|
||||||
@@ -92,22 +132,34 @@ class SyncService(
|
|||||||
pruneStaleIds(
|
pruneStaleIds(
|
||||||
seenAlbums,
|
seenAlbums,
|
||||||
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
|
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
{ library.deleteAlbums(source.id, it) },
|
{
|
||||||
|
it.forEach { id -> changedOrigins += origin(QueueKind.Album, id) }
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
val artistIds = connection.albumArtistIds(it.toSet()).values.flatten()
|
||||||
|
val trackArtistIds = connection.songArtistIds(it.toSet()).values.flatten()
|
||||||
|
changedOrigins += (artistIds + trackArtistIds).map { id -> origin(QueueKind.Artist, id) }
|
||||||
|
library.deleteAlbums(source.id, it)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
)
|
)
|
||||||
if (pruned > prunedBefore) creditsChanged = true
|
|
||||||
pruned +=
|
pruned +=
|
||||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||||
deleteStaleDiscs(it)
|
deleteStaleDiscs(it)
|
||||||
}
|
}
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncSongs() {
|
private suspend fun syncSongs(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.songs().collect { batch ->
|
source.songs().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
writeSongs(batch)
|
changed = writeSongs(batch) || changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
songsSynced += batch.size
|
songsSynced += batch.size
|
||||||
}
|
}
|
||||||
@@ -116,13 +168,15 @@ class SyncService(
|
|||||||
pruneStaleIds(
|
pruneStaleIds(
|
||||||
seen,
|
seen,
|
||||||
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
|
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
{ library.deleteSongs(source.id, it) },
|
{
|
||||||
|
deleteSongsAndRecordOrigins(it)
|
||||||
|
},
|
||||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
)
|
)
|
||||||
if (pruned > prunedBefore) creditsChanged = true
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeSongs(batch: List<Song>) {
|
private suspend fun writeSongs(batch: List<Song>): Boolean {
|
||||||
val names = batch.associate { it.id to it.genreNames() }
|
val names = batch.associate { it.id to it.genreNames() }
|
||||||
val genres =
|
val genres =
|
||||||
batch.flatMap { song ->
|
batch.flatMap { song ->
|
||||||
@@ -132,50 +186,207 @@ class SyncService(
|
|||||||
batch.flatMap { song ->
|
batch.flatMap { song ->
|
||||||
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
||||||
}
|
}
|
||||||
db.useWriterConnection { connection ->
|
return commitOrigins {
|
||||||
connection.immediateTransaction {
|
db.useWriterConnection { connection ->
|
||||||
val albumChanged = connection.albumIdsChanged(batch)
|
connection.immediateTransaction {
|
||||||
if (albumChanged) creditsChanged = true
|
val before = connection.totalChanges()
|
||||||
val resolved = connection.resolveSongSortNames(batch)
|
val previousStates = connection.storedSongSyncStates(batch.map { it.id })
|
||||||
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
val albumArtists = connection.albumArtistIds(batch.mapNotNull { it.albumId }.toSet())
|
||||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
val resolved = connection.resolveSongSortNames(batch)
|
||||||
batch.forEach { song ->
|
val nextStates = resolved.associate { it.id to it.syncState(albumArtists) }
|
||||||
statement.bind(1, song.sourceId)
|
val downloadedIds = connection.completedDownloadIds(batch.map { it.id })
|
||||||
statement.bind(2, song.id)
|
val changedSongIds = mutableSetOf<String>()
|
||||||
statement.bind(3, names.getValue(song.id).size.toLong())
|
connection.upsertChanged("songs", primaryKey, resolved, onChanged = { changedSongIds += it.id }) { it.columns() }
|
||||||
statement.step()
|
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
statement.reset()
|
batch.forEach { song ->
|
||||||
statement.clearBindings()
|
statement.bind(1, song.sourceId)
|
||||||
|
statement.bind(2, song.id)
|
||||||
|
statement.bind(3, names.getValue(song.id).size.toLong())
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
statement.clearBindings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
connection.upsertChanged("song_genres", songGenreKey, genres) { it.columns() }
|
||||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
val artistRowsBefore = connection.totalChanges()
|
batch.forEach { song ->
|
||||||
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
statement.bind(1, song.sourceId)
|
||||||
batch.forEach { song ->
|
statement.bind(2, song.id)
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(3, song.artistPositions().size.toLong())
|
||||||
statement.bind(2, song.id)
|
statement.step()
|
||||||
statement.bind(3, song.artistPositions().size.toLong())
|
statement.reset()
|
||||||
statement.step()
|
statement.clearBindings()
|
||||||
statement.reset()
|
}
|
||||||
statement.clearBindings()
|
|
||||||
}
|
}
|
||||||
}
|
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
|
||||||
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
connection.recordPlaylistOrigins(batch.filter { previousStates[it.id] == null }.map { it.id })
|
||||||
val artistRowsChanged = connection.totalChanges() != artistRowsBefore
|
batch.forEach { song ->
|
||||||
if (albumChanged || artistRowsChanged) {
|
val previous = previousStates[song.id]
|
||||||
connection.markCreditsStale(batch.first().sourceId)
|
val next = nextStates.getValue(song.id)
|
||||||
creditsChanged = true
|
if (song.id in changedSongIds || previous != next) {
|
||||||
|
recordSongOrigins(previous, next)
|
||||||
|
}
|
||||||
|
if (song.id in downloadedIds && (previous == null || previous.sortName != next.sortName)) {
|
||||||
|
changedOrigins += origin(QueueKind.Downloaded, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val changed = connection.totalChanges() != before
|
||||||
|
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun aggregatePlayData() {
|
private suspend fun deleteSongsAndRecordOrigins(ids: Collection<String>) {
|
||||||
db.libraryDao().recomputePlayData(source.id)
|
ids.forEach { changedOrigins += origin(QueueKind.Song, it) }
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
val previousStates = connection.storedSongSyncStates(ids.toList())
|
||||||
|
connection.recordPlaylistOrigins(previousStates.keys)
|
||||||
|
previousStates.values.forEach { recordSongOrigins(it, null) }
|
||||||
|
if (connection.completedDownloadIds(ids.toList()).isNotEmpty()) changedOrigins += origin(QueueKind.Downloaded, "")
|
||||||
|
db.libraryDao().deleteSongs(source.id, ids)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun recomputeArtistSongCounts() {
|
private suspend fun PooledConnection.recordPlaylistOrigins(songIds: Collection<String>) {
|
||||||
db.libraryDao().recomputeArtistSongCounts(source.id)
|
readSourceIds(
|
||||||
|
songIds,
|
||||||
|
{ "SELECT playlistId FROM playlist_songs WHERE sourceId = ? AND songId IN ($it)" },
|
||||||
|
) { statement ->
|
||||||
|
changedOrigins += origin(QueueKind.Playlist, statement.getText(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.recordSongOrigins(
|
||||||
|
previous: SongSyncState?,
|
||||||
|
next: SongSyncState?,
|
||||||
|
) {
|
||||||
|
val states = listOfNotNull(previous, next)
|
||||||
|
states.mapTo(changedOrigins) { origin(QueueKind.Song, it.id) }
|
||||||
|
states.mapNotNullTo(changedOrigins) { it.albumId?.let { id -> origin(QueueKind.Album, id) } }
|
||||||
|
states.flatMapTo(changedOrigins) { state -> state.artistIds.map { origin(QueueKind.Artist, it) } }
|
||||||
|
states.flatMapTo(changedOrigins) { state -> state.genres.map { origin(QueueKind.Genre, it) } }
|
||||||
|
if (states.any { it.starred }) changedOrigins += origin(QueueKind.Starred, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.storedSongSyncStates(ids: List<String>): Map<String, SongSyncState> {
|
||||||
|
val states = mutableMapOf<String, MutableSongSyncState>()
|
||||||
|
readSourceIds(
|
||||||
|
ids,
|
||||||
|
{ "SELECT id, albumId, artistId, sortName, starred FROM songs WHERE sourceId = ? AND id IN ($it)" },
|
||||||
|
) { statement ->
|
||||||
|
val state = MutableSongSyncState()
|
||||||
|
state.albumId = if (statement.isNull(1)) null else statement.getText(1)
|
||||||
|
if (!statement.isNull(2)) state.artistIds += statement.getText(2)
|
||||||
|
state.sortName = statement.getText(3)
|
||||||
|
state.starred = !statement.isNull(4)
|
||||||
|
states[statement.getText(0)] = state
|
||||||
|
}
|
||||||
|
readSourceIds(ids, { "SELECT songId, artistId FROM song_artists WHERE sourceId = ? AND songId IN ($it)" }) { statement ->
|
||||||
|
states[statement.getText(0)]?.artistIds?.plusAssign(statement.getText(1))
|
||||||
|
}
|
||||||
|
readSourceIds(ids, { "SELECT songId, genre FROM song_genres WHERE sourceId = ? AND songId IN ($it)" }) { statement ->
|
||||||
|
states[statement.getText(0)]?.genres?.plusAssign(statement.getText(1))
|
||||||
|
}
|
||||||
|
val albumArtists = albumArtistIds(states.values.mapNotNull { it.albumId }.toSet())
|
||||||
|
return states.mapValues { (id, state) ->
|
||||||
|
SongSyncState(
|
||||||
|
id = id,
|
||||||
|
albumId = state.albumId,
|
||||||
|
artistIds = state.artistIds + state.albumId?.let { albumArtists[it].orEmpty() }.orEmpty(),
|
||||||
|
genres = state.genres,
|
||||||
|
starred = state.starred,
|
||||||
|
sortName = state.sortName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.completedDownloadIds(ids: List<String>): Set<String> {
|
||||||
|
val result = mutableSetOf<String>()
|
||||||
|
readSourceIds(
|
||||||
|
ids,
|
||||||
|
{ "SELECT songId FROM song_downloads WHERE sourceId = ? AND status = 'Completed' AND songId IN ($it)" },
|
||||||
|
) { statement ->
|
||||||
|
result += statement.getText(0)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.albumArtistIds(albumIds: Set<String>): Map<String, Set<String>> {
|
||||||
|
val artists = mutableMapOf<String, MutableSet<String>>()
|
||||||
|
readSourceIds(albumIds, { "SELECT id, artistId FROM albums WHERE sourceId = ? AND artistId IS NOT NULL AND id IN ($it)" }) {
|
||||||
|
artists.getOrPut(it.getText(0)) { mutableSetOf() } += it.getText(1)
|
||||||
|
}
|
||||||
|
readSourceIds(albumIds, { "SELECT albumId, artistId FROM album_artists WHERE sourceId = ? AND albumId IN ($it)" }) {
|
||||||
|
artists.getOrPut(it.getText(0)) { mutableSetOf() } += it.getText(1)
|
||||||
|
}
|
||||||
|
return artists
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.songArtistIds(albumIds: Set<String>): Map<String, Set<String>> {
|
||||||
|
val result = mutableMapOf<String, MutableSet<String>>()
|
||||||
|
readSourceIds(albumIds, {
|
||||||
|
"SELECT albumId, artistId FROM songs WHERE sourceId = ? AND artistId IS NOT NULL AND albumId IN ($it)"
|
||||||
|
}) { statement ->
|
||||||
|
result.getOrPut(statement.getText(0)) { mutableSetOf() } += statement.getText(1)
|
||||||
|
}
|
||||||
|
albumIds.chunked(SQL_VARIABLES).forEach { chunk ->
|
||||||
|
val placeholders = chunk.joinToString(",") { "?" }
|
||||||
|
usePrepared(
|
||||||
|
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"WHERE song_artists.sourceId = ? AND songs.albumId IN ($placeholders)",
|
||||||
|
) { statement ->
|
||||||
|
statement.bindLong(1, source.id)
|
||||||
|
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
|
||||||
|
while (statement.step()) result.getOrPut(statement.getText(0)) { mutableSetOf() } += statement.getText(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.readSourceIds(
|
||||||
|
ids: Collection<String>,
|
||||||
|
sql: (placeholders: String) -> String,
|
||||||
|
row: (SQLiteStatement) -> Unit,
|
||||||
|
) {
|
||||||
|
ids.chunked(SQL_VARIABLES).forEach { chunk ->
|
||||||
|
usePrepared(sql(chunk.joinToString(",") { "?" })) { statement ->
|
||||||
|
statement.bindLong(1, source.id)
|
||||||
|
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
|
||||||
|
while (statement.step()) row(statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun origin(
|
||||||
|
kind: QueueKind,
|
||||||
|
refId: String,
|
||||||
|
) = QueueOrigin(kind, source.id, refId)
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.persistPendingOrigins() {
|
||||||
|
val origins = transactionOrigins.orEmpty()
|
||||||
|
origins.chunked(SQL_VARIABLES).forEach { chunk ->
|
||||||
|
usePrepared("INSERT OR IGNORE INTO pending_sync_origins (sourceId, kind, refId) VALUES (?, ?, ?)") { statement ->
|
||||||
|
chunk.forEach { origin ->
|
||||||
|
statement.bindLong(1, origin.sourceId)
|
||||||
|
statement.bindText(2, origin.kind.name)
|
||||||
|
statement.bindText(3, origin.refId)
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
statement.clearBindings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun recomputeAggregates() {
|
||||||
|
db.libraryDao().recomputeAggregates(source.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun PooledConnection.totalChanges(): Long {
|
private suspend fun PooledConnection.totalChanges(): Long {
|
||||||
@@ -187,8 +398,6 @@ class SyncService(
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dbTotalChanges(): Long = db.useWriterConnection { it.totalChanges() }
|
|
||||||
|
|
||||||
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
|
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
|
||||||
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
|
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
|
||||||
statement.bindLong(1, sourceId)
|
statement.bindLong(1, sourceId)
|
||||||
@@ -196,28 +405,6 @@ class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun PooledConnection.albumIdsChanged(batch: List<Song>): Boolean {
|
|
||||||
val expected = batch.associate { it.id to it.albumId }.toMutableMap()
|
|
||||||
if (expected.isEmpty()) return false
|
|
||||||
var changed = false
|
|
||||||
batch
|
|
||||||
.map { it.sourceId to it.id }
|
|
||||||
.chunked(SQL_VARIABLES)
|
|
||||||
.forEach { chunk ->
|
|
||||||
val placeholders = chunk.joinToString(",") { "?" }
|
|
||||||
usePrepared("SELECT id, albumId FROM songs WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
|
|
||||||
statement.bindLong(1, chunk.first().first)
|
|
||||||
chunk.forEachIndexed { index, (_, id) -> statement.bindText(index + 2, id) }
|
|
||||||
while (statement.step()) {
|
|
||||||
val id = statement.getText(0)
|
|
||||||
val albumId = if (statement.isNull(1)) null else statement.getText(1)
|
|
||||||
if (expected.remove(id) != albumId) changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed || expected.isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
|
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
|
||||||
if (batch.isEmpty()) return batch
|
if (batch.isEmpty()) return batch
|
||||||
val sortNames =
|
val sortNames =
|
||||||
@@ -287,11 +474,20 @@ class SyncService(
|
|||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
source.playlists().collect { batch ->
|
source.playlists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
write("playlists", primaryKey, batch, onChanged = { changedOrigins += origin(QueueKind.Playlist, it.id) }) { it.columns() }
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
playlistsSynced += batch.size
|
playlistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruned += pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
pruned +=
|
||||||
|
pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, {
|
||||||
|
it.forEach { id -> changedOrigins += origin(QueueKind.Playlist, id) }
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
library.deletePlaylists(source.id, it)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPlaylistSongs() {
|
private suspend fun syncPlaylistSongs() {
|
||||||
@@ -300,57 +496,113 @@ class SyncService(
|
|||||||
val counts = HashMap<String, Long>()
|
val counts = HashMap<String, Long>()
|
||||||
source.playlistSongs(playlistIds).collect { batch ->
|
source.playlistSongs(playlistIds).collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
write("playlist_songs", playlistSongKey, batch, onChanged = { changedOrigins += origin(QueueKind.Playlist, it.playlistId) }) {
|
||||||
|
it.columns()
|
||||||
|
}
|
||||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||||
playlistSongsSynced += batch.size
|
playlistSongsSynced += batch.size
|
||||||
}
|
}
|
||||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
playlistIds.forEach { playlistId ->
|
||||||
|
deletePlaylistSongsFrom(playlistId, counts[playlistId] ?: 0L)
|
||||||
|
}
|
||||||
library.deleteOrphanPlaylistSongs(source.id)
|
library.deleteOrphanPlaylistSongs(source.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun deletePlaylistSongsFrom(
|
||||||
|
playlistId: String,
|
||||||
|
position: Long,
|
||||||
|
): Unit =
|
||||||
|
commitOrigins {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
val changed = db.libraryDao().deletePlaylistSongsFrom(source.id, playlistId, position) > 0
|
||||||
|
if (changed) {
|
||||||
|
changedOrigins += origin(QueueKind.Playlist, playlistId)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
||||||
private suspend fun <T> write(
|
private suspend fun <T> write(
|
||||||
table: String,
|
table: String,
|
||||||
columns: List<String>,
|
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
values: (T) -> List<Any?>,
|
preserved: Set<String> = emptySet(),
|
||||||
) {
|
markStale: Boolean = false,
|
||||||
db.useWriterConnection { connection ->
|
onChanged: (T) -> Unit = {},
|
||||||
connection.immediateTransaction {
|
columns: (T) -> List<Pair<String, Any?>>,
|
||||||
connection.upsertChanged(table, columns, key, rows, values)
|
): Boolean =
|
||||||
|
commitOrigins {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
val before = connection.totalChanges()
|
||||||
|
connection.upsertChanged(table, key, rows, preserved, onChanged, columns)
|
||||||
|
val changed = connection.totalChanges() != before
|
||||||
|
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
changed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun writeAlbumsAndDiscs(
|
private suspend fun writeAlbumsAndDiscs(
|
||||||
albums: List<Album>,
|
albums: List<Album>,
|
||||||
discs: List<Disc>,
|
discs: List<Disc>,
|
||||||
) {
|
): Boolean {
|
||||||
val artists =
|
val artists =
|
||||||
albums.flatMap { album ->
|
albums.flatMap { album ->
|
||||||
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
||||||
}
|
}
|
||||||
db.useWriterConnection { connection ->
|
return commitOrigins {
|
||||||
connection.immediateTransaction {
|
db.useWriterConnection { connection ->
|
||||||
val resolved = connection.resolveAlbumSortNames(albums)
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
val before = connection.totalChanges()
|
||||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
val previousArtists = connection.albumArtistIds(albums.map { it.id }.toSet())
|
||||||
val artistRowsBefore = connection.totalChanges()
|
val previousTrackArtists = connection.songArtistIds(albums.map { it.id }.toSet())
|
||||||
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
val changedAlbums = mutableSetOf<String>()
|
||||||
albums.forEach { album ->
|
val resolved = connection.resolveAlbumSortNames(albums)
|
||||||
statement.bind(1, album.sourceId)
|
connection.upsertChanged(
|
||||||
statement.bind(2, album.id)
|
"albums",
|
||||||
statement.bind(3, album.artistPositions().size.toLong())
|
primaryKey,
|
||||||
statement.step()
|
resolved,
|
||||||
statement.reset()
|
onChanged = {
|
||||||
statement.clearBindings()
|
changedAlbums += it.id
|
||||||
|
changedOrigins += origin(QueueKind.Album, it.id)
|
||||||
|
},
|
||||||
|
) { it.columns() }
|
||||||
|
connection.upsertChanged(
|
||||||
|
"discs",
|
||||||
|
discKey,
|
||||||
|
discs,
|
||||||
|
onChanged = { changedOrigins += origin(QueueKind.Album, it.albumId) },
|
||||||
|
) { 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.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
albums.forEach { album ->
|
||||||
if (connection.totalChanges() != artistRowsBefore) {
|
val previous = previousArtists[album.id].orEmpty()
|
||||||
connection.markCreditsStale(albums.first().sourceId)
|
val current = (album.artistPositions().map { it.second.id } + album.artistId).filterNotNull().toSet()
|
||||||
creditsChanged = true
|
if (album.id in changedAlbums || previous != current) {
|
||||||
|
changedOrigins += (previous + current).map { origin(QueueKind.Artist, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changedAlbums.forEach { albumId ->
|
||||||
|
changedOrigins += previousTrackArtists[albumId].orEmpty().map { origin(QueueKind.Artist, it) }
|
||||||
|
}
|
||||||
|
val changed = connection.totalChanges() != before
|
||||||
|
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,17 +610,21 @@ class SyncService(
|
|||||||
|
|
||||||
private suspend fun deleteStaleDiscs(stale: Collection<DiscKey>) {
|
private suspend fun deleteStaleDiscs(stale: Collection<DiscKey>) {
|
||||||
stale.chunked(DELETE_CHUNK).forEach { chunk ->
|
stale.chunked(DELETE_CHUNK).forEach { chunk ->
|
||||||
db.useWriterConnection { connection ->
|
commitOrigins {
|
||||||
connection.immediateTransaction {
|
chunk.forEach { changedOrigins += origin(QueueKind.Album, it.albumId) }
|
||||||
connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement ->
|
db.useWriterConnection { connection ->
|
||||||
chunk.forEach { key ->
|
connection.immediateTransaction {
|
||||||
statement.bind(1, source.id)
|
connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement ->
|
||||||
statement.bind(2, key.albumId)
|
chunk.forEach { key ->
|
||||||
statement.bind(3, key.disc)
|
statement.bind(1, source.id)
|
||||||
statement.step()
|
statement.bind(2, key.albumId)
|
||||||
statement.reset()
|
statement.bind(3, key.disc)
|
||||||
statement.clearBindings()
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
statement.clearBindings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
connection.persistPendingOrigins()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,7 +645,7 @@ class SyncService(
|
|||||||
if (ids.isEmpty()) return pruned
|
if (ids.isEmpty()) return pruned
|
||||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||||
if (stale.isNotEmpty()) markStale?.invoke()
|
if (stale.isNotEmpty()) markStale?.invoke()
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { commitOrigins { delete(it) } }
|
||||||
pruned += stale.size
|
pruned += stale.size
|
||||||
if (ids.size < PRUNE_PAGE) return pruned
|
if (ids.size < PRUNE_PAGE) return pruned
|
||||||
afterId = ids.last()
|
afterId = ids.last()
|
||||||
@@ -425,8 +681,26 @@ data class SyncSummary(
|
|||||||
val playlists: Int,
|
val playlists: Int,
|
||||||
val playlistSongs: Int,
|
val playlistSongs: Int,
|
||||||
val pruned: Int,
|
val pruned: Int,
|
||||||
|
val changedOrigins: Set<QueueOrigin> = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class SongSyncState(
|
||||||
|
val id: String,
|
||||||
|
val albumId: String?,
|
||||||
|
val artistIds: Set<String>,
|
||||||
|
val genres: Set<String>,
|
||||||
|
val starred: Boolean,
|
||||||
|
val sortName: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class MutableSongSyncState {
|
||||||
|
var albumId: String? = null
|
||||||
|
var sortName: String = ""
|
||||||
|
val artistIds = mutableSetOf<String>()
|
||||||
|
val genres = mutableSetOf<String>()
|
||||||
|
var starred = false
|
||||||
|
}
|
||||||
|
|
||||||
private const val DELETE_CHUNK = 500
|
private const val DELETE_CHUNK = 500
|
||||||
private const val SQL_VARIABLES = 500
|
private const val SQL_VARIABLES = 500
|
||||||
private const val PRUNE_PAGE = 500
|
private const val PRUNE_PAGE = 500
|
||||||
@@ -465,57 +739,8 @@ private fun discHash(
|
|||||||
|
|
||||||
private val primaryKey = listOf("sourceId", "id")
|
private val primaryKey = listOf("sourceId", "id")
|
||||||
|
|
||||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName")
|
|
||||||
|
|
||||||
private val albumColumns =
|
|
||||||
listOf(
|
|
||||||
"sourceId",
|
|
||||||
"id",
|
|
||||||
"artistId",
|
|
||||||
"name",
|
|
||||||
"albumArtist",
|
|
||||||
"created",
|
|
||||||
"coverArt",
|
|
||||||
"genre",
|
|
||||||
"year",
|
|
||||||
"starred",
|
|
||||||
"songCount",
|
|
||||||
"artists",
|
|
||||||
"sortName",
|
|
||||||
"albumArtistSort",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||||
|
|
||||||
private val discColumns = listOf("sourceId", "albumId", "disc", "title")
|
|
||||||
|
|
||||||
private val songColumns =
|
|
||||||
listOf(
|
|
||||||
"sourceId",
|
|
||||||
"id",
|
|
||||||
"albumId",
|
|
||||||
"artistId",
|
|
||||||
"title",
|
|
||||||
"album",
|
|
||||||
"artist",
|
|
||||||
"duration",
|
|
||||||
"track",
|
|
||||||
"disc",
|
|
||||||
"starred",
|
|
||||||
"created",
|
|
||||||
"playCount",
|
|
||||||
"played",
|
|
||||||
"trackGain",
|
|
||||||
"albumGain",
|
|
||||||
"trackPeak",
|
|
||||||
"albumPeak",
|
|
||||||
"artists",
|
|
||||||
"sortName",
|
|
||||||
"artistSort",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
|
||||||
|
|
||||||
private fun Song.genreNames(): List<String> =
|
private fun Song.genreNames(): List<String> =
|
||||||
genres
|
genres
|
||||||
.flatMap { it.genreParts() }
|
.flatMap { it.genreParts() }
|
||||||
@@ -523,74 +748,110 @@ private fun Song.genreNames(): List<String> =
|
|||||||
.map { part -> id3v1Genre(part) ?: part }
|
.map { part -> id3v1Genre(part) ?: part }
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|
||||||
|
private fun Song.syncState(albumArtists: Map<String, Set<String>>): SongSyncState {
|
||||||
|
val artistIds = artistPositions().mapTo(mutableSetOf()) { it.second.id }
|
||||||
|
this.artistId?.let(artistIds::add)
|
||||||
|
albumId?.let { artistIds += albumArtists[it].orEmpty() }
|
||||||
|
return SongSyncState(
|
||||||
|
id = id,
|
||||||
|
albumId = albumId,
|
||||||
|
artistIds = artistIds,
|
||||||
|
genres = genreNames().toSet(),
|
||||||
|
starred = starred != null,
|
||||||
|
sortName = sortName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
|
// 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 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 songGenreKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
|
|
||||||
|
|
||||||
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||||
|
|
||||||
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
|
|
||||||
|
|
||||||
private val songArtistKey = listOf("sourceId", "songId", "position")
|
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
|
||||||
|
|
||||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||||
|
|
||||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, sortName)
|
private fun Artist.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
private fun Album.values() =
|
"sourceId" to sourceId,
|
||||||
listOf<Any?>(
|
"id" to id,
|
||||||
sourceId,
|
"name" to name,
|
||||||
id,
|
"albumCount" to albumCount,
|
||||||
artistId,
|
"starred" to starred,
|
||||||
name,
|
"coverArt" to coverArt,
|
||||||
albumArtist,
|
"artistImageUrl" to artistImageUrl,
|
||||||
created,
|
"sortName" to sortName,
|
||||||
coverArt,
|
|
||||||
genre,
|
|
||||||
year,
|
|
||||||
starred,
|
|
||||||
songCount,
|
|
||||||
ArtistRefsCodec.encode(artistRefs),
|
|
||||||
sortName,
|
|
||||||
albumArtistSort,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
|
private fun Album.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"id" to id,
|
||||||
|
"artistId" to artistId,
|
||||||
|
"name" to name,
|
||||||
|
"albumArtist" to albumArtist,
|
||||||
|
"created" to created,
|
||||||
|
"coverArt" to coverArt,
|
||||||
|
"genre" to genre,
|
||||||
|
"year" to year,
|
||||||
|
"starred" to starred,
|
||||||
|
"songCount" to songCount,
|
||||||
|
"artists" to ArtistRefsCodec.encode(artistRefs),
|
||||||
|
"sortName" to sortName,
|
||||||
|
"albumArtistSort" to albumArtistSort,
|
||||||
|
)
|
||||||
|
|
||||||
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
|
private fun AlbumArtistRef.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"albumId" to albumId,
|
||||||
|
"position" to position,
|
||||||
|
"artistId" to artistId,
|
||||||
|
"name" to name,
|
||||||
|
)
|
||||||
|
|
||||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
private fun SongArtistRef.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"songId" to songId,
|
||||||
|
"position" to position,
|
||||||
|
"artistId" to artistId,
|
||||||
|
"name" to name,
|
||||||
|
)
|
||||||
|
|
||||||
private fun Song.values() =
|
private fun Disc.columns(): List<Pair<String, Any?>> =
|
||||||
listOf<Any?>(
|
listOf(
|
||||||
sourceId,
|
"sourceId" to sourceId,
|
||||||
id,
|
"albumId" to albumId,
|
||||||
albumId,
|
"disc" to disc,
|
||||||
artistId,
|
"title" to title,
|
||||||
title,
|
)
|
||||||
album,
|
|
||||||
artist,
|
private fun Song.columns(): List<Pair<String, Any?>> =
|
||||||
duration,
|
listOf(
|
||||||
track,
|
"sourceId" to sourceId,
|
||||||
disc,
|
"id" to id,
|
||||||
starred,
|
"albumId" to albumId,
|
||||||
created,
|
"artistId" to artistId,
|
||||||
playCount,
|
"title" to title,
|
||||||
played,
|
"album" to album,
|
||||||
trackGain,
|
"artist" to artist,
|
||||||
albumGain,
|
"duration" to duration,
|
||||||
trackPeak,
|
"track" to track,
|
||||||
albumPeak,
|
"disc" to disc,
|
||||||
ArtistRefsCodec.encode(artistRefs),
|
"starred" to starred,
|
||||||
sortName,
|
"created" to created,
|
||||||
artistSort,
|
"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>> =
|
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
|
||||||
@@ -602,32 +863,59 @@ private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.po
|
|||||||
|
|
||||||
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||||
|
|
||||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
private fun SongGenre.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"songId" to songId,
|
||||||
|
"position" to position,
|
||||||
|
"genre" to genre,
|
||||||
|
)
|
||||||
|
|
||||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
private fun Playlist.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"id" to id,
|
||||||
|
"name" to name,
|
||||||
|
"comment" to comment,
|
||||||
|
"coverArt" to coverArt,
|
||||||
|
"songCount" to songCount,
|
||||||
|
"created" to created,
|
||||||
|
"changed" to changed,
|
||||||
|
"duration" to duration,
|
||||||
|
"owner" to owner,
|
||||||
|
"shared" to shared,
|
||||||
|
)
|
||||||
|
|
||||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
private fun PlaylistSong.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"playlistId" to playlistId,
|
||||||
|
"songId" to songId,
|
||||||
|
"position" to position,
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun <T> PooledConnection.upsertChanged(
|
private suspend fun <T> PooledConnection.upsertChanged(
|
||||||
table: String,
|
table: String,
|
||||||
columns: List<String>,
|
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
values: (T) -> List<Any?>,
|
preserved: Set<String> = emptySet(),
|
||||||
|
onChanged: (T) -> Unit = {},
|
||||||
|
columns: (T) -> List<Pair<String, Any?>>,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
val columnNames = columns(rows.first()).map { it.first }
|
||||||
val updates = columns.filterNot { it in key }
|
val updates = columnNames.filterNot { it in key || it in preserved }
|
||||||
val sql =
|
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 " +
|
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||||
updates.joinToString { "$it = excluded.$it" } +
|
updates.joinToString { "$it = excluded.$it" } +
|
||||||
" WHERE " +
|
" WHERE " +
|
||||||
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" } +
|
||||||
|
" RETURNING 1"
|
||||||
usePrepared(sql) { statement ->
|
usePrepared(sql) { statement ->
|
||||||
rows.forEach { row ->
|
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()
|
if (statement.step()) onChanged(row)
|
||||||
statement.reset()
|
statement.reset()
|
||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ fun appModule(
|
|||||||
scope = get(),
|
scope = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
single { LibraryRepository(get(), get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||||
single {
|
single {
|
||||||
@@ -120,6 +120,7 @@ fun appModule(
|
|||||||
downloadsDir = downloadsRoot(context),
|
downloadsDir = downloadsRoot(context),
|
||||||
artworkStore = get(),
|
artworkStore = get(),
|
||||||
artworkFetcher = get(),
|
artworkFetcher = get(),
|
||||||
|
queueRepository = get(),
|
||||||
showMessage = toast,
|
showMessage = toast,
|
||||||
dispatcher = Dispatchers.IO,
|
dispatcher = Dispatchers.IO,
|
||||||
scope = get(named("io")),
|
scope = get(named("io")),
|
||||||
@@ -128,7 +129,7 @@ fun appModule(
|
|||||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
||||||
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
single { PlaybackController(get(), get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||||
single(createdAtStart = true) {
|
single(createdAtStart = true) {
|
||||||
val playback = get<PlaybackController>()
|
val playback = get<PlaybackController>()
|
||||||
Scrobbler(
|
Scrobbler(
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
|
import com.subtracks.data.repo.QueueResetItem
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
@@ -220,30 +225,74 @@ class Media3PlayerHandle(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
|
private fun toQueueItem(mediaItem: MediaItem): QueueItem = queueItemFromMediaItem(mediaItem)
|
||||||
QueueItem(
|
|
||||||
id = mediaItem.mediaId,
|
|
||||||
title =
|
|
||||||
mediaItem.mediaMetadata.title
|
|
||||||
?.toString()
|
|
||||||
.orEmpty(),
|
|
||||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
|
||||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
|
||||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
|
||||||
durationMs =
|
|
||||||
mediaItem.mediaMetadata.extras
|
|
||||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
|
||||||
?.getLong(EXTRA_DURATION_MS),
|
|
||||||
replayGain = mediaItem.replayGain(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun queueItemFromMediaItem(mediaItem: MediaItem): QueueItem =
|
||||||
|
QueueItem(
|
||||||
|
id = mediaItem.mediaId,
|
||||||
|
title =
|
||||||
|
mediaItem.mediaMetadata.title
|
||||||
|
?.toString()
|
||||||
|
.orEmpty(),
|
||||||
|
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||||
|
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||||
|
artists = mediaItem.artistRefs(),
|
||||||
|
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||||
|
durationMs =
|
||||||
|
mediaItem.mediaMetadata.extras
|
||||||
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||||
|
?.getLong(EXTRA_DURATION_MS),
|
||||||
|
replayGain = mediaItem.replayGain(),
|
||||||
|
resetItem = mediaItem.resetItem(),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun MediaItem.resetItem(): QueueResetItem? {
|
||||||
|
val extras = mediaMetadata.extras ?: return null
|
||||||
|
val kind = extras.getString(EXTRA_RESET_KIND)?.let { runCatching { QueueKind.valueOf(it) }.getOrNull() } ?: return null
|
||||||
|
val refId = extras.getString(EXTRA_RESET_REF_ID) ?: return null
|
||||||
|
val songId = extras.getString(EXTRA_RESET_SONG_ID) ?: return null
|
||||||
|
if (!extras.containsKey(EXTRA_RESET_SOURCE_ID) ||
|
||||||
|
!extras.containsKey(EXTRA_RESET_POSITION) ||
|
||||||
|
!extras.containsKey(EXTRA_RESET_ENTRY_OCCURRENCE) ||
|
||||||
|
!extras.containsKey(EXTRA_RESET_OCCURRENCE)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return QueueResetItem(
|
||||||
|
origin = QueueOrigin(kind, extras.getLong(EXTRA_RESET_SOURCE_ID), refId),
|
||||||
|
songId = songId,
|
||||||
|
position = extras.getLong(EXTRA_RESET_POSITION),
|
||||||
|
upNext = extras.getBoolean(EXTRA_RESET_UP_NEXT),
|
||||||
|
entryOccurrence = extras.getInt(EXTRA_RESET_ENTRY_OCCURRENCE),
|
||||||
|
occurrence = extras.getLong(EXTRA_RESET_OCCURRENCE),
|
||||||
|
originEntryId = extras.takeIf { it.containsKey(EXTRA_RESET_ORIGIN_ENTRY_ID) }?.getLong(EXTRA_RESET_ORIGIN_ENTRY_ID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
||||||
|
mediaMetadata.extras
|
||||||
|
?.getString(EXTRA_ARTISTS)
|
||||||
|
?.let(ArtistRefsCodec::decode)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||||
|
private const val EXTRA_ARTISTS = "artists"
|
||||||
private const val EXTRA_DURATION_MS = "durationMs"
|
private const val EXTRA_DURATION_MS = "durationMs"
|
||||||
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||||
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||||
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
||||||
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
||||||
|
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
|
||||||
|
private const val EXTRA_RESET_KIND = "resetKind"
|
||||||
|
private const val EXTRA_RESET_SOURCE_ID = "resetSourceId"
|
||||||
|
private const val EXTRA_RESET_REF_ID = "resetRefId"
|
||||||
|
private const val EXTRA_RESET_SONG_ID = "resetSongId"
|
||||||
|
private const val EXTRA_RESET_POSITION = "resetPosition"
|
||||||
|
private const val EXTRA_RESET_UP_NEXT = "resetUpNext"
|
||||||
|
private const val EXTRA_RESET_ENTRY_OCCURRENCE = "resetEntryOccurrence"
|
||||||
|
private const val EXTRA_RESET_OCCURRENCE = "resetOccurrence"
|
||||||
|
private const val EXTRA_RESET_ORIGIN_ENTRY_ID = "resetOriginEntryId"
|
||||||
|
|
||||||
internal fun MediaItem?.replayGain(): ReplayGain {
|
internal fun MediaItem?.replayGain(): ReplayGain {
|
||||||
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
||||||
@@ -255,6 +304,8 @@ internal fun MediaItem?.replayGain(): ReplayGain {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false
|
||||||
|
|
||||||
private fun Bundle.putGain(
|
private fun Bundle.putGain(
|
||||||
key: String,
|
key: String,
|
||||||
value: Float?,
|
value: Float?,
|
||||||
@@ -283,11 +334,24 @@ internal fun queueMediaItem(
|
|||||||
.setExtras(
|
.setExtras(
|
||||||
Bundle().apply {
|
Bundle().apply {
|
||||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||||
|
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||||
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||||
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
||||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
||||||
|
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
|
||||||
|
item.resetItem?.let { reset ->
|
||||||
|
putString(EXTRA_RESET_KIND, reset.origin.kind.name)
|
||||||
|
putLong(EXTRA_RESET_SOURCE_ID, reset.origin.sourceId)
|
||||||
|
putString(EXTRA_RESET_REF_ID, reset.origin.refId)
|
||||||
|
putString(EXTRA_RESET_SONG_ID, reset.songId)
|
||||||
|
putLong(EXTRA_RESET_POSITION, reset.position)
|
||||||
|
putBoolean(EXTRA_RESET_UP_NEXT, reset.upNext)
|
||||||
|
putInt(EXTRA_RESET_ENTRY_OCCURRENCE, reset.entryOccurrence)
|
||||||
|
putLong(EXTRA_RESET_OCCURRENCE, reset.occurrence)
|
||||||
|
reset.originEntryId?.let { putLong(EXTRA_RESET_ORIGIN_ENTRY_ID, it) }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
).build()
|
).build()
|
||||||
val builder =
|
val builder =
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,17 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.SongItem
|
import com.subtracks.data.model.SongItem
|
||||||
|
|
||||||
fun SongItem.toQueueItem() =
|
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||||
QueueItem(
|
QueueItem(
|
||||||
id = song.id,
|
id = song.id,
|
||||||
title = song.title,
|
title = song.title,
|
||||||
artist = song.artist,
|
artist = song.artist,
|
||||||
|
artists = song.artistRefs,
|
||||||
album = song.album,
|
album = song.album,
|
||||||
coverArtId = coverArt,
|
coverArtId = coverArt,
|
||||||
durationMs = song.duration?.let { it * 1000 },
|
durationMs = song.duration?.let { it * 1000 },
|
||||||
replayGain = song.replayGain(),
|
replayGain = song.replayGain(),
|
||||||
|
albumContext = albumContext,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,17 +29,20 @@ internal fun Song.replayGain(): ReplayGain =
|
|||||||
internal fun replayGainLinearGain(
|
internal fun replayGainLinearGain(
|
||||||
gain: ReplayGain,
|
gain: ReplayGain,
|
||||||
settings: ReplayGainSettings,
|
settings: ReplayGainSettings,
|
||||||
|
albumContext: Boolean = false,
|
||||||
): Float {
|
): 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
|
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
|
||||||
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
|
// "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.
|
// headroom without a peak, so a peakless track is only ever attenuated.
|
||||||
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
|
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
|
||||||
val albumPeak = gain.albumPeak?.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) =
|
val (requestedDb, peak) =
|
||||||
when (settings.mode) {
|
if (album) {
|
||||||
ReplayGainMode.Off -> return 1f
|
(gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
||||||
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
} else {
|
||||||
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
(gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
||||||
}
|
}
|
||||||
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
||||||
val requested = gainDb + settings.preampDb
|
val requested = gainDb + settings.preampDb
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ internal class ReplayGainController(
|
|||||||
private val apply: (Float) -> Unit,
|
private val apply: (Float) -> Unit,
|
||||||
) {
|
) {
|
||||||
private var track = ReplayGain()
|
private var track = ReplayGain()
|
||||||
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
private var albumContext = false
|
||||||
|
private var settings = ReplayGainSettings(ReplayGainMode.Auto, 0f)
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun applyItem(item: MediaItem) {
|
fun applyItem(item: MediaItem) {
|
||||||
track = item.replayGain()
|
track = item.replayGain()
|
||||||
|
albumContext = item.albumContext()
|
||||||
applyGain()
|
applyGain()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +24,6 @@ internal class ReplayGainController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun applyGain() {
|
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.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.AlbumInfoDialog
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
|
import com.subtracks.ui.components.ArtistInfoDialog
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.ItemContextMenu
|
import com.subtracks.ui.components.ItemContextMenu
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.PlaylistInfoDialog
|
||||||
import com.subtracks.ui.components.SongInfoDialog
|
import com.subtracks.ui.components.SongInfoDialog
|
||||||
import com.subtracks.ui.components.statusBarScrim
|
import com.subtracks.ui.components.statusBarScrim
|
||||||
import com.subtracks.ui.downloads.DownloadsRoute
|
import com.subtracks.ui.downloads.DownloadsRoute
|
||||||
@@ -230,7 +236,8 @@ private fun MainNavigation() {
|
|||||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val contextMenuHost = remember { ContextMenuHost() }
|
val contextMenuHost = remember { ContextMenuHost() }
|
||||||
var infoSong by remember { mutableStateOf<Song?>(null) }
|
var infoTarget by remember { mutableStateOf<MenuTarget?>(null) }
|
||||||
|
var artistChooser by remember { mutableStateOf<List<ArtistRef>?>(null) }
|
||||||
|
|
||||||
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
||||||
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||||
@@ -312,7 +319,8 @@ private fun MainNavigation() {
|
|||||||
nowPlayingLauncher.openNowPlaying.collect {
|
nowPlayingLauncher.openNowPlaying.collect {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
contextMenuHost.dismissTransients()
|
contextMenuHost.dismissTransients()
|
||||||
infoSong = null
|
infoTarget = null
|
||||||
|
artistChooser = null
|
||||||
showingQueue = false
|
showingQueue = false
|
||||||
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
||||||
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
||||||
@@ -375,7 +383,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPlaylistClick = { playlist ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -418,7 +434,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPlaylistClick = { playlist ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -514,7 +538,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -538,7 +570,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -562,7 +602,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||||
onViewArtist = { artistId ->
|
onViewArtist = { artistId ->
|
||||||
@@ -588,7 +636,15 @@ private fun MainNavigation() {
|
|||||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||||
},
|
},
|
||||||
onArtistClick = { artist ->
|
onArtistClick = { artist ->
|
||||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(
|
||||||
|
artist.id,
|
||||||
|
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPlaylistClick = { playlist ->
|
onPlaylistClick = { playlist ->
|
||||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||||
@@ -725,23 +781,69 @@ private fun MainNavigation() {
|
|||||||
target = target,
|
target = target,
|
||||||
actions = contextMenuHost.actions,
|
actions = contextMenuHost.actions,
|
||||||
onDismiss = { contextMenuHost.dismiss() },
|
onDismiss = { contextMenuHost.dismiss() },
|
||||||
onInfo = { infoSong = it },
|
onInfo = { infoTarget = it },
|
||||||
|
onChooseArtists = { artistChooser = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
infoSong?.let { song ->
|
artistChooser?.let { artists ->
|
||||||
val genres by
|
ArtistChooserSheet(
|
||||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
artists = artists,
|
||||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
onArtistClick = { artist ->
|
||||||
SongInfoDialog(
|
artistChooser = null
|
||||||
song = song,
|
navigateDetail(
|
||||||
download = downloads[song.id],
|
Routes.ARTIST_DETAIL,
|
||||||
localFile = downloadRepository.localFile(song.id),
|
"artistId",
|
||||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
artist.id,
|
||||||
onDismiss = { infoSong = null },
|
Routes.artist(artist.id),
|
||||||
genres = genres,
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { artistChooser = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
when (val target = infoTarget) {
|
||||||
|
is MenuTarget.Song -> {
|
||||||
|
val song = target.song
|
||||||
|
val genres by
|
||||||
|
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
SongInfoDialog(
|
||||||
|
song = song,
|
||||||
|
download = downloads[song.id],
|
||||||
|
localFile = downloadRepository.localFile(song.id),
|
||||||
|
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
genres = genres,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Album -> {
|
||||||
|
AlbumInfoDialog(
|
||||||
|
album = target.album,
|
||||||
|
download = target.download,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Artist -> {
|
||||||
|
ArtistInfoDialog(
|
||||||
|
artist = target.artist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Playlist -> {
|
||||||
|
PlaylistInfoDialog(
|
||||||
|
playlist = target.playlist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
data class ArtistArt(
|
||||||
|
val image: CoverArtRef? = null,
|
||||||
|
val thumbnail: CoverArtRef? = null,
|
||||||
|
val covers: List<CoverArtRef> = emptyList(),
|
||||||
|
val fallback: ArtistArt? = null,
|
||||||
|
) {
|
||||||
|
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SourceRepository.artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt =
|
||||||
|
resolveArtistArt(
|
||||||
|
artist = artist,
|
||||||
|
coverArt = this::coverArt,
|
||||||
|
artistImage = this::artistImage,
|
||||||
|
offline = offline.value,
|
||||||
|
forIcon = forIcon,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveArtistArt(
|
||||||
|
artist: Artist,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artistImage: (String, String?) -> CoverArtRef?,
|
||||||
|
offline: Boolean,
|
||||||
|
forIcon: Boolean,
|
||||||
|
): ArtistArt {
|
||||||
|
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
|
||||||
|
val grid =
|
||||||
|
ArtistArt(
|
||||||
|
thumbnail = coverIds.singleOrNull()?.let { coverArt(it, true) },
|
||||||
|
covers = coverIds.take(4).mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
|
||||||
|
)
|
||||||
|
val own =
|
||||||
|
artist.coverArt?.let { coverId ->
|
||||||
|
coverArt(coverId, forIcon)?.let { image ->
|
||||||
|
ArtistArt(
|
||||||
|
image = image,
|
||||||
|
thumbnail = if (forIcon) image else coverArt(coverId, true),
|
||||||
|
fallback = grid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artist.artistImageUrl
|
||||||
|
?.let { artistImage(artist.id, it) }
|
||||||
|
?.let { ArtistArt(image = it, thumbnail = it, fallback = own ?: grid) }
|
||||||
|
?: own
|
||||||
|
?: grid
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistIcon(
|
||||||
|
artist: Artist,
|
||||||
|
art: ArtistArt,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var shown by remember(artist.id, art) { mutableStateOf(art) }
|
||||||
|
val showFallback = {
|
||||||
|
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
shown.image != null -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = shown.image,
|
||||||
|
name = artist.name,
|
||||||
|
modifier = modifier,
|
||||||
|
onError = showFallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
shown.covers.size >= 2 -> {
|
||||||
|
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistHeroArt(
|
||||||
|
art: ArtistArt,
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var shown by remember(art) { mutableStateOf(art) }
|
||||||
|
val showFallback = {
|
||||||
|
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
shown.image != null -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = shown.image,
|
||||||
|
name = name,
|
||||||
|
thumbnailRef = shown.thumbnail,
|
||||||
|
showPlaceholder = false,
|
||||||
|
modifier = modifier,
|
||||||
|
onError = showFallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
shown.covers.isNotEmpty() -> {
|
||||||
|
ArtistArtGrid(
|
||||||
|
covers = shown.covers,
|
||||||
|
name = name,
|
||||||
|
singleThumbnail = shown.thumbnail,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CoverArt(ref = null, name = name, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistArtGrid(
|
||||||
|
covers: List<CoverArtRef>,
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
singleThumbnail: CoverArtRef? = null,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
covers.size >= 4 -> {
|
||||||
|
Column(modifier) {
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
covers.take(2).forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
covers.drop(2).take(2).forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 3 -> {
|
||||||
|
Box(modifier) {
|
||||||
|
covers.forEachIndexed { index, ref ->
|
||||||
|
CoverArt(
|
||||||
|
ref = ref,
|
||||||
|
name = name,
|
||||||
|
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 2 -> {
|
||||||
|
Row(modifier) {
|
||||||
|
covers.forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 1 -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = covers.first(),
|
||||||
|
name = name,
|
||||||
|
thumbnailRef = singleThumbnail,
|
||||||
|
showPlaceholder = false,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class YRegionShape(
|
||||||
|
private val region: Int,
|
||||||
|
) : Shape {
|
||||||
|
override fun createOutline(
|
||||||
|
size: Size,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
density: Density,
|
||||||
|
): Outline {
|
||||||
|
val center = Offset(size.width / 2f, size.height / 2f)
|
||||||
|
val left = rayPoint(size, 150.0)
|
||||||
|
val right = rayPoint(size, 30.0)
|
||||||
|
val top = Offset(size.width / 2f, 0f)
|
||||||
|
val path = Path()
|
||||||
|
when (region) {
|
||||||
|
0 -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(right.x, right.y)
|
||||||
|
addClockwise(path, size, right, left)
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(left.x, left.y)
|
||||||
|
addClockwise(path, size, left, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(top.x, top.y)
|
||||||
|
addClockwise(path, size, top, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.lineTo(center.x, center.y)
|
||||||
|
path.close()
|
||||||
|
return Outline.Generic(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rayPoint(
|
||||||
|
size: Size,
|
||||||
|
angleDegrees: Double,
|
||||||
|
): Offset {
|
||||||
|
val cx = size.width / 2.0
|
||||||
|
val cy = size.height / 2.0
|
||||||
|
val radians = angleDegrees * PI / 180.0
|
||||||
|
val dx = cos(radians)
|
||||||
|
val dy = sin(radians)
|
||||||
|
val tx =
|
||||||
|
when {
|
||||||
|
dx > 0 -> (size.width - cx) / dx
|
||||||
|
dx < 0 -> -cx / dx
|
||||||
|
else -> Double.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
val ty =
|
||||||
|
when {
|
||||||
|
dy > 0 -> (size.height - cy) / dy
|
||||||
|
dy < 0 -> -cy / dy
|
||||||
|
else -> Double.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
val t = min(tx, ty)
|
||||||
|
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addClockwise(
|
||||||
|
path: Path,
|
||||||
|
size: Size,
|
||||||
|
from: Offset,
|
||||||
|
to: Offset,
|
||||||
|
) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val corners =
|
||||||
|
listOf(
|
||||||
|
Offset(0f, 0f),
|
||||||
|
Offset(w, 0f),
|
||||||
|
Offset(w, h),
|
||||||
|
Offset(0f, h),
|
||||||
|
)
|
||||||
|
val fromParam = perimeterParam(from, size)
|
||||||
|
val toParam = perimeterParam(to, size)
|
||||||
|
if (fromParam <= toParam) {
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
} else {
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) > fromParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) < toParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
}
|
||||||
|
path.lineTo(to.x, to.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun perimeterParam(
|
||||||
|
point: Offset,
|
||||||
|
size: Size,
|
||||||
|
): Float {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
return when {
|
||||||
|
point.y <= 0f -> point.x
|
||||||
|
point.x >= w -> w + point.y
|
||||||
|
point.y >= h -> w + h + (w - point.x)
|
||||||
|
else -> 2f * w + h + (h - point.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ import androidx.compose.material3.ModalBottomSheet
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
@@ -28,7 +27,6 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.ArtistRef
|
import com.subtracks.data.model.ArtistRef
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
const val ARTIST_CHOOSER_TAG = "artistChooser"
|
const val ARTIST_CHOOSER_TAG = "artistChooser"
|
||||||
|
|
||||||
@@ -40,7 +38,6 @@ fun ArtistChooserSheet(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -66,13 +63,7 @@ fun ArtistChooserSheet(
|
|||||||
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
||||||
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
|
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
modifier =
|
modifier = Modifier.clickable { onArtistClick(artist) },
|
||||||
Modifier.clickable {
|
|
||||||
scope.launch {
|
|
||||||
sheetState.hide()
|
|
||||||
onArtistClick(artist)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,12 +85,15 @@ fun CoverArt(
|
|||||||
square: Boolean = true,
|
square: Boolean = true,
|
||||||
elevation: Dp = 0.dp,
|
elevation: Dp = 0.dp,
|
||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
|
onError: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
val refKey = ref?.cacheKey
|
val refKey = ref?.cacheKey
|
||||||
val thumbnailKey = thumbnailRef?.cacheKey
|
val thumbnailKey = thumbnailRef?.cacheKey
|
||||||
|
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
|
||||||
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailFailed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||||
val letterScheme =
|
val letterScheme =
|
||||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||||
@@ -115,6 +118,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -131,7 +138,10 @@ fun CoverArt(
|
|||||||
.ratioOrNull()
|
.ratioOrNull()
|
||||||
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -160,7 +170,10 @@ fun CoverArt(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
filterQuality = FilterQuality.High,
|
filterQuality = FilterQuality.High,
|
||||||
)
|
)
|
||||||
@@ -179,6 +192,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -208,6 +225,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
thumbnailLoaded: Boolean,
|
thumbnailLoaded: Boolean,
|
||||||
onThumbnailLoaded: () -> Unit,
|
onThumbnailLoaded: () -> Unit,
|
||||||
onThumbnailRatio: (Float) -> Unit,
|
onThumbnailRatio: (Float) -> Unit,
|
||||||
|
onThumbnailError: () -> Unit,
|
||||||
contentScale: ContentScale,
|
contentScale: ContentScale,
|
||||||
colorFilter: ColorFilter?,
|
colorFilter: ColorFilter?,
|
||||||
placeholderContentColor: Color?,
|
placeholderContentColor: Color?,
|
||||||
@@ -246,6 +264,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onError = { onThumbnailError() },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class PendingDownloadDelete(
|
data class PendingDownloadDelete(
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -18,6 +29,70 @@ data class PendingDownloadDelete(
|
|||||||
val bytes: Long,
|
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
|
@Composable
|
||||||
fun DeleteDownloadsDialog(
|
fun DeleteDownloadsDialog(
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -47,12 +48,17 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -98,12 +104,13 @@ fun HeroDetailScaffold(
|
|||||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
contentReady: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
ArtworkTheme(effectiveArtwork) {
|
ArtworkTheme(effectiveArtwork) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberRestorableLazyListState(contentReady)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -114,9 +121,9 @@ fun HeroDetailScaffold(
|
|||||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||||
|
|
||||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val scrollPx by remember {
|
val scrollPx by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val index = listState.firstVisibleItemIndex
|
val index = listState.firstVisibleItemIndex
|
||||||
@@ -206,6 +213,31 @@ fun HeroDetailScaffold(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||||
|
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||||
|
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||||
|
LaunchedEffect(contentReady) {
|
||||||
|
if (contentReady && pending) {
|
||||||
|
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||||
|
state.scrollToItem(target, offset)
|
||||||
|
pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||||
|
.collect { (currentIndex, currentOffset) ->
|
||||||
|
if (!pending) {
|
||||||
|
index = currentIndex
|
||||||
|
offset = currentOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeroSubtitle(
|
private fun HeroSubtitle(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -291,7 +323,11 @@ fun HeroHeader(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.heightIn(min = 32.dp)
|
.heightIn(min = 32.dp)
|
||||||
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClickLabel = stringResource(R.string.open_artist),
|
||||||
|
) { onSubtitleClick() },
|
||||||
contentAlignment = Alignment.TopCenter,
|
contentAlignment = Alignment.TopCenter,
|
||||||
) {
|
) {
|
||||||
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||||
import androidx.compose.material.icons.rounded.Album
|
import androidx.compose.material.icons.rounded.Album
|
||||||
import androidx.compose.material.icons.rounded.Cancel
|
import androidx.compose.material.icons.rounded.Cancel
|
||||||
@@ -50,6 +51,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
@@ -80,7 +82,7 @@ sealed interface MenuTarget {
|
|||||||
) : MenuTarget {
|
) : MenuTarget {
|
||||||
override val title: String get() = song.title
|
override val title: String get() = song.title
|
||||||
|
|
||||||
override val subtitle: String? get() = song.artist
|
override val subtitle: String? get() = song.artistSubtitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Album(
|
data class Album(
|
||||||
@@ -197,7 +199,8 @@ fun ItemContextMenu(
|
|||||||
target: MenuTarget,
|
target: MenuTarget,
|
||||||
actions: ItemActions,
|
actions: ItemActions,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onInfo: ((SongModel) -> Unit)? = null,
|
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||||
|
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -210,6 +213,11 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun replaceWith(after: () -> Unit) {
|
||||||
|
onDismiss()
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
|
||||||
val queueRef = target.queueRef()
|
val queueRef = target.queueRef()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -256,18 +264,9 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
target.song.artistRefs.forEach { artist ->
|
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
actions.viewArtist?.let { view ->
|
|
||||||
MenuItem(
|
|
||||||
Icons.Rounded.Person,
|
|
||||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
|
||||||
) {
|
|
||||||
dismiss { view(artist.id) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onInfo?.let { info ->
|
onInfo?.let { info ->
|
||||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,15 +278,9 @@ fun ItemContextMenu(
|
|||||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
target.album.artistRefs.forEach { artist ->
|
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
actions.viewArtist?.let { view ->
|
onInfo?.let { info ->
|
||||||
MenuItem(
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
Icons.Rounded.Person,
|
|
||||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
|
||||||
) {
|
|
||||||
dismiss { view(artist.id) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,11 +291,17 @@ fun ItemContextMenu(
|
|||||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MenuTarget.Playlist -> {
|
is MenuTarget.Playlist -> {
|
||||||
queueItems()
|
queueItems()
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
@@ -342,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistItems(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
actions: ItemActions,
|
||||||
|
onChooseArtists: ((List<ArtistRef>) -> Unit)?,
|
||||||
|
dismiss: (() -> Unit) -> Unit,
|
||||||
|
replaceWith: (() -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
val view = actions.viewArtist ?: return
|
||||||
|
val distinct = artists.distinctBy { it.id }
|
||||||
|
when (distinct.size) {
|
||||||
|
0 -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
val artist = distinct.first()
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
|
) {
|
||||||
|
dismiss { view(artist.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (onChooseArtists == null) {
|
||||||
|
distinct.forEach { artist ->
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
|
) {
|
||||||
|
dismiss { view(artist.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val fallback = stringResource(R.string.resources_filter_artist)
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } },
|
||||||
|
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||||
|
) {
|
||||||
|
replaceWith { onChooseArtists(distinct) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DownloadItem(
|
private fun DownloadItem(
|
||||||
download: SongDownload?,
|
download: SongDownload?,
|
||||||
@@ -439,11 +487,19 @@ private fun MenuItem(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
label: String,
|
label: String,
|
||||||
tint: Color? = null,
|
tint: Color? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(label) },
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||||
|
trailingContent = trailing,
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
|
||||||
|
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AlbumInfoDialog(
|
||||||
|
album: Album,
|
||||||
|
download: ListDownloadStatus?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_title), album.name)
|
||||||
|
InfoRow(stringResource(R.string.info_sort_title), album.sortName.takeIfDifferentFrom(album.name))
|
||||||
|
InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names())
|
||||||
|
InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort.takeIfDifferentFrom(album.albumArtist))
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.resources_filter_genre), album.genre)
|
||||||
|
InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
download?.takeIf { it.total > 0 }?.let { status ->
|
||||||
|
InfoRow(
|
||||||
|
stringResource(R.string.status_downloaded),
|
||||||
|
stringResource(R.string.info_downloaded_count, status.downloaded, status.total),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistInfoDialog(
|
||||||
|
artist: Artist,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_filter_artist), artist.name)
|
||||||
|
InfoRow(stringResource(R.string.info_sort_name), artist.sortName.takeIfDifferentFrom(artist.name))
|
||||||
|
InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistInfoDialog(
|
||||||
|
playlist: Playlist,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_title), playlist.name)
|
||||||
|
InfoRow(stringResource(R.string.info_owner), playlist.owner)
|
||||||
|
InfoRow(stringResource(R.string.info_shared), if (playlist.shared) stringResource(R.string.info_yes) else null)
|
||||||
|
InfoRow(stringResource(R.string.info_songs), playlist.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_length), playlist.duration.takeIf { it > 0 }?.let(DateUtils::formatElapsedTime))
|
||||||
|
InfoRow(stringResource(R.string.info_created), playlist.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_updated), playlist.changed.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.takeIfDifferentFrom(regular: String?): String? =
|
||||||
|
this?.trim()?.takeIf { value -> value.isNotEmpty() && !value.equals(regular?.trim(), ignoreCase = true) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun InfoRow(
|
||||||
|
label: String,
|
||||||
|
value: String?,
|
||||||
|
) {
|
||||||
|
if (value.isNullOrBlank()) return
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(INFO_LABEL_GAP))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ArtistRef>.names(): String? =
|
||||||
|
map { it.name }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(ARTIST_SEPARATOR)
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
private fun formatDate(
|
||||||
|
context: Context,
|
||||||
|
epochSeconds: Long,
|
||||||
|
): String =
|
||||||
|
DateUtils.formatDateTime(
|
||||||
|
context,
|
||||||
|
epochSeconds * 1000,
|
||||||
|
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val INFO_LABEL_WIDTH = 100.dp
|
||||||
|
private val INFO_LABEL_GAP = 12.dp
|
||||||
@@ -20,6 +20,10 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
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.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
@@ -43,6 +49,12 @@ fun SearchField(
|
|||||||
) {
|
) {
|
||||||
val hint = stringResource(R.string.search_input_placeholder)
|
val hint = stringResource(R.string.search_input_placeholder)
|
||||||
val closeLabel = stringResource(R.string.close_search)
|
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() }
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
@@ -62,8 +74,11 @@ fun SearchField(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = textFieldValue,
|
||||||
onValueChange = onValueChange,
|
onValueChange = {
|
||||||
|
textFieldValue = it
|
||||||
|
onValueChange(it.text)
|
||||||
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
@@ -73,7 +88,7 @@ fun SearchField(
|
|||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.semantics { contentDescription = hint },
|
.semantics { contentDescription = hint },
|
||||||
decorationBox = { inner ->
|
decorationBox = { inner ->
|
||||||
if (value.isEmpty()) {
|
if (textFieldValue.text.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = hint,
|
text = hint,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
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 android.text.format.Formatter
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
|||||||
title = { Text(stringResource(R.string.info)) },
|
title = { Text(stringResource(R.string.info)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
val multipleArtists = song.artistNames().size > 1
|
||||||
|
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
|
||||||
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
||||||
InfoRow(stringResource(R.string.resources_filter_artist), song.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.resources_filter_album), song.album)
|
||||||
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||||
InfoRow(
|
InfoRow(
|
||||||
@@ -92,32 +97,6 @@ fun SongInfoDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun InfoRow(
|
|
||||||
label: String,
|
|
||||||
value: String?,
|
|
||||||
) {
|
|
||||||
if (value.isNullOrBlank()) return
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val INFO_LABEL_WIDTH = 80.dp
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun downloadStatus(status: DownloadStatus): String =
|
private fun downloadStatus(status: DownloadStatus): String =
|
||||||
when (status) {
|
when (status) {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class DownloadsViewModel(
|
|||||||
fun deleteSongs(songIds: List<String>) {
|
fun deleteSongs(songIds: List<String>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val source = sourceId.first()
|
val source = sourceId.first()
|
||||||
songIds.forEach { downloadRepository.remove(source, it) }
|
downloadRepository.remove(source, songIds)
|
||||||
playbackController.refreshMediaItems()
|
playbackController.refreshMediaItems()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistIcon
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
@@ -108,6 +110,7 @@ fun HomeListRoute(
|
|||||||
genres = genres,
|
genres = genres,
|
||||||
decades = decades,
|
decades = decades,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
@@ -133,6 +136,7 @@ fun HomeListScreen(
|
|||||||
genres: List<String> = emptyList(),
|
genres: List<String> = emptyList(),
|
||||||
decades: List<Long> = emptyList(),
|
decades: List<Long> = emptyList(),
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
onBack: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -191,7 +195,7 @@ fun HomeListScreen(
|
|||||||
OfflineListKind.Artists -> {
|
OfflineListKind.Artists -> {
|
||||||
ArtistsList(
|
ArtistsList(
|
||||||
artists = artists,
|
artists = artists,
|
||||||
coverArt = coverArt,
|
artistArt = artistArt,
|
||||||
info = { artistListInfo(request, it, now, resources) },
|
info = { artistListInfo(request, it, now, resources) },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
@@ -245,7 +249,7 @@ fun HomeListScreen(
|
|||||||
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
||||||
ArtistsList(
|
ArtistsList(
|
||||||
artists = artists,
|
artists = artists,
|
||||||
coverArt = coverArt,
|
artistArt = artistArt,
|
||||||
info = { artistListInfo(request, it, now, resources) },
|
info = { artistListInfo(request, it, now, resources) },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
@@ -443,7 +447,7 @@ internal fun AlbumListRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistsList(
|
private fun ArtistsList(
|
||||||
artists: LazyPagingItems<Artist>,
|
artists: LazyPagingItems<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
info: (Artist) -> String?,
|
info: (Artist) -> String?,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
@@ -460,12 +464,13 @@ private fun ArtistsList(
|
|||||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||||
val artist = artists[index]
|
val artist = artists[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
|
val art = artistArt(artist)
|
||||||
ArtistListRow(
|
ArtistListRow(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
coverArt = coverArt,
|
art = art,
|
||||||
info = info(artist),
|
info = info(artist),
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,7 +480,7 @@ private fun ArtistsList(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun ArtistListRow(
|
internal fun ArtistListRow(
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
art: ArtistArt,
|
||||||
info: String?,
|
info: String?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
@@ -488,9 +493,9 @@ internal fun ArtistListRow(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = coverArt(artist.coverArt, true),
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -156,6 +158,11 @@ class HomeListViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun play(song: Song) {
|
fun play(song: Song) {
|
||||||
val genre = request.genre
|
val genre = request.genre
|
||||||
when {
|
when {
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistIcon
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
@@ -137,6 +139,7 @@ fun HomeRoute(
|
|||||||
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
||||||
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
@@ -152,6 +155,7 @@ fun HomeRoute(
|
|||||||
HomeScreen(
|
HomeScreen(
|
||||||
feed = feed,
|
feed = feed,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
@@ -173,6 +177,7 @@ fun HomeScreen(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -215,6 +220,7 @@ fun HomeScreen(
|
|||||||
HomeSection.RecentlyPlayedArtists,
|
HomeSection.RecentlyPlayedArtists,
|
||||||
feed.recentlyPlayedArtists,
|
feed.recentlyPlayedArtists,
|
||||||
coverArt,
|
coverArt,
|
||||||
|
artistArt,
|
||||||
onArtistClick,
|
onArtistClick,
|
||||||
onLongClick,
|
onLongClick,
|
||||||
onMore,
|
onMore,
|
||||||
@@ -249,6 +255,7 @@ fun HomeScreen(
|
|||||||
HomeSection.MostPlayedArtists,
|
HomeSection.MostPlayedArtists,
|
||||||
feed.mostPlayedArtists,
|
feed.mostPlayedArtists,
|
||||||
coverArt,
|
coverArt,
|
||||||
|
artistArt,
|
||||||
onArtistClick,
|
onArtistClick,
|
||||||
onLongClick,
|
onLongClick,
|
||||||
onMore,
|
onMore,
|
||||||
@@ -296,6 +303,7 @@ fun OfflineHomeScreen(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
|
|||||||
) {
|
) {
|
||||||
homeTitle()
|
homeTitle()
|
||||||
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
||||||
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
downloadedArtistRow(artists, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||||
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
||||||
songList(
|
songList(
|
||||||
key = "downloaded-songs",
|
key = "downloaded-songs",
|
||||||
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
|
|||||||
|
|
||||||
private fun LazyListScope.downloadedArtistRow(
|
private fun LazyListScope.downloadedArtistRow(
|
||||||
artists: LazyPagingItems<Artist>,
|
artists: LazyPagingItems<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
onMore: () -> Unit,
|
onMore: () -> Unit,
|
||||||
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
|
|||||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||||
val artist = artists[index]
|
val artist = artists[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
val ref = coverArt(artist.coverArt, true)
|
val art = artistArt(artist)
|
||||||
ArtistTile(
|
ArtistTile(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
ref = ref,
|
art = art,
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -607,6 +615,7 @@ private fun LazyListScope.artistRow(
|
|||||||
section: HomeSection,
|
section: HomeSection,
|
||||||
artists: List<Artist>,
|
artists: List<Artist>,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onArtistClick: (Artist) -> Unit,
|
onArtistClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
onMore: (HomeSection) -> Unit,
|
onMore: (HomeSection) -> Unit,
|
||||||
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
|
|||||||
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
||||||
) {
|
) {
|
||||||
items(artists, key = { it.id }) { artist ->
|
items(artists, key = { it.id }) { artist ->
|
||||||
val ref = coverArt(artist.coverArt, true)
|
val art = artistArt(artist)
|
||||||
ArtistTile(
|
ArtistTile(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
ref = ref,
|
art = art,
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,7 +788,7 @@ private fun AlbumTile(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistTile(
|
private fun ArtistTile(
|
||||||
artist: Artist,
|
artist: Artist,
|
||||||
ref: CoverArtRef?,
|
art: ArtistArt,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -787,9 +796,9 @@ private fun ArtistTile(
|
|||||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = ref,
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff
|
|||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -127,6 +129,11 @@ class HomeViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun playStarred(song: Song) {
|
fun playStarred(song: Song) {
|
||||||
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -30,6 +29,7 @@ import com.subtracks.data.model.Album
|
|||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
|
import com.subtracks.data.model.DownloadList
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -39,16 +39,16 @@ import com.subtracks.playback.PlaybackController
|
|||||||
import com.subtracks.ui.components.ArtistChooserSheet
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.HeroDetailScaffold
|
import com.subtracks.ui.components.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.HeroHeader
|
import com.subtracks.ui.components.HeroHeader
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||||
|
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -67,28 +67,23 @@ fun AlbumDetailRoute(
|
|||||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
val album by viewModel.album.collectAsStateWithLifecycle()
|
||||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
||||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val dismissals = contextMenuHost?.dismissals ?: emptyFlow()
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() }
|
||||||
var showArtistChooser by remember { mutableStateOf(false) }
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
|
DismissOnRequest(dismissals) { showArtistChooser = false }
|
||||||
pendingDelete = null
|
|
||||||
showArtistChooser = false
|
|
||||||
}
|
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||||
if (action == BulkDownloadAction.Delete) {
|
pendingDelete.onAction(album?.name.orEmpty(), DownloadList.Album, albumId, action) { _, _, requested ->
|
||||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
viewModel.onDownloadAction(requested)
|
||||||
} else {
|
|
||||||
viewModel.onDownloadAction(action)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val actions =
|
val actions =
|
||||||
@@ -141,13 +136,8 @@ fun AlbumDetailRoute(
|
|||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { bytes ->
|
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||||
name = album?.name.orEmpty(),
|
|
||||||
bytes = bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showArtistChooser) {
|
if (showArtistChooser) {
|
||||||
@@ -215,6 +205,7 @@ fun AlbumDetailScreen(
|
|||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = album?.name.orEmpty(),
|
title = album?.name.orEmpty(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
contentReady = songs.isNotEmpty(),
|
||||||
header = { controlsModifier, topInset ->
|
header = { controlsModifier, topInset ->
|
||||||
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
||||||
HeroHeader(
|
HeroHeader(
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
|||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
val album: StateFlow<Album?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val songs: Flow<List<Song>> =
|
val songs: StateFlow<List<Song>> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
val discs: StateFlow<List<Disc>> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -27,12 +28,15 @@ import androidx.compose.foundation.layout.statusBars
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.Album
|
||||||
|
import androidx.compose.material.icons.rounded.Mic
|
||||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||||
import androidx.compose.material.icons.rounded.Shuffle
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
import androidx.compose.material.icons.rounded.Star
|
import androidx.compose.material.icons.rounded.Star
|
||||||
@@ -48,53 +52,70 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.Shadow
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistAlbums
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistHeroArt
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.ListDownloadIndicator
|
import com.subtracks.ui.components.ListDownloadIndicator
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.PendingDownloadDelete
|
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||||
import com.subtracks.ui.components.bulkRef
|
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.components.statusBarScrim
|
import com.subtracks.ui.components.statusBarScrim
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
@@ -106,10 +127,15 @@ import com.subtracks.ui.theme.heroBarColor
|
|||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
private val ART_HEIGHT = 420.dp
|
private val ART_HEIGHT = 420.dp
|
||||||
private val TITLE_INSET = 16.dp
|
private val TITLE_INSET = 16.dp
|
||||||
@@ -120,6 +146,12 @@ private val SHUFFLE_ICON = 30.dp
|
|||||||
private val SHUFFLE_END = 22.dp
|
private val SHUFFLE_END = 22.dp
|
||||||
private val SHUFFLE_RESERVE = 86.dp
|
private val SHUFFLE_RESERVE = 86.dp
|
||||||
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
|
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
|
||||||
|
private val BIO_ACTION_RESERVE = 104.dp
|
||||||
|
private val BIO_ACTION_HEIGHT = 48.dp
|
||||||
|
private val BIO_ACTION_OFFSET = 16.dp
|
||||||
|
private val BIO_ACTION_END_OFFSET = 8.dp
|
||||||
|
private val BIO_TOP_GAP = 8.dp
|
||||||
|
private val BIO_BOTTOM_GAP = 8.dp
|
||||||
|
|
||||||
internal fun estimateScrollPx(
|
internal fun estimateScrollPx(
|
||||||
index: Int,
|
index: Int,
|
||||||
@@ -139,11 +171,14 @@ private const val ARTIST_NAME_MAX_LINES = 6
|
|||||||
private const val THEME_TRANSITION_MS = 100
|
private const val THEME_TRANSITION_MS = 100
|
||||||
private const val SCRIM_FADE_MS = 180
|
private const val SCRIM_FADE_MS = 180
|
||||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
||||||
|
internal const val ARTIST_BIO_MORE_TAG = "artistBioMore"
|
||||||
|
internal const val ARTIST_BIO_LESS_TAG = "artistBioLess"
|
||||||
|
|
||||||
internal fun isAlbumArtist(
|
private data class BioSnapshot(
|
||||||
albums: List<Album>,
|
val known: Boolean,
|
||||||
artistId: String,
|
val present: Boolean,
|
||||||
): Boolean = albums.any { album -> album.artistId == artistId || album.artistRefs.any { it.id == artistId } }
|
val biography: String?,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailRoute(
|
fun ArtistDetailRoute(
|
||||||
@@ -157,46 +192,51 @@ fun ArtistDetailRoute(
|
|||||||
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
val artist by viewModel.artist.collectAsStateWithLifecycle()
|
||||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
val artistName by viewModel.artistName.collectAsStateWithLifecycle()
|
||||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
|
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||||
|
val bioSnapshots =
|
||||||
|
remember(viewModel) {
|
||||||
|
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||||
|
}
|
||||||
|
val bioSnapshot by
|
||||||
|
bioSnapshots.collectAsStateWithLifecycle(
|
||||||
|
initialValue = BioSnapshot(known = false, present = false, biography = null),
|
||||||
|
)
|
||||||
|
val bioLoading by viewModel.bioLoading.collectAsStateWithLifecycle()
|
||||||
|
val bioUnavailable by viewModel.bioUnavailable.collectAsStateWithLifecycle()
|
||||||
|
val bioExpanded by viewModel.bioExpanded.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(artistId) { viewModel.refreshBio() }
|
||||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||||
|
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
|
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
val actions =
|
val actions =
|
||||||
ItemActions(
|
ItemActions(
|
||||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||||
bulkDownload = { target, action ->
|
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setStar = setStar,
|
setStar = setStar,
|
||||||
viewAlbum = onViewAlbum,
|
viewAlbum = onViewAlbum,
|
||||||
)
|
)
|
||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
artistName = artistName.orEmpty(),
|
artistName = artistName.orEmpty(),
|
||||||
isAlbumArtist = albums?.let { isAlbumArtist(it, artistId) },
|
albums = albums ?: ArtistAlbums(),
|
||||||
albums = albums.orEmpty(),
|
|
||||||
albumsLoaded = albums != null,
|
albumsLoaded = albums != null,
|
||||||
|
bio = bioSnapshot.biography,
|
||||||
|
bioLoading = bioLoading && !bioSnapshot.present,
|
||||||
|
bioUnavailable = bioUnavailable && !bioSnapshot.present,
|
||||||
|
bioPending = !bioSnapshot.known,
|
||||||
|
bioExpanded = bioExpanded,
|
||||||
art = art,
|
art = art,
|
||||||
artThumbnail = artThumbnail,
|
artThumbnail = artThumbnail,
|
||||||
|
heroArt = heroArt,
|
||||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -206,16 +246,13 @@ fun ArtistDetailRoute(
|
|||||||
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
||||||
starred = artist?.starred != null,
|
starred = artist?.starred != null,
|
||||||
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
||||||
|
onExpandBio = viewModel::expandBio,
|
||||||
|
onCollapseBio = viewModel::collapseBio,
|
||||||
onShuffle = viewModel::shuffle,
|
onShuffle = viewModel::shuffle,
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { pending ->
|
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||||
name = pending.name,
|
|
||||||
bytes = pending.bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,11 +262,16 @@ fun ArtistDetailScreen(
|
|||||||
artist: Artist?,
|
artist: Artist?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
artistName: String = "",
|
artistName: String = "",
|
||||||
isAlbumArtist: Boolean? = null,
|
albums: ArtistAlbums = ArtistAlbums(),
|
||||||
albums: List<Album>,
|
|
||||||
albumsLoaded: Boolean = true,
|
albumsLoaded: Boolean = true,
|
||||||
|
bio: String? = null,
|
||||||
|
bioLoading: Boolean = false,
|
||||||
|
bioUnavailable: Boolean = false,
|
||||||
|
bioPending: Boolean = false,
|
||||||
|
bioExpanded: Boolean = false,
|
||||||
art: CoverArtRef?,
|
art: CoverArtRef?,
|
||||||
artThumbnail: CoverArtRef? = null,
|
artThumbnail: CoverArtRef? = null,
|
||||||
|
heroArt: ArtistArt? = null,
|
||||||
artwork: ArtworkColors? = null,
|
artwork: ArtworkColors? = null,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -239,9 +281,11 @@ fun ArtistDetailScreen(
|
|||||||
onMore: () -> Unit = {},
|
onMore: () -> Unit = {},
|
||||||
starred: Boolean = false,
|
starred: Boolean = false,
|
||||||
onToggleStar: (() -> Unit)? = null,
|
onToggleStar: (() -> Unit)? = null,
|
||||||
|
onExpandBio: () -> Unit = {},
|
||||||
|
onCollapseBio: () -> Unit = {},
|
||||||
onShuffle: () -> Unit = {},
|
onShuffle: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyGridState()
|
val listState = rememberRestorableLazyGridState(albumsLoaded)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val lineHeight =
|
val lineHeight =
|
||||||
@@ -252,9 +296,24 @@ fun ArtistDetailScreen(
|
|||||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||||
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
||||||
val albumCount = albums.size.toLong()
|
val albumCount = albums.count.toLong()
|
||||||
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
||||||
val artistType = stringResource(if (isAlbumArtist == true) R.string.artist_type_album_artist else R.string.artist_type_artist)
|
val artistType =
|
||||||
|
stringResource(
|
||||||
|
if (albums.albumCredits.isNotEmpty()) R.string.artist_type_album_artist else R.string.artist_type_artist,
|
||||||
|
)
|
||||||
|
val albumCountText = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
|
||||||
|
val metaText = if (albumsLoaded) stringResource(R.string.artist_meta, artistType, albumCountText) else ""
|
||||||
|
val hasBio = !bio.isNullOrBlank()
|
||||||
|
val bioCollapsed = hasBio && !bioExpanded
|
||||||
|
val bioText =
|
||||||
|
when {
|
||||||
|
bioPending -> ""
|
||||||
|
hasBio -> bio.orEmpty()
|
||||||
|
bioLoading -> stringResource(R.string.artist_bio_loading)
|
||||||
|
bioUnavailable -> stringResource(R.string.artist_bio_unavailable)
|
||||||
|
else -> stringResource(R.string.artist_bio_empty)
|
||||||
|
}
|
||||||
var titleHeightPx by remember { mutableFloatStateOf(0f) }
|
var titleHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
|
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
|
||||||
val titleMaxLines =
|
val titleMaxLines =
|
||||||
@@ -273,8 +332,8 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var imageHeightPx by remember { mutableFloatStateOf(0f) }
|
var imageHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var albumHeightPx by remember { mutableFloatStateOf(0f) }
|
var albumHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
||||||
val scrollPx by remember {
|
val scrollPx by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -306,7 +365,9 @@ fun ArtistDetailScreen(
|
|||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
val barHeightPx = with(density) { barHeight.toPx() }
|
val barHeightPx = with(density) { barHeight.toPx() }
|
||||||
val gradientScrollPx = scrollPx - imageHeightPx
|
// The gradient is anchored to the hero image, not the whole header, whose bio can expand.
|
||||||
|
val heroHeightPx = with(density) { ART_HEIGHT.toPx() }
|
||||||
|
val gradientScrollPx = scrollPx - heroHeightPx
|
||||||
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
|
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
|
||||||
HeroGradient(
|
HeroGradient(
|
||||||
colors = effectiveArtwork,
|
colors = effectiveArtwork,
|
||||||
@@ -328,11 +389,9 @@ fun ArtistDetailScreen(
|
|||||||
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
CoverArt(
|
ArtistHeroArt(
|
||||||
ref = art,
|
art = heroArt ?: ArtistArt(image = art, thumbnail = artThumbnail),
|
||||||
name = displayName,
|
name = displayName,
|
||||||
thumbnailRef = artThumbnail,
|
|
||||||
showPlaceholder = art == null,
|
|
||||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||||
)
|
)
|
||||||
if (nameScrimAlpha > 0f) {
|
if (nameScrimAlpha > 0f) {
|
||||||
@@ -384,100 +443,71 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = TITLE_INSET),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (albumsLoaded) artistType else "",
|
text = metaText,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(end = SHUFFLE_RESERVE),
|
||||||
|
)
|
||||||
|
ArtistBio(
|
||||||
|
text = bioText,
|
||||||
|
color =
|
||||||
|
if (hasBio) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
|
},
|
||||||
|
collapsed = bioCollapsed,
|
||||||
|
expanded = hasBio && bioExpanded,
|
||||||
|
artist = artist,
|
||||||
|
starred = starred,
|
||||||
|
onToggleStar = onToggleStar,
|
||||||
|
onMore = onMore,
|
||||||
|
onExpand = onExpandBio,
|
||||||
|
onCollapse = onCollapseBio,
|
||||||
|
modifier =
|
||||||
|
Modifier.padding(
|
||||||
|
bottom = if (hasBio) BIO_BOTTOM_GAP else 0.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
// Hold the row at the button height even without the buttons, so the
|
|
||||||
// label-to-count gap is the same for every artist type.
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
if (albumsLoaded) {
|
|
||||||
pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (artist != null) {
|
|
||||||
IconButton(
|
|
||||||
onClick = { onToggleStar?.invoke() },
|
|
||||||
enabled = onToggleStar != null,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
|
||||||
contentDescription =
|
|
||||||
stringResource(
|
|
||||||
if (starred) R.string.actions_unstar else R.string.actions_star,
|
|
||||||
),
|
|
||||||
tint =
|
|
||||||
if (starred) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onMore) {
|
|
||||||
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
|
if (albums.albumCredits.isNotEmpty()) {
|
||||||
val art = coverArt(album.coverArt, false)
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
val thumbnail = coverArt(album.coverArt, true)
|
ArtistSectionHeader(stringResource(R.string.artist_section_albums), Icons.Rounded.Album)
|
||||||
PrefetchArtworkSeeds(thumbnail)
|
}
|
||||||
Column(
|
itemsIndexed(albums.albumCredits, key = { _, album -> album.id }) { index, album ->
|
||||||
modifier =
|
ArtistAlbumCard(
|
||||||
Modifier
|
album = album,
|
||||||
.padding(
|
index = index,
|
||||||
start = if (index % 2 == 0) 16.dp else 0.dp,
|
coverArt = coverArt,
|
||||||
end = if (index % 2 == 1) 16.dp else 0.dp,
|
downloadStatus = albumDownloads[album.id],
|
||||||
).combinedClickable(
|
onClick = { onAlbumClick(album) },
|
||||||
onClick = { onAlbumClick(album) },
|
onLongClick = { thumbnail ->
|
||||||
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) },
|
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
|
||||||
).onSizeChanged { albumHeightPx = it.height.toFloat() },
|
},
|
||||||
) {
|
onSizeChanged = { albumHeightPx = it },
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
|
||||||
) {
|
|
||||||
CoverArt(
|
|
||||||
ref = art,
|
|
||||||
name = album.name,
|
|
||||||
thumbnailRef = thumbnail,
|
|
||||||
showPlaceholder = art == null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
ListDownloadIndicator(
|
|
||||||
status = albumDownloads[album.id],
|
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
|
|
||||||
scrim = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = album.name,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
|
||||||
)
|
)
|
||||||
Text(
|
}
|
||||||
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
}
|
||||||
style = MaterialTheme.typography.bodySmall,
|
if (albums.appearances.isNotEmpty()) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
ArtistSectionHeader(stringResource(R.string.artist_section_appearances), Icons.Rounded.Mic, topGap = 16.dp)
|
||||||
|
}
|
||||||
|
itemsIndexed(albums.appearances, key = { _, album -> album.id }) { index, album ->
|
||||||
|
ArtistAlbumCard(
|
||||||
|
album = album,
|
||||||
|
index = index,
|
||||||
|
coverArt = coverArt,
|
||||||
|
downloadStatus = albumDownloads[album.id],
|
||||||
|
onClick = { onAlbumClick(album) },
|
||||||
|
onLongClick = { thumbnail ->
|
||||||
|
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
|
||||||
|
},
|
||||||
|
onSizeChanged = { albumHeightPx = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,6 +553,434 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState {
|
||||||
|
val state = rememberLazyGridState()
|
||||||
|
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||||
|
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||||
|
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||||
|
LaunchedEffect(contentReady) {
|
||||||
|
if (contentReady && pending) {
|
||||||
|
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||||
|
state.scrollToItem(target, offset)
|
||||||
|
pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||||
|
.collect { (currentIndex, currentOffset) ->
|
||||||
|
if (!pending) {
|
||||||
|
index = currentIndex
|
||||||
|
offset = currentOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
|
||||||
|
@Composable
|
||||||
|
private fun ArtistBio(
|
||||||
|
text: String,
|
||||||
|
color: Color,
|
||||||
|
collapsed: Boolean,
|
||||||
|
expanded: Boolean,
|
||||||
|
artist: Artist?,
|
||||||
|
starred: Boolean,
|
||||||
|
onToggleStar: (() -> Unit)?,
|
||||||
|
onMore: () -> Unit,
|
||||||
|
onExpand: () -> Unit,
|
||||||
|
onCollapse: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val style = MaterialTheme.typography.bodyMedium
|
||||||
|
val moreText = stringResource(R.string.artist_bio_more)
|
||||||
|
val lessText = stringResource(R.string.artist_bio_less)
|
||||||
|
BoxWithConstraints(modifier.fillMaxWidth().padding(top = BIO_TOP_GAP)) {
|
||||||
|
val fullWidth = with(density) { maxWidth.roundToPx() }
|
||||||
|
val reserve = with(density) { BIO_ACTION_RESERVE.roundToPx() }
|
||||||
|
val lineHeightPx =
|
||||||
|
remember(style) {
|
||||||
|
measurer.measure(AnnotatedString("X"), style, maxLines = 1).size.height
|
||||||
|
}.coerceAtLeast(1)
|
||||||
|
val reserveLines =
|
||||||
|
ceil(with(density) { (BIO_ACTION_HEIGHT - BIO_ACTION_OFFSET).toPx() } / lineHeightPx)
|
||||||
|
.toInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
val bioLines =
|
||||||
|
remember(
|
||||||
|
text,
|
||||||
|
fullWidth,
|
||||||
|
reserve,
|
||||||
|
reserveLines,
|
||||||
|
collapsed,
|
||||||
|
expanded,
|
||||||
|
moreText,
|
||||||
|
lessText,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
collapsed -> {
|
||||||
|
splitBioCollapsed(
|
||||||
|
measurer,
|
||||||
|
text,
|
||||||
|
style,
|
||||||
|
fullWidth,
|
||||||
|
reserve,
|
||||||
|
reserveLines,
|
||||||
|
moreText,
|
||||||
|
maxLines = 3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded -> {
|
||||||
|
splitBioExpanded(measurer, text, style, fullWidth, reserve, reserveLines, lessText)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
BioLines(
|
||||||
|
splitBio(measurer, text, style, fullWidth, reserve, reserveLines).map {
|
||||||
|
AnnotatedString(it.ifEmpty { " " })
|
||||||
|
},
|
||||||
|
more = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
bioLines.lines.forEachIndexed { index, line ->
|
||||||
|
val isControl = index == bioLines.lines.lastIndex && (bioLines.more || bioLines.less)
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = style,
|
||||||
|
color = color,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
when {
|
||||||
|
isControl && bioLines.more -> Modifier.testTag(ARTIST_BIO_MORE_TAG).clickable(onClick = onExpand)
|
||||||
|
isControl && bioLines.less -> Modifier.testTag(ARTIST_BIO_LESS_TAG).clickable(onClick = onCollapse)
|
||||||
|
else -> Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = BIO_ACTION_END_OFFSET, y = -BIO_ACTION_OFFSET),
|
||||||
|
) {
|
||||||
|
ArtistActions(artist, starred, onToggleStar, onMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bioLineWidth(
|
||||||
|
index: Int,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
): Int = if (index < reserveLines) fullWidth - reserve else fullWidth
|
||||||
|
|
||||||
|
/** Splits text at every source line break, then wraps it to the width left by the actions. */
|
||||||
|
internal fun splitBio(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
): List<String> {
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
text.split('\n').forEach { sourceLine ->
|
||||||
|
val words = sourceLine.trim().split(' ').filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) {
|
||||||
|
lines += ""
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
var current = ""
|
||||||
|
for (word in words) {
|
||||||
|
val candidate = if (current.isEmpty()) word else "$current $word"
|
||||||
|
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||||
|
current = candidate
|
||||||
|
} else if (current.isEmpty()) {
|
||||||
|
lines += candidate
|
||||||
|
} else {
|
||||||
|
lines += current
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.isNotEmpty()) lines += current
|
||||||
|
}
|
||||||
|
while (lines.isNotEmpty() && lines.last().isBlank()) lines.removeAt(lines.lastIndex)
|
||||||
|
return lines.ifEmpty { listOf("") }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun splitBioExpanded(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
lessText: String,
|
||||||
|
): BioLines {
|
||||||
|
val lines = splitBio(measurer, text, style, fullWidth, reserve, reserveLines).mapTo(mutableListOf()) { AnnotatedString(it) }
|
||||||
|
appendBioControl(lines, lessText, measurer, style, fullWidth, reserve, reserveLines)
|
||||||
|
return BioLines(lines, more = false, less = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendBioControl(
|
||||||
|
lines: MutableList<AnnotatedString>,
|
||||||
|
label: String,
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
) {
|
||||||
|
while (lines.isNotEmpty() && lines.last().text.isBlank()) lines.removeAt(lines.lastIndex)
|
||||||
|
val control =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(" ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(label) }
|
||||||
|
}
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
lines += control
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lastIndex = lines.lastIndex
|
||||||
|
val available = bioLineWidth(lastIndex, fullWidth, reserve, reserveLines)
|
||||||
|
val combined =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(lines[lastIndex])
|
||||||
|
append(control)
|
||||||
|
}
|
||||||
|
if (measurer.measure(combined, style, maxLines = 1).size.width <= available) {
|
||||||
|
lines[lastIndex] = combined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val words = lines[lastIndex].text.split(' ').filter { it.isNotEmpty() }
|
||||||
|
if (words.size > 1) {
|
||||||
|
val tailLine =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(words.last())
|
||||||
|
append(control)
|
||||||
|
}
|
||||||
|
if (measurer.measure(tailLine, style, maxLines = 1).size.width <= available) {
|
||||||
|
lines[lastIndex] = AnnotatedString(words.dropLast(1).joinToString(" "))
|
||||||
|
lines += tailLine
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines += control
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class BioLines(
|
||||||
|
val lines: List<AnnotatedString>,
|
||||||
|
val more: Boolean,
|
||||||
|
val less: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Collapses text to [maxLines] lines, appending a bold More only when it does not fit. */
|
||||||
|
internal fun splitBioCollapsed(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
moreText: String,
|
||||||
|
maxLines: Int = 2,
|
||||||
|
): BioLines {
|
||||||
|
val words =
|
||||||
|
text
|
||||||
|
.replace("\n\n", " ")
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.split(' ')
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) return BioLines(listOf(AnnotatedString("")), more = false)
|
||||||
|
val lines = mutableListOf<AnnotatedString>()
|
||||||
|
var index = 0
|
||||||
|
while (index < words.size && lines.size < maxLines - 1) {
|
||||||
|
var current = ""
|
||||||
|
while (index < words.size) {
|
||||||
|
val candidate = if (current.isEmpty()) words[index] else "$current ${words[index]}"
|
||||||
|
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||||
|
current = candidate
|
||||||
|
index++
|
||||||
|
} else if (current.isEmpty()) {
|
||||||
|
current = words[index]
|
||||||
|
index++
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines += AnnotatedString(current)
|
||||||
|
}
|
||||||
|
if (index >= words.size) {
|
||||||
|
return BioLines(lines, more = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val remainingAvailable = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||||
|
val remaining = words.drop(index).joinToString(" ")
|
||||||
|
if (measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= remainingAvailable) {
|
||||||
|
lines += AnnotatedString(remaining)
|
||||||
|
return BioLines(lines, more = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val more =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append("… ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
|
||||||
|
}
|
||||||
|
val moreWidth =
|
||||||
|
measurer.measure(more, style, maxLines = 1).size.width +
|
||||||
|
measurer.measure(AnnotatedString(" "), style, maxLines = 1).size.width
|
||||||
|
val lastAvailable = (bioLineWidth(lines.size, fullWidth, reserve, reserveLines) - moreWidth).coerceAtLeast(1)
|
||||||
|
var truncated = ""
|
||||||
|
while (index < words.size) {
|
||||||
|
val candidate = if (truncated.isEmpty()) words[index] else "$truncated ${words[index]}"
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= lastAvailable) {
|
||||||
|
truncated = candidate
|
||||||
|
index++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val lastLine =
|
||||||
|
buildAnnotatedString {
|
||||||
|
if (truncated.isNotEmpty()) append("$truncated ")
|
||||||
|
append("… ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
|
||||||
|
}
|
||||||
|
lines += lastLine
|
||||||
|
return BioLines(lines, more = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistSectionHeader(
|
||||||
|
text: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
topGap: Dp = 0.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = topGap, bottom = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = text.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistActions(
|
||||||
|
artist: Artist?,
|
||||||
|
starred: Boolean,
|
||||||
|
onToggleStar: (() -> Unit)?,
|
||||||
|
onMore: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (artist == null) return
|
||||||
|
IconButton(
|
||||||
|
onClick = { onToggleStar?.invoke() },
|
||||||
|
enabled = onToggleStar != null,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||||
|
contentDescription =
|
||||||
|
stringResource(
|
||||||
|
if (starred) R.string.actions_unstar else R.string.actions_star,
|
||||||
|
),
|
||||||
|
tint =
|
||||||
|
if (starred) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onMore) {
|
||||||
|
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistAlbumCard(
|
||||||
|
album: Album,
|
||||||
|
index: Int,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
downloadStatus: ListDownloadStatus?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: (CoverArtRef?) -> Unit,
|
||||||
|
onSizeChanged: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
val art = coverArt(album.coverArt, false)
|
||||||
|
val thumbnail = coverArt(album.coverArt, true)
|
||||||
|
PrefetchArtworkSeeds(thumbnail)
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(
|
||||||
|
start = if (index % 2 == 0) 16.dp else 0.dp,
|
||||||
|
end = if (index % 2 == 1) 16.dp else 0.dp,
|
||||||
|
).combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = { onLongClick(thumbnail) },
|
||||||
|
).onSizeChanged { onSizeChanged(it.height.toFloat()) },
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
||||||
|
) {
|
||||||
|
CoverArt(
|
||||||
|
ref = art,
|
||||||
|
name = album.name,
|
||||||
|
thumbnailRef = thumbnail,
|
||||||
|
showPlaceholder = art == null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
ListDownloadIndicator(
|
||||||
|
status = downloadStatus,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
|
||||||
|
scrim = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = album.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistTitle(
|
private fun ArtistTitle(
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package com.subtracks.ui.library
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.data.model.Album
|
|
||||||
import com.subtracks.data.model.Artist
|
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.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
@@ -12,9 +13,10 @@ import com.subtracks.data.repo.DownloadRepository
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -23,8 +25,6 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.onStart
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -38,23 +38,26 @@ class ArtistDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
|
val artist: StateFlow<Artist?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val artistName: Flow<String?> =
|
val artistName: StateFlow<String?> =
|
||||||
sourceId.flatMapLatest { id ->
|
sourceId
|
||||||
combine(
|
.flatMapLatest { id ->
|
||||||
libraryRepository.artist(id, artistId),
|
combine(
|
||||||
libraryRepository.artistName(id, artistId),
|
libraryRepository.artist(id, artistId),
|
||||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
libraryRepository.artistName(id, artistId),
|
||||||
}
|
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
val albums: Flow<List<Album>?> =
|
val albums: StateFlow<ArtistAlbums?> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||||
.map<List<Album>, List<Album>?> { it }
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
.onStart { emit(null) }
|
|
||||||
|
|
||||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||||
sourceId
|
sourceId
|
||||||
@@ -66,6 +69,55 @@ class ArtistDetailViewModel(
|
|||||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
|
val artistBio: 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(
|
suspend fun downloadedBytes(
|
||||||
list: DownloadList,
|
list: DownloadList,
|
||||||
refId: String,
|
refId: String,
|
||||||
@@ -88,30 +140,36 @@ class ArtistDetailViewModel(
|
|||||||
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
||||||
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
||||||
|
|
||||||
|
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
|
||||||
|
val heroArt: StateFlow<ArtistArt?> = _heroArt
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
artist
|
sourceRepository.activeSource
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.collectLatest { loaded ->
|
.flatMapLatest { sourceId ->
|
||||||
val (full, thumbnail) = resolveArt(loaded)
|
combine(
|
||||||
_art.value = full
|
libraryRepository.artist(sourceId, artistId),
|
||||||
_artThumbnail.value = thumbnail
|
libraryRepository.artistCoverArts(sourceId, artistId),
|
||||||
|
sourceRepository.offline,
|
||||||
|
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
|
||||||
|
}.collectLatest { input ->
|
||||||
|
val resolved = resolveArtistArtInput(input)
|
||||||
|
if (resolved == null) {
|
||||||
|
_art.value = null
|
||||||
|
_artThumbnail.value = null
|
||||||
|
_heroArt.value = null
|
||||||
|
return@collectLatest
|
||||||
|
}
|
||||||
|
val hero = sourceRepository.artistArt(resolved, forIcon = false)
|
||||||
|
val icon = sourceRepository.artistArt(resolved, forIcon = true)
|
||||||
|
_art.value = hero.image ?: hero.covers.firstOrNull()
|
||||||
|
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
||||||
|
_heroArt.value = hero
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> {
|
|
||||||
if (artist.coverArt == null) return null to null
|
|
||||||
repeat(3) { attempt ->
|
|
||||||
val full = sourceRepository.coverArt(artist.coverArt)
|
|
||||||
if (full != null) {
|
|
||||||
return full to sourceRepository.coverArt(artist.coverArt, thumbnail = true)
|
|
||||||
}
|
|
||||||
if (attempt < 2) delay(300)
|
|
||||||
}
|
|
||||||
return null to null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun coverArt(
|
fun coverArt(
|
||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
@@ -123,3 +181,35 @@ class ArtistDetailViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class ArtistArtInput(
|
||||||
|
val artist: Artist?,
|
||||||
|
val artistId: String,
|
||||||
|
val offline: Boolean,
|
||||||
|
val sourceId: Long,
|
||||||
|
val covers: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveArtistArtInput(input: ArtistArtInput): Artist? {
|
||||||
|
val artist = input.artist
|
||||||
|
if (input.covers.isEmpty()) return artist
|
||||||
|
if (artist == null) {
|
||||||
|
return Artist(
|
||||||
|
sourceId = input.sourceId,
|
||||||
|
id = input.artistId,
|
||||||
|
name = "",
|
||||||
|
albumCount = 0,
|
||||||
|
starred = null,
|
||||||
|
coverArts = input.covers,
|
||||||
|
downloadedCoverArts = if (input.offline) input.covers else emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return if (input.offline) {
|
||||||
|
artist.copy(
|
||||||
|
coverArts = input.covers,
|
||||||
|
downloadedCoverArts = artist.downloadedCoverArts.ifEmpty { input.covers },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
artist.copy(coverArts = input.covers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ import com.subtracks.R
|
|||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistIcon
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.FilteredEmptyState
|
import com.subtracks.ui.components.FilteredEmptyState
|
||||||
import com.subtracks.ui.components.ListDownloadIndicator
|
import com.subtracks.ui.components.ListDownloadIndicator
|
||||||
@@ -55,6 +56,7 @@ fun ArtistsContent(
|
|||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
scrollToTopKey: Any? = null,
|
scrollToTopKey: Any? = null,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -82,7 +84,8 @@ fun ArtistsContent(
|
|||||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||||
val artist = items[index]
|
val artist = items[index]
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
|
val art = artistArt(artist)
|
||||||
|
PrefetchArtworkSeeds(art.primary)
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(
|
||||||
@@ -104,9 +107,9 @@ fun ArtistsContent(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
CoverArt(
|
ArtistIcon(
|
||||||
ref = coverArt(artist.coverArt, true),
|
artist = artist,
|
||||||
name = artist.name,
|
art = art,
|
||||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -117,7 +120,7 @@ fun ArtistsContent(
|
|||||||
Modifier.combinedClickable(
|
Modifier.combinedClickable(
|
||||||
onClick = { onArtistClick(artist) },
|
onClick = { onArtistClick(artist) },
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
|
onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id]))
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -24,6 +26,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.PagerState
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -42,6 +45,7 @@ import androidx.compose.material.icons.rounded.Search
|
|||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
@@ -64,13 +68,11 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
@@ -92,12 +94,15 @@ import androidx.compose.ui.layout.layout
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
@@ -118,14 +123,14 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.PendingDownloadDelete
|
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||||
import com.subtracks.ui.components.SearchField
|
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.components.statusBarScrim
|
||||||
import com.subtracks.ui.home.HomeRoute
|
import com.subtracks.ui.home.HomeRoute
|
||||||
import com.subtracks.ui.home.HomeSection
|
import com.subtracks.ui.home.HomeSection
|
||||||
@@ -136,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor
|
|||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -271,25 +275,13 @@ fun LibraryRoute(
|
|||||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
||||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||||
val downloads = downloadsState?.value ?: emptyMap()
|
val downloads = downloadsState?.value ?: emptyMap()
|
||||||
val scope = rememberCoroutineScope()
|
val focusManager = LocalFocusManager.current
|
||||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
|
||||||
val itemActions =
|
val itemActions =
|
||||||
ItemActions(
|
ItemActions(
|
||||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||||
bulkDownload = { target, action ->
|
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setStar = setStar,
|
setStar = setStar,
|
||||||
viewAlbum = onViewAlbum,
|
viewAlbum = onViewAlbum,
|
||||||
viewArtist = onViewArtist,
|
viewArtist = onViewArtist,
|
||||||
@@ -305,13 +297,17 @@ fun LibraryRoute(
|
|||||||
artists = viewModel.artists,
|
artists = viewModel.artists,
|
||||||
playlists = viewModel.playlists,
|
playlists = viewModel.playlists,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSync = viewModel::sync,
|
onSync = viewModel::sync,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
onItemLongClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
contextMenuHost?.show(it, itemActions)
|
||||||
|
},
|
||||||
listQuery = displayQuery,
|
listQuery = displayQuery,
|
||||||
resetKeys = resetKeys,
|
resetKeys = resetKeys,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
@@ -365,13 +361,8 @@ fun LibraryRoute(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { pending ->
|
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||||
name = pending.name,
|
|
||||||
bytes = pending.bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +381,7 @@ fun LibraryScreen(
|
|||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
onOpenSearch: () -> Unit = {},
|
onOpenSearch: () -> Unit = {},
|
||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
onExitOffline: () -> Unit = {},
|
onExitOffline: () -> Unit = {},
|
||||||
@@ -424,9 +416,14 @@ fun LibraryScreen(
|
|||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
var topRequest by remember { mutableIntStateOf(0) }
|
var topRequest by remember { mutableIntStateOf(0) }
|
||||||
val searchFocus = remember { FocusRequester() }
|
val searchFocus = remember { FocusRequester() }
|
||||||
|
val closeSearch = {
|
||||||
|
searchActive = false
|
||||||
|
onSearchChange("")
|
||||||
|
}
|
||||||
|
|
||||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||||
DismissOnRequest(dismissals) { showOptions = false }
|
DismissOnRequest(dismissals) { showOptions = false }
|
||||||
|
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||||
|
|
||||||
LaunchedEffect(pagerState) {
|
LaunchedEffect(pagerState) {
|
||||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||||
@@ -445,14 +442,8 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissSearch() {
|
|
||||||
if (searchActive) {
|
|
||||||
searchActive = false
|
|
||||||
onSearchChange("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
||||||
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
||||||
@@ -503,7 +494,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onAlbumClick(it)
|
onAlbumClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -523,7 +514,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onArtistClick(it)
|
onArtistClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -534,6 +525,7 @@ fun LibraryScreen(
|
|||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = artistDownloads,
|
downloadStatuses = artistDownloads,
|
||||||
scrollToTopKey = scrollToTopKey,
|
scrollToTopKey = scrollToTopKey,
|
||||||
|
artistArt = artistArt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +535,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onPlaylistClick(it)
|
onPlaylistClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -583,8 +575,6 @@ fun LibraryScreen(
|
|||||||
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||||
},
|
},
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
offline = offline,
|
|
||||||
onExitOffline = onExitOffline,
|
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
syncing = syncing,
|
syncing = syncing,
|
||||||
)
|
)
|
||||||
@@ -595,10 +585,7 @@ fun LibraryScreen(
|
|||||||
SearchField(
|
SearchField(
|
||||||
value = search,
|
value = search,
|
||||||
onValueChange = onSearchChange,
|
onValueChange = onSearchChange,
|
||||||
onClose = {
|
onClose = closeSearch,
|
||||||
searchActive = false
|
|
||||||
onSearchChange("")
|
|
||||||
},
|
|
||||||
focusRequester = searchFocus,
|
focusRequester = searchFocus,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -609,11 +596,29 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!searchActive) {
|
if (!searchActive) {
|
||||||
|
val fabContainerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
val fabContentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground
|
||||||
|
if (offline) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onExitOffline,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(start = 16.dp, bottom = tabBarHeight + 16.dp)
|
||||||
|
.size(48.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.CloudOff,
|
||||||
|
contentDescription = stringResource(R.string.offline_mode_status),
|
||||||
|
modifier = Modifier.size(24.dp).offset(y = (-0.75).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
val searchFab = selectedTab == LibraryTab.Home
|
val searchFab = selectedTab == LibraryTab.Home
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = fabContainerColor,
|
||||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
contentColor = fabContentColor,
|
||||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||||
) {
|
) {
|
||||||
@@ -632,11 +637,18 @@ fun LibraryScreen(
|
|||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.offset(x = 4.dp, y = (-4).dp)
|
.offset(x = 1.dp, y = 2.5.dp)
|
||||||
.size(8.dp)
|
.size(11.dp)
|
||||||
.clip(CircleShape)
|
.background(fabContainerColor, CircleShape),
|
||||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
contentAlignment = Alignment.Center,
|
||||||
)
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(7.dp)
|
||||||
|
.background(fabContentColor, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,7 +708,7 @@ private fun ListOptionsSheet(
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -818,6 +830,7 @@ private fun ListOptionsSheet(
|
|||||||
|
|
||||||
private val BOTTOM_CLEARANCE = 80.dp
|
private val BOTTOM_CLEARANCE = 80.dp
|
||||||
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
||||||
|
private val TAB_STRIP_LEADING_GAP = 8.dp
|
||||||
private val TAB_ICON_SIZE = 24.dp
|
private val TAB_ICON_SIZE = 24.dp
|
||||||
private val TAB_VERTICAL_PADDING = 6.dp
|
private val TAB_VERTICAL_PADDING = 6.dp
|
||||||
private val TOUCH_TARGET = 48.dp
|
private val TOUCH_TARGET = 48.dp
|
||||||
@@ -850,13 +863,12 @@ internal fun LibraryTabs(
|
|||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
onTabSelected: (LibraryTab) -> Unit,
|
onTabSelected: (LibraryTab) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
offline: Boolean = false,
|
|
||||||
onExitOffline: () -> Unit = {},
|
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
syncing: Boolean = false,
|
syncing: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val indicatorStretch = 18.dp
|
val indicatorStretch = 18.dp
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||||
val page = pagerState.currentPage
|
val page = pagerState.currentPage
|
||||||
val fraction = pagerState.currentPageOffsetFraction
|
val fraction = pagerState.currentPageOffsetFraction
|
||||||
@@ -888,79 +900,113 @@ internal fun LibraryTabs(
|
|||||||
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
||||||
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
var viewportWidth by remember { mutableIntStateOf(0) }
|
||||||
|
var contentWidth by remember { mutableIntStateOf(0) }
|
||||||
|
var revealed by remember { mutableStateOf(false) }
|
||||||
|
val leadingGap = with(density) { TAB_STRIP_LEADING_GAP.toPx() }
|
||||||
|
LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) {
|
||||||
|
if (viewportWidth == 0) return@LaunchedEffect
|
||||||
|
val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect
|
||||||
|
val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else leadingGap + settled.left
|
||||||
|
val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else leadingGap + settled.right
|
||||||
|
val target =
|
||||||
|
when {
|
||||||
|
start - leadingGap < scrollState.value -> start - leadingGap
|
||||||
|
end > scrollState.value + viewportWidth -> end - viewportWidth
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (target != null) {
|
||||||
|
val offset = target.roundToInt().coerceIn(0, scrollState.maxValue)
|
||||||
|
if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset)
|
||||||
|
}
|
||||||
|
revealed = true
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(headerColor)
|
.background(headerColor)
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Row(
|
||||||
modifier =
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Modifier.drawBehind {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
|
||||||
drawRoundRect(
|
|
||||||
color = indicatorColor,
|
|
||||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
|
||||||
size = Size(indicatorRight - indicatorLeft, height),
|
|
||||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onSizeChanged { viewportWidth = it.width }
|
||||||
|
.horizontalScroll(scrollState)
|
||||||
|
.onSizeChanged { contentWidth = it.width },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
) {
|
||||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||||
TabButton(
|
Box(
|
||||||
tab = tab,
|
modifier =
|
||||||
artwork = artwork,
|
Modifier
|
||||||
progress =
|
.drawBehind {
|
||||||
when (index) {
|
if (current != null &&
|
||||||
page -> 1f - transition
|
indicatorLeft != null &&
|
||||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
indicatorRight != null &&
|
||||||
else -> 0f
|
indicatorRight > indicatorLeft
|
||||||
|
) {
|
||||||
|
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||||
|
drawRoundRect(
|
||||||
|
color = indicatorColor,
|
||||||
|
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||||
|
size = Size(indicatorRight - indicatorLeft, height),
|
||||||
|
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
indicatorLeft = indicatorLeft,
|
) {
|
||||||
indicatorRight = indicatorRight,
|
Row(
|
||||||
onClick = { onTabSelected(tab) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier =
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
Modifier.onGloballyPositioned { coordinates ->
|
) {
|
||||||
bounds[index] = coordinates.boundsInParent()
|
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||||
},
|
TabButton(
|
||||||
)
|
tab = tab,
|
||||||
}
|
artwork = artwork,
|
||||||
Spacer(Modifier.weight(1f))
|
progress =
|
||||||
if (offline) {
|
when (index) {
|
||||||
IconButton(onClick = onExitOffline) {
|
page -> 1f - transition
|
||||||
Icon(
|
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||||
imageVector = Icons.Rounded.CloudOff,
|
else -> 0f
|
||||||
contentDescription = stringResource(R.string.offline_mode_status),
|
},
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
indicatorLeft = indicatorLeft,
|
||||||
)
|
indicatorRight = indicatorRight,
|
||||||
}
|
onClick = { onTabSelected(tab) },
|
||||||
}
|
modifier =
|
||||||
IconButton(onClick = onOpenSettings) {
|
Modifier.onGloballyPositioned { coordinates ->
|
||||||
Box(contentAlignment = Alignment.Center) {
|
bounds[index] = coordinates.boundsInParent()
|
||||||
Icon(
|
},
|
||||||
imageVector = Icons.Rounded.Settings,
|
|
||||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
if (syncing) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Settings,
|
||||||
|
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
if (syncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import com.subtracks.data.repo.SourceRepository
|
|||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -211,6 +213,11 @@ class LibraryViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun sync() {
|
fun sync() {
|
||||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -18,6 +17,7 @@ import androidx.paging.compose.itemKey
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.DownloadList
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -27,16 +27,16 @@ import com.subtracks.data.source.StarType
|
|||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.HeroDetailScaffold
|
import com.subtracks.ui.components.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.HeroHeader
|
import com.subtracks.ui.components.HeroHeader
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||||
|
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -55,21 +55,17 @@ fun PlaylistDetailRoute(
|
|||||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow()) { _, _ -> viewModel.downloadedBytes() }
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||||
if (action == BulkDownloadAction.Delete) {
|
pendingDelete.onAction(playlist?.name.orEmpty(), DownloadList.Playlist, playlistId, action) { _, _, requested ->
|
||||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
viewModel.onDownloadAction(requested)
|
||||||
} else {
|
|
||||||
viewModel.onDownloadAction(action)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val actions =
|
val actions =
|
||||||
@@ -104,13 +100,8 @@ fun PlaylistDetailRoute(
|
|||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { bytes ->
|
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||||
name = playlist?.name.orEmpty(),
|
|
||||||
bytes = bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +129,7 @@ fun PlaylistDetailScreen(
|
|||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = playlist?.name.orEmpty(),
|
title = playlist?.name.orEmpty(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
contentReady = songs.itemCount > 0,
|
||||||
header = { controlsModifier, topInset ->
|
header = { controlsModifier, topInset ->
|
||||||
HeroHeader(
|
HeroHeader(
|
||||||
art = coverArt(playlist?.coverArt, false),
|
art = coverArt(playlist?.coverArt, false),
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
val playlist: StateFlow<Playlist?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val songs: Flow<PagingData<PlaylistSongItem>> =
|
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import com.subtracks.data.model.DownloadStatus
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
|
|
||||||
private val TRACK_COLUMN_WIDTH = 24.dp
|
private val TRACK_COLUMN_WIDTH = 24.dp
|
||||||
private val TIME_COLUMN_WIDTH = 36.dp
|
private val TIME_COLUMN_WIDTH = 36.dp
|
||||||
@@ -146,7 +147,7 @@ fun SongRow(
|
|||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
Text(
|
||||||
text = song.artist.orEmpty().ifEmpty { "\u00A0" },
|
text = song.artistSubtitle().orEmpty().ifEmpty { "\u00A0" },
|
||||||
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkTheme
|
import com.subtracks.ui.theme.ArtworkTheme
|
||||||
import com.subtracks.ui.theme.playerSurfaceColor
|
import com.subtracks.ui.theme.playerSurfaceColor
|
||||||
@@ -139,7 +140,7 @@ fun MiniPlayer(
|
|||||||
modifier = Modifier.basicMarquee(),
|
modifier = Modifier.basicMarquee(),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.artist ?: item.album.orEmpty(),
|
text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import com.subtracks.ui.components.ContextMenuHost
|
|||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
import com.subtracks.ui.components.prefetchArtworkRatio
|
import com.subtracks.ui.components.prefetchArtworkRatio
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||||
@@ -576,7 +577,10 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
||||||
Text(
|
Text(
|
||||||
text = state.item?.artist.orEmpty(),
|
text =
|
||||||
|
state.item
|
||||||
|
?.let { artistSubtitle(it.artist, it.artists) }
|
||||||
|
.orEmpty(),
|
||||||
style = subtitleStyle,
|
style = subtitleStyle,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ data class QueueRow(
|
|||||||
val position: Long,
|
val position: Long,
|
||||||
val song: SongItem,
|
val song: SongItem,
|
||||||
val upNext: Boolean = false,
|
val upNext: Boolean = false,
|
||||||
|
val resetVersion: Long = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Both must match: the position alone can transiently point at the row above while the list
|
// Both must match: the position alone can transiently point at the row above while the list
|
||||||
@@ -96,6 +97,11 @@ internal fun QueueRow.isPlaying(
|
|||||||
currentSongId: String?,
|
currentSongId: String?,
|
||||||
): Boolean = position == currentPosition && song.song.id == currentSongId
|
): Boolean = position == currentPosition && song.song.id == currentSongId
|
||||||
|
|
||||||
|
private data class ReorderRollback(
|
||||||
|
val before: List<Long>,
|
||||||
|
val after: List<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
class QueueViewModel(
|
class QueueViewModel(
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
@@ -114,11 +120,16 @@ class QueueViewModel(
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
private var reorderPending = false
|
private var reorderPending = false
|
||||||
|
private var olderLoadPending = false
|
||||||
|
private var dragRollback: ReorderRollback? = null
|
||||||
|
|
||||||
private var nextId = 0L
|
private var nextId = 0L
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item, item.upNext)
|
private fun newRow(
|
||||||
|
item: QueueWindowItem,
|
||||||
|
resetVersion: Long,
|
||||||
|
) = QueueRow(nextId++, item.position, item.item, item.upNext, resetVersion)
|
||||||
|
|
||||||
private fun visible(item: QueueWindowItem): Boolean = !sourceRepository.offline.value || downloads.localUri(item.item.song.id) != null
|
private fun visible(item: QueueWindowItem): Boolean = !sourceRepository.offline.value || downloads.localUri(item.item.song.id) != null
|
||||||
|
|
||||||
@@ -128,10 +139,11 @@ class QueueViewModel(
|
|||||||
private fun rowFor(
|
private fun rowFor(
|
||||||
item: QueueWindowItem,
|
item: QueueWindowItem,
|
||||||
reused: Map<String, MutableList<Long>>,
|
reused: Map<String, MutableList<Long>>,
|
||||||
|
resetVersion: Long,
|
||||||
): QueueRow {
|
): QueueRow {
|
||||||
val ids = reused[item.item.song.id]
|
val ids = reused[item.item.song.id]
|
||||||
val id = if (!ids.isNullOrEmpty()) ids.removeAt(0) else nextId++
|
val id = if (!ids.isNullOrEmpty()) ids.removeAt(0) else nextId++
|
||||||
return QueueRow(id, item.position, item.item, item.upNext)
|
return QueueRow(id, item.position, item.item, item.upNext, resetVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun open() {
|
fun open() {
|
||||||
@@ -147,7 +159,9 @@ class QueueViewModel(
|
|||||||
}?.coerceIn(0, snapshot.size - 1) ?: 0L
|
}?.coerceIn(0, snapshot.size - 1) ?: 0L
|
||||||
val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
|
val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
|
||||||
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(snapshot.size - 1)
|
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(snapshot.size - 1)
|
||||||
rows.addAll(queueRepository.range(snapshot, start, end).filter(::visible).map { rowFor(it, reused) })
|
rows.addAll(
|
||||||
|
queueRepository.range(snapshot, start, end).filter(::visible).map { rowFor(it, reused, snapshot.resetVersion) },
|
||||||
|
)
|
||||||
initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0)
|
initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
ready = true
|
ready = true
|
||||||
@@ -167,12 +181,21 @@ class QueueViewModel(
|
|||||||
rows.clear()
|
rows.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val start = (rows.minOfOrNull { it.position } ?: 0L).coerceIn(0, snapshot.size - 1)
|
val firstPosition = rows.minOfOrNull { it.position } ?: OLDER_LOAD.toLong()
|
||||||
|
val start = (firstPosition - OLDER_LOAD).coerceAtLeast(0)
|
||||||
val rawEnd = (rows.maxOfOrNull { it.position } ?: (start + QUEUE_CHUNK - 1)).coerceIn(start, snapshot.size - 1)
|
val rawEnd = (rows.maxOfOrNull { it.position } ?: (start + QUEUE_CHUNK - 1)).coerceIn(start, snapshot.size - 1)
|
||||||
val end = rawEnd.coerceAtMost(start + MAX_SCAN_POSITIONS - 1)
|
val end = rawEnd.coerceAtMost(start + MAX_SCAN_POSITIONS - 1)
|
||||||
val loaded = queueRepository.range(snapshot, start, end).filter(::visible)
|
val loaded = queueRepository.range(snapshot, start, end).filter(::visible)
|
||||||
val reused = reusedIds()
|
val reused = reusedIds()
|
||||||
val updated = loaded.map { rowFor(it, reused) }
|
val scanned = loaded.map { rowFor(it, reused, snapshot.resetVersion) }
|
||||||
|
val firstRow = rows.minByOrNull { it.position }
|
||||||
|
val windowStart =
|
||||||
|
if (firstRow == null || olderLoadPending) {
|
||||||
|
start
|
||||||
|
} else {
|
||||||
|
scanned.firstOrNull { it.id == firstRow.id && it.position < firstPosition }?.position ?: firstPosition
|
||||||
|
}
|
||||||
|
val updated = scanned.filter { it.position >= windowStart }
|
||||||
if (end >= rawEnd) {
|
if (end >= rawEnd) {
|
||||||
if (updated.size == rows.size) {
|
if (updated.size == rows.size) {
|
||||||
updated.forEachIndexed { index, row -> if (rows[index] != row) rows[index] = row }
|
updated.forEachIndexed { index, row -> if (rows[index] != row) rows[index] = row }
|
||||||
@@ -181,34 +204,37 @@ class QueueViewModel(
|
|||||||
rows.addAll(updated)
|
rows.addAll(updated)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only the head of a sparse offline window was resolved; keep the tail on screen,
|
|
||||||
// dropping any tail row whose id was reused for a head row.
|
|
||||||
val usedIds = updated.mapTo(HashSet()) { it.id }
|
val usedIds = updated.mapTo(HashSet()) { it.id }
|
||||||
val tail = rows.filter { it.position > end && it.id !in usedIds }
|
val unresolvedTail = rows.filter { it.position > end && it.id !in usedIds }
|
||||||
rows.clear()
|
rows.clear()
|
||||||
rows.addAll(updated)
|
rows.addAll(updated)
|
||||||
rows.addAll(tail)
|
rows.addAll(unresolvedTail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadOlder() {
|
fun loadOlder() {
|
||||||
val top = rows.minOfOrNull { it.position } ?: return
|
val top = rows.minOfOrNull { it.position } ?: return
|
||||||
if (top <= 0L) return
|
if (top <= 0L) return
|
||||||
|
olderLoadPending = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutex.withLock {
|
try {
|
||||||
val first = rows.minOfOrNull { it.position } ?: return@withLock
|
mutex.withLock {
|
||||||
if (first <= 0L) return@withLock
|
val first = rows.minOfOrNull { it.position } ?: return@withLock
|
||||||
val snapshot = queueRepository.snapshot()
|
if (first <= 0L) return@withLock
|
||||||
val collected = mutableListOf<QueueWindowItem>()
|
val snapshot = queueRepository.snapshot()
|
||||||
var end = first - 1
|
val collected = mutableListOf<QueueWindowItem>()
|
||||||
while (end >= 0L && collected.size < QUEUE_CHUNK) {
|
var end = first - 1
|
||||||
val start = (end - QUEUE_CHUNK + 1).coerceAtLeast(0)
|
while (end >= 0L && collected.size < QUEUE_CHUNK) {
|
||||||
collected.addAll(0, queueRepository.range(snapshot, start, end).filter(::visible))
|
val start = (end - QUEUE_CHUNK + 1).coerceAtLeast(0)
|
||||||
end = start - 1
|
collected.addAll(0, queueRepository.range(snapshot, start, end).filter(::visible))
|
||||||
|
end = start - 1
|
||||||
|
}
|
||||||
|
if (collected.isEmpty()) return@withLock
|
||||||
|
rows.addAll(0, collected.map { newRow(it, snapshot.resetVersion) })
|
||||||
|
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(rows.size - 1)
|
||||||
}
|
}
|
||||||
if (collected.isEmpty()) return@withLock
|
} finally {
|
||||||
rows.addAll(0, collected.map(::newRow))
|
olderLoadPending = false
|
||||||
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(rows.size - 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,7 +254,7 @@ class QueueViewModel(
|
|||||||
start = to + 1
|
start = to + 1
|
||||||
}
|
}
|
||||||
if (collected.isEmpty()) return@withLock
|
if (collected.isEmpty()) return@withLock
|
||||||
rows.addAll(collected.map(::newRow))
|
rows.addAll(collected.map { newRow(it, snapshot.resetVersion) })
|
||||||
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(0)
|
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,24 +267,68 @@ class QueueViewModel(
|
|||||||
if (fromIndex == toIndex) return
|
if (fromIndex == toIndex) return
|
||||||
if (fromIndex !in rows.indices || toIndex !in rows.indices) return
|
if (fromIndex !in rows.indices || toIndex !in rows.indices) return
|
||||||
if (rows[fromIndex].upNext != rows[toIndex].upNext) return
|
if (rows[fromIndex].upNext != rows[toIndex].upNext) return
|
||||||
|
if (rows[fromIndex].resetVersion != rows[toIndex].resetVersion) return
|
||||||
|
val before = dragRollback?.before ?: rows.map { it.id }
|
||||||
rows.add(toIndex, rows.removeAt(fromIndex))
|
rows.add(toIndex, rows.removeAt(fromIndex))
|
||||||
|
dragRollback = ReorderRollback(before, rows.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun play(position: Long) {
|
fun play(position: Long) {
|
||||||
viewModelScope.launch { playbackController.playAt(position) }
|
val resetVersion = rows.firstOrNull { it.position == position }?.resetVersion ?: return
|
||||||
|
viewModelScope.launch { playbackController.playAt(position, resetVersion) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rollbackReorder(edit: ReorderRollback?) {
|
||||||
|
if (edit == null || rows.map { it.id } != edit.after) return
|
||||||
|
val current = rows.associateBy { it.id }
|
||||||
|
rows.clear()
|
||||||
|
rows.addAll(edit.before.map { current.getValue(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rejectMove(
|
||||||
|
edit: ReorderRollback?,
|
||||||
|
onComplete: (Boolean) -> Unit = {},
|
||||||
|
): Boolean {
|
||||||
|
viewModelScope.launch {
|
||||||
|
mutex.withLock {
|
||||||
|
rollbackReorder(edit)
|
||||||
|
reconcileLocked()
|
||||||
|
}
|
||||||
|
onComplete(false)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelMove() {
|
||||||
|
val edit = dragRollback ?: return
|
||||||
|
dragRollback = null
|
||||||
|
rejectMove(edit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun move(
|
fun move(
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
|
targetResetVersion: Long? = null,
|
||||||
|
onComplete: (Boolean) -> Unit = {},
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (from == to) return false
|
val edit = dragRollback
|
||||||
if (reorderPending) return false
|
dragRollback = null
|
||||||
|
if (from == to || reorderPending) return rejectMove(edit, onComplete)
|
||||||
|
val resetVersion = rows.firstOrNull { it.position == from }?.resetVersion ?: return rejectMove(edit, onComplete)
|
||||||
|
val targetResetVersion = targetResetVersion ?: rows.firstOrNull { it.position == to }?.resetVersion ?: resetVersion
|
||||||
|
if (resetVersion != targetResetVersion) return rejectMove(edit, onComplete)
|
||||||
|
val expectedContentVersion = queueRepository.contentVersion()
|
||||||
reorderPending = true
|
reorderPending = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
playbackController.move(from, to)
|
val applied =
|
||||||
mutex.withLock { reconcileLocked() }
|
mutex.withLock {
|
||||||
|
val applied = playbackController.move(from, to, resetVersion, expectedContentVersion)
|
||||||
|
if (!applied) rollbackReorder(edit)
|
||||||
|
reconcileLocked()
|
||||||
|
applied
|
||||||
|
}
|
||||||
|
onComplete(applied)
|
||||||
} finally {
|
} finally {
|
||||||
reorderPending = false
|
reorderPending = false
|
||||||
}
|
}
|
||||||
@@ -266,14 +336,38 @@ class QueueViewModel(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(position: Long) {
|
fun remove(
|
||||||
|
position: Long,
|
||||||
|
onComplete: (Boolean) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val expected = rows.firstOrNull { it.position == position }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
mutex.withLock {
|
val applied =
|
||||||
val index = rows.indexOfFirst { it.position == position }
|
mutex.withLock {
|
||||||
if (index >= 0) rows.removeAt(index)
|
if (expected == null) {
|
||||||
playbackController.removeAt(position)
|
reconcileLocked()
|
||||||
reconcileLocked()
|
return@withLock false
|
||||||
}
|
}
|
||||||
|
val index =
|
||||||
|
rows.indexOfFirst {
|
||||||
|
it.id == expected.id && it.position == expected.position && it.resetVersion == expected.resetVersion
|
||||||
|
}
|
||||||
|
if (index < 0 || (playbackController.state.value.shuffle && !rows[index].upNext)) {
|
||||||
|
reconcileLocked()
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
val applied = playbackController.removeAt(expected.position, expected.resetVersion)
|
||||||
|
if (applied) {
|
||||||
|
val currentIndex =
|
||||||
|
rows.indexOfFirst {
|
||||||
|
it.id == expected.id && it.position == expected.position && it.resetVersion == expected.resetVersion
|
||||||
|
}
|
||||||
|
if (currentIndex >= 0) rows.removeAt(currentIndex)
|
||||||
|
}
|
||||||
|
reconcileLocked()
|
||||||
|
applied
|
||||||
|
}
|
||||||
|
onComplete(applied)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +412,10 @@ fun QueueRoute(
|
|||||||
coverArt = sourceRepository::coverArt,
|
coverArt = sourceRepository::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onPlay = viewModel::play,
|
onPlay = viewModel::play,
|
||||||
onRemove = viewModel::remove,
|
onRemove = { position, onComplete -> viewModel.remove(position, onComplete) },
|
||||||
onReorder = viewModel::reorder,
|
onReorder = viewModel::reorder,
|
||||||
onMove = viewModel::move,
|
onMove = { move, onComplete -> viewModel.move(move.from, move.to, move.resetVersion, onComplete) },
|
||||||
|
onCancelMove = viewModel::cancelMove,
|
||||||
onLoadOlder = viewModel::loadOlder,
|
onLoadOlder = viewModel::loadOlder,
|
||||||
onLoadNewer = viewModel::loadNewer,
|
onLoadNewer = viewModel::loadNewer,
|
||||||
onUndo = viewModel::undo,
|
onUndo = viewModel::undo,
|
||||||
@@ -338,9 +433,10 @@ fun QueueScreen(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlay: (Long) -> Unit,
|
onPlay: (Long) -> Unit,
|
||||||
onRemove: (Long) -> Unit,
|
onRemove: (Long, (Boolean) -> Unit) -> Unit,
|
||||||
onReorder: (Int, Int) -> Unit,
|
onReorder: (Int, Int) -> Unit,
|
||||||
onMove: (Long, Long) -> Boolean,
|
onMove: (QueueMove, (Boolean) -> Unit) -> Unit,
|
||||||
|
onCancelMove: () -> Unit,
|
||||||
onLoadOlder: () -> Unit,
|
onLoadOlder: () -> Unit,
|
||||||
onLoadNewer: () -> Unit,
|
onLoadNewer: () -> Unit,
|
||||||
onUndo: () -> Unit,
|
onUndo: () -> Unit,
|
||||||
@@ -480,20 +576,25 @@ fun QueueScreen(
|
|||||||
Modifier.draggableHandle(
|
Modifier.draggableHandle(
|
||||||
onDragStarted = { dragId = row.id },
|
onDragStarted = { dragId = row.id },
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
dragId?.let { id ->
|
val move = dragId?.let { id -> rows.dropMoveFor(id) }
|
||||||
rows.dropMoveFor(id)?.let { move ->
|
if (move == null) {
|
||||||
if (onMove(move.from, move.to)) showUndo(queueReordered)
|
onCancelMove()
|
||||||
}
|
} else {
|
||||||
|
onMove(move) { applied -> if (applied) showUndo(queueReordered) }
|
||||||
}
|
}
|
||||||
dragId = null
|
dragId = null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onPlay(row.position) },
|
onClick = { onPlay(row.position) },
|
||||||
onRemove = {
|
onRemove =
|
||||||
onRemove(row.position)
|
if (enabled) {
|
||||||
showUndo(queueRemoved)
|
{
|
||||||
},
|
onRemove(row.position) { applied -> if (applied) showUndo(queueRemoved) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -557,9 +658,10 @@ internal fun dropTarget(
|
|||||||
return (before - if (before > from) 1 else 0) + 1
|
return (before - if (before > from) 1 else 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
internal data class QueueMove(
|
data class QueueMove(
|
||||||
val from: Long,
|
val from: Long,
|
||||||
val to: Long,
|
val to: Long,
|
||||||
|
val resetVersion: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
||||||
@@ -567,7 +669,8 @@ internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
|||||||
if (index < 0) return null
|
if (index < 0) return null
|
||||||
val from = this[index].position
|
val from = this[index].position
|
||||||
val to = dropTarget(this, index, from)
|
val to = dropTarget(this, index, from)
|
||||||
return if (to == from) null else QueueMove(from, to)
|
val resetVersion = this.getOrNull(index + 1)?.resetVersion ?: this.getOrNull(index - 1)?.resetVersion ?: this[index].resetVersion
|
||||||
|
return if (to == from) null else QueueMove(from, to, resetVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ModifierParameter")
|
@Suppress("ModifierParameter")
|
||||||
@@ -579,7 +682,7 @@ private fun QueueRowItem(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
dragHandle: Modifier?,
|
dragHandle: Modifier?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
SongRow(
|
SongRow(
|
||||||
song = row.song.song,
|
song = row.song.song,
|
||||||
@@ -588,12 +691,14 @@ private fun QueueRowItem(
|
|||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
IconButton(onClick = onRemove) {
|
if (onRemove != null) {
|
||||||
Icon(
|
IconButton(onClick = onRemove) {
|
||||||
imageVector = Icons.Rounded.Close,
|
Icon(
|
||||||
contentDescription = stringResource(R.string.queue_remove),
|
imageVector = Icons.Rounded.Close,
|
||||||
modifier = Modifier.size(20.dp),
|
contentDescription = stringResource(R.string.queue_remove),
|
||||||
)
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (dragHandle != null) {
|
if (dragHandle != null) {
|
||||||
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
@@ -85,6 +87,7 @@ fun SearchRoute(
|
|||||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
val results by viewModel.results.collectAsStateWithLifecycle()
|
val results by viewModel.results.collectAsStateWithLifecycle()
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
|
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
|
||||||
val actions =
|
val actions =
|
||||||
ItemActions(
|
ItemActions(
|
||||||
@@ -104,13 +107,29 @@ fun SearchRoute(
|
|||||||
if (next != 0) filterMask = next
|
if (next != 0) filterMask = next
|
||||||
},
|
},
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = {
|
||||||
onArtistClick = onArtistClick,
|
focusManager.clearFocus()
|
||||||
onPlaylistClick = onPlaylistClick,
|
onAlbumClick(it)
|
||||||
onSongClick = viewModel::play,
|
},
|
||||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
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,
|
filters: Set<SearchFilter> = ALL_FILTERS,
|
||||||
onToggleFilter: (SearchFilter) -> Unit = {},
|
onToggleFilter: (SearchFilter) -> Unit = {},
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
onBack: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -198,7 +218,7 @@ fun SearchScreen(
|
|||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
) {
|
) {
|
||||||
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
||||||
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick)
|
if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick)
|
||||||
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
||||||
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
||||||
}
|
}
|
||||||
@@ -291,19 +311,20 @@ private fun LazyListScope.albumResults(
|
|||||||
private fun LazyListScope.artistResults(
|
private fun LazyListScope.artistResults(
|
||||||
artists: List<Artist>,
|
artists: List<Artist>,
|
||||||
header: String,
|
header: String,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onClick: (Artist) -> Unit,
|
onClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (artists.isEmpty()) return
|
if (artists.isEmpty()) return
|
||||||
resultHeader(header)
|
resultHeader(header)
|
||||||
items(artists, key = { "artist-${it.id}" }) { artist ->
|
items(artists, key = { "artist-${it.id}" }) { artist ->
|
||||||
|
val art = artistArt(artist)
|
||||||
ArtistListRow(
|
ArtistListRow(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
coverArt = coverArt,
|
art = art,
|
||||||
info = null,
|
info = null,
|
||||||
onClick = { onClick(artist) },
|
onClick = { onClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import com.subtracks.data.model.Song
|
|||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import com.subtracks.ui.home.playSongInContext
|
import com.subtracks.ui.home.playSongInContext
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -74,6 +76,11 @@ class SearchViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
fun play(song: Song) {
|
fun play(song: Song) {
|
||||||
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ internal fun replayGainModeLabel(
|
|||||||
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
|
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
|
||||||
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
|
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
|
||||||
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
|
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(
|
internal fun replayGainPreampLabel(
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
|||||||
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||||
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||||
import com.subtracks.data.prefs.ReplayGainMode
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import com.subtracks.data.prefs.SongTapBehavior
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
@@ -101,6 +103,8 @@ private enum class SettingsDialog {
|
|||||||
SyncMode,
|
SyncMode,
|
||||||
ReplayGain,
|
ReplayGain,
|
||||||
ReplayGainPreamp,
|
ReplayGainPreamp,
|
||||||
|
SortNames,
|
||||||
|
SongTap,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -128,6 +132,8 @@ fun SettingsRoute(
|
|||||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||||
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||||
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||||
|
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
|
||||||
|
val songTapBehavior by viewModel.songTapBehavior.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
@@ -143,6 +149,8 @@ fun SettingsRoute(
|
|||||||
scrobbling = scrobbling,
|
scrobbling = scrobbling,
|
||||||
replayGainMode = replayGainMode,
|
replayGainMode = replayGainMode,
|
||||||
replayGainPreamp = replayGainPreamp,
|
replayGainPreamp = replayGainPreamp,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
songTapBehavior = songTapBehavior,
|
||||||
keepScreenOn = keepScreenOn,
|
keepScreenOn = keepScreenOn,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
verboseLogging = verboseLogging,
|
verboseLogging = verboseLogging,
|
||||||
@@ -158,6 +166,8 @@ fun SettingsRoute(
|
|||||||
onScrobblingChange = viewModel::setScrobbling,
|
onScrobblingChange = viewModel::setScrobbling,
|
||||||
onReplayGainModeChange = viewModel::setReplayGainMode,
|
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||||
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||||
|
onSortNameModeChange = viewModel::setSortNameMode,
|
||||||
|
onSongTapBehaviorChange = viewModel::setSongTapBehavior,
|
||||||
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
||||||
onOfflineChange = viewModel::setOfflineMode,
|
onOfflineChange = viewModel::setOfflineMode,
|
||||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||||
@@ -203,8 +213,10 @@ fun SettingsScreen(
|
|||||||
verboseLogging: Boolean = false,
|
verboseLogging: Boolean = false,
|
||||||
syncMode: SyncMode = SyncMode.WifiOnly,
|
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||||
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
|
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||||
replayGainPreamp: Float = 0f,
|
replayGainPreamp: Float = 0f,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.Name,
|
||||||
|
songTapBehavior: SongTapBehavior = SongTapBehavior.InOrder,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onEditServer: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
@@ -217,6 +229,8 @@ fun SettingsScreen(
|
|||||||
onScrobblingChange: (Boolean) -> Unit,
|
onScrobblingChange: (Boolean) -> Unit,
|
||||||
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||||
onReplayGainPreampChange: (Float) -> Unit = {},
|
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||||
|
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||||
|
onSongTapBehaviorChange: (SongTapBehavior) -> Unit = {},
|
||||||
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
||||||
onOfflineChange: (Boolean) -> Unit = {},
|
onOfflineChange: (Boolean) -> Unit = {},
|
||||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||||
@@ -348,6 +362,15 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item { SectionHeader(stringResource(R.string.settings_library_name)) }
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||||
|
supportingContent = { Text(sortNameModeLabel(LocalResources.current, sortNameMode)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SortNames },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -405,6 +428,14 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_music_options_song_tap_title)) },
|
||||||
|
supportingContent = { Text(songTapBehaviorLabel(LocalResources.current, songTapBehavior)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SongTap },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -603,6 +634,22 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SortNames -> {
|
||||||
|
SortNameModeDialog(
|
||||||
|
selected = sortNameMode,
|
||||||
|
onSelect = onSortNameModeChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SongTap -> {
|
||||||
|
SongTapBehaviorDialog(
|
||||||
|
selected = songTapBehavior,
|
||||||
|
onSelect = onSongTapBehaviorChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
@@ -756,6 +803,95 @@ 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)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun songTapBehaviorLabel(
|
||||||
|
resources: Resources,
|
||||||
|
behavior: SongTapBehavior,
|
||||||
|
): String =
|
||||||
|
when (behavior) {
|
||||||
|
SongTapBehavior.InOrder -> resources.getString(R.string.settings_music_options_song_tap_in_order)
|
||||||
|
SongTapBehavior.KeepShuffle -> resources.getString(R.string.settings_music_options_song_tap_keep_shuffle)
|
||||||
|
SongTapBehavior.Shuffle -> resources.getString(R.string.settings_music_options_song_tap_shuffle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SongTapBehaviorDialog(
|
||||||
|
selected: SongTapBehavior,
|
||||||
|
onSelect: (SongTapBehavior) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_music_options_song_tap_title)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.settings_music_options_song_tap_prompt),
|
||||||
|
options = SongTapBehavior.entries.map { it to songTapBehaviorLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplayGainModeDialog(
|
private fun ReplayGainModeDialog(
|
||||||
selected: ReplayGainMode,
|
selected: ReplayGainMode,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.subtracks.data.model.Source
|
|||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
import com.subtracks.data.prefs.ReplayGainMode
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import com.subtracks.data.prefs.SongTapBehavior
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
@@ -69,13 +71,23 @@ class SettingsViewModel(
|
|||||||
val replayGainMode: StateFlow<ReplayGainMode> =
|
val replayGainMode: StateFlow<ReplayGainMode> =
|
||||||
userPreferences
|
userPreferences
|
||||||
.replayGainMode()
|
.replayGainMode()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Auto)
|
||||||
|
|
||||||
val replayGainPreamp: StateFlow<Float> =
|
val replayGainPreamp: StateFlow<Float> =
|
||||||
userPreferences
|
userPreferences
|
||||||
.replayGainPreampDb()
|
.replayGainPreampDb()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
||||||
|
|
||||||
|
val sortNameMode: StateFlow<SortNameMode> =
|
||||||
|
userPreferences
|
||||||
|
.sortNameMode()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
|
||||||
|
|
||||||
|
val songTapBehavior: StateFlow<SongTapBehavior> =
|
||||||
|
userPreferences
|
||||||
|
.songTapBehavior()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SongTapBehavior.InOrder)
|
||||||
|
|
||||||
val keepScreenOn: StateFlow<Boolean> =
|
val keepScreenOn: StateFlow<Boolean> =
|
||||||
userPreferences
|
userPreferences
|
||||||
.keepScreenOn()
|
.keepScreenOn()
|
||||||
@@ -143,4 +155,12 @@ class SettingsViewModel(
|
|||||||
fun setKeepScreenOn(enabled: Boolean) {
|
fun setKeepScreenOn(enabled: Boolean) {
|
||||||
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
|
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSortNameMode(mode: SortNameMode) {
|
||||||
|
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSongTapBehavior(behavior: SongTapBehavior) {
|
||||||
|
viewModelScope.launch { userPreferences.setSongTapBehavior(behavior) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,18 @@
|
|||||||
<string name="resources_album_name">专辑</string>
|
<string name="resources_album_name">专辑</string>
|
||||||
<string name="resources_artist_name">歌手</string>
|
<string name="resources_artist_name">歌手</string>
|
||||||
<string name="resources_playlist_name">播放列表</string>
|
<string name="resources_playlist_name">播放列表</string>
|
||||||
|
<string name="controls_shuffle_play">随机播放</string>
|
||||||
|
<string name="controls_play">播放</string>
|
||||||
|
<string name="controls_pause">暂停</string>
|
||||||
|
<string name="controls_next">下一首</string>
|
||||||
|
<string name="controls_previous">上一首</string>
|
||||||
|
<string name="controls_queue">队列</string>
|
||||||
|
<string name="controls_more">更多</string>
|
||||||
|
<string name="navigation_back">背 景</string>
|
||||||
|
<string name="settings_servers_actions_add">添加服务器</string>
|
||||||
|
<string name="settings_servers_actions_delete">删除服务器</string>
|
||||||
|
<string name="settings_servers_actions_edit">修改服务器</string>
|
||||||
|
<string name="settings_servers_actions_save">保存并同步</string>
|
||||||
|
<string name="settings_servers_fields_address">服务器地址</string>
|
||||||
|
<string name="settings_servers_name">服务器</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -35,6 +35,14 @@
|
|||||||
<string name="resources_filter_artist">Artist</string>
|
<string name="resources_filter_artist">Artist</string>
|
||||||
<string name="artist_type_artist">Artist</string>
|
<string name="artist_type_artist">Artist</string>
|
||||||
<string name="artist_type_album_artist">Album 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_genre">Genre</string>
|
||||||
<string name="resources_filter_starred">Starred</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>
|
<string name="resources_song_list_delete_all_content">%1$s\n\nDeleting will free %2$s</string>
|
||||||
@@ -71,10 +79,17 @@
|
|||||||
<string name="settings_about_verbose_logging">Verbose logging</string>
|
<string name="settings_about_verbose_logging">Verbose logging</string>
|
||||||
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</string>
|
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</string>
|
||||||
<string name="settings_about_version">Version %1$s</string>
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_library_name">Library</string>
|
||||||
<string name="settings_music_name">Playback</string>
|
<string name="settings_music_name">Playback</string>
|
||||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||||
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
||||||
|
<string name="settings_music_options_song_tap_in_order">Play without shuffling</string>
|
||||||
|
<string name="settings_music_options_song_tap_keep_shuffle">Keep current shuffle mode</string>
|
||||||
|
<string name="settings_music_options_song_tap_prompt">When tapping a song in a list</string>
|
||||||
|
<string name="settings_music_options_song_tap_shuffle">Play and shuffle</string>
|
||||||
|
<string name="settings_music_options_song_tap_title">Song tap behavior</string>
|
||||||
<string name="settings_replay_gain_mode_album">Album gain</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_off">Off</string>
|
||||||
<string name="settings_replay_gain_mode_track">Track gain</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_decrease">Lower preamp</string>
|
||||||
@@ -87,6 +102,11 @@
|
|||||||
<string name="settings_replay_gain_title">ReplayGain</string>
|
<string name="settings_replay_gain_title">ReplayGain</string>
|
||||||
<string name="settings_music_options_keep_screen_on_description">Keep the screen on while now playing is open</string>
|
<string name="settings_music_options_keep_screen_on_description">Keep the screen on while now playing is open</string>
|
||||||
<string name="settings_music_options_keep_screen_on_title">Keep screen on</string>
|
<string name="settings_music_options_keep_screen_on_title">Keep screen on</string>
|
||||||
|
<string name="settings_sort_names_title">Sort names</string>
|
||||||
|
<string name="settings_sort_names_prompt">Choose how names are ordered in album and artist lists.</string>
|
||||||
|
<string name="sort_name_mode_sort_name">Use sort names</string>
|
||||||
|
<string name="sort_name_mode_name">Use regular names</string>
|
||||||
|
<string name="sort_name_mode_non_latin">Non-Latin names only</string>
|
||||||
<string name="settings_network_name">Network</string>
|
<string name="settings_network_name">Network</string>
|
||||||
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
|
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
|
||||||
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
|
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
|
||||||
@@ -182,6 +202,24 @@
|
|||||||
<string name="info_sample_rate_value">%1$d Hz</string>
|
<string name="info_sample_rate_value">%1$d Hz</string>
|
||||||
<string name="info_channels">Channels</string>
|
<string name="info_channels">Channels</string>
|
||||||
<string name="info_close">Close</string>
|
<string name="info_close">Close</string>
|
||||||
|
<string name="info_artists">Artists</string>
|
||||||
|
<string name="info_artists_sort">Artists sort</string>
|
||||||
|
<string name="info_album_artists">Album artists</string>
|
||||||
|
<string name="info_album_artist_sort">Album artist sort</string>
|
||||||
|
<string name="info_sort_title">Sort title</string>
|
||||||
|
<string name="info_artist_sort">Artist sort</string>
|
||||||
|
<string name="info_sort_name">Sort name</string>
|
||||||
|
<string name="info_albums">Albums</string>
|
||||||
|
<string name="info_songs">Songs</string>
|
||||||
|
<string name="info_plays">Plays</string>
|
||||||
|
<string name="info_last_played">Last played</string>
|
||||||
|
<string name="info_added">Added</string>
|
||||||
|
<string name="info_created">Created</string>
|
||||||
|
<string name="info_updated">Updated</string>
|
||||||
|
<string name="info_owner">Owner</string>
|
||||||
|
<string name="info_shared">Shared</string>
|
||||||
|
<string name="info_length">Length</string>
|
||||||
|
<string name="info_downloaded_count">%1$d of %2$d</string>
|
||||||
|
|
||||||
<string name="disc_header">Disc %1$d</string>
|
<string name="disc_header">Disc %1$d</string>
|
||||||
<string name="disc_track">%1$s: Disc %2$d</string>
|
<string name="disc_track">%1$s: Disc %2$d</string>
|
||||||
|
|||||||
@@ -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
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
@@ -22,12 +18,7 @@ class ArtworkSeedDaoTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
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 kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class DownloadDaoTest {
|
||||||
|
private lateinit var db: SubtracksDatabase
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
db = createTestDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completedDownloadsUseTheDownloadedQueueVisibilityAndOrder() =
|
||||||
|
runTest {
|
||||||
|
val sourceId =
|
||||||
|
db.sourcesDao().insertSource(
|
||||||
|
Source(name = "source", address = "http://localhost", isActive = true, createdAt = 0),
|
||||||
|
)
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
song(sourceId, "s1", "Zulu"),
|
||||||
|
song(sourceId, "s2", "Alpha"),
|
||||||
|
song(sourceId, "s3", "Running"),
|
||||||
|
song(sourceId, "s4", "Pruned"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s3", DownloadStatus.Running, downloadedAt = 2))
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s4", DownloadStatus.Completed, downloadedAt = 3))
|
||||||
|
db.libraryDao().deleteSongs(sourceId, listOf("s4"))
|
||||||
|
|
||||||
|
assertEquals(listOf("s2", "s1"), db.downloadDao().completedDownloads(sourceId).map { it.songId })
|
||||||
|
assertEquals(
|
||||||
|
setOf("s1", "s2", "s3"),
|
||||||
|
db.downloadDao().queueVisibleSongIds(sourceId, listOf("s1", "s2", "s3", "s4")).toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun song(
|
||||||
|
sourceId: Long,
|
||||||
|
id: String,
|
||||||
|
sortName: String,
|
||||||
|
) = Song(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = id,
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
title = sortName,
|
||||||
|
sortName = sortName,
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
genre = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
@@ -27,12 +23,7 @@ class LibraryHomeDaoTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
|
|
||||||
|
internal fun LibraryDao.albumPages(
|
||||||
|
sourceId: Long,
|
||||||
|
starredFilter: Int = 0,
|
||||||
|
search: String = "",
|
||||||
|
sort: AlbumSort = AlbumSort.Name,
|
||||||
|
descending: Boolean = false,
|
||||||
|
downloaded: Boolean = false,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
|
): PagingSource<Int, Album> =
|
||||||
|
if (downloaded) {
|
||||||
|
downloadedAlbumsPaging(
|
||||||
|
albumsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
starredFilter = starredFilter,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = true,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
albumsPaging(
|
||||||
|
albumsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
starredFilter = starredFilter,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = false,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun LibraryDao.artistPages(
|
||||||
|
sourceId: Long,
|
||||||
|
starredFilter: Int = 0,
|
||||||
|
search: String = "",
|
||||||
|
sort: ArtistSort = ArtistSort.Name,
|
||||||
|
descending: Boolean = false,
|
||||||
|
downloaded: Boolean = false,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
|
): PagingSource<Int, Artist> =
|
||||||
|
if (downloaded) {
|
||||||
|
downloadedArtistsPaging(
|
||||||
|
artistsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
starredFilter = starredFilter,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = true,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
artistsPaging(
|
||||||
|
artistsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
starredFilter = starredFilter,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = false,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun LibraryDao.playlistPages(
|
||||||
|
sourceId: Long,
|
||||||
|
search: String = "",
|
||||||
|
sort: PlaylistSort = PlaylistSort.Name,
|
||||||
|
descending: Boolean = false,
|
||||||
|
downloaded: Boolean = false,
|
||||||
|
): PagingSource<Int, Playlist> =
|
||||||
|
if (downloaded) {
|
||||||
|
downloadedPlaylistsPaging(
|
||||||
|
playlistsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
playlistsPaging(
|
||||||
|
playlistsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.SQLiteConnection
|
import androidx.sqlite.SQLiteConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.sqlite.execSQL
|
import androidx.sqlite.execSQL
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.model.PlaylistSong
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
|
import com.subtracks.data.repo.QueueRepository
|
||||||
|
import com.subtracks.data.repo.QueueResetPreparation
|
||||||
|
import com.subtracks.data.repo.removeAt
|
||||||
|
import com.subtracks.data.repo.replace
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -136,6 +147,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
|
@Test
|
||||||
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
|
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -272,6 +405,226 @@ class MigrationsTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theCreditTotalsBackfillUsesEveryCreditedSong() =
|
||||||
|
runTest {
|
||||||
|
driver.open(":memory:").use { migrated ->
|
||||||
|
applySchema(migrated, 33)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (2, 't', 'http://y', 1, 0)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (3, 'u', 'http://z', 1, 0)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||||
|
"VALUES (1, 'a1', 'Album Artist', 1, 0, 0, NULL)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||||
|
"VALUES (1, 'a2', 'Guest', 0, 0, 0, NULL)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||||
|
"VALUES (2, 'b1', 'Unsynced', 7, 6, 5, 100)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||||
|
"VALUES (3, 'c1', 'Track Only', 7, 6, 5, 100)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||||
|
"VALUES (1, 'al1', 'a1', 'Album', 'Album Artist', 0, 1)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||||
|
"VALUES (1, 'al2', 'a2', 'Empty Album', 'Guest', 0, 0)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
|
||||||
|
"VALUES (1, 'al1', 0, 'a1', 'Album Artist')",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
|
||||||
|
"VALUES (1, 's1', 'al1', 'a1', 'One', 4, 200)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||||
|
"VALUES (1, 's1', 0, 'a1', 'Album Artist')",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||||
|
"VALUES (1, 's1', 1, 'a2', 'Guest')",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
|
||||||
|
"VALUES (3, 's3', NULL, 'c1', 'Three', 2, 50)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||||
|
"VALUES (3, 's3', 0, 'c1', 'Track Only')",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
|
||||||
|
"VALUES (3, 's4', 'missing', 'c1', 'Four', 3, 60)",
|
||||||
|
)
|
||||||
|
|
||||||
|
MIGRATION_33_34.migrate(migrated)
|
||||||
|
|
||||||
|
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a1'"))
|
||||||
|
assertEquals(1L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a1'"))
|
||||||
|
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a1'"))
|
||||||
|
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a1'"))
|
||||||
|
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a2'"))
|
||||||
|
assertEquals(2L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a2'"))
|
||||||
|
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a2'"))
|
||||||
|
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a2'"))
|
||||||
|
assertEquals(6L, long(migrated, "SELECT songCount FROM artists WHERE id = 'b1'"))
|
||||||
|
assertEquals(7L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'b1'"))
|
||||||
|
assertEquals(5L, long(migrated, "SELECT playCount FROM artists WHERE id = 'b1'"))
|
||||||
|
assertEquals(100L, long(migrated, "SELECT played FROM artists WHERE id = 'b1'"))
|
||||||
|
assertEquals(2L, long(migrated, "SELECT songCount FROM artists WHERE id = 'c1'"))
|
||||||
|
assertEquals(0L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'c1'"))
|
||||||
|
assertEquals(5L, long(migrated, "SELECT playCount FROM artists WHERE id = 'c1'"))
|
||||||
|
assertEquals(60L, long(migrated, "SELECT played FROM artists WHERE id = 'c1'"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun migratedFragmentedDuplicateEntriesAreLeftUnresetWhenTheirIdentityIsUnknown() =
|
||||||
|
runTest {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val name = "migration-38-queue-${System.nanoTime()}.db"
|
||||||
|
val databaseFile = context.getDatabasePath(name)
|
||||||
|
databaseFile.parentFile?.mkdirs()
|
||||||
|
driver.open(databaseFile.absolutePath).use { migrated ->
|
||||||
|
applySchema(migrated, 38)
|
||||||
|
migrated.execSQL("PRAGMA user_version = 38")
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 'server', 'http://localhost', 1, 0)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO songs (sourceId, id, title) VALUES " +
|
||||||
|
"(1, 's1', 'One'), (1, 's2', 'Two'), (1, 's3', 'Three')",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO playlist_songs (sourceId, playlistId, songId, position) VALUES " +
|
||||||
|
"(1, 'p1', 's1', 0), (1, 'p1', 's2', 1), (1, 'p1', 's3', 2)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO queue_entries (position, sourceId, kind, refId, rangeStart, rangeEnd) VALUES " +
|
||||||
|
"(0, 1, 'Playlist', 'p1', 0, 0), (1, 1, 'Playlist', 'p1', 2, 2), " +
|
||||||
|
"(2, 1, 'Playlist', 'p1', 0, 0), (3, 1, 'Playlist', 'p1', 2, 2)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val db =
|
||||||
|
Room
|
||||||
|
.databaseBuilder<SubtracksDatabase>(context, name)
|
||||||
|
.setDriver(driver)
|
||||||
|
.addMigrations(MIGRATION_38_39)
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
val repository = QueueRepository(db)
|
||||||
|
val point = repository.captureResetPoint()
|
||||||
|
|
||||||
|
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||||
|
|
||||||
|
val after = repository.snapshot()
|
||||||
|
assertEquals(4, after.entries.size)
|
||||||
|
assertEquals(listOf(null, null, null, null), after.entries.map { it.entry.originEntryId })
|
||||||
|
assertEquals(
|
||||||
|
listOf("s1", "s3", "s1", "s3"),
|
||||||
|
(0 until after.size).map { repository.itemAt(after, it)?.song?.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.removeAt(after, 0)
|
||||||
|
repository.replace(after.entries.map { it.entry })
|
||||||
|
val undoPoint = repository.captureResetPoint()
|
||||||
|
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), undoPoint)
|
||||||
|
|
||||||
|
val undone = repository.snapshot()
|
||||||
|
assertEquals(4, undone.entries.size)
|
||||||
|
assertEquals(
|
||||||
|
listOf("s1", "s3", "s1", "s3"),
|
||||||
|
(0 until undone.size).map { repository.itemAt(undone, it)?.song?.id },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRetainedMigratedOriginStillRemapsAndPublishesWhenItsRowsChange() =
|
||||||
|
runTest {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val name = "migration-38-remap-${System.nanoTime()}.db"
|
||||||
|
databaseFile(context, name).parentFile?.mkdirs()
|
||||||
|
driver.open(databaseFile(context, name).absolutePath).use { migrated ->
|
||||||
|
applySchema(migrated, 38)
|
||||||
|
migrated.execSQL("PRAGMA user_version = 38")
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 'server', 'http://localhost', 1, 0)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO songs (sourceId, id, title) VALUES " +
|
||||||
|
"(1, 's0', 'Zero'), (1, 's1', 'One'), (1, 's2', 'Two'), (1, 's3', 'Three'), (1, 's4', 'Four')",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO playlist_songs (sourceId, playlistId, songId, position) VALUES " +
|
||||||
|
"(1, 'p1', 's1', 0), (1, 'p1', 's2', 1), (1, 'p1', 's3', 2), (1, 'p1', 's4', 3)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO queue_entries (position, sourceId, kind, refId, rangeStart, rangeEnd) VALUES " +
|
||||||
|
"(0, 1, 'Playlist', 'p1', 0, 1), (1, 1, 'Playlist', 'p1', 2, 3)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val db =
|
||||||
|
Room
|
||||||
|
.databaseBuilder<SubtracksDatabase>(context, name)
|
||||||
|
.setDriver(driver)
|
||||||
|
.addMigrations(MIGRATION_38_39)
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
val repository = QueueRepository(db)
|
||||||
|
repository.setCursor(2)
|
||||||
|
val point = repository.captureResetPoint()
|
||||||
|
var resets = 0
|
||||||
|
repository.setResetListener {
|
||||||
|
resets++
|
||||||
|
QueueResetPreparation(it.snapshot)
|
||||||
|
}
|
||||||
|
db.libraryDao().deletePlaylistSongsFrom(1, "p1", 0)
|
||||||
|
db.libraryDao().upsertPlaylistSongs(
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "p1", "s0", 0),
|
||||||
|
PlaylistSong(1, "p1", "s1", 1),
|
||||||
|
PlaylistSong(1, "p1", "s2", 2),
|
||||||
|
PlaylistSong(1, "p1", "s3", 3),
|
||||||
|
PlaylistSong(1, "p1", "s4", 4),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||||
|
|
||||||
|
val snapshot = repository.snapshot()
|
||||||
|
assertEquals(1, resets)
|
||||||
|
assertEquals(3L, repository.cursor())
|
||||||
|
assertEquals("s3", repository.itemAt(snapshot, repository.cursor())?.song?.id)
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun databaseFile(
|
||||||
|
context: Context,
|
||||||
|
name: String,
|
||||||
|
): File = context.getDatabasePath(name)
|
||||||
|
|
||||||
private fun text(
|
private fun text(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
sql: String,
|
sql: String,
|
||||||
@@ -290,6 +643,16 @@ class MigrationsTest {
|
|||||||
statement.getLong(0)
|
statement.getLong(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun triggerSql(
|
||||||
|
connection: SQLiteConnection,
|
||||||
|
trigger: String,
|
||||||
|
): String =
|
||||||
|
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?").use { statement ->
|
||||||
|
statement.bindText(1, trigger)
|
||||||
|
check(statement.step()) { "No trigger $trigger" }
|
||||||
|
statement.getText(0)
|
||||||
|
}
|
||||||
|
|
||||||
private fun indexColumn(
|
private fun indexColumn(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
index: String,
|
index: String,
|
||||||
@@ -323,6 +686,10 @@ class MigrationsTest {
|
|||||||
for (i in 0 until indices.length()) {
|
for (i in 0 until indices.length()) {
|
||||||
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name))
|
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name))
|
||||||
}
|
}
|
||||||
|
val triggers = entity.optJSONArray("contentSyncTriggers") ?: continue
|
||||||
|
for (i in 0 until triggers.length()) {
|
||||||
|
connection.execSQL(triggers.getString(i))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.immediateTransaction
|
import androidx.room3.immediateTransaction
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
@@ -14,6 +11,9 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.Source
|
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.MusicSource
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.data.sync.SyncService
|
import com.subtracks.data.sync.SyncService
|
||||||
@@ -37,9 +37,8 @@ class QueryTimingMeasurementTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||||
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build()
|
db = createTestDatabase(driver)
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -105,28 +104,37 @@ class QueryTimingMeasurementTest {
|
|||||||
|
|
||||||
private suspend fun measureReads() {
|
private suspend fun measureReads() {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
timed("albums first page (name)") { dao.albumsByName(1, 0, "").firstPage() }
|
timed("albums first page (name)") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).firstPage() }
|
||||||
timed("albums first page (artist)") { dao.albumsByArtist(1, 0, "").firstPage() }
|
timed("albums first page (name, downloaded)") {
|
||||||
timed("albums first page (year)") { dao.albumsByYear(1, 0, "").firstPage() }
|
dao.albumPages(1, 0, "", sort = AlbumSort.Name, downloaded = true).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 (artist)") { dao.albumPages(1, 0, "", sort = AlbumSort.Artist).firstPage() }
|
||||||
timed("albums first page (frequent)") { dao.albumsByFrequent(1, 0, "").firstPage() }
|
timed("albums first page (year)") { dao.albumPages(1, 0, "", sort = AlbumSort.Year).firstPage() }
|
||||||
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
|
timed("albums first page (added)") { dao.albumPages(1, 0, "", sort = AlbumSort.Added).firstPage() }
|
||||||
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
|
timed("albums first page (starred)") { dao.albumPages(1, 0, "", sort = AlbumSort.Starred).firstPage() }
|
||||||
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").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 albums") { dao.albumsForArtist(1, "ar0").first() }
|
||||||
timed("artist name") { dao.artistName(1, "ar0").first() }
|
timed("artist name") { dao.artistName(1, "ar0").first() }
|
||||||
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
|
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
|
||||||
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
|
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
|
||||||
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
|
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
|
||||||
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
|
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
|
||||||
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
|
timed("artists downloaded filter") { dao.artistPages(1, 0, "", downloaded = true).firstPage() }
|
||||||
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
|
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
|
||||||
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
|
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
|
||||||
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
|
timed("playlists first page (name)") { dao.playlistPages(1, "", sort = PlaylistSort.Name).firstPage() }
|
||||||
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
|
timed("playlists first page (name, downloaded)") {
|
||||||
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
|
dao.playlistPages(1, "", sort = PlaylistSort.Name, downloaded = true).firstPage()
|
||||||
timed("albums search short") { dao.albumsByName(1, 0, "al").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("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
|
||||||
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
|
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
|
||||||
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
|
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
|
||||||
@@ -134,7 +142,7 @@ class QueryTimingMeasurementTest {
|
|||||||
timed("home recently starred") { dao.recentlyStarredSongs(1, 20).first() }
|
timed("home recently starred") { dao.recentlyStarredSongs(1, 20).first() }
|
||||||
timed("home genres") { dao.genresByMostPlayed(1).first() }
|
timed("home genres") { dao.genresByMostPlayed(1).first() }
|
||||||
timed("home decades") { dao.decades(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> =
|
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()
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ class FakeDownloadEngine : DownloadEngine {
|
|||||||
var enqueueDelayMs = 0L
|
var enqueueDelayMs = 0L
|
||||||
var failEnqueueFor: String? = null
|
var failEnqueueFor: String? = null
|
||||||
var failCancelOnEmpty = false
|
var failCancelOnEmpty = false
|
||||||
|
var beforeDownload: ((Long) -> Unit)? = null
|
||||||
|
|
||||||
override fun enqueue(request: EngineRequest): Long {
|
override fun enqueue(request: EngineRequest): Long {
|
||||||
if (enqueueDelayMs > 0) Thread.sleep(enqueueDelayMs)
|
if (enqueueDelayMs > 0) Thread.sleep(enqueueDelayMs)
|
||||||
@@ -23,7 +24,10 @@ class FakeDownloadEngine : DownloadEngine {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun download(id: Long): EngineDownload? = downloads[id]
|
override fun download(id: Long): EngineDownload? {
|
||||||
|
beforeDownload?.invoke(id)
|
||||||
|
return downloads[id]
|
||||||
|
}
|
||||||
|
|
||||||
override fun cancel(ids: List<Long>) {
|
override fun cancel(ids: List<Long>) {
|
||||||
if (ids.isEmpty() && failCancelOnEmpty) throw IllegalArgumentException("no ids")
|
if (ids.isEmpty() && failCancelOnEmpty) throw IllegalArgumentException("no ids")
|
||||||
|
|||||||
@@ -51,6 +51,42 @@ class UserPreferencesTest {
|
|||||||
file.delete()
|
file.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theSortNameModeRoundTrips() =
|
||||||
|
runTest {
|
||||||
|
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||||
|
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||||
|
val prefs = UserPreferences(store)
|
||||||
|
|
||||||
|
assertEquals(SortNameMode.Name, prefs.sortNameMode().first())
|
||||||
|
|
||||||
|
prefs.setSortNameMode(SortNameMode.NonLatin)
|
||||||
|
|
||||||
|
assertEquals(SortNameMode.NonLatin, prefs.sortNameMode().first())
|
||||||
|
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun songTapBehaviorDefaultsToInOrderAndRoundTrips() =
|
||||||
|
runTest {
|
||||||
|
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||||
|
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||||
|
val prefs = UserPreferences(store)
|
||||||
|
|
||||||
|
assertEquals(SongTapBehavior.InOrder, prefs.songTapBehavior().first())
|
||||||
|
|
||||||
|
prefs.setSongTapBehavior(SongTapBehavior.Shuffle)
|
||||||
|
|
||||||
|
assertEquals(SongTapBehavior.Shuffle, prefs.songTapBehavior().first())
|
||||||
|
|
||||||
|
store.edit { it[stringPreferencesKey("song_tap_behavior")] = "Bogus" }
|
||||||
|
|
||||||
|
assertEquals(SongTapBehavior.InOrder, prefs.songTapBehavior().first())
|
||||||
|
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theSelectedLibraryTabRoundTrips() =
|
fun theSelectedLibraryTabRoundTrips() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -210,13 +246,13 @@ class UserPreferencesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun replayGainDefaultsToTrackModeAndRoundTrips() =
|
fun replayGainDefaultsToAutoModeAndRoundTrips() =
|
||||||
runTest {
|
runTest {
|
||||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||||
val prefs = UserPreferences(store)
|
val prefs = UserPreferences(store)
|
||||||
|
|
||||||
assertEquals(ReplayGainMode.Track, prefs.replayGainMode().first())
|
assertEquals(ReplayGainMode.Auto, prefs.replayGainMode().first())
|
||||||
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
|
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
|
||||||
|
|
||||||
prefs.setReplayGainMode(ReplayGainMode.Album)
|
prefs.setReplayGainMode(ReplayGainMode.Album)
|
||||||
@@ -225,6 +261,9 @@ class UserPreferencesTest {
|
|||||||
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
|
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
|
||||||
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
|
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
|
||||||
|
|
||||||
|
prefs.setReplayGainMode(ReplayGainMode.Auto)
|
||||||
|
assertEquals(ReplayGainMode.Auto, prefs.replayGainMode().first())
|
||||||
|
|
||||||
prefs.setReplayGainPreampDb(99f)
|
prefs.setReplayGainPreampDb(99f)
|
||||||
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
|
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,18 @@ import android.content.Context
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.paging.PagingDataEvent
|
import androidx.paging.PagingDataEvent
|
||||||
import androidx.paging.PagingDataPresenter
|
import androidx.paging.PagingDataPresenter
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.model.Album
|
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.Song
|
||||||
|
import com.subtracks.data.model.SongDownload
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.prefs.AlbumSort
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
@@ -51,6 +54,7 @@ class LibraryRepositoryTest {
|
|||||||
private lateinit var db: SubtracksDatabase
|
private lateinit var db: SubtracksDatabase
|
||||||
private lateinit var sourceRepository: SourceRepository
|
private lateinit var sourceRepository: SourceRepository
|
||||||
private lateinit var repository: LibraryRepository
|
private lateinit var repository: LibraryRepository
|
||||||
|
private lateinit var queues: QueueRepository
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
private val messages = ArrayList<String>()
|
private val messages = ArrayList<String>()
|
||||||
private lateinit var resources: Resources
|
private lateinit var resources: Resources
|
||||||
@@ -59,19 +63,18 @@ class LibraryRepositoryTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
resources = context.resources
|
resources = context.resources
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
sourceRepository =
|
sourceRepository =
|
||||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||||
|
queues = QueueRepository(db)
|
||||||
repository =
|
repository =
|
||||||
LibraryRepository(
|
LibraryRepository(
|
||||||
db,
|
db,
|
||||||
sourceRepository,
|
sourceRepository,
|
||||||
NetworkServerActionSink(sourceRepository),
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
fakeUserPreferences(),
|
||||||
|
queueRepository = queues,
|
||||||
showMessage = { messages += it.resolve(resources) },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
@@ -159,6 +162,26 @@ class LibraryRepositoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun starringAMissingSongDoesNotResetTheStarredQueue() =
|
||||||
|
runBlocking {
|
||||||
|
withSource { server ->
|
||||||
|
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||||
|
db.libraryDao().setSongStar(sourceId, "s1", 1)
|
||||||
|
queues.replace(listOf(queues.starredEntry(sourceId).copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
val before = queues.snapshot()
|
||||||
|
server.enqueue(ok())
|
||||||
|
|
||||||
|
assertTrue(repository.setStar(StarType.Song, "pruned-id", true).isSuccess)
|
||||||
|
|
||||||
|
val after = queues.snapshot()
|
||||||
|
assertEquals(before.resetVersion, after.resetVersion)
|
||||||
|
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
|
||||||
|
assertNull(db.libraryDao().songOnce(sourceId, "pruned-id"))
|
||||||
|
assertEquals("/rest/star.view", server.takeRequest().requestUrl!!.encodedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun albumSongOrdinalFollowsTheAlbumOrder() =
|
fun albumSongOrdinalFollowsTheAlbumOrder() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -203,6 +226,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(
|
private suspend fun awaitRefreshes(
|
||||||
presenter: RecordingPresenter,
|
presenter: RecordingPresenter,
|
||||||
target: Int,
|
target: Int,
|
||||||
@@ -279,6 +493,13 @@ class LibraryRepositoryTest {
|
|||||||
genre = null,
|
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 ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
|
||||||
|
|
||||||
private fun failed(code: Int) =
|
private fun failed(code: Int) =
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package com.subtracks.data.repo
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.PendingActionKind
|
import com.subtracks.data.model.PendingActionKind
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
@@ -82,12 +79,7 @@ class PendingServerActionSinkTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
||||||
delegate = RecordingSink()
|
delegate = RecordingSink()
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useReaderConnection
|
import androidx.room3.useReaderConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -26,12 +23,7 @@ class QueueKeysetTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
repository = QueueRepository(db)
|
repository = QueueRepository(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +150,18 @@ class QueueKeysetTest {
|
|||||||
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
|
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
|
@Test
|
||||||
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
|
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -268,6 +272,22 @@ class QueueKeysetTest {
|
|||||||
assertTrue(ids.all { it in expected })
|
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
|
@Test
|
||||||
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
|
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
|
import com.subtracks.data.model.QueueEntry
|
||||||
|
|
||||||
|
suspend fun QueueRepository.replace(
|
||||||
|
entries: List<QueueEntry>,
|
||||||
|
shuffleSeed: Long? = null,
|
||||||
|
upNext: List<QueueEntry> = emptyList(),
|
||||||
|
upNextAnchor: Long = 0,
|
||||||
|
) = withMutationLock {
|
||||||
|
replace(entries, shuffleSeed, upNext, upNextAnchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun QueueRepository.setShuffle(seed: Long?) =
|
||||||
|
withMutationLock {
|
||||||
|
setShuffle(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun QueueRepository.addUpNext(
|
||||||
|
currentPosition: Long,
|
||||||
|
entry: QueueEntry,
|
||||||
|
playNext: Boolean,
|
||||||
|
): Long = withMutationLock { addUpNext(currentPosition, entry, playNext) }
|
||||||
|
|
||||||
|
suspend fun QueueRepository.reanchorFor(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
target: Long,
|
||||||
|
): Reanchored = withMutationLock { reanchorFor(snapshot, target) }
|
||||||
|
|
||||||
|
suspend fun QueueRepository.clearUpNext() =
|
||||||
|
withMutationLock {
|
||||||
|
clearUpNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun QueueRepository.removeUpNextAt(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
index: Long,
|
||||||
|
): Boolean = withMutationLock { removeUpNextAt(snapshot, index) }
|
||||||
|
|
||||||
|
suspend fun QueueRepository.removeAt(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
position: Long,
|
||||||
|
): Boolean = withMutationLock { removeAt(snapshot, position) }
|
||||||
|
|
||||||
|
suspend fun QueueRepository.move(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
from: Long,
|
||||||
|
to: Long,
|
||||||
|
): Boolean = withMutationLock { move(snapshot, from, to) }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,15 @@ package com.subtracks.data.repo
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.TEST_TIMEOUT_MS
|
import com.subtracks.TEST_TIMEOUT_MS
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
@@ -33,6 +33,7 @@ import okhttp3.mockwebserver.RecordedRequest
|
|||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -56,11 +57,7 @@ class SourceRepositoryTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
resources = context.resources
|
resources = context.resources
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||||
prefs = fakeUserPreferences()
|
prefs = fakeUserPreferences()
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@@ -283,7 +280,36 @@ class SourceRepositoryTest {
|
|||||||
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
|
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
|
||||||
}
|
}
|
||||||
|
|
||||||
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 =
|
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||||
withTimeout(TEST_TIMEOUT_MS) {
|
withTimeout(TEST_TIMEOUT_MS) {
|
||||||
|
|||||||
@@ -111,6 +111,37 @@ class SubsonicSourceTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun artistBiographyReadsGetArtistInfo2() =
|
||||||
|
runBlocking {
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse().setBody(
|
||||||
|
"<subsonic-response status=\"ok\"><artistInfo2>" +
|
||||||
|
"<biography>From Last.fm</biography>" +
|
||||||
|
"</artistInfo2></subsonic-response>",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val biography = SubsonicSource(1, client()).artistBiography("ar1")
|
||||||
|
|
||||||
|
assertEquals("From Last.fm", biography)
|
||||||
|
val request = server.takeRequest()
|
||||||
|
assertEquals("/rest/getArtistInfo2.view", request.requestUrl!!.encodedPath)
|
||||||
|
assertEquals("ar1", request.requestUrl!!.queryParameter("id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun artistBiographyIsNullWhenTheServerHasNone() =
|
||||||
|
runBlocking {
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse().setBody(
|
||||||
|
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(SubsonicSource(1, client()).artistBiography("ar1"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun artistsAreEmittedInBoundedBatches() =
|
fun artistsAreEmittedInBoundedBatches() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user