Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +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(ArtistSearchTriggerOptimization)
|
.addSubtracksDatabaseCallbacks()
|
||||||
.addCallback(SqliteLifecycleLogging)
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun RoomDatabase.Builder<SubtracksDatabase>.addSubtracksDatabaseCallbacks(): RoomDatabase.Builder<SubtracksDatabase> =
|
||||||
|
addCallback(ArtistSearchTriggerOptimization).addCallback(SqliteLifecycleLogging)
|
||||||
|
|||||||
@@ -10,15 +10,21 @@ import androidx.room3.Transaction
|
|||||||
import androidx.room3.Upsert
|
import androidx.room3.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.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
|
||||||
|
|
||||||
@@ -36,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"
|
||||||
@@ -70,6 +57,10 @@ internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName C
|
|||||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
|
|
||||||
|
private const val ALBUM_NAME_SORT_KEY_MODE =
|
||||||
|
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||||
|
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
||||||
|
|
||||||
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)"
|
||||||
|
|
||||||
@@ -93,116 +84,19 @@ internal const val CREDITED_SONG_ROWS =
|
|||||||
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||||
"WHERE album_artists.sourceId = :sourceId"
|
"WHERE album_artists.sourceId = :sourceId"
|
||||||
|
|
||||||
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
|
internal val ALBUMS_FOR_ARTIST =
|
||||||
internal const val CREDITED_ALBUM_ROWS =
|
|
||||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
|
||||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
|
||||||
"FROM album_artists JOIN albums " +
|
|
||||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM song_artists JOIN songs " +
|
|
||||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId"
|
|
||||||
|
|
||||||
internal const val ALBUMS_FOR_ARTIST =
|
|
||||||
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
||||||
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||||
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||||
|
|
||||||
internal const val ALBUM_CREDIT_ALBUM_IDS =
|
internal val ALBUM_CREDIT_ALBUM_IDS =
|
||||||
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
||||||
"WHERE artistId = :artistId AND creditType = 'album'"
|
"WHERE artistId = :artistId AND creditType = 'album'"
|
||||||
|
|
||||||
// Download visibility follows the credit path: an album credit includes the album when any song is
|
internal val DOWNLOADED_ARTIST_ALBUMS =
|
||||||
// downloaded, while a track credit includes it only when the credited track is downloaded.
|
|
||||||
internal const val DOWNLOADED_ALBUM_ROWS =
|
|
||||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
|
||||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId, songs.artistId FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
|
|
||||||
|
|
||||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
|
||||||
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
||||||
|
|
||||||
private const val DOWNLOADED_COVER_ALBUM_ROWS =
|
private const val ARTIST_COVER_RANKS_TAIL =
|
||||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
|
||||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId, songs.artistId FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
|
||||||
"AND sd.status = 'Completed' " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
|
|
||||||
|
|
||||||
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
|
|
||||||
private const val ARTIST_COVER_CREDITS =
|
|
||||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
|
||||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"AND albums.coverArt IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
|
||||||
"FROM album_artists JOIN albums " +
|
|
||||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM song_artists JOIN songs " +
|
|
||||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
|
|
||||||
|
|
||||||
internal const val ARTIST_ART_UPDATE_SQL =
|
|
||||||
"WITH credits AS (SELECT artistId, albumId, " +
|
|
||||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
|
||||||
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
|
||||||
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||||
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
@@ -210,7 +104,22 @@ internal const val ARTIST_ART_UPDATE_SQL =
|
|||||||
"WHERE albums.coverArt IS NOT NULL), " +
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
|
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
|
||||||
"deduped AS (SELECT artistId, coverArt, " +
|
"deduped AS (SELECT artistId, coverArt, " +
|
||||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked), " +
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
||||||
|
|
||||||
|
private val ARTIST_COVER_RANKS =
|
||||||
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
|
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||||
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
|
private val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||||
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
|
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||||
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
|
internal val ARTIST_ART_UPDATE_SQL =
|
||||||
|
"WITH $ARTIST_COVER_RANKS, " +
|
||||||
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
"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" +
|
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
") GROUP BY artistId), " +
|
") GROUP BY artistId), " +
|
||||||
@@ -235,7 +144,11 @@ internal const val ARTIST_ART_UPDATE_SQL =
|
|||||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
||||||
|
|
||||||
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
internal val ARTIST_COVER_ARTS_SQL =
|
||||||
|
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||||
|
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
||||||
|
|
||||||
|
internal val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||||
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||||
"WHERE artistId IN (:artistIds)), " +
|
"WHERE artistId IN (:artistIds)), " +
|
||||||
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
@@ -257,36 +170,14 @@ internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
|||||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER =
|
internal const val ARTIST_COLUMNS_TAIL =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, " +
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"artists.downloadedCoverArts, artists.playCount, artists.played, artists.songCount, " +
|
||||||
"AND $ARTIST_CREDITED " +
|
"artists.sortName, artists.credited"
|
||||||
"AND (:downloadedFilter = 0 OR EXISTS (" +
|
|
||||||
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
|
|
||||||
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
|
|
||||||
"AND (:search = '' " +
|
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
|
||||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
|
||||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
|
||||||
|
|
||||||
internal const val ARTIST_COLUMNS =
|
internal const val ARTIST_COLUMNS =
|
||||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
"artists.sourceId, artists.id, artists.name, artists.albumCount, $ARTIST_COLUMNS_TAIL"
|
||||||
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
|
||||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
|
|
||||||
|
|
||||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
|
||||||
// an artist matches the albums its detail screen lists.
|
|
||||||
internal const val ARTISTS_SELECT =
|
|
||||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
|
||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
|
||||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
|
||||||
") ELSE artists.albumCount END AS albumCount, " +
|
|
||||||
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
|
||||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
|
|
||||||
|
|
||||||
internal const val ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
|
||||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
@@ -296,26 +187,13 @@ internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, s
|
|||||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_RECENT = "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"
|
||||||
@@ -344,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 {
|
||||||
@@ -376,48 +287,37 @@ interface LibraryDao {
|
|||||||
)
|
)
|
||||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||||
|
|
||||||
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
|
@RawQuery
|
||||||
// queries are separate because their visibility follows the credit path.
|
suspend fun recomputeArtistAggregates(query: RoomRawQuery): Int
|
||||||
@Query(
|
|
||||||
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
|
||||||
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
|
||||||
"totals AS (" +
|
|
||||||
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
|
||||||
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
|
||||||
"FROM credited GROUP BY artistId" +
|
|
||||||
"), album_totals AS (" +
|
|
||||||
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
|
||||||
"FROM credited_albums GROUP BY artistId" +
|
|
||||||
") " +
|
|
||||||
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
|
||||||
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
|
||||||
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
|
||||||
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
|
||||||
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
|
||||||
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
|
||||||
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
|
||||||
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
|
||||||
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
|
||||||
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
|
||||||
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
|
||||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
|
||||||
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
|
||||||
"WHERE artists.sourceId = :sourceId) c " +
|
|
||||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
|
||||||
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
|
||||||
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
|
||||||
"OR artists.credited != c.credited)",
|
|
||||||
)
|
|
||||||
suspend fun recomputeArtistAggregates(sourceId: Long)
|
|
||||||
|
|
||||||
@Query(ARTIST_ART_UPDATE_SQL)
|
suspend fun recomputeArtistAggregates(sourceId: Long) {
|
||||||
suspend fun recomputeArtistArt(sourceId: Long)
|
recomputeArtistAggregates(rawQuery(ARTIST_AGGREGATES_SQL, mapOf("sourceId" to sourceId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@RawQuery
|
||||||
|
suspend fun recomputeArtistArt(query: RoomRawQuery): Int
|
||||||
|
|
||||||
|
suspend fun recomputeArtistArt(sourceId: Long) {
|
||||||
|
recomputeArtistArt(rawQuery(ARTIST_ART_UPDATE_SQL, mapOf("sourceId" to sourceId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||||
|
fun artistCoverArts(query: RoomRawQuery): Flow<List<String>>
|
||||||
|
|
||||||
|
fun artistCoverArts(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>> = artistCoverArts(rawQuery(ARTIST_COVER_ARTS_SQL, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||||
|
|
||||||
|
@RawQuery
|
||||||
|
suspend fun recomputeDownloadedArtistArt(query: RoomRawQuery): Int
|
||||||
|
|
||||||
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
|
|
||||||
suspend fun recomputeDownloadedArtistArt(
|
suspend fun recomputeDownloadedArtistArt(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistIds: List<String>,
|
artistIds: List<String>,
|
||||||
)
|
) {
|
||||||
|
recomputeDownloadedArtistArt(downloadedArtistArtQuery(sourceId, artistIds))
|
||||||
|
}
|
||||||
|
|
||||||
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
||||||
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
||||||
@@ -566,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 $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<Artist>>
|
): Flow<List<Artist>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -598,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(
|
||||||
@@ -745,239 +648,27 @@ interface LibraryDao {
|
|||||||
@RawQuery(observedEntities = [Playlist::class])
|
@RawQuery(observedEntities = [Playlist::class])
|
||||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
|
// 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>
|
||||||
@@ -1211,17 +902,21 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Flow<List<String>>
|
): Flow<List<String>>
|
||||||
|
|
||||||
@Query(ALBUMS_FOR_ARTIST)
|
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||||
|
fun albumsForArtist(query: RoomRawQuery): Flow<List<Album>>
|
||||||
|
|
||||||
fun albumsForArtist(
|
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>>
|
||||||
|
|
||||||
@Query(ALBUM_CREDIT_ALBUM_IDS)
|
|
||||||
fun albumCreditAlbumIds(
|
fun albumCreditAlbumIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<String>>
|
): Flow<List<String>> = albumCreditAlbumIds(rawQuery(ALBUM_CREDIT_ALBUM_IDS, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||||
|
|
||||||
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
||||||
fun discs(
|
fun discs(
|
||||||
@@ -1258,36 +953,16 @@ interface LibraryDao {
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
): PagingSource<Int, PlaylistSongItem>
|
): PagingSource<Int, PlaylistSongItem>
|
||||||
|
|
||||||
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
|
@RawQuery(
|
||||||
@Query(
|
observedEntities = [Album::class, Song::class, SongDownload::class, AlbumArtistRef::class, SongArtistRef::class],
|
||||||
"SELECT * FROM (" +
|
|
||||||
"SELECT albums.* FROM albums " +
|
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM album_artists " +
|
|
||||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM song_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
|
||||||
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
|
||||||
)
|
)
|
||||||
|
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 =
|
||||||
|
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
|
||||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||||
) { stmt ->
|
bindings =
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
mapOf(
|
||||||
stmt.bindLong(13, cutoff)
|
"sourceId" to sourceId,
|
||||||
|
"starredFilter" to starredFilter,
|
||||||
|
"search" to search,
|
||||||
|
"cutoff" to cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
|
||||||
|
// index, so the SQL can keep its names and only the first-appearance order needs binding.
|
||||||
|
internal fun rawQuery(
|
||||||
|
sql: String,
|
||||||
|
bindings: Map<String, Any?>,
|
||||||
|
): RoomRawQuery {
|
||||||
|
val values =
|
||||||
|
Regex(":(\\w+)")
|
||||||
|
.findAll(sql)
|
||||||
|
.map { it.groupValues[1] }
|
||||||
|
.distinct()
|
||||||
|
.map(bindings::getValue)
|
||||||
|
.toList()
|
||||||
|
return RoomRawQuery(sql) { statement ->
|
||||||
|
values.forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SQLiteStatement.bindAlbumFilters(
|
private fun SQLiteStatement.bind(
|
||||||
sourceId: Long,
|
index: Int,
|
||||||
starredFilter: Int,
|
value: Any?,
|
||||||
search: String,
|
|
||||||
) {
|
) {
|
||||||
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 $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
|
||||||
stmt.bindLong(1, sourceId)
|
|
||||||
stmt.bindInt(2, starredFilter)
|
|
||||||
stmt.bindInt(3, starredFilter)
|
|
||||||
stmt.bindInt(4, starredFilter)
|
|
||||||
stmt.bindText(5, search)
|
|
||||||
stmt.bindText(6, search)
|
|
||||||
stmt.bindText(7, search)
|
|
||||||
stmt.bindText(8, search)
|
|
||||||
stmt.bindText(9, search)
|
|
||||||
stmt.bindText(10, search)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ->
|
|
||||||
stmt.bindLong(1, sourceId)
|
|
||||||
stmt.bindText(2, search)
|
|
||||||
stmt.bindText(3, search)
|
|
||||||
stmt.bindText(4, search)
|
|
||||||
stmt.bindText(5, search)
|
|
||||||
stmt.bindText(6, search)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun albumArtistSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> {
|
||||||
|
"albumArtistSort COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
SortNameMode.Name -> {
|
||||||
|
"albumArtist COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
SortNameMode.NonLatin -> {
|
||||||
|
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistNameSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||||
|
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||||
|
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun albumOrder(
|
private fun albumOrder(
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
AlbumSort.Name -> {
|
||||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
val name = albumNameSortKey(mode)
|
||||||
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
}
|
||||||
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
|
||||||
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
AlbumSort.Artist -> {
|
||||||
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
val artist = albumArtistSortKey(mode)
|
||||||
|
val name = albumNameSortKey(mode)
|
||||||
|
if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id"
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Year -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Added -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Starred -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Frequent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Recent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun artistOrder(
|
private fun artistOrder(
|
||||||
sort: ArtistSort,
|
sort: ArtistSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
ArtistSort.Name -> {
|
ArtistSort.Name -> {
|
||||||
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
|
val name = artistNameSortKey(mode)
|
||||||
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
}
|
}
|
||||||
|
|
||||||
ArtistSort.AlbumCount -> {
|
ArtistSort.AlbumCount -> {
|
||||||
|
|||||||
@@ -706,6 +706,30 @@ val MIGRATION_35_36 =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_36_37 =
|
||||||
|
object : Migration(36, 37) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " +
|
||||||
|
"ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_37_38 =
|
||||||
|
object : Migration(37, 38) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `owner` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `shared` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||||
val triggers = mutableListOf<String>()
|
val triggers = mutableListOf<String>()
|
||||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||||
@@ -782,4 +806,6 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_33_34,
|
MIGRATION_33_34,
|
||||||
MIGRATION_34_35,
|
MIGRATION_34_35,
|
||||||
MIGRATION_35_36,
|
MIGRATION_35_36,
|
||||||
|
MIGRATION_36_37,
|
||||||
|
MIGRATION_37_38,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -214,14 +214,34 @@ 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 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(
|
||||||
@@ -243,17 +263,6 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.id FROM song_genres " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
|
||||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
|
||||||
)
|
|
||||||
suspend fun genreSongIds(
|
|
||||||
sourceId: Long,
|
|
||||||
genre: String,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
||||||
suspend fun downloadedLength(sourceId: Long): Long
|
suspend fun downloadedLength(sourceId: Long): Long
|
||||||
|
|
||||||
@@ -272,12 +281,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 " +
|
||||||
@@ -290,12 +293,4 @@ interface QueueDao {
|
|||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.id FROM song_downloads d " +
|
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
|
||||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
|
||||||
)
|
|
||||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 36,
|
version = 38,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
|
|||||||
@@ -58,9 +58,14 @@ data class SongDownload(
|
|||||||
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
||||||
) {
|
) {
|
||||||
val progress: Float?
|
val progress: Float?
|
||||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
get() = fractionOf(bytes, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun fractionOf(
|
||||||
|
bytes: Long,
|
||||||
|
total: Long,
|
||||||
|
): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||||
|
|
||||||
enum class DownloadList { Album, Playlist, Artist }
|
enum class DownloadList { Album, Playlist, Artist }
|
||||||
|
|
||||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||||
@@ -99,7 +104,7 @@ 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(
|
||||||
|
|||||||
@@ -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"],
|
||||||
@@ -131,7 +132,9 @@ data class ArtistBio(
|
|||||||
Index("sourceId"),
|
Index("sourceId"),
|
||||||
Index("sourceId", "artistId"),
|
Index("sourceId", "artistId"),
|
||||||
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||||
|
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
|
||||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||||
|
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||||
Index(
|
Index(
|
||||||
name = "index_albums_year",
|
name = "index_albums_year",
|
||||||
value = ["sourceId", "year", "sortName", "id"],
|
value = ["sourceId", "year", "sortName", "id"],
|
||||||
@@ -251,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(
|
||||||
|
|||||||
@@ -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,6 +59,8 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
|||||||
|
|
||||||
enum class SyncMode { Off, WifiOnly, Always }
|
enum class SyncMode { Off, WifiOnly, Always }
|
||||||
|
|
||||||
|
enum class SongTapBehavior { InOrder, KeepShuffle, Shuffle }
|
||||||
|
|
||||||
enum class ReplayGainMode { Off, Track, Album, Auto }
|
enum class ReplayGainMode { Off, Track, Album, Auto }
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
@@ -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) {
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ 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
|
||||||
@@ -24,27 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
|
|||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LibraryRepository(
|
class LibraryRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val userPreferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
) {
|
) {
|
||||||
@@ -111,12 +116,18 @@ class LibraryRepository(
|
|||||||
fun searchAlbums(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Album>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Artist>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchPlaylists(
|
fun searchPlaylists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -126,7 +137,10 @@ class LibraryRepository(
|
|||||||
fun searchSongs(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<AlbumSongItem>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||||
|
|
||||||
@@ -178,69 +192,20 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Album>> =
|
): Flow<PagingData<Album>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
pager(40, enablePlaceholders = true) {
|
pager(40, enablePlaceholders = true) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
albumsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
starredFilter = starred.ordinal,
|
||||||
when (sort) {
|
search = search,
|
||||||
AlbumSort.Name -> {
|
sort = sort,
|
||||||
if (descending) {
|
descending = descending,
|
||||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
downloaded = downloaded,
|
||||||
} else {
|
sortNameMode = mode,
|
||||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
)
|
||||||
}
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,53 +217,20 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Artist>> =
|
): Flow<PagingData<Artist>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
pager(60) {
|
pager(60) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
artistsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
starredFilter = starred.ordinal,
|
||||||
when (sort) {
|
search = search,
|
||||||
ArtistSort.Name -> {
|
sort = sort,
|
||||||
if (descending) {
|
descending = descending,
|
||||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
downloaded = downloaded,
|
||||||
} else {
|
sortNameMode = mode,
|
||||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
)
|
||||||
}
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,36 +243,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(
|
||||||
@@ -424,6 +335,11 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun artistCoverArts(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
|
||||||
|
|
||||||
fun albumDiscs(
|
fun albumDiscs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
@@ -444,10 +360,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,
|
||||||
|
|||||||
@@ -209,6 +209,202 @@ data class Reanchored(
|
|||||||
val changed: Boolean,
|
val changed: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private interface QueueKindResolver {
|
||||||
|
fun supportsKeyset(key: OrderKey): Boolean = false
|
||||||
|
|
||||||
|
suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
): List<SongItem>
|
||||||
|
|
||||||
|
suspend fun rowsFrom(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
): List<SongItem>? = null
|
||||||
|
|
||||||
|
suspend fun rowsBefore(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
): List<SongItem>? = null
|
||||||
|
|
||||||
|
suspend fun rank(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
): Long? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val queueKindResolvers =
|
||||||
|
mapOf<QueueKind, QueueKindResolver>(
|
||||||
|
QueueKind.Song to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.songLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
|
||||||
|
override suspend fun rank(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null
|
||||||
|
},
|
||||||
|
QueueKind.Playlist to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override fun supportsKeyset(key: OrderKey) = key.position != null
|
||||||
|
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.playlistLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
|
||||||
|
override suspend fun rowsFrom(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||||
|
|
||||||
|
override suspend fun rowsBefore(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||||
|
|
||||||
|
override suspend fun rank(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
) = dao.playlistRank(entry.sourceId, entry.refId, songId)
|
||||||
|
},
|
||||||
|
QueueKind.Album to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
|
||||||
|
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.albumLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
|
||||||
|
override suspend fun rowsFrom(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||||
|
|
||||||
|
override suspend fun rowsBefore(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||||
|
|
||||||
|
override suspend fun rank(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
) = dao.albumRank(entry.sourceId, entry.refId, songId)
|
||||||
|
},
|
||||||
|
QueueKind.Artist to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.artistLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
},
|
||||||
|
QueueKind.Genre to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.genreLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
},
|
||||||
|
QueueKind.Downloaded to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.downloadedLength(entry.sourceId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||||
|
},
|
||||||
|
QueueKind.Starred to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.starredLength(entry.sourceId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.starredSongs(entry.sourceId, offset, limit)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
|
||||||
|
|
||||||
class QueueRepository(
|
class QueueRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
) {
|
) {
|
||||||
@@ -216,12 +412,6 @@ class QueueRepository(
|
|||||||
|
|
||||||
private val queueVersion = AtomicLong()
|
private val queueVersion = AtomicLong()
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cachedFlatIds: List<String>? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cachedFlatVersion = -1L
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var shuffleRemoved: ShuffleState? = null
|
private var shuffleRemoved: ShuffleState? = null
|
||||||
|
|
||||||
@@ -235,6 +425,9 @@ class QueueRepository(
|
|||||||
internal var offsetSeeks = 0L
|
internal var offsetSeeks = 0L
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
internal var resolvedRowCount = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
private val cursorMutex = Mutex()
|
private val cursorMutex = Mutex()
|
||||||
|
|
||||||
fun playlistEntry(
|
fun playlistEntry(
|
||||||
@@ -388,7 +581,6 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun invalidateLibraryCache() {
|
fun invalidateLibraryCache() {
|
||||||
cachedFlatIds = null
|
|
||||||
lengthCache.clear()
|
lengthCache.clear()
|
||||||
keyCache.clear()
|
keyCache.clear()
|
||||||
shuffleRemoved = null
|
shuffleRemoved = null
|
||||||
@@ -409,8 +601,8 @@ class QueueRepository(
|
|||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Long? {
|
): Long? {
|
||||||
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
|
val upIndex = flatIndexOf(snapshot.upNext, songId)
|
||||||
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
|
if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
|
||||||
return combinedContextIndexOf(snapshot, songId)
|
return combinedContextIndexOf(snapshot, songId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,9 +610,8 @@ class QueueRepository(
|
|||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Long? {
|
): Long? {
|
||||||
val flat = flatIds(snapshot).indexOf(songId)
|
val flat = flatIndexOf(snapshot.entries, songId) ?: return null
|
||||||
if (flat < 0) return null
|
return snapshot.playContext(flat)?.let { snapshot.combined(it) }
|
||||||
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun range(
|
suspend fun range(
|
||||||
@@ -633,7 +824,6 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
queueVersion.incrementAndGet()
|
queueVersion.incrementAndGet()
|
||||||
cachedFlatIds = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
||||||
@@ -650,34 +840,40 @@ class QueueRepository(
|
|||||||
queueVersion.incrementAndGet()
|
queueVersion.incrementAndGet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
|
private suspend fun flatIndexOf(
|
||||||
val cached = cachedFlatIds
|
entries: List<ResolvedQueueEntry>,
|
||||||
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
|
songId: String,
|
||||||
val ids = idsFor(snapshot.entries)
|
): Long? {
|
||||||
cachedFlatIds = ids
|
var start = 0L
|
||||||
cachedFlatVersion = snapshot.version
|
for (resolved in entries) {
|
||||||
return ids
|
val entry = resolved.entry
|
||||||
|
val rank = resolverFor(entry.kind).rank(dao, entry, songId)
|
||||||
|
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
|
||||||
|
return start + rank - entry.offset
|
||||||
|
}
|
||||||
|
val fallback = fallbackRank(entry, resolved.length, songId)
|
||||||
|
if (fallback != null) return start + fallback - entry.offset
|
||||||
|
start += resolved.length
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
|
private suspend fun fallbackRank(
|
||||||
val ids = ArrayList<String>()
|
entry: QueueEntry,
|
||||||
for (entry in resolved) {
|
length: Long,
|
||||||
val ref = entry.entry
|
songId: String,
|
||||||
val entryIds =
|
): Long? {
|
||||||
when (ref.kind) {
|
var offset = entry.offset
|
||||||
QueueKind.Song -> listOf(ref.refId)
|
val end = entry.offset + length
|
||||||
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
|
while (offset < end) {
|
||||||
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
|
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
|
||||||
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
|
val batch = rows(entry, offset, limit)
|
||||||
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
|
val index = batch.indexOfFirst { it.song.id == songId }
|
||||||
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
|
if (index >= 0) return offset + index
|
||||||
QueueKind.Starred -> dao.starredSongIds(ref.sourceId)
|
if (batch.size < limit) return null
|
||||||
|
offset += batch.size
|
||||||
}
|
}
|
||||||
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
|
return null
|
||||||
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
|
|
||||||
ids.addAll(entryIds.subList(from, to))
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun contextPlayRange(
|
private suspend fun contextPlayRange(
|
||||||
@@ -688,21 +884,11 @@ class QueueRepository(
|
|||||||
if (first > last) return emptyList()
|
if (first > last) return emptyList()
|
||||||
val items = mutableListOf<QueueWindowItem>()
|
val items = mutableListOf<QueueWindowItem>()
|
||||||
if (snapshot.shuffled) {
|
if (snapshot.shuffled) {
|
||||||
val sourceId =
|
|
||||||
snapshot.entries
|
|
||||||
.firstOrNull()
|
|
||||||
?.entry
|
|
||||||
?.sourceId ?: return emptyList()
|
|
||||||
val ids = flatIds(snapshot)
|
|
||||||
val requested = mutableListOf<Pair<Long, String>>()
|
|
||||||
for (play in first..last) {
|
for (play in first..last) {
|
||||||
val flat = snapshot.flatContext(play) ?: continue
|
val located = snapshot.locateContext(play) ?: continue
|
||||||
val id = ids.getOrNull(flat.toInt()) ?: continue
|
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
|
||||||
requested += play to id
|
items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition)
|
||||||
}
|
}
|
||||||
if (requested.isEmpty()) return emptyList()
|
|
||||||
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
|
|
||||||
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
var start = 0L
|
var start = 0L
|
||||||
@@ -757,27 +943,20 @@ class QueueRepository(
|
|||||||
): List<SongItem> {
|
): List<SongItem> {
|
||||||
if (entry.kind == QueueKind.Song) {
|
if (entry.kind == QueueKind.Song) {
|
||||||
offsetSeeks++
|
offsetSeeks++
|
||||||
return dao.song(entry.sourceId, entry.refId, offset, limit)
|
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
|
||||||
}
|
}
|
||||||
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
||||||
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
||||||
if (anchored != null) {
|
if (anchored != null) {
|
||||||
keysetSeeks++
|
keysetSeeks++
|
||||||
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
||||||
|
resolvedRowCount += anchored.size
|
||||||
return anchored
|
return anchored
|
||||||
}
|
}
|
||||||
offsetSeeks++
|
offsetSeeks++
|
||||||
val fresh =
|
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
|
||||||
when (entry.kind) {
|
|
||||||
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
|
||||||
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
|
||||||
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
|
||||||
QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
|
||||||
QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit)
|
|
||||||
QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit)
|
|
||||||
QueueKind.Song -> emptyList()
|
|
||||||
}
|
|
||||||
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
||||||
|
resolvedRowCount += fresh.size
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,7 +989,7 @@ class QueueRepository(
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
cursor: KeyCursor,
|
cursor: KeyCursor,
|
||||||
): List<SongItem>? {
|
): List<SongItem>? {
|
||||||
if (!cursor.key.seekable(entry.kind)) return null
|
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
|
||||||
val from = cursor.ordinal
|
val from = cursor.ordinal
|
||||||
// A backward seek from a far cursor is not cheaper than the offset query: the album
|
// A backward seek from a far cursor is not cheaper than the offset query: the album
|
||||||
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
|
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
|
||||||
@@ -836,43 +1015,14 @@ class QueueRepository(
|
|||||||
key: OrderKey,
|
key: OrderKey,
|
||||||
skip: Long,
|
skip: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<SongItem> =
|
): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
|
||||||
when (entry.kind) {
|
|
||||||
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
|
||||||
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
|
||||||
QueueKind.Artist -> emptyList()
|
|
||||||
QueueKind.Genre -> emptyList()
|
|
||||||
QueueKind.Downloaded -> emptyList()
|
|
||||||
QueueKind.Starred -> emptyList()
|
|
||||||
QueueKind.Song -> emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun rowsBefore(
|
private suspend fun rowsBefore(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
key: OrderKey,
|
key: OrderKey,
|
||||||
skip: Long,
|
skip: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<SongItem> =
|
): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
|
||||||
when (entry.kind) {
|
|
||||||
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
|
||||||
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
|
||||||
QueueKind.Artist -> emptyList()
|
|
||||||
QueueKind.Genre -> emptyList()
|
|
||||||
QueueKind.Downloaded -> emptyList()
|
|
||||||
QueueKind.Starred -> emptyList()
|
|
||||||
QueueKind.Song -> emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun OrderKey.seekable(kind: QueueKind): Boolean =
|
|
||||||
when (kind) {
|
|
||||||
QueueKind.Playlist -> position != null
|
|
||||||
QueueKind.Album -> disc != null && track != null
|
|
||||||
QueueKind.Artist -> false
|
|
||||||
QueueKind.Genre -> false
|
|
||||||
QueueKind.Downloaded -> false
|
|
||||||
QueueKind.Starred -> false
|
|
||||||
QueueKind.Song -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun lengthOf(
|
private suspend fun lengthOf(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
@@ -897,36 +1047,7 @@ class QueueRepository(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||||
val total =
|
val total = resolverFor(kind).length(dao, this)
|
||||||
when (kind) {
|
|
||||||
QueueKind.Song -> {
|
|
||||||
dao.songLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Playlist -> {
|
|
||||||
dao.playlistLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Album -> {
|
|
||||||
dao.albumLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Artist -> {
|
|
||||||
dao.artistLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Genre -> {
|
|
||||||
dao.genreLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Downloaded -> {
|
|
||||||
dao.downloadedLength(sourceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Starred -> {
|
|
||||||
dao.starredLength(sourceId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val available = (total - offset).coerceAtLeast(0)
|
val available = (total - offset).coerceAtLeast(0)
|
||||||
return count?.coerceAtMost(available) ?: available
|
return count?.coerceAtMost(available) ?: available
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,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(
|
||||||
@@ -365,6 +367,8 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
|||||||
|
|
||||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
internal fun Attributes.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)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class SyncService(
|
|||||||
source.artists().collect { batch ->
|
source.artists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
changed =
|
changed =
|
||||||
write("artists", artistColumns, primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.values() } ||
|
write("artists", primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.columns() } ||
|
||||||
changed
|
changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
artistsSynced += batch.size
|
artistsSynced += batch.size
|
||||||
@@ -137,7 +137,7 @@ class SyncService(
|
|||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
val before = connection.totalChanges()
|
val before = connection.totalChanges()
|
||||||
val resolved = connection.resolveSongSortNames(batch)
|
val resolved = connection.resolveSongSortNames(batch)
|
||||||
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
connection.upsertChanged("songs", primaryKey, resolved) { it.columns() }
|
||||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -148,7 +148,7 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
connection.upsertChanged("song_genres", songGenreKey, genres) { it.columns() }
|
||||||
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -159,7 +159,7 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
|
||||||
(connection.totalChanges() != before).also { changed ->
|
(connection.totalChanges() != before).also { changed ->
|
||||||
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ 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) { it.columns() }
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
playlistsSynced += batch.size
|
playlistsSynced += batch.size
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ 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) { 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
|
||||||
}
|
}
|
||||||
@@ -280,17 +280,16 @@ class SyncService(
|
|||||||
// 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>,
|
||||||
preserved: Set<String> = emptySet(),
|
preserved: Set<String> = emptySet(),
|
||||||
markStale: Boolean = false,
|
markStale: Boolean = false,
|
||||||
values: (T) -> List<Any?>,
|
columns: (T) -> List<Pair<String, Any?>>,
|
||||||
): Boolean =
|
): Boolean =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
val before = connection.totalChanges()
|
val before = connection.totalChanges()
|
||||||
connection.upsertChanged(table, columns, key, rows, preserved, values)
|
connection.upsertChanged(table, key, rows, preserved, columns)
|
||||||
(connection.totalChanges() != before).also { changed ->
|
(connection.totalChanges() != before).also { changed ->
|
||||||
if (changed && markStale) connection.markCreditsStale(source.id)
|
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||||
}
|
}
|
||||||
@@ -309,8 +308,8 @@ class SyncService(
|
|||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
val before = connection.totalChanges()
|
val before = connection.totalChanges()
|
||||||
val resolved = connection.resolveAlbumSortNames(albums)
|
val resolved = connection.resolveAlbumSortNames(albums)
|
||||||
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
connection.upsertChanged("albums", primaryKey, resolved) { it.columns() }
|
||||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
connection.upsertChanged("discs", discKey, discs) { it.columns() }
|
||||||
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
||||||
albums.forEach { album ->
|
albums.forEach { album ->
|
||||||
statement.bind(1, album.sourceId)
|
statement.bind(1, album.sourceId)
|
||||||
@@ -321,7 +320,7 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() }
|
||||||
(connection.totalChanges() != before).also { changed ->
|
(connection.totalChanges() != before).also { changed ->
|
||||||
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||||
}
|
}
|
||||||
@@ -438,57 +437,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", "artistImageUrl", "sortName")
|
|
||||||
|
|
||||||
private val albumColumns =
|
|
||||||
listOf(
|
|
||||||
"sourceId",
|
|
||||||
"id",
|
|
||||||
"artistId",
|
|
||||||
"name",
|
|
||||||
"albumArtist",
|
|
||||||
"created",
|
|
||||||
"coverArt",
|
|
||||||
"genre",
|
|
||||||
"year",
|
|
||||||
"starred",
|
|
||||||
"songCount",
|
|
||||||
"artists",
|
|
||||||
"sortName",
|
|
||||||
"albumArtistSort",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
private val 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() }
|
||||||
@@ -499,71 +449,93 @@ private fun Song.genreNames(): List<String> =
|
|||||||
// 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, artistImageUrl, 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>> =
|
||||||
@@ -575,32 +547,56 @@ private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.po
|
|||||||
|
|
||||||
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
private fun 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>,
|
||||||
preserved: Set<String> = emptySet(),
|
preserved: Set<String> = emptySet(),
|
||||||
values: (T) -> List<Any?>,
|
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 || it in preserved }
|
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" }
|
||||||
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()
|
statement.step()
|
||||||
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(), 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 {
|
||||||
@@ -128,7 +128,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,6 +12,8 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
@@ -229,6 +231,7 @@ class Media3PlayerHandle(
|
|||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||||
|
artists = mediaItem.artistRefs(),
|
||||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||||
durationMs =
|
durationMs =
|
||||||
mediaItem.mediaMetadata.extras
|
mediaItem.mediaMetadata.extras
|
||||||
@@ -238,7 +241,14 @@ class Media3PlayerHandle(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
||||||
|
mediaMetadata.extras
|
||||||
|
?.getString(EXTRA_ARTISTS)
|
||||||
|
?.let(ArtistRefsCodec::decode)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||||
|
private const val EXTRA_ARTISTS = "artists"
|
||||||
private const val EXTRA_DURATION_MS = "durationMs"
|
private const val EXTRA_DURATION_MS = "durationMs"
|
||||||
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||||
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||||
@@ -286,6 +296,7 @@ internal fun queueMediaItem(
|
|||||||
.setExtras(
|
.setExtras(
|
||||||
Bundle().apply {
|
Bundle().apply {
|
||||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||||
|
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||||
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.prefs.SongTapBehavior
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.QueueSnapshot
|
import com.subtracks.data.repo.QueueSnapshot
|
||||||
@@ -21,6 +24,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
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
|
||||||
@@ -36,6 +40,7 @@ data class QueueItem(
|
|||||||
val durationMs: Long? = null,
|
val durationMs: Long? = null,
|
||||||
val replayGain: ReplayGain = ReplayGain(),
|
val replayGain: ReplayGain = ReplayGain(),
|
||||||
val albumContext: Boolean = false,
|
val albumContext: Boolean = false,
|
||||||
|
val artists: List<ArtistRef> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class QueueContext(
|
data class QueueContext(
|
||||||
@@ -102,9 +107,9 @@ private data class ReloadUndo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private data class PendingPlay(
|
private data class PendingPlay(
|
||||||
val entries: List<QueueEntry>,
|
val entry: QueueEntry,
|
||||||
val position: Long,
|
val position: Long,
|
||||||
val disableShuffle: Boolean,
|
val behavior: SongTapBehavior?,
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlaybackController(
|
class PlaybackController(
|
||||||
@@ -112,6 +117,7 @@ class PlaybackController(
|
|||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val connection: PlayerConnection,
|
private val connection: PlayerConnection,
|
||||||
private val downloads: DownloadRepository,
|
private val downloads: DownloadRepository,
|
||||||
|
private val preferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
|
||||||
) {
|
) {
|
||||||
@@ -126,7 +132,7 @@ class PlaybackController(
|
|||||||
private var player: PlayerHandle? = null
|
private var player: PlayerHandle? = null
|
||||||
private var connecting = false
|
private var connecting = false
|
||||||
private var pendingPlay: PendingPlay? = null
|
private var pendingPlay: PendingPlay? = null
|
||||||
private var pendingShuffle: List<QueueEntry>? = null
|
private var pendingShuffle: QueueEntry? = null
|
||||||
private var positionJob: Job? = null
|
private var positionJob: Job? = null
|
||||||
private var windowJob: Job? = null
|
private var windowJob: Job? = null
|
||||||
private var bufferingJob: Job? = null
|
private var bufferingJob: Job? = null
|
||||||
@@ -189,7 +195,7 @@ class PlaybackController(
|
|||||||
val shuffle = pendingShuffle
|
val shuffle = pendingShuffle
|
||||||
pendingShuffle = null
|
pendingShuffle = null
|
||||||
when {
|
when {
|
||||||
pending != null -> start(pending.entries, pending.position, pending.disableShuffle)
|
pending != null -> startPending(pending)
|
||||||
shuffle != null -> startShuffled(shuffle)
|
shuffle != null -> startShuffled(shuffle)
|
||||||
else -> restore()
|
else -> restore()
|
||||||
}
|
}
|
||||||
@@ -209,100 +215,85 @@ class PlaybackController(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
startOrdinal: Long,
|
startOrdinal: Long,
|
||||||
) = playContext(queueRepository.albumEntry(sourceId, albumId), startOrdinal)
|
) = play(queueRepository.albumEntry(sourceId, albumId), startOrdinal)
|
||||||
|
|
||||||
fun playSong(
|
fun playSong(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
) = playContext(queueRepository.songEntry(sourceId, songId), 0)
|
) = play(queueRepository.songEntry(sourceId, songId), 0)
|
||||||
|
|
||||||
fun playPlaylist(
|
fun playPlaylist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
startPosition: Long,
|
startPosition: Long,
|
||||||
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), startPosition)
|
) = play(queueRepository.playlistEntry(sourceId, playlistId), startPosition)
|
||||||
|
|
||||||
fun playGenre(
|
fun playGenre(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
startOrdinal: Long,
|
startOrdinal: Long,
|
||||||
) = playContext(queueRepository.genreEntry(sourceId, genre), startOrdinal)
|
) = play(queueRepository.genreEntry(sourceId, genre), startOrdinal)
|
||||||
|
|
||||||
fun playDownloaded(
|
fun playDownloaded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
startOrdinal: Long,
|
startOrdinal: Long,
|
||||||
) = playContext(queueRepository.downloadedEntry(sourceId), startOrdinal)
|
) = play(queueRepository.downloadedEntry(sourceId), startOrdinal)
|
||||||
|
|
||||||
fun playStarred(
|
fun playStarred(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
startOrdinal: Long,
|
startOrdinal: Long,
|
||||||
) = playContext(queueRepository.starredEntry(sourceId), startOrdinal)
|
) = play(queueRepository.starredEntry(sourceId), startOrdinal)
|
||||||
|
|
||||||
fun playAlbumInOrder(
|
fun playAlbumInOrder(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
) = playContext(queueRepository.albumEntry(sourceId, albumId), 0, disableShuffle = true)
|
) = play(queueRepository.albumEntry(sourceId, albumId), 0, SongTapBehavior.InOrder)
|
||||||
|
|
||||||
fun playPlaylistInOrder(
|
fun playPlaylistInOrder(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), 0, disableShuffle = true)
|
) = play(queueRepository.playlistEntry(sourceId, playlistId), 0, SongTapBehavior.InOrder)
|
||||||
|
|
||||||
fun shuffleAlbum(
|
fun shuffleAlbum(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
) = shuffleContext(queueRepository.albumEntry(sourceId, albumId))
|
) = shufflePlay(queueRepository.albumEntry(sourceId, albumId))
|
||||||
|
|
||||||
fun shufflePlaylist(
|
fun shufflePlaylist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
) = shuffleContext(queueRepository.playlistEntry(sourceId, playlistId))
|
) = shufflePlay(queueRepository.playlistEntry(sourceId, playlistId))
|
||||||
|
|
||||||
fun shuffleArtist(
|
fun shuffleArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
) = shufflePlay(queueRepository.artistEntry(sourceId, artistId))
|
||||||
|
|
||||||
fun shuffleGenre(
|
fun shuffleGenre(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
) = shuffleContext(queueRepository.genreEntry(sourceId, genre))
|
) = shufflePlay(queueRepository.genreEntry(sourceId, genre))
|
||||||
|
|
||||||
fun shuffleStarred(sourceId: Long) = shuffleContext(queueRepository.starredEntry(sourceId))
|
fun shuffleStarred(sourceId: Long) = shufflePlay(queueRepository.starredEntry(sourceId))
|
||||||
|
|
||||||
fun shuffleDownloaded(sourceId: Long) = shuffleContext(queueRepository.downloadedEntry(sourceId))
|
fun shuffleDownloaded(sourceId: Long) = shufflePlay(queueRepository.downloadedEntry(sourceId))
|
||||||
|
|
||||||
private fun playContext(
|
private fun shufflePlay(entry: QueueEntry) {
|
||||||
entry: QueueEntry,
|
|
||||||
start: Long,
|
|
||||||
disableShuffle: Boolean = false,
|
|
||||||
) = play(listOf(entry), start, disableShuffle)
|
|
||||||
|
|
||||||
private fun shuffleContext(entry: QueueEntry) = shufflePlay(listOf(entry))
|
|
||||||
|
|
||||||
private fun shufflePlay(entries: List<QueueEntry>) {
|
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
pendingShuffle = entries
|
pendingShuffle = entry
|
||||||
connect()
|
connect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scope.launch { startShuffled(entries) }
|
scope.launch { startShuffled(entry) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun startShuffled(entries: List<QueueEntry>) =
|
private suspend fun startShuffled(entry: QueueEntry) =
|
||||||
startLock.withLock {
|
startLock.withLock {
|
||||||
val entry = entries.first()
|
|
||||||
windowJob?.cancel()
|
|
||||||
windowStart = 0
|
|
||||||
windowEnd = -1
|
|
||||||
lastEdit = null
|
|
||||||
endedHandled = false
|
|
||||||
val previous = snapshot
|
val previous = snapshot
|
||||||
val previousEntry = previous?.entries?.firstOrNull()?.entry
|
val previousEntry = previous?.entries?.firstOrNull()?.entry
|
||||||
val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId
|
val same = previousEntry != null && previousEntry.kind == entry.kind && previousEntry.refId == entry.refId
|
||||||
val avoid = if (same) currentPosition()?.let { previous.flatContext(previous.anchorContextPlay(it)) } else null
|
val avoid = if (same) currentPosition()?.let { previous.flatContext(previous.anchorContextPlay(it)) } else null
|
||||||
queueRepository.replace(entries)
|
var snap = resetQueue(entry)
|
||||||
var snap = queueRepository.snapshot()
|
|
||||||
if (snap.contextSize == 0L) {
|
if (snap.contextSize == 0L) {
|
||||||
stopLocked()
|
stopLocked()
|
||||||
return@withLock
|
return@withLock
|
||||||
@@ -317,6 +308,18 @@ class PlaybackController(
|
|||||||
loadWindow(0, autoplay = true)
|
loadWindow(0, autoplay = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun resetQueue(entry: QueueEntry): QueueSnapshot {
|
||||||
|
windowJob?.cancel()
|
||||||
|
windowStart = 0
|
||||||
|
windowEnd = -1
|
||||||
|
lastEdit = null
|
||||||
|
endedHandled = false
|
||||||
|
queueRepository.replace(listOf(entry))
|
||||||
|
val snapshot = queueRepository.snapshot()
|
||||||
|
this.snapshot = snapshot
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
private fun randomIndex(
|
private fun randomIndex(
|
||||||
size: Long,
|
size: Long,
|
||||||
avoid: Long?,
|
avoid: Long?,
|
||||||
@@ -978,43 +981,53 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun play(
|
private fun play(
|
||||||
entries: List<QueueEntry>,
|
entry: QueueEntry,
|
||||||
startPosition: Long,
|
startPosition: Long,
|
||||||
disableShuffle: Boolean = false,
|
behavior: SongTapBehavior? = null,
|
||||||
) {
|
) {
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
pendingPlay = PendingPlay(entries, startPosition, disableShuffle)
|
pendingPlay = PendingPlay(entry, startPosition, behavior)
|
||||||
connect()
|
connect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scope.launch { start(entries, startPosition, disableShuffle) }
|
scope.launch {
|
||||||
|
startLock.withLock {
|
||||||
|
startLocked(entry, startPosition, behavior)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun start(
|
private suspend fun startPending(pending: PendingPlay) {
|
||||||
entries: List<QueueEntry>,
|
startLock.withLock {
|
||||||
|
startLocked(pending.entry, pending.position, pending.behavior)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun startLocked(
|
||||||
|
entry: QueueEntry,
|
||||||
startPosition: Long,
|
startPosition: Long,
|
||||||
disableShuffle: Boolean = false,
|
behavior: SongTapBehavior?,
|
||||||
) = startLock.withLock {
|
) {
|
||||||
windowJob?.cancel()
|
val resolved = behavior ?: preferences.songTapBehavior().first()
|
||||||
windowStart = 0
|
|
||||||
windowEnd = -1
|
|
||||||
lastEdit = null
|
|
||||||
endedHandled = false
|
|
||||||
val modes = queueRepository.modes()
|
val modes = queueRepository.modes()
|
||||||
shuffleEnabled = !disableShuffle && modes.shuffle
|
val shuffle =
|
||||||
|
when (resolved) {
|
||||||
|
SongTapBehavior.InOrder -> false
|
||||||
|
SongTapBehavior.KeepShuffle -> modes.shuffle
|
||||||
|
SongTapBehavior.Shuffle -> true
|
||||||
|
}
|
||||||
|
val snapshot = resetQueue(entry)
|
||||||
|
shuffleEnabled = shuffle
|
||||||
repeatMode = modes.repeat.toRepeatMode()
|
repeatMode = modes.repeat.toRepeatMode()
|
||||||
player?.setRepeatOne(repeatMode == RepeatMode.One)
|
player?.setRepeatOne(repeatMode == RepeatMode.One)
|
||||||
queueRepository.replace(entries)
|
queueSourceId = entry.sourceId
|
||||||
val snapshot = queueRepository.snapshot()
|
|
||||||
this.snapshot = snapshot
|
|
||||||
queueSourceId = entries.firstOrNull()?.sourceId
|
|
||||||
if (snapshot.size == 0L) {
|
if (snapshot.size == 0L) {
|
||||||
stopLocked()
|
stopLocked()
|
||||||
return@withLock
|
return
|
||||||
}
|
}
|
||||||
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
|
val start = startPosition.coerceIn(0, snapshot.contextSize - 1)
|
||||||
val position =
|
val position =
|
||||||
if (shuffleEnabled) {
|
if (shuffle) {
|
||||||
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start))
|
queueRepository.setShuffle(pinnedSeed(snapshot.contextSize, start))
|
||||||
0L
|
0L
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.SongItem
|
import com.subtracks.data.model.SongItem
|
||||||
|
|
||||||
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||||
@@ -7,6 +8,7 @@ fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
|||||||
id = song.id,
|
id = song.id,
|
||||||
title = song.title,
|
title = song.title,
|
||||||
artist = song.artist,
|
artist = song.artist,
|
||||||
|
artists = song.artistRefs,
|
||||||
album = song.album,
|
album = song.album,
|
||||||
coverArtId = coverArt,
|
coverArtId = coverArt,
|
||||||
durationMs = song.duration?.let { it * 1000 },
|
durationMs = song.duration?.let { it * 1000 },
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -773,11 +781,30 @@ 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 ->
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = artists,
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
artistChooser = null
|
||||||
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(artist.id),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { artistChooser = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val target = infoTarget) {
|
||||||
|
is MenuTarget.Song -> {
|
||||||
|
val song = target.song
|
||||||
val genres by
|
val genres by
|
||||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
@@ -786,10 +813,37 @@ private fun MainNavigation() {
|
|||||||
download = downloads[song.id],
|
download = downloads[song.id],
|
||||||
localFile = downloadRepository.localFile(song.id),
|
localFile = downloadRepository.localFile(song.id),
|
||||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||||
onDismiss = { infoSong = null },
|
onDismiss = { infoTarget = null },
|
||||||
genres = genres,
|
genres = genres,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Album -> {
|
||||||
|
AlbumInfoDialog(
|
||||||
|
album = target.album,
|
||||||
|
download = target.download,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Artist -> {
|
||||||
|
ArtistInfoDialog(
|
||||||
|
artist = target.artist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Playlist -> {
|
||||||
|
PlaylistInfoDialog(
|
||||||
|
playlist = target.playlist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.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
|
||||||
@@ -197,7 +199,8 @@ fun ItemContextMenu(
|
|||||||
target: MenuTarget,
|
target: MenuTarget,
|
||||||
actions: ItemActions,
|
actions: ItemActions,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onInfo: ((SongModel) -> Unit)? = null,
|
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||||
|
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -210,6 +213,11 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun replaceWith(after: () -> Unit) {
|
||||||
|
onDismiss()
|
||||||
|
after()
|
||||||
|
}
|
||||||
|
|
||||||
val queueRef = target.queueRef()
|
val queueRef = target.queueRef()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -256,18 +264,9 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
target.song.artistRefs.forEach { artist ->
|
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
actions.viewArtist?.let { view ->
|
|
||||||
MenuItem(
|
|
||||||
Icons.Rounded.Person,
|
|
||||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
|
||||||
) {
|
|
||||||
dismiss { view(artist.id) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onInfo?.let { info ->
|
onInfo?.let { info ->
|
||||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,15 +278,9 @@ fun ItemContextMenu(
|
|||||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
target.album.artistRefs.forEach { artist ->
|
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
actions.viewArtist?.let { view ->
|
onInfo?.let { info ->
|
||||||
MenuItem(
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
Icons.Rounded.Person,
|
|
||||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
|
||||||
) {
|
|
||||||
dismiss { view(artist.id) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,11 +291,17 @@ fun ItemContextMenu(
|
|||||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MenuTarget.Playlist -> {
|
is MenuTarget.Playlist -> {
|
||||||
queueItems()
|
queueItems()
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
@@ -342,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistItems(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
actions: ItemActions,
|
||||||
|
onChooseArtists: ((List<ArtistRef>) -> Unit)?,
|
||||||
|
dismiss: (() -> Unit) -> Unit,
|
||||||
|
replaceWith: (() -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
val view = actions.viewArtist ?: return
|
||||||
|
val distinct = artists.distinctBy { it.id }
|
||||||
|
when (distinct.size) {
|
||||||
|
0 -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
val artist = distinct.first()
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
|
) {
|
||||||
|
dismiss { view(artist.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
if (onChooseArtists == null) {
|
||||||
|
distinct.forEach { artist ->
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||||
|
) {
|
||||||
|
dismiss { view(artist.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val fallback = stringResource(R.string.resources_filter_artist)
|
||||||
|
MenuItem(
|
||||||
|
Icons.Rounded.Person,
|
||||||
|
distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } },
|
||||||
|
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||||
|
) {
|
||||||
|
replaceWith { onChooseArtists(distinct) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DownloadItem(
|
private fun DownloadItem(
|
||||||
download: SongDownload?,
|
download: SongDownload?,
|
||||||
@@ -439,11 +487,19 @@ private fun MenuItem(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
label: String,
|
label: String,
|
||||||
tint: Color? = null,
|
tint: Color? = null,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(label) },
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||||
|
trailingContent = trailing,
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
|
||||||
|
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AlbumInfoDialog(
|
||||||
|
album: Album,
|
||||||
|
download: ListDownloadStatus?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_title), album.name)
|
||||||
|
InfoRow(stringResource(R.string.info_sort_title), album.sortName.takeIfDifferentFrom(album.name))
|
||||||
|
InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names())
|
||||||
|
InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort.takeIfDifferentFrom(album.albumArtist))
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.resources_filter_genre), album.genre)
|
||||||
|
InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
download?.takeIf { it.total > 0 }?.let { status ->
|
||||||
|
InfoRow(
|
||||||
|
stringResource(R.string.status_downloaded),
|
||||||
|
stringResource(R.string.info_downloaded_count, status.downloaded, status.total),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistInfoDialog(
|
||||||
|
artist: Artist,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_filter_artist), artist.name)
|
||||||
|
InfoRow(stringResource(R.string.info_sort_name), artist.sortName.takeIfDifferentFrom(artist.name))
|
||||||
|
InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaylistInfoDialog(
|
||||||
|
playlist: Playlist,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.info)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
InfoRow(stringResource(R.string.resources_sort_by_title), playlist.name)
|
||||||
|
InfoRow(stringResource(R.string.info_owner), playlist.owner)
|
||||||
|
InfoRow(stringResource(R.string.info_shared), if (playlist.shared) stringResource(R.string.info_yes) else null)
|
||||||
|
InfoRow(stringResource(R.string.info_songs), playlist.songCount.takeIf { it > 0 }?.toString())
|
||||||
|
InfoRow(stringResource(R.string.info_length), playlist.duration.takeIf { it > 0 }?.let(DateUtils::formatElapsedTime))
|
||||||
|
InfoRow(stringResource(R.string.info_created), playlist.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
InfoRow(stringResource(R.string.info_updated), playlist.changed.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun String?.takeIfDifferentFrom(regular: String?): String? =
|
||||||
|
this?.trim()?.takeIf { value -> value.isNotEmpty() && !value.equals(regular?.trim(), ignoreCase = true) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun InfoRow(
|
||||||
|
label: String,
|
||||||
|
value: String?,
|
||||||
|
) {
|
||||||
|
if (value.isNullOrBlank()) return
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(INFO_LABEL_GAP))
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<ArtistRef>.names(): String? =
|
||||||
|
map { it.name }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(ARTIST_SEPARATOR)
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
private fun formatDate(
|
||||||
|
context: Context,
|
||||||
|
epochSeconds: Long,
|
||||||
|
): String =
|
||||||
|
DateUtils.formatDateTime(
|
||||||
|
context,
|
||||||
|
epochSeconds * 1000,
|
||||||
|
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val INFO_LABEL_WIDTH = 100.dp
|
||||||
|
private val INFO_LABEL_GAP = 12.dp
|
||||||
@@ -1,12 +1,28 @@
|
|||||||
package com.subtracks.ui.components
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
|
||||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||||
|
|
||||||
internal fun Song.artistSubtitle(): String? {
|
internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists)
|
||||||
|
|
||||||
|
internal fun Song.artistNames(): List<String> = artistNames(artist, artists)
|
||||||
|
|
||||||
|
internal fun artistNames(
|
||||||
|
artist: String?,
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
): List<String> {
|
||||||
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
|
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
|
||||||
if (names.isEmpty()) return artist?.takeIf { it.isNotBlank() }
|
return names.ifEmpty { listOfNotNull(artist?.takeIf { it.isNotBlank() }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistSubtitle(
|
||||||
|
artist: String?,
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
): String? {
|
||||||
|
val names = artistNames(artist, artists)
|
||||||
|
if (names.isEmpty()) return null
|
||||||
val primary = artist?.takeIf { it in names }
|
val primary = artist?.takeIf { it in names }
|
||||||
return buildList {
|
return buildList {
|
||||||
primary?.let(::add)
|
primary?.let(::add)
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
|
|||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
|||||||
title = { Text(stringResource(R.string.info)) },
|
title = { Text(stringResource(R.string.info)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
val multipleArtists = song.artistNames().size > 1
|
||||||
|
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
|
||||||
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
||||||
InfoRow(stringResource(R.string.resources_filter_artist), song.artistSubtitle())
|
InfoRow(stringResource(R.string.info_sort_title), song.sortName.takeIfDifferentFrom(song.title))
|
||||||
|
InfoRow(
|
||||||
|
stringResource(
|
||||||
|
if (multipleArtists) R.string.info_artists else R.string.resources_filter_artist,
|
||||||
|
),
|
||||||
|
song.artistSubtitle(),
|
||||||
|
)
|
||||||
|
InfoRow(
|
||||||
|
stringResource(if (multipleArtists) R.string.info_artists_sort else R.string.info_artist_sort),
|
||||||
|
song.artistSort.takeIfDifferentFrom(regularArtistSort),
|
||||||
|
)
|
||||||
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
||||||
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||||
InfoRow(
|
InfoRow(
|
||||||
@@ -92,32 +97,6 @@ fun SongInfoDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun InfoRow(
|
|
||||||
label: String,
|
|
||||||
value: String?,
|
|
||||||
) {
|
|
||||||
if (value.isNullOrBlank()) return
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val INFO_LABEL_WIDTH = 80.dp
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun downloadStatus(status: DownloadStatus): String =
|
private fun downloadStatus(status: DownloadStatus): String =
|
||||||
when (status) {
|
when (status) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.statusBars
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
@@ -55,10 +56,12 @@ 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
|
||||||
@@ -107,13 +110,12 @@ 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
|
||||||
@@ -126,7 +128,6 @@ 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.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
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
|
||||||
@@ -191,9 +192,9 @@ fun ArtistDetailRoute(
|
|||||||
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
val artist by viewModel.artist.collectAsStateWithLifecycle()
|
||||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
val artistName by viewModel.artistName.collectAsStateWithLifecycle()
|
||||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
|
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||||
val bioSnapshots =
|
val bioSnapshots =
|
||||||
remember(viewModel) {
|
remember(viewModel) {
|
||||||
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||||
@@ -213,26 +214,13 @@ fun ArtistDetailRoute(
|
|||||||
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,
|
||||||
)
|
)
|
||||||
@@ -263,13 +251,8 @@ fun ArtistDetailRoute(
|
|||||||
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 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +285,7 @@ fun ArtistDetailScreen(
|
|||||||
onCollapseBio: () -> Unit = {},
|
onCollapseBio: () -> Unit = {},
|
||||||
onShuffle: () -> Unit = {},
|
onShuffle: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyGridState()
|
val listState = rememberRestorableLazyGridState(albumsLoaded)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val lineHeight =
|
val lineHeight =
|
||||||
@@ -349,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 {
|
||||||
@@ -570,6 +553,31 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState {
|
||||||
|
val state = rememberLazyGridState()
|
||||||
|
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||||
|
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||||
|
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||||
|
LaunchedEffect(contentReady) {
|
||||||
|
if (contentReady && pending) {
|
||||||
|
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||||
|
state.scrollToItem(target, offset)
|
||||||
|
pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||||
|
.collect { (currentIndex, currentOffset) ->
|
||||||
|
if (!pending) {
|
||||||
|
index = currentIndex
|
||||||
|
offset = currentOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
|
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistBio(
|
private fun ArtistBio(
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import com.subtracks.ui.components.ArtistArt
|
|||||||
import com.subtracks.ui.components.artistArt
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.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
|
||||||
@@ -26,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
|
||||||
|
|
||||||
@@ -41,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
|
||||||
|
.flatMapLatest { id ->
|
||||||
combine(
|
combine(
|
||||||
libraryRepository.artist(id, artistId),
|
libraryRepository.artist(id, artistId),
|
||||||
libraryRepository.artistName(id, artistId),
|
libraryRepository.artistName(id, artistId),
|
||||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||||
}
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
val albums: Flow<ArtistAlbums?> =
|
val albums: StateFlow<ArtistAlbums?> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||||
.map<ArtistAlbums, ArtistAlbums?> { it }
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
.onStart { emit(null) }
|
|
||||||
|
|
||||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||||
sourceId
|
sourceId
|
||||||
@@ -69,7 +69,10 @@ class ArtistDetailViewModel(
|
|||||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
val artistBio: Flow<ArtistBio?> = sourceId.flatMapLatest { libraryRepository.artistBio(it, artistId) }
|
val artistBio: StateFlow<ArtistBio?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.artistBio(it, artistId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
private val _bioLoading = MutableStateFlow(true)
|
private val _bioLoading = MutableStateFlow(true)
|
||||||
val bioLoading: StateFlow<Boolean> = _bioLoading
|
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||||
@@ -142,15 +145,24 @@ class ArtistDetailViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
sourceRepository.activeSource
|
||||||
|
.filterNotNull()
|
||||||
|
.flatMapLatest { sourceId ->
|
||||||
combine(
|
combine(
|
||||||
artist.filterNotNull(),
|
libraryRepository.artist(sourceId, artistId),
|
||||||
|
libraryRepository.artistCoverArts(sourceId, artistId),
|
||||||
sourceRepository.offline,
|
sourceRepository.offline,
|
||||||
sourceRepository.activeSource,
|
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
|
||||||
) { loaded, _, activeSource -> loaded to activeSource }
|
}.collectLatest { input ->
|
||||||
.collectLatest { (loaded, activeSource) ->
|
val resolved = resolveArtistArtInput(input)
|
||||||
if (activeSource == null) return@collectLatest
|
if (resolved == null) {
|
||||||
val hero = sourceRepository.artistArt(loaded, forIcon = false)
|
_art.value = null
|
||||||
val icon = sourceRepository.artistArt(loaded, forIcon = true)
|
_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()
|
_art.value = hero.image ?: hero.covers.firstOrNull()
|
||||||
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
||||||
_heroArt.value = hero
|
_heroArt.value = hero
|
||||||
@@ -169,3 +181,35 @@ class ArtistDetailViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class ArtistArtInput(
|
||||||
|
val artist: Artist?,
|
||||||
|
val artistId: String,
|
||||||
|
val offline: Boolean,
|
||||||
|
val sourceId: Long,
|
||||||
|
val covers: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveArtistArtInput(input: ArtistArtInput): Artist? {
|
||||||
|
val artist = input.artist
|
||||||
|
if (input.covers.isEmpty()) return artist
|
||||||
|
if (artist == null) {
|
||||||
|
return Artist(
|
||||||
|
sourceId = input.sourceId,
|
||||||
|
id = input.artistId,
|
||||||
|
name = "",
|
||||||
|
albumCount = 0,
|
||||||
|
starred = null,
|
||||||
|
coverArts = input.covers,
|
||||||
|
downloadedCoverArts = if (input.offline) input.covers else emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return if (input.offline) {
|
||||||
|
artist.copy(
|
||||||
|
coverArts = input.covers,
|
||||||
|
downloadedCoverArts = artist.downloadedCoverArts.ifEmpty { input.covers },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
artist.copy(coverArts = input.covers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.BorderStroke
|
|||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.PagerState
|
import androidx.compose.foundation.pager.PagerState
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -43,6 +45,7 @@ import androidx.compose.material.icons.rounded.Search
|
|||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
@@ -65,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
|
||||||
@@ -94,12 +95,14 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
|||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
@@ -122,13 +125,12 @@ 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.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
|
||||||
@@ -139,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
|
||||||
@@ -274,26 +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
|
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,
|
||||||
@@ -373,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 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,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,
|
||||||
)
|
)
|
||||||
@@ -615,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),
|
||||||
) {
|
) {
|
||||||
@@ -638,10 +637,16 @@ 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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,6 +654,7 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showOptions) {
|
if (showOptions) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
@@ -702,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)
|
||||||
@@ -824,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
|
||||||
@@ -856,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
|
||||||
@@ -894,18 +900,61 @@ 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),
|
||||||
) {
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onSizeChanged { viewportWidth = it.width }
|
||||||
|
.horizontalScroll(scrollState)
|
||||||
|
.onSizeChanged { contentWidth = it.width },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.drawBehind {
|
Modifier
|
||||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
.drawBehind {
|
||||||
|
if (current != null &&
|
||||||
|
indicatorLeft != null &&
|
||||||
|
indicatorRight != null &&
|
||||||
|
indicatorRight > indicatorLeft
|
||||||
|
) {
|
||||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = indicatorColor,
|
color = indicatorColor,
|
||||||
@@ -917,7 +966,6 @@ internal fun LibraryTabs(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
@@ -940,14 +988,7 @@ internal fun LibraryTabs(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
}
|
||||||
if (offline) {
|
|
||||||
IconButton(onClick = onExitOffline) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.CloudOff,
|
|
||||||
contentDescription = stringResource(R.string.offline_mode_status),
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpenSettings) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
@@ -970,7 +1011,6 @@ internal fun LibraryTabs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -205,6 +215,8 @@ fun SettingsScreen(
|
|||||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||||
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||||
replayGainPreamp: Float = 0f,
|
replayGainPreamp: Float = 0f,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.Name,
|
||||||
|
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
|
||||||
@@ -76,6 +78,16 @@ class SettingsViewModel(
|
|||||||
.replayGainPreampDb()
|
.replayGainPreampDb()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
||||||
|
|
||||||
|
val sortNameMode: StateFlow<SortNameMode> =
|
||||||
|
userPreferences
|
||||||
|
.sortNameMode()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
|
||||||
|
|
||||||
|
val 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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 986 B After Width: | Height: | Size: 944 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#120C12</color>
|
<color name="ic_launcher_background">#2D1C2D</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -79,9 +79,15 @@
|
|||||||
<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_auto">Auto</string>
|
||||||
<string name="settings_replay_gain_mode_off">Off</string>
|
<string name="settings_replay_gain_mode_off">Off</string>
|
||||||
@@ -96,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>
|
||||||
@@ -191,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>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 889 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 827 B After Width: | Height: | Size: 581 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#1A1200</color>
|
<color name="ic_launcher_background">#2E2000</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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,14 +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 =
|
db = createTestDatabase(driver)
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(driver)
|
|
||||||
.addCallback(ArtistSearchTriggerOptimization)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -110,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() }
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room3.Room
|
||||||
|
import androidx.sqlite.SQLiteDriver
|
||||||
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
|
||||||
|
fun createTestDatabase(driver: SQLiteDriver = BundledSQLiteDriver()): SubtracksDatabase {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
return Room
|
||||||
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
|
.setDriver(driver)
|
||||||
|
.addSubtracksDatabaseCallbacks()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -51,6 +51,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 {
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ package com.subtracks.data.repo
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
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.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.download.ArtworkFetcher
|
import com.subtracks.data.download.ArtworkFetcher
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
@@ -84,11 +83,7 @@ class DownloadRepositoryTest {
|
|||||||
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()}"))
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
|
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ 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.ArtistBio
|
||||||
@@ -63,11 +62,7 @@ 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)
|
||||||
@@ -76,6 +71,7 @@ class LibraryRepositoryTest {
|
|||||||
db,
|
db,
|
||||||
sourceRepository,
|
sourceRepository,
|
||||||
NetworkServerActionSink(sourceRepository),
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
fakeUserPreferences(),
|
||||||
showMessage = { messages += it.resolve(resources) },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
@@ -207,6 +203,42 @@ class LibraryRepositoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDownloadedAlbumGridOnlyPresentsDownloadedRows() =
|
||||||
|
runBlocking {
|
||||||
|
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
|
||||||
|
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 1), seedAlbum(sourceId, 2)))
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
seedSong(sourceId).copy(id = "s1", albumId = "al-0001"),
|
||||||
|
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||||
|
|
||||||
|
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
|
val presenter = RecordingPresenter(presenterDispatcher)
|
||||||
|
val job =
|
||||||
|
launch {
|
||||||
|
repository
|
||||||
|
.albums(
|
||||||
|
sourceId = sourceId,
|
||||||
|
sort = AlbumSort.Name,
|
||||||
|
descending = false,
|
||||||
|
starred = StarredFilter.Any,
|
||||||
|
search = "",
|
||||||
|
downloaded = true,
|
||||||
|
).collect { presenter.collectFrom(it) }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
awaitRefreshes(presenter, 1)
|
||||||
|
assertEquals(1, presented(presenter, presenterDispatcher))
|
||||||
|
} finally {
|
||||||
|
job.cancelAndJoin()
|
||||||
|
presenterDispatcher.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
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.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.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.DownloadStatus
|
import com.subtracks.data.model.DownloadStatus
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
@@ -33,12 +30,7 @@ class QueueRepositoryTest {
|
|||||||
|
|
||||||
@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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,6 +727,32 @@ class QueueRepositoryTest {
|
|||||||
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
|
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffledLargeContextWindowOnlyResolvesRequestedRows() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
db.libraryDao().upsertPlaylists(listOf(playlist("huge", "Huge")))
|
||||||
|
db.useWriterConnection { transactor ->
|
||||||
|
transactor.usePrepared(
|
||||||
|
"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 199999) " +
|
||||||
|
"INSERT INTO playlist_songs SELECT 1, 'huge', 's1', x FROM n",
|
||||||
|
) { statement ->
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repository.snapshotAfter(listOf(repository.playlistEntry(1, "huge")))
|
||||||
|
repository.setShuffle(42L)
|
||||||
|
val snapshot = repository.snapshot()
|
||||||
|
|
||||||
|
val window = repository.window(snapshot, center = 100_000, radius = 25)
|
||||||
|
|
||||||
|
assertEquals(200_000L, snapshot.contextSize)
|
||||||
|
assertEquals(51, window.size)
|
||||||
|
assertEquals(51L, repository.resolvedRowCount)
|
||||||
|
assertEquals((99_975L..100_025L).toList(), window.map { it.position })
|
||||||
|
assertTrue(window.all { it.item.song.id == "s1" })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shuffledRangeResolvesEntriesInPositionOrder() =
|
fun shuffledRangeResolvesEntriesInPositionOrder() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -859,6 +877,55 @@ class QueueRepositoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contextIndexUsesTheFirstDuplicateOrdinalAcrossSparsePositions() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||||
|
db.libraryDao().upsertPlaylistSongs(
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "duplicates", "s2", 10),
|
||||||
|
PlaylistSong(1, "duplicates", "s1", 20),
|
||||||
|
PlaylistSong(1, "duplicates", "s2", 30),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "duplicates")))
|
||||||
|
|
||||||
|
assertEquals(0L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||||
|
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contextIndexFindsADuplicateInsideTheEntryRange() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||||
|
db.libraryDao().upsertPlaylistSongs(
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "duplicates", "s2", 10),
|
||||||
|
PlaylistSong(1, "duplicates", "s1", 20),
|
||||||
|
PlaylistSong(1, "duplicates", "s2", 30),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val entry = repository.playlistEntry(1, "duplicates").copy(rangeStart = 1, rangeEnd = 2)
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(entry))
|
||||||
|
|
||||||
|
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contextIndexFallsBackToBoundedRowsForGenre() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
insertGenre("s1", "Rock")
|
||||||
|
insertGenre("s3", "Rock")
|
||||||
|
insertGenre("s4", "Rock")
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock")))
|
||||||
|
|
||||||
|
assertEquals(2L, repository.combinedContextIndexOf(snapshot, "s4"))
|
||||||
|
assertTrue(repository.resolvedRowCount <= QUEUE_CHUNK)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
|
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ 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.artistImageKey
|
||||||
@@ -58,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)
|
||||||
@@ -314,7 +309,7 @@ class SourceRepositoryTest {
|
|||||||
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
|
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
|
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null }!! }
|
||||||
|
|
||||||
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||||
withTimeout(TEST_TIMEOUT_MS) {
|
withTimeout(TEST_TIMEOUT_MS) {
|
||||||
|
|||||||
@@ -745,6 +745,29 @@ class SubsonicXmlTest {
|
|||||||
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsPlaylistOwnerAndSharing() {
|
||||||
|
val playlists =
|
||||||
|
parse(
|
||||||
|
"""
|
||||||
|
<subsonic-response status="ok">
|
||||||
|
<playlists>
|
||||||
|
<playlist id="pl1" name="Shared" owner="austin" public="true" songCount="2" duration="120"/>
|
||||||
|
<playlist id="pl2" name="Private" owner="austin" public="false" songCount="1" duration="60"/>
|
||||||
|
<playlist id="pl3" name="Numeric" owner="austin" public="1" songCount="1" duration="60"/>
|
||||||
|
</playlists>
|
||||||
|
</subsonic-response>
|
||||||
|
""".trimIndent(),
|
||||||
|
tag = "playlist",
|
||||||
|
create = { SubsonicXml.playlist(1, it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("austin", playlists[0].owner)
|
||||||
|
assertTrue(playlists[0].shared)
|
||||||
|
assertEquals(false, playlists[1].shared)
|
||||||
|
assertTrue(playlists[2].shared)
|
||||||
|
}
|
||||||
|
|
||||||
private class CountingInputStream(
|
private class CountingInputStream(
|
||||||
input: InputStream,
|
input: InputStream,
|
||||||
) : FilterInputStream(input) {
|
) : FilterInputStream(input) {
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ package com.subtracks.data.sync
|
|||||||
|
|
||||||
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.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
@@ -55,11 +54,7 @@ class SyncManagerTest {
|
|||||||
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)
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
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.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.LoggingSQLiteDriver
|
import com.subtracks.data.db.LoggingSQLiteDriver
|
||||||
import com.subtracks.data.db.SqlExecution
|
import com.subtracks.data.db.SqlExecution
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.db.artistsDefaultQuery
|
import com.subtracks.data.db.artistPages
|
||||||
|
import com.subtracks.data.db.artistsPageQuery
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.ArtistRef
|
import com.subtracks.data.model.ArtistRef
|
||||||
@@ -43,12 +42,7 @@ class SyncServiceTest {
|
|||||||
|
|
||||||
@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
|
||||||
@@ -75,7 +69,7 @@ class SyncServiceTest {
|
|||||||
2,
|
2,
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.size,
|
.size,
|
||||||
)
|
)
|
||||||
@@ -98,7 +92,7 @@ class SyncServiceTest {
|
|||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -172,7 +166,7 @@ class SyncServiceTest {
|
|||||||
"Old",
|
"Old",
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.single()
|
.single()
|
||||||
.name,
|
.name,
|
||||||
@@ -185,7 +179,7 @@ class SyncServiceTest {
|
|||||||
"New",
|
"New",
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.single()
|
.single()
|
||||||
.name,
|
.name,
|
||||||
@@ -258,6 +252,19 @@ class SyncServiceTest {
|
|||||||
assertEquals(5678L, stored?.starred)
|
assertEquals(5678L, stored?.starred)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncStoresPlaylistOwnerAndSharing() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source = FakeMusicSource(playlists = listOf(playlist("p1").copy(owner = "austin", shared = true)))
|
||||||
|
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
val stored = db.libraryDao().playlist(1, "p1").first()
|
||||||
|
assertEquals("austin", stored?.owner)
|
||||||
|
assertEquals(true, stored?.shared)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun syncStoresSongGenresInOrder() =
|
fun syncStoresSongGenresInOrder() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -1094,16 +1101,16 @@ class SyncServiceTest {
|
|||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
val query = artistsDefaultQuery(1, 0, "alpha", ArtistSort.Name, false)
|
val query = artistsPageQuery(1, 0, "alpha", ArtistSort.Name, false, false)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "alpha")
|
.artistPages(1, 0, "alpha")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -1190,7 +1197,7 @@ class SyncServiceTest {
|
|||||||
listOf("a1", "combo"),
|
listOf("a1", "combo"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -1203,7 +1210,7 @@ class SyncServiceTest {
|
|||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -1421,7 +1428,7 @@ class SyncServiceTest {
|
|||||||
SyncService(db, source).sync()
|
SyncService(db, source).sync()
|
||||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||||
|
|
||||||
val filtered = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
val filtered = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
|
||||||
assertEquals(listOf("A", "B"), filtered.map { it.name })
|
assertEquals(listOf("A", "B"), filtered.map { it.name })
|
||||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||||
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
||||||
@@ -1525,7 +1532,7 @@ class SyncServiceTest {
|
|||||||
albums.size.toLong(),
|
albums.size.toLong(),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.first { it.id == "a2" }
|
.first { it.id == "a2" }
|
||||||
.albumCount,
|
.albumCount,
|
||||||
@@ -1767,7 +1774,7 @@ class SyncServiceTest {
|
|||||||
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
|
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
val downloadedArtists = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
val downloadedArtists = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
|
||||||
for (artist in downloadedArtists) {
|
for (artist in downloadedArtists) {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"${artist.id} downloaded albums",
|
"${artist.id} downloaded albums",
|
||||||
@@ -1832,13 +1839,8 @@ class SyncServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun anIdenticalSyncSkipsTheAggregateQueries() =
|
fun anIdenticalSyncSkipsTheAggregateQueries() =
|
||||||
runTest {
|
runTest {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
||||||
val loggingDb =
|
val loggingDb = createTestDatabase(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
loggingDb.sourcesDao().upsertSource(
|
loggingDb.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.subtracks.playback
|
|||||||
|
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -70,6 +71,21 @@ class MediaItemTest {
|
|||||||
assertEquals(gain, item.replayGain())
|
assertEquals(gain, item.replayGain())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theItemCarriesEveryArtist() {
|
||||||
|
val artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B"))
|
||||||
|
|
||||||
|
val item =
|
||||||
|
queueMediaItem(
|
||||||
|
song.copy(artists = artists),
|
||||||
|
local = null,
|
||||||
|
stream = "http://server/stream?s=s1",
|
||||||
|
transcode = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(artists, item.artistRefs())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theItemCarriesItsGainContext() {
|
fun theItemCarriesItsGainContext() {
|
||||||
val item =
|
val item =
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package com.subtracks.playback
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
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.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
@@ -14,6 +15,7 @@ import com.subtracks.UiMessage
|
|||||||
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.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
@@ -27,20 +29,24 @@ import com.subtracks.data.model.Song
|
|||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.SongTapBehavior
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -83,11 +89,7 @@ class PlaybackControllerTest {
|
|||||||
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()
|
|
||||||
prefs = fakeUserPreferences()
|
prefs = fakeUserPreferences()
|
||||||
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
||||||
sources =
|
sources =
|
||||||
@@ -118,6 +120,7 @@ class PlaybackControllerTest {
|
|||||||
queues,
|
queues,
|
||||||
FakePlayerConnection(handle),
|
FakePlayerConnection(handle),
|
||||||
downloads,
|
downloads,
|
||||||
|
prefs,
|
||||||
showMessage = { messages += it.resolve(resources) },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = controllerScope,
|
scope = controllerScope,
|
||||||
)
|
)
|
||||||
@@ -2499,7 +2502,7 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun tappingASongWhileShuffledPlaysThatSong() {
|
fun tappingASongTurnsOffShuffleAndPlaysThatSong() {
|
||||||
seedAlbum(5, sourceId = 1)
|
seedAlbum(5, sourceId = 1)
|
||||||
|
|
||||||
controller.playAlbum(1, "al1", 0)
|
controller.playAlbum(1, "al1", 0)
|
||||||
@@ -2514,12 +2517,128 @@ class PlaybackControllerTest {
|
|||||||
controller.playAlbum(1, "al1", 3)
|
controller.playAlbum(1, "al1", 3)
|
||||||
await {
|
await {
|
||||||
controller.state.value.item
|
controller.state.value.item
|
||||||
?.id == "s4"
|
?.id == "s4" && !controller.state.value.shuffle
|
||||||
}
|
}
|
||||||
|
|
||||||
assertEquals("s4", handle.currentItem?.id)
|
assertEquals("s4", handle.currentItem?.id)
|
||||||
val shuffled = runBlocking { queues.snapshot() }
|
assertEquals(3L, controller.state.value.position)
|
||||||
assertEquals("s4", runBlocking { queues.itemAt(shuffled, controller.state.value.position!!)?.song?.id })
|
val snapshot = runBlocking { queues.snapshot() }
|
||||||
|
assertFalse(snapshot.shuffled)
|
||||||
|
assertEquals("s4", runBlocking { queues.itemAt(snapshot, controller.state.value.position!!)?.song?.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tappingASongCanKeepTheCurrentShuffleMode() {
|
||||||
|
seedAlbum(5, sourceId = 1)
|
||||||
|
runBlocking { prefs.setSongTapBehavior(SongTapBehavior.KeepShuffle) }
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
controller.toggleShuffle()
|
||||||
|
await { controller.state.value.shuffle }
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 3)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s4" && controller.state.value.shuffle
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(0L, controller.state.value.position)
|
||||||
|
val snapshot = runBlocking { queues.snapshot() }
|
||||||
|
assertTrue(snapshot.shuffled)
|
||||||
|
assertEquals("s4", runBlocking { queues.itemAt(snapshot, controller.state.value.position!!)?.song?.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tappingASongCanStartShuffled() {
|
||||||
|
seedAlbum(5, sourceId = 1)
|
||||||
|
runBlocking { prefs.setSongTapBehavior(SongTapBehavior.Shuffle) }
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 3)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s4" && controller.state.value.shuffle
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(0L, controller.state.value.position)
|
||||||
|
assertTrue(runBlocking { queues.snapshot().shuffled })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playAllStartsInOrderRegardlessOfTheSongTapBehavior() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
runBlocking { prefs.setSongTapBehavior(SongTapBehavior.Shuffle) }
|
||||||
|
controller.shuffleAlbum(1, "al1")
|
||||||
|
await { controller.state.value.shuffle }
|
||||||
|
|
||||||
|
controller.playAlbumInOrder(1, "al1")
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1" && !controller.state.value.shuffle
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(0L, controller.state.value.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun twoTapsKeepTheirOrderWhileThePreferenceReadIsSlow() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
val (slowPrefs, entered, gate) = gatedSongTapPreferences()
|
||||||
|
val slowController =
|
||||||
|
PlaybackController(
|
||||||
|
sources,
|
||||||
|
queues,
|
||||||
|
FakePlayerConnection(handle),
|
||||||
|
downloads,
|
||||||
|
slowPrefs,
|
||||||
|
scope = CoroutineScope(SupervisorJob() + dispatcher).also { extraScopes += it },
|
||||||
|
)
|
||||||
|
slowController.connect()
|
||||||
|
await { slowController.ready.value }
|
||||||
|
|
||||||
|
slowController.playAlbum(1, "al1", 0)
|
||||||
|
runBlocking { entered.await() }
|
||||||
|
slowController.playAlbum(1, "al1", 1)
|
||||||
|
gate.complete(Unit)
|
||||||
|
runBlocking { delay(1_000) }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"s2",
|
||||||
|
slowController.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(1L, slowController.state.value.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSongTappedWhileThePlayerConnectsKeepsItsOrder() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
val (slowPrefs, entered, gate) = gatedSongTapPreferences()
|
||||||
|
val slowController =
|
||||||
|
PlaybackController(
|
||||||
|
sources,
|
||||||
|
queues,
|
||||||
|
FakePlayerConnection(handle),
|
||||||
|
downloads,
|
||||||
|
slowPrefs,
|
||||||
|
scope = CoroutineScope(SupervisorJob() + dispatcher).also { extraScopes += it },
|
||||||
|
)
|
||||||
|
|
||||||
|
slowController.playAlbum(1, "al1", 0)
|
||||||
|
runBlocking { entered.await() }
|
||||||
|
slowController.playAlbum(1, "al1", 1)
|
||||||
|
gate.complete(Unit)
|
||||||
|
runBlocking { delay(1_000) }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"s2",
|
||||||
|
slowController.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(1L, slowController.state.value.position)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -2735,24 +2854,6 @@ class PlaybackControllerTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun playInOrderTurnsOffShuffle() {
|
|
||||||
seedAlbum(3, sourceId = 1)
|
|
||||||
|
|
||||||
controller.shuffleAlbum(1, "al1")
|
|
||||||
await { controller.state.value.shuffle }
|
|
||||||
|
|
||||||
controller.playAlbumInOrder(1, "al1")
|
|
||||||
await { !controller.state.value.shuffle }
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"s1",
|
|
||||||
controller.state.value.item
|
|
||||||
?.id,
|
|
||||||
)
|
|
||||||
assertEquals(0L, controller.state.value.position)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shufflePlayOnASingleTrackAlbumPlaysIt() {
|
fun shufflePlayOnASingleTrackAlbumPlaysIt() {
|
||||||
seedAlbum(1, sourceId = 1)
|
seedAlbum(1, sourceId = 1)
|
||||||
@@ -3659,12 +3760,36 @@ class PlaybackControllerTest {
|
|||||||
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
|
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun gatedSongTapPreferences(): Triple<UserPreferences, CompletableDeferred<Unit>, CompletableDeferred<Unit>> {
|
||||||
|
val entered = CompletableDeferred<Unit>()
|
||||||
|
val gate = CompletableDeferred<Unit>()
|
||||||
|
var reads = 0
|
||||||
|
val prefs =
|
||||||
|
UserPreferences(
|
||||||
|
object : DataStore<Preferences> {
|
||||||
|
private val state = MutableStateFlow(emptyPreferences())
|
||||||
|
|
||||||
|
override val data: Flow<Preferences> =
|
||||||
|
state.onStart {
|
||||||
|
if (reads++ == 0) {
|
||||||
|
entered.complete(Unit)
|
||||||
|
gate.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
|
||||||
|
transform(state.value).also { state.value = it }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return Triple(prefs, entered, gate)
|
||||||
|
}
|
||||||
|
|
||||||
private fun controllerOn(
|
private fun controllerOn(
|
||||||
dispatcher: CoroutineDispatcher,
|
dispatcher: CoroutineDispatcher,
|
||||||
handle: FakePlayerHandle,
|
handle: FakePlayerHandle,
|
||||||
): PlaybackController {
|
): PlaybackController {
|
||||||
val scope = CoroutineScope(SupervisorJob() + dispatcher).also { extraScopes += it }
|
val scope = CoroutineScope(SupervisorJob() + dispatcher).also { extraScopes += it }
|
||||||
return PlaybackController(sources, queues, FakePlayerConnection(handle), downloads, scope = scope)
|
return PlaybackController(sources, queues, FakePlayerConnection(handle), downloads, prefs, scope = scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected playback state", predicate)
|
private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected playback state", predicate)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -57,6 +58,29 @@ class QueueItemTest {
|
|||||||
assertEquals(true, item.albumContext)
|
assertEquals(true, item.albumContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun carriesEveryArtist() {
|
||||||
|
val song =
|
||||||
|
Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "s1",
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
title = "Song",
|
||||||
|
album = "Album",
|
||||||
|
artist = "A • B",
|
||||||
|
duration = 100,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val item = AlbumSongItem(song, coverArt = null).toQueueItem()
|
||||||
|
|
||||||
|
assertEquals(listOf("A", "B"), item.artists.map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aSongWithoutGainCarriesNoGain() {
|
fun aSongWithoutGainCarriesNoGain() {
|
||||||
val item =
|
val item =
|
||||||
|
|||||||
@@ -40,12 +40,14 @@ import com.subtracks.data.model.Source
|
|||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.prefs.ListQuery
|
import com.subtracks.data.prefs.ListQuery
|
||||||
import com.subtracks.data.repo.SEARCH_RESULT_LIMIT
|
import com.subtracks.data.repo.SEARCH_RESULT_LIMIT
|
||||||
|
import com.subtracks.data.repo.normalizeBiography
|
||||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||||
import com.subtracks.data.source.subsonic.TestServer
|
import com.subtracks.data.source.subsonic.TestServer
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import com.subtracks.data.sync.SyncService
|
import com.subtracks.data.sync.SyncService
|
||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.playback.QueueItem
|
import com.subtracks.playback.QueueItem
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.home.HomeFeed
|
import com.subtracks.ui.home.HomeFeed
|
||||||
import com.subtracks.ui.home.HomeScreen
|
import com.subtracks.ui.home.HomeScreen
|
||||||
import com.subtracks.ui.library.AlbumDetailScreen
|
import com.subtracks.ui.library.AlbumDetailScreen
|
||||||
@@ -82,11 +84,15 @@ import javax.imageio.ImageIO
|
|||||||
* by `:*UnitTest` or the integration suite; run it explicitly with `gradle :app:demoScreenshots`.
|
* by `:*UnitTest` or the integration suite; run it explicitly with `gradle :app:demoScreenshots`.
|
||||||
* The synced database is rebuilt each run; downloaded cover art is cached under the system temp dir
|
* The synced database is rebuilt each run; downloaded cover art is cached under the system temp dir
|
||||||
* so reruns do not re-fetch it.
|
* so reruns do not re-fetch it.
|
||||||
|
*
|
||||||
|
* The device is a 393x786 dp phone at 440 dpi, which renders 1080x2161. A capture one pixel over
|
||||||
|
* 1080x2160 breaks the stores' 2:1 limit, so the captures are cropped to 1080x2160 afterwards, with
|
||||||
|
* the last row taken from the row above it.
|
||||||
*/
|
*/
|
||||||
@OptIn(DelicateCoilApi::class)
|
@OptIn(DelicateCoilApi::class)
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
@Config(qualifiers = "w393dp-h786dp-normal-long-notround-any-440dpi-keyshidden-nonav")
|
||||||
class DemoScreenshotTest {
|
class DemoScreenshotTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
@@ -123,6 +129,7 @@ class DemoScreenshotTest {
|
|||||||
HomeScreen(
|
HomeScreen(
|
||||||
feed = demo.feed,
|
feed = demo.feed,
|
||||||
coverArt = demo.coverArt,
|
coverArt = demo.coverArt,
|
||||||
|
artistArt = demo::artistArt,
|
||||||
playingSongId = demo.homePlayingSong.id,
|
playingSongId = demo.homePlayingSong.id,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@@ -178,6 +185,7 @@ class DemoScreenshotTest {
|
|||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = demo.artist,
|
artist = demo.artist,
|
||||||
artistName = demo.artist.name,
|
artistName = demo.artist.name,
|
||||||
|
bio = demo.artistBio,
|
||||||
albums =
|
albums =
|
||||||
ArtistAlbums(
|
ArtistAlbums(
|
||||||
albumCredits =
|
albumCredits =
|
||||||
@@ -216,6 +224,7 @@ class DemoScreenshotTest {
|
|||||||
onSync = {},
|
onSync = {},
|
||||||
onOpenSettings = {},
|
onOpenSettings = {},
|
||||||
artwork = demo.artwork(demo.nowPlayingAlbum.coverArt),
|
artwork = demo.artwork(demo.nowPlayingAlbum.coverArt),
|
||||||
|
artistArt = demo::artistArt,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
demo.MiniPlayerOverlay(demo.nowPlayingSong, demo.nowPlayingAlbum, progressInset = true)
|
demo.MiniPlayerOverlay(demo.nowPlayingSong, demo.nowPlayingAlbum, progressInset = true)
|
||||||
@@ -228,6 +237,7 @@ class DemoScreenshotTest {
|
|||||||
onQueryChange = {},
|
onQueryChange = {},
|
||||||
results = demo.search,
|
results = demo.search,
|
||||||
coverArt = demo.coverArt,
|
coverArt = demo.coverArt,
|
||||||
|
artistArt = demo::artistArt,
|
||||||
playingSongId = demo.nowPlayingSong.id,
|
playingSongId = demo.nowPlayingSong.id,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
@@ -288,7 +298,7 @@ class DemoScreenshotTest {
|
|||||||
|
|
||||||
private fun reencodeOpaqueRgb(file: File) {
|
private fun reencodeOpaqueRgb(file: File) {
|
||||||
val decoded = ImageIO.read(file)
|
val decoded = ImageIO.read(file)
|
||||||
val rgb = BufferedImage(decoded.width, decoded.height, BufferedImage.TYPE_INT_RGB)
|
val rgb = BufferedImage(decoded.width, decoded.width * 2, BufferedImage.TYPE_INT_RGB)
|
||||||
rgb.createGraphics().run {
|
rgb.createGraphics().run {
|
||||||
drawImage(decoded, 0, 0, null)
|
drawImage(decoded, 0, 0, null)
|
||||||
dispose()
|
dispose()
|
||||||
@@ -329,6 +339,7 @@ class DemoScreenshotTest {
|
|||||||
val homePlayingAlbum: Album,
|
val homePlayingAlbum: Album,
|
||||||
val homePlayingSong: Song,
|
val homePlayingSong: Song,
|
||||||
val artist: Artist,
|
val artist: Artist,
|
||||||
|
val artistBio: String?,
|
||||||
val artistAlbums: List<Album>,
|
val artistAlbums: List<Album>,
|
||||||
val search: SearchResults,
|
val search: SearchResults,
|
||||||
val coverArt: (String?, Boolean) -> CoverArtRef?,
|
val coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
@@ -371,6 +382,15 @@ class DemoScreenshotTest {
|
|||||||
return artworkColorsFromSeeds(primary?.rgb ?: FALLBACK_SEED, null)
|
return artworkColorsFromSeeds(primary?.rgb ?: FALLBACK_SEED, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun artistArt(artist: Artist): ArtistArt {
|
||||||
|
val thumbnail = artist.coverArt?.let { coverArt(it, true) }
|
||||||
|
return ArtistArt(
|
||||||
|
image = thumbnail,
|
||||||
|
thumbnail = thumbnail,
|
||||||
|
covers = artist.coverArts.take(4).mapNotNull { coverArt(it, true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val FALLBACK_SEED = 0xFF3A7BD5.toInt()
|
private const val FALLBACK_SEED = 0xFF3A7BD5.toInt()
|
||||||
private const val DEMO_BASE = "https://demo.navidrome.org/"
|
private const val DEMO_BASE = "https://demo.navidrome.org/"
|
||||||
@@ -417,9 +437,11 @@ class DemoScreenshotTest {
|
|||||||
val homePlayingAlbum = allAlbums.firstOrNull { it.name == "Shaking The Habitual" } ?: allAlbums.last()
|
val homePlayingAlbum = allAlbums.firstOrNull { it.name == "Shaking The Habitual" } ?: allAlbums.last()
|
||||||
val homePlayingSong = songsOf(homePlayingAlbum).first()
|
val homePlayingSong = songsOf(homePlayingAlbum).first()
|
||||||
val artist =
|
val artist =
|
||||||
allArtists.firstOrNull { it.name == "Ugress" }
|
allArtists.firstOrNull { it.name == "Nine Inch Nails" }
|
||||||
|
?: allArtists.firstOrNull { it.name == "pornophonique" }
|
||||||
?: allArtists.maxByOrNull { it.albumCount }
|
?: allArtists.maxByOrNull { it.albumCount }
|
||||||
?: error("demo library has no artists")
|
?: error("demo library has no artists")
|
||||||
|
val artistBio = normalizeBiography(source.artistBiography(artist.id))
|
||||||
val artistAlbums = dao.albumsForArtist(1, artist.id).first()
|
val artistAlbums = dao.albumsForArtist(1, artist.id).first()
|
||||||
|
|
||||||
val coverArtIds =
|
val coverArtIds =
|
||||||
@@ -509,6 +531,7 @@ class DemoScreenshotTest {
|
|||||||
homePlayingAlbum = homePlayingAlbum,
|
homePlayingAlbum = homePlayingAlbum,
|
||||||
homePlayingSong = homePlayingSong,
|
homePlayingSong = homePlayingSong,
|
||||||
artist = artist,
|
artist = artist,
|
||||||
|
artistBio = artistBio,
|
||||||
artistAlbums = artistAlbums.filter { it.coverArt != null },
|
artistAlbums = artistAlbums.filter { it.coverArt != null },
|
||||||
search = search,
|
search = search,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.subtracks.ui
|
|||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.foundation.pager.PagerState
|
import androidx.compose.foundation.pager.PagerState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.test.assertWidthIsEqualTo
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
@@ -16,6 +18,9 @@ import androidx.compose.ui.test.performTouchInput
|
|||||||
import androidx.compose.ui.test.swipeDown
|
import androidx.compose.ui.test.swipeDown
|
||||||
import androidx.compose.ui.test.swipeLeft
|
import androidx.compose.ui.test.swipeLeft
|
||||||
import androidx.compose.ui.test.swipeRight
|
import androidx.compose.ui.test.swipeRight
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.LoadStates
|
import androidx.paging.LoadStates
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
@@ -342,6 +347,204 @@ class LibraryScreenTest {
|
|||||||
assertTrue("the tab row should reflow in one direction, but the icons moved $samples", monotonic)
|
assertTrue("the tab row should reflow in one direction, but the icons moved $samples", monotonic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||||
|
fun aLongTabKeepsTheGearFullSizeAndScrollsIntoView() {
|
||||||
|
var labelWidth = 0f
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
labelWidth =
|
||||||
|
rememberTextMeasurer()
|
||||||
|
.measure(
|
||||||
|
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
).size.width
|
||||||
|
.toFloat()
|
||||||
|
LibraryTabs(
|
||||||
|
pagerState =
|
||||||
|
remember {
|
||||||
|
PagerState(
|
||||||
|
currentPage = LibraryTab.Playlists.ordinal,
|
||||||
|
pageCount = { LibraryTab.entries.size },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onTabSelected = {},
|
||||||
|
onOpenSettings = {},
|
||||||
|
artwork = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule
|
||||||
|
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
|
||||||
|
.assertWidthIsEqualTo(40.dp)
|
||||||
|
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "ar-rEG-w320dp-h640dp-xhdpi")
|
||||||
|
fun aLongTabScrollsIntoViewInRtl() {
|
||||||
|
var labelWidth = 0f
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
labelWidth =
|
||||||
|
rememberTextMeasurer()
|
||||||
|
.measure(
|
||||||
|
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
).size.width
|
||||||
|
.toFloat()
|
||||||
|
LibraryTabs(
|
||||||
|
pagerState =
|
||||||
|
remember {
|
||||||
|
PagerState(
|
||||||
|
currentPage = LibraryTab.Playlists.ordinal,
|
||||||
|
pageCount = { LibraryTab.entries.size },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onTabSelected = {},
|
||||||
|
onOpenSettings = {},
|
||||||
|
artwork = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule
|
||||||
|
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
|
||||||
|
.assertWidthIsEqualTo(40.dp)
|
||||||
|
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||||
|
fun movingToALongTabScrollsItIntoView() {
|
||||||
|
val selectedTab = mutableStateOf(LibraryTab.Home)
|
||||||
|
var labelWidth = 0f
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
labelWidth =
|
||||||
|
rememberTextMeasurer()
|
||||||
|
.measure(
|
||||||
|
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
).size.width
|
||||||
|
.toFloat()
|
||||||
|
LibraryScreen(
|
||||||
|
selectedTab = selectedTab.value,
|
||||||
|
onTabSelected = { selectedTab.value = it },
|
||||||
|
albums = remember { flowOf(PagingData.empty<Album>()) },
|
||||||
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
|
coverArt = { _, _ -> null },
|
||||||
|
onAlbumClick = {},
|
||||||
|
onArtistClick = {},
|
||||||
|
onPlaylistClick = {},
|
||||||
|
onSync = {},
|
||||||
|
onOpenSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.runOnIdle { selectedTab.value = LibraryTab.Playlists }
|
||||||
|
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
|
||||||
|
composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Playlists).width >= expected - 1f }
|
||||||
|
|
||||||
|
composeRule
|
||||||
|
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
|
||||||
|
.assertWidthIsEqualTo(40.dp)
|
||||||
|
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||||
|
fun returningToTheFirstTabLeavesAGapAtTheEdge() {
|
||||||
|
val selectedTab = mutableStateOf(LibraryTab.Playlists)
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
LibraryScreen(
|
||||||
|
selectedTab = selectedTab.value,
|
||||||
|
onTabSelected = { selectedTab.value = it },
|
||||||
|
albums = remember { flowOf(PagingData.empty<Album>()) },
|
||||||
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
|
coverArt = { _, _ -> null },
|
||||||
|
onAlbumClick = {},
|
||||||
|
onArtistClick = {},
|
||||||
|
onPlaylistClick = {},
|
||||||
|
onSync = {},
|
||||||
|
onOpenSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.runOnIdle { selectedTab.value = LibraryTab.Home }
|
||||||
|
val expectedGap = with(composeRule.density) { TAB_EDGE_GAP.toPx() }
|
||||||
|
composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Home).left <= expectedGap + 1f }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"the first tab should keep a gap from the screen edge",
|
||||||
|
expectedGap,
|
||||||
|
tabBounds(LibraryTab.Home).left,
|
||||||
|
1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertTabFullyVisible(
|
||||||
|
tab: LibraryTab,
|
||||||
|
labelWidth: Float,
|
||||||
|
) {
|
||||||
|
val bounds = tabBounds(tab)
|
||||||
|
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
|
||||||
|
assertEquals("the selected tab should be shown in full, but was $bounds", expected, bounds.width, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
|
||||||
|
fun theOfflineIndicatorGoesOnline() {
|
||||||
|
var exited = false
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
LibraryScreen(
|
||||||
|
selectedTab = LibraryTab.Playlists,
|
||||||
|
onTabSelected = {},
|
||||||
|
albums = remember { flowOf(PagingData.empty<Album>()) },
|
||||||
|
artists = remember { flowOf(PagingData.empty<Artist>()) },
|
||||||
|
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
|
||||||
|
coverArt = { _, _ -> null },
|
||||||
|
onAlbumClick = {},
|
||||||
|
onArtistClick = {},
|
||||||
|
onPlaylistClick = {},
|
||||||
|
onSync = {},
|
||||||
|
onOpenSettings = {},
|
||||||
|
offline = true,
|
||||||
|
onExitOffline = { exited = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule
|
||||||
|
.onNodeWithContentDescription(composeRule.activity.getString(R.string.offline_mode_status))
|
||||||
|
.performClick()
|
||||||
|
assertTrue("the offline indicator should leave offline mode", exited)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tabBounds(tab: LibraryTab) =
|
||||||
|
composeRule
|
||||||
|
.onNodeWithContentDescription(composeRule.activity.getString(tab.label))
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
.boundsInRoot
|
||||||
|
|
||||||
private val settledLoadStates =
|
private val settledLoadStates =
|
||||||
LoadStates(
|
LoadStates(
|
||||||
refresh = LoadState.NotLoading(endOfPaginationReached = true),
|
refresh = LoadState.NotLoading(endOfPaginationReached = true),
|
||||||
@@ -378,3 +581,6 @@ class LibraryScreenTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val TAB_LABEL_CHROME = 56.dp
|
||||||
|
private val TAB_EDGE_GAP = 8.dp
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.subtracks.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
|
import com.subtracks.data.download.ArtworkStore
|
||||||
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
|
import com.subtracks.data.repo.NetworkServerActionSink
|
||||||
|
import com.subtracks.data.repo.QueueRepository
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.playback.FakePlayerConnection
|
||||||
|
import com.subtracks.playback.FakePlayerHandle
|
||||||
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ViewModelRepositoryFixture {
|
||||||
|
val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
|
|
||||||
|
init {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val db = createTestDatabase()
|
||||||
|
val artworkStore = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||||
|
val downloadEngine = FakeDownloadEngine()
|
||||||
|
val downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}")
|
||||||
|
val sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artworkStore, scope = repoScope)
|
||||||
|
val downloadRepository =
|
||||||
|
DownloadRepository(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
downloadEngine,
|
||||||
|
downloadsDir,
|
||||||
|
artworkStore = artworkStore,
|
||||||
|
artworkFetcher = { ByteArray(0) },
|
||||||
|
scope = repoScope,
|
||||||
|
)
|
||||||
|
val queueRepository = QueueRepository(db)
|
||||||
|
val controller =
|
||||||
|
PlaybackController(
|
||||||
|
sourceRepository,
|
||||||
|
queueRepository,
|
||||||
|
FakePlayerConnection(FakePlayerHandle()),
|
||||||
|
downloadRepository,
|
||||||
|
fakeUserPreferences(),
|
||||||
|
scope = controllerScope,
|
||||||
|
)
|
||||||
|
val libraryRepository =
|
||||||
|
LibraryRepository(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
fakeUserPreferences(),
|
||||||
|
scope = repoScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||||
|
db.close()
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
dispatcher.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,13 @@ import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
|||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
|
import com.subtracks.data.model.DownloadList
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -55,6 +60,115 @@ class DeleteDownloadsDialogTest {
|
|||||||
composeRule.onNodeWithText("512 B", substring = true).assertIsDisplayed()
|
composeRule.onNodeWithText("512 B", substring = true).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sharedWorkflowLooksUpBytesAndConfirmsTheRequestedTargetOnce() {
|
||||||
|
val actions = mutableListOf<Triple<DownloadList, String, BulkDownloadAction>>()
|
||||||
|
lateinit var controller: PendingDownloadDeleteController
|
||||||
|
renderWorkflow(
|
||||||
|
downloadedBytes = { list, refId ->
|
||||||
|
assertEquals(DownloadList.Album, list)
|
||||||
|
assertEquals("al1", refId)
|
||||||
|
1_500_000
|
||||||
|
},
|
||||||
|
onAction = { list, refId, action -> actions += Triple(list, refId, action) },
|
||||||
|
onController = { controller = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
composeRule.runOnIdle {
|
||||||
|
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
|
||||||
|
}
|
||||||
|
composeRule.waitUntil { controller.pending != null }
|
||||||
|
composeRule.onNodeWithText("Kid A", substring = true).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("MB", substring = true).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Delete").performClick()
|
||||||
|
|
||||||
|
composeRule.runOnIdle {
|
||||||
|
assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Delete)), actions)
|
||||||
|
assertNull(controller.pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sharedWorkflowCancelDoesNotConfirm() {
|
||||||
|
val actions = mutableListOf<BulkDownloadAction>()
|
||||||
|
lateinit var controller: PendingDownloadDeleteController
|
||||||
|
renderWorkflow(
|
||||||
|
onAction = { _, _, action -> actions += action },
|
||||||
|
onController = { controller = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
composeRule.runOnIdle {
|
||||||
|
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
|
||||||
|
}
|
||||||
|
composeRule.waitUntil { controller.pending != null }
|
||||||
|
composeRule.onNodeWithText("Cancel").performClick()
|
||||||
|
|
||||||
|
composeRule.runOnIdle {
|
||||||
|
assertEquals(emptyList<BulkDownloadAction>(), actions)
|
||||||
|
assertNull(controller.pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sharedWorkflowDismissesWithTheContextMenu() {
|
||||||
|
val dismissals = MutableSharedFlow<Unit>(replay = 1)
|
||||||
|
lateinit var controller: PendingDownloadDeleteController
|
||||||
|
renderWorkflow(dismissals = dismissals, onController = { controller = it })
|
||||||
|
|
||||||
|
composeRule.runOnIdle {
|
||||||
|
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
|
||||||
|
}
|
||||||
|
composeRule.waitUntil { controller.pending != null }
|
||||||
|
composeRule.runOnIdle { dismissals.tryEmit(Unit) }
|
||||||
|
|
||||||
|
composeRule.waitUntil { controller.pending == null }
|
||||||
|
composeRule.onNodeWithText("Delete").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sharedWorkflowDispatchesNonDeleteActionsImmediately() {
|
||||||
|
val actions = mutableListOf<Triple<DownloadList, String, BulkDownloadAction>>()
|
||||||
|
var lookups = 0
|
||||||
|
lateinit var controller: PendingDownloadDeleteController
|
||||||
|
renderWorkflow(
|
||||||
|
downloadedBytes = { _, _ ->
|
||||||
|
lookups++
|
||||||
|
0
|
||||||
|
},
|
||||||
|
onAction = { list, refId, action -> actions += Triple(list, refId, action) },
|
||||||
|
onController = { controller = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
composeRule.runOnIdle {
|
||||||
|
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Download) { list, refId, action ->
|
||||||
|
actions += Triple(list, refId, action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.runOnIdle {
|
||||||
|
assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Download)), actions)
|
||||||
|
assertEquals(0, lookups)
|
||||||
|
assertNull(controller.pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderWorkflow(
|
||||||
|
dismissals: kotlinx.coroutines.flow.Flow<Unit> = emptyFlow(),
|
||||||
|
downloadedBytes: suspend (DownloadList, String) -> Long = { _, _ -> 0 },
|
||||||
|
onAction: (DownloadList, String, BulkDownloadAction) -> Unit = { _, _, _ -> },
|
||||||
|
onController: (PendingDownloadDeleteController) -> Unit,
|
||||||
|
) {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
val controller = rememberPendingDownloadDelete(dismissals, downloadedBytes)
|
||||||
|
onController(controller)
|
||||||
|
PendingDownloadDeleteConfirmation(controller) { pending ->
|
||||||
|
onAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun render(
|
private fun render(
|
||||||
bytes: Long = 0,
|
bytes: Long = 0,
|
||||||
onConfirm: () -> Unit = {},
|
onConfirm: () -> Unit = {},
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.hasScrollAction
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performScrollToNode
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class HeroDetailScaffoldRestoreTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRestoredScreenWaitsForItsRowsBeforeRestoringTheScrollPosition() {
|
||||||
|
var visible by mutableStateOf(true)
|
||||||
|
var rows by mutableIntStateOf(100)
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
val holder = rememberSaveableStateHolder()
|
||||||
|
if (visible) {
|
||||||
|
holder.SaveableStateProvider("detail") {
|
||||||
|
HeroDetailScaffold(
|
||||||
|
artwork = null,
|
||||||
|
title = "Album",
|
||||||
|
onBack = {},
|
||||||
|
header = { _, _ -> Box(Modifier.height(200.dp)) },
|
||||||
|
content = { rowModifier -> rowItems(rows, rowModifier) },
|
||||||
|
contentReady = rows > 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Item 20"))
|
||||||
|
composeRule.onNodeWithText("Item 20").assertIsDisplayed()
|
||||||
|
|
||||||
|
composeRule.runOnIdle { visible = false }
|
||||||
|
composeRule.runOnIdle {
|
||||||
|
rows = 0
|
||||||
|
visible = true
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.runOnIdle { rows = 100 }
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Item 20").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.rowItems(
|
||||||
|
count: Int,
|
||||||
|
modifier: Modifier,
|
||||||
|
) {
|
||||||
|
repeat(count) { index ->
|
||||||
|
item(key = index) {
|
||||||
|
Box(modifier.height(48.dp)) {
|
||||||
|
Text("Item $index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,18 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.filter
|
||||||
|
import androidx.compose.ui.test.filterToOne
|
||||||
|
import androidx.compose.ui.test.hasClickAction
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.ArtistRef
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -44,34 +47,69 @@ class ItemContextMenuArtistTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun showsOneEntryPerArtistAndOpensTheChosenOne() {
|
fun pluralArtistsShareOneEntryThatOpensTheChooser() {
|
||||||
var opened: String? = null
|
var opened: List<ArtistRef>? = null
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
var show by remember { mutableStateOf(true) }
|
var show by remember { mutableStateOf(true) }
|
||||||
if (show) {
|
if (show) {
|
||||||
ItemContextMenu(
|
ItemContextMenu(
|
||||||
target = MenuTarget.Song(song),
|
target = MenuTarget.Song(song),
|
||||||
actions = ItemActions(viewArtist = { opened = it }),
|
actions = ItemActions(viewArtist = {}),
|
||||||
onDismiss = { show = false },
|
onDismiss = { show = false },
|
||||||
|
onChooseArtists = { opened = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeRule.onNodeWithText("A").assertIsDisplayed()
|
val entry = composeRule.onAllNodesWithText("A · B").filterToOne(hasClickAction())
|
||||||
composeRule.onNodeWithText("B").assertIsDisplayed()
|
entry.assertIsDisplayed()
|
||||||
|
assertTrue(
|
||||||
|
composeRule
|
||||||
|
.onAllNodesWithText("A")
|
||||||
|
.filter(hasClickAction())
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
composeRule
|
||||||
|
.onAllNodesWithText("B")
|
||||||
|
.filter(hasClickAction())
|
||||||
|
.fetchSemanticsNodes()
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
|
||||||
composeRule.mainClock.autoAdvance = false
|
composeRule.mainClock.autoAdvance = false
|
||||||
composeRule.onNodeWithText("B").performClick()
|
entry.performClick()
|
||||||
composeRule.mainClock.advanceTimeBy(1_000)
|
|
||||||
|
|
||||||
assertEquals("ar2", opened)
|
assertEquals(listOf("ar1", "ar2"), opened?.map { it.id })
|
||||||
|
composeRule.mainClock.advanceTimeBy(1_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun longMenusScrollToTheLastArtist() {
|
fun aSingleArtistOpensDirectly() {
|
||||||
var opened: String? = null
|
var opened: String? = null
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
ItemContextMenu(
|
||||||
|
target = MenuTarget.Song(song.copy(artists = listOf(ArtistRef("ar1", "A")))),
|
||||||
|
actions = ItemActions(viewArtist = { opened = it }),
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.mainClock.autoAdvance = false
|
||||||
|
composeRule.onAllNodesWithText("A").filterToOne(hasClickAction()).performClick()
|
||||||
|
composeRule.mainClock.advanceTimeBy(1_000)
|
||||||
|
|
||||||
|
assertEquals("ar1", opened)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun manyArtistsStillShareOneEntry() {
|
||||||
|
var opened: List<ArtistRef>? = null
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
SubtracksTheme {
|
SubtracksTheme {
|
||||||
ItemContextMenu(
|
ItemContextMenu(
|
||||||
@@ -79,18 +117,23 @@ class ItemContextMenuArtistTest {
|
|||||||
MenuTarget.Song(
|
MenuTarget.Song(
|
||||||
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
|
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
|
||||||
),
|
),
|
||||||
actions = ItemActions(viewArtist = { opened = it }),
|
actions = ItemActions(viewArtist = {}),
|
||||||
onDismiss = {},
|
onDismiss = {},
|
||||||
|
onChooseArtists = { opened = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed()
|
val entry =
|
||||||
|
composeRule
|
||||||
|
.onAllNodesWithText((1..20).joinToString(" · ") { "Artist $it" })
|
||||||
|
.filterToOne(hasClickAction())
|
||||||
|
entry.assertIsDisplayed()
|
||||||
|
|
||||||
composeRule.mainClock.autoAdvance = false
|
composeRule.mainClock.autoAdvance = false
|
||||||
composeRule.onNodeWithText("Artist 20").performClick()
|
entry.performClick()
|
||||||
composeRule.mainClock.advanceTimeBy(1_000)
|
|
||||||
|
|
||||||
assertEquals("ar20", opened)
|
assertEquals(20, opened?.size)
|
||||||
|
composeRule.mainClock.advanceTimeBy(1_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class ItemContextMenuInfoTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
private val album =
|
||||||
|
Album(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
name = "Album",
|
||||||
|
albumArtist = "Artist",
|
||||||
|
created = 0,
|
||||||
|
coverArt = null,
|
||||||
|
genre = null,
|
||||||
|
year = null,
|
||||||
|
starred = null,
|
||||||
|
songCount = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAlbumMenuOpensItsInfo() {
|
||||||
|
var opened: MenuTarget? = null
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
ItemContextMenu(
|
||||||
|
target = MenuTarget.Album(album),
|
||||||
|
actions = ItemActions(),
|
||||||
|
onDismiss = {},
|
||||||
|
onInfo = { opened = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Info").performClick()
|
||||||
|
composeRule.mainClock.advanceTimeBy(1_000)
|
||||||
|
|
||||||
|
assertEquals(album, (opened as? MenuTarget.Album)?.album)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anArtistMenuOpensItsInfo() {
|
||||||
|
var opened: MenuTarget? = null
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
ItemContextMenu(
|
||||||
|
target =
|
||||||
|
MenuTarget.Artist(
|
||||||
|
Artist(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "ar1",
|
||||||
|
name = "Artist",
|
||||||
|
albumCount = 1,
|
||||||
|
starred = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions = ItemActions(),
|
||||||
|
onDismiss = {},
|
||||||
|
onInfo = { opened = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Info").performClick()
|
||||||
|
composeRule.mainClock.advanceTimeBy(1_000)
|
||||||
|
|
||||||
|
assertEquals("ar1", (opened as? MenuTarget.Artist)?.artist?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPlaylistMenuOpensItsInfo() {
|
||||||
|
var opened: MenuTarget? = null
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
ItemContextMenu(
|
||||||
|
target =
|
||||||
|
MenuTarget.Playlist(
|
||||||
|
Playlist(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "pl1",
|
||||||
|
name = "Playlist",
|
||||||
|
comment = null,
|
||||||
|
coverArt = null,
|
||||||
|
songCount = 1,
|
||||||
|
created = 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions = ItemActions(),
|
||||||
|
onDismiss = {},
|
||||||
|
onInfo = { opened = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Info").performClick()
|
||||||
|
composeRule.mainClock.advanceTimeBy(1_000)
|
||||||
|
|
||||||
|
assertEquals("pl1", (opened as? MenuTarget.Playlist)?.playlist?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class LibraryInfoDialogTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAlbumShowsEveryArtistAndBothSortNames() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
AlbumInfoDialog(
|
||||||
|
album =
|
||||||
|
Album(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
name = "Album",
|
||||||
|
albumArtist = "A",
|
||||||
|
created = 0,
|
||||||
|
coverArt = null,
|
||||||
|
genre = "Rock",
|
||||||
|
year = 1999,
|
||||||
|
starred = null,
|
||||||
|
songCount = 12,
|
||||||
|
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||||
|
sortName = "Album, The",
|
||||||
|
albumArtistSort = "A, The",
|
||||||
|
),
|
||||||
|
download = null,
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("A · B").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Album, The").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("A, The").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("1999").assertIsDisplayed()
|
||||||
|
|
||||||
|
val label = composeRule.onNodeWithText("Album artist sort").fetchSemanticsNode().boundsInRoot
|
||||||
|
val value = composeRule.onNodeWithText("A, The").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue("the value must be separated from its label", value.left > label.right)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anArtistShowsItsSortNameAndCounts() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
ArtistInfoDialog(
|
||||||
|
artist =
|
||||||
|
Artist(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "ar1",
|
||||||
|
name = "The Artist",
|
||||||
|
albumCount = 4,
|
||||||
|
starred = null,
|
||||||
|
songCount = 40,
|
||||||
|
sortName = "Artist, The",
|
||||||
|
),
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Artist, The").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("4").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("40").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anArtistHidesASortNameThatMatchesItsName() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
ArtistInfoDialog(
|
||||||
|
artist =
|
||||||
|
Artist(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "ar1",
|
||||||
|
name = "The Artist",
|
||||||
|
albumCount = 1,
|
||||||
|
starred = null,
|
||||||
|
sortName = "the artist",
|
||||||
|
),
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Sort name").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAlbumHidesSortNamesThatMatchTheRegularNames() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
AlbumInfoDialog(
|
||||||
|
album =
|
||||||
|
Album(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
name = "Album",
|
||||||
|
albumArtist = "Artist",
|
||||||
|
created = 0,
|
||||||
|
coverArt = null,
|
||||||
|
genre = null,
|
||||||
|
year = null,
|
||||||
|
starred = null,
|
||||||
|
songCount = 1,
|
||||||
|
sortName = "album",
|
||||||
|
albumArtistSort = "artist",
|
||||||
|
),
|
||||||
|
download = null,
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithText("Album artist sort").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anAlbumsDatesAreListedTogether() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
AlbumInfoDialog(
|
||||||
|
album =
|
||||||
|
Album(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
name = "Album",
|
||||||
|
albumArtist = "Artist",
|
||||||
|
created = 86400,
|
||||||
|
coverArt = null,
|
||||||
|
genre = null,
|
||||||
|
year = null,
|
||||||
|
starred = null,
|
||||||
|
songCount = 1,
|
||||||
|
playCount = 3,
|
||||||
|
played = 172800,
|
||||||
|
),
|
||||||
|
download = null,
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val added = composeRule.onNodeWithText("Added").fetchSemanticsNode().boundsInRoot
|
||||||
|
val lastPlayed = composeRule.onNodeWithText("Last played").fetchSemanticsNode().boundsInRoot
|
||||||
|
val plays = composeRule.onNodeWithText("Plays").fetchSemanticsNode().boundsInRoot
|
||||||
|
assertTrue("last played must follow added", lastPlayed.top >= added.bottom)
|
||||||
|
assertTrue("plays must not sit between the dates", plays.top >= lastPlayed.bottom)
|
||||||
|
assertEquals(
|
||||||
|
"no row may sit between added and last played",
|
||||||
|
lastPlayed.top - added.top,
|
||||||
|
plays.top - lastPlayed.top,
|
||||||
|
0.5f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPlaylistShowsOwnerSharingLengthAndDates() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
PlaylistInfoDialog(
|
||||||
|
playlist =
|
||||||
|
Playlist(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "pl1",
|
||||||
|
name = "Mix",
|
||||||
|
comment = null,
|
||||||
|
coverArt = null,
|
||||||
|
songCount = 12,
|
||||||
|
created = 1600000000,
|
||||||
|
changed = 1700000000,
|
||||||
|
duration = 3725,
|
||||||
|
owner = "austin",
|
||||||
|
shared = true,
|
||||||
|
),
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Owner").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("austin").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Shared").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Yes").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("12").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("1:02:05").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Created").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Updated").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSingleArtistTrackUsesSingularLabels() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
SongInfoDialog(
|
||||||
|
song = song(artists = listOf(ArtistRef("ar1", "Alpha")), artistSort = "Beta"),
|
||||||
|
download = null,
|
||||||
|
localFile = null,
|
||||||
|
streamEncoding = null,
|
||||||
|
onDismiss = {},
|
||||||
|
genres = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Artist").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Artist sort").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Artists").assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun duplicateAndBlankArtistRefsStaySingular() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
SongInfoDialog(
|
||||||
|
song =
|
||||||
|
song(
|
||||||
|
artists = listOf(ArtistRef("ar1", "Alpha"), ArtistRef("ar2", "Alpha"), ArtistRef("ar3", "")),
|
||||||
|
artistSort = "Beta",
|
||||||
|
),
|
||||||
|
download = null,
|
||||||
|
localFile = null,
|
||||||
|
streamEncoding = null,
|
||||||
|
onDismiss = {},
|
||||||
|
genres = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Artist").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Artists").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aMixedArtistTrackUsesPluralLabelsForBothRows() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
SongInfoDialog(
|
||||||
|
song =
|
||||||
|
song(
|
||||||
|
artists = listOf(ArtistRef("ar1", "Artist A"), ArtistRef("ar2", "Artist B")),
|
||||||
|
artistSort = "Alpha",
|
||||||
|
),
|
||||||
|
download = null,
|
||||||
|
localFile = null,
|
||||||
|
streamEncoding = null,
|
||||||
|
onDismiss = {},
|
||||||
|
genres = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Artists").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Artists sort").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Artist").assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithText("Alpha").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTrackHidesATitleAndArtistSortThatMatchTheRegularNames() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
SongInfoDialog(
|
||||||
|
song = song(artists = listOf(ArtistRef("ar1", "Artist")), sortName = "song", artistSort = "artist"),
|
||||||
|
download = null,
|
||||||
|
localFile = null,
|
||||||
|
streamEncoding = null,
|
||||||
|
onDismiss = {},
|
||||||
|
genres = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aMixedArtistTrackWithoutASortShowsOnlyTheArtistsRow() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
SongInfoDialog(
|
||||||
|
song =
|
||||||
|
song(
|
||||||
|
artists = listOf(ArtistRef("ar1", "Artist A"), ArtistRef("ar2", "Artist B")),
|
||||||
|
artistSort = "",
|
||||||
|
),
|
||||||
|
download = null,
|
||||||
|
localFile = null,
|
||||||
|
streamEncoding = null,
|
||||||
|
onDismiss = {},
|
||||||
|
genres = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Artists").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Artist A · Artist B").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTrackWithOnlyAnArtistSortShowsOnlyTheSortRow() {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
SongInfoDialog(
|
||||||
|
song = song(artists = emptyList(), artist = null, artistSort = "Alpha"),
|
||||||
|
download = null,
|
||||||
|
localFile = null,
|
||||||
|
streamEncoding = null,
|
||||||
|
onDismiss = {},
|
||||||
|
genres = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Artist sort").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Alpha").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Artist").assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithText("Artists").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun song(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
artist: String? = artists.firstOrNull()?.name,
|
||||||
|
sortName: String = "Song",
|
||||||
|
artistSort: String = artist.orEmpty(),
|
||||||
|
) = Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "s1",
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = artists.firstOrNull()?.id,
|
||||||
|
title = "Song",
|
||||||
|
album = "Album",
|
||||||
|
artist = artist,
|
||||||
|
duration = 200,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
artists = artists,
|
||||||
|
sortName = sortName,
|
||||||
|
artistSort = artistSort,
|
||||||
|
)
|
||||||
|
}
|
||||||