mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
No commits in common. "784d9260d11259bb32d27acd329fa815bc20cd3d" and "0bf0e1816cb54615b6075519cd831a5e51e74a5d" have entirely different histories.
784d9260d1
...
0bf0e1816c
@ -449,7 +449,7 @@ nucleus.application {
|
|||||||
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
|
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
|
||||||
// --- ADD THESE FOR SPEED & LOW RAM ---
|
// --- ADD THESE FOR SPEED & LOW RAM ---
|
||||||
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
|
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
|
||||||
"-Xmx1g", // Ceiling, not a reservation: the heap only grows to what is live (#3200, #3193)
|
"-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room)
|
||||||
"-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
|
"-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
|
||||||
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
|
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
|
||||||
)
|
)
|
||||||
@ -556,7 +556,7 @@ nucleus.application {
|
|||||||
// ProGuard is not used for native images; -Os is the size equivalent
|
// ProGuard is not used for native images; -Os is the size equivalent
|
||||||
optimization = NativeImageOptimization.SIZE
|
optimization = NativeImageOptimization.SIZE
|
||||||
// Same ceiling as the JVM distribution's -Xmx
|
// Same ceiling as the JVM distribution's -Xmx
|
||||||
maxHeapSize = "1g"
|
maxHeapSize = "384m"
|
||||||
// Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed
|
// Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed
|
||||||
// a second ~30 MB copy as a resource
|
// a second ~30 MB copy as a resource
|
||||||
buildArgs.add("-H:+UnlockExperimentalVMOptions")
|
buildArgs.add("-H:+UnlockExperimentalVMOptions")
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
data class MediaItem(
|
data class MediaItem(
|
||||||
@ -85,9 +84,7 @@ sealed interface QueueEntry {
|
|||||||
val artists: List<String>,
|
val artists: List<String>,
|
||||||
val duration: Long,
|
val duration: Long,
|
||||||
val album: String?,
|
val album: String?,
|
||||||
// Not persisted: the queue is saved on every change and covers made it hundreds of MB
|
val coverBytes: ByteArray?,
|
||||||
// of JSON (#3193). Restore fills them back in from the local media index.
|
|
||||||
@Transient val coverBytes: ByteArray? = null,
|
|
||||||
override val url: String,
|
override val url: String,
|
||||||
override val addedBy: String = "") : QueueEntry {
|
override val addedBy: String = "") : QueueEntry {
|
||||||
override val artworkUrl: String? get() = null
|
override val artworkUrl: String? get() = null
|
||||||
|
|||||||
@ -20,10 +20,8 @@ package org.spotube.spotube.core.audioplayer
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
import org.spotube.spotube.core.di.injectLogger
|
import org.spotube.spotube.core.di.injectLogger
|
||||||
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
||||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
|
||||||
import org.spotube.spotube.modules.plugin.PluginProvider
|
import org.spotube.spotube.modules.plugin.PluginProvider
|
||||||
import org.spotube.spotube.modules.settings.SettingsProvider
|
import org.spotube.spotube.modules.settings.SettingsProvider
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
@ -39,7 +37,6 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@ -133,15 +130,14 @@ class DeviceAudioPlayerQueue(
|
|||||||
startPosition: Int,
|
startPosition: Int,
|
||||||
collectionEntry: QueueCollectionEntry?) {
|
collectionEntry: QueueCollectionEntry?) {
|
||||||
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
||||||
val resolved = resolveMediaItems(entries)
|
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
||||||
val mediaItems = resolved.map { it.first }
|
|
||||||
if (mediaItems.isEmpty()) {
|
if (mediaItems.isEmpty()) {
|
||||||
logger.w { "load: no valid media items after filtering blanks" }
|
logger.w { "load: no valid media items after filtering blanks" }
|
||||||
}
|
}
|
||||||
val normalizedStartPosition =
|
val normalizedStartPosition =
|
||||||
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
|
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
|
||||||
logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" }
|
logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" }
|
||||||
entryByMediaUrl.value = entriesByUrl(resolved)
|
entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! }
|
||||||
updateCollectionContext(collectionEntry)
|
updateCollectionContext(collectionEntry)
|
||||||
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
|
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
|
||||||
persistQueueStateIfReady()
|
persistQueueStateIfReady()
|
||||||
@ -405,66 +401,19 @@ class DeviceAudioPlayerQueue(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val restored = resolveMediaItems(withLocalCovers(state.entries))
|
val mediaItems = state.entries.mapNotNull { it.toMediaItem() }
|
||||||
if (restored.isEmpty()) {
|
if (mediaItems.isEmpty()) {
|
||||||
logger.w { "restorePersistedState: all entries had blank urls" }
|
logger.w { "restorePersistedState: all entries had blank urls" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val mediaItems = restored.map { it.first }
|
entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! }
|
||||||
entryByMediaUrl.value = entriesByUrl(restored)
|
|
||||||
val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex)
|
val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex)
|
||||||
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
|
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
|
||||||
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)
|
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)
|
||||||
logger.i { "Persisted state restored successfully" }
|
logger.i { "Persisted state restored successfully" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts each entry once. Looking every item's entry up again by converting the whole list
|
|
||||||
* per item was quadratic: ~900k suspending conversions to start a 1353-track playlist.
|
|
||||||
*/
|
|
||||||
private suspend fun resolveMediaItems(entries: List<QueueEntry>): List<Pair<MediaItem, QueueEntry>> =
|
|
||||||
entries.mapNotNull { entry -> entry.toMediaItem()?.let { it to entry } }
|
|
||||||
|
|
||||||
/** First entry wins for a repeated url, as before. */
|
|
||||||
private fun entriesByUrl(resolved: List<Pair<MediaItem, QueueEntry>>): Map<String, QueueEntry> = buildMap {
|
|
||||||
resolved.forEach { (item, entry) -> if (item.url !in this) put(item.url, entry) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Local covers aren't persisted with the queue; look them up in the local media index. */
|
|
||||||
private suspend fun withLocalCovers(entries: List<QueueEntry>): List<QueueEntry> {
|
|
||||||
if (entries.none { it is QueueEntry.LocalTrack }) return entries
|
|
||||||
val covers = runCatching {
|
|
||||||
get<LocalMediaCacheRepository>().cacheFlow.first().folders
|
|
||||||
.flatMap { it.tracks }
|
|
||||||
.mapNotNull { track -> track.coverBytes?.let { track.path to it } }
|
|
||||||
.toMap()
|
|
||||||
}.getOrElse {
|
|
||||||
logger.w(it) { "Couldn't read local media index for queue covers" }
|
|
||||||
emptyMap()
|
|
||||||
}
|
|
||||||
if (covers.isEmpty()) return entries
|
|
||||||
return entries.map { entry ->
|
|
||||||
if (entry is QueueEntry.LocalTrack && entry.coverBytes == null) {
|
|
||||||
covers[entry.url]?.let { entry.copy(coverBytes = it) } ?: entry
|
|
||||||
} else entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs from fire-and-forget collectors on a scope without an exception handler, where a
|
|
||||||
* failed write (disk full, I/O error) would otherwise crash the app. Losing one save is fine.
|
|
||||||
*/
|
|
||||||
private suspend fun persistQueueStateIfReady() {
|
private suspend fun persistQueueStateIfReady() {
|
||||||
try {
|
|
||||||
persistQueueState()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.e(e) { "Failed to persist queue state" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun persistQueueState() {
|
|
||||||
if (isRestoringPersistedState) {
|
if (isRestoringPersistedState) {
|
||||||
logger.v { "Skipping persist: still restoring persisted state" }
|
logger.v { "Skipping persist: still restoring persisted state" }
|
||||||
return
|
return
|
||||||
|
|||||||
@ -61,13 +61,13 @@ class Database(val paths: Paths) {
|
|||||||
|
|
||||||
val audioPlayerQueueDataStore: DataStore<Preferences> by lazy {
|
val audioPlayerQueueDataStore: DataStore<Preferences> by lazy {
|
||||||
PreferenceDataStoreFactory.createWithPath(
|
PreferenceDataStoreFactory.createWithPath(
|
||||||
produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath()) }
|
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val localMediaDataStore: DataStore<Preferences> by lazy {
|
val localMediaDataStore: DataStore<Preferences> by lazy {
|
||||||
PreferenceDataStoreFactory.createWithPath(
|
PreferenceDataStoreFactory.createWithPath(
|
||||||
produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath()) }
|
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,23 +83,7 @@ class Database(val paths: Paths) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DataStore reads a whole preferences file into the heap. Earlier builds stored full-size
|
|
||||||
* embedded covers as JSON number arrays in the queue and local media stores, growing them to
|
|
||||||
* hundreds of MB and crashing with OutOfMemoryError on load (#3193). Both are rebuildable, so
|
|
||||||
* drop one that is far past any sane size instead of loading it.
|
|
||||||
*/
|
|
||||||
private fun dropIfOversized(path: okio.Path): okio.Path {
|
|
||||||
runCatching {
|
|
||||||
val size = FileSystem.SYSTEM.metadataOrNull(path)?.size ?: return path
|
|
||||||
if (size > MAX_CACHE_STORE_BYTES) FileSystem.SYSTEM.delete(path)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MAX_CACHE_STORE_BYTES = 8L * 1024 * 1024
|
|
||||||
|
|
||||||
/** Preference files written by previous launches; their presence means an upgrade. */
|
/** Preference files written by previous launches; their presence means an upgrade. */
|
||||||
val EXISTING_INSTALL_PROBE_FILES = listOf(
|
val EXISTING_INSTALL_PROBE_FILES = listOf(
|
||||||
"spotube.preferences_pb",
|
"spotube.preferences_pb",
|
||||||
|
|||||||
@ -196,18 +196,22 @@ class CacheManager(
|
|||||||
val (start, end) = range
|
val (start, end) = range
|
||||||
val length = end - start + 1
|
val length = end - start + 1
|
||||||
val contentRange = "bytes $start-$end/$fileSize"
|
val contentRange = "bytes $start-$end/$fileSize"
|
||||||
call.respond(object : OutgoingContent.WriteChannelContent() {
|
fileSystem.source(filePath).buffer().use { source ->
|
||||||
override val status: HttpStatusCode = HttpStatusCode.PartialContent
|
source.skip(start)
|
||||||
override val contentLength: Long = length
|
val bytes = source.readByteArray(length)
|
||||||
override val contentType: ContentType = entry.toContentType()
|
call.respond(object : OutgoingContent.WriteChannelContent() {
|
||||||
override val headers: Headers = HeadersBuilder().apply {
|
override val status: HttpStatusCode = HttpStatusCode.PartialContent
|
||||||
append(HttpHeaders.ContentRange, contentRange)
|
override val contentLength: Long = bytes.size.toLong()
|
||||||
append(HttpHeaders.AcceptRanges, "bytes")
|
override val contentType: ContentType = entry.toContentType()
|
||||||
}.build()
|
override val headers: Headers = HeadersBuilder().apply {
|
||||||
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
|
append(HttpHeaders.ContentRange, contentRange)
|
||||||
streamFileRange(fileSystem, filePath, start, length, channel)
|
append(HttpHeaders.AcceptRanges, "bytes")
|
||||||
}
|
}.build()
|
||||||
})
|
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
|
||||||
|
channel.writeFully(bytes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
call.respond(object : OutgoingContent.NoContent() {
|
call.respond(object : OutgoingContent.NoContent() {
|
||||||
@ -219,42 +223,19 @@ class CacheManager(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
call.respond(object : OutgoingContent.WriteChannelContent() {
|
fileSystem.source(filePath).buffer().use { source ->
|
||||||
override val status: HttpStatusCode = HttpStatusCode.OK
|
val bytes = source.readByteArray()
|
||||||
override val contentLength: Long = fileSize
|
call.respond(object : OutgoingContent.WriteChannelContent() {
|
||||||
override val contentType: ContentType = entry.toContentType()
|
override val status: HttpStatusCode = HttpStatusCode.OK
|
||||||
override val headers: Headers = HeadersBuilder().apply {
|
override val contentLength: Long = bytes.size.toLong()
|
||||||
append(HttpHeaders.AcceptRanges, "bytes")
|
override val contentType: ContentType = entry.toContentType()
|
||||||
}.build()
|
override val headers: Headers = HeadersBuilder().apply {
|
||||||
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
|
append(HttpHeaders.AcceptRanges, "bytes")
|
||||||
streamFileRange(fileSystem, filePath, 0, fileSize, channel)
|
}.build()
|
||||||
}
|
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
|
||||||
})
|
channel.writeFully(bytes)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
|
||||||
|
|
||||||
private const val STREAM_CHUNK_BYTES = 64 * 1024
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies [length] bytes from [start] in fixed-size chunks. Cached tracks are tens of MB and the
|
|
||||||
* player re-requests them on every seek, so they must never be read into the heap whole.
|
|
||||||
*/
|
|
||||||
internal suspend fun streamFileRange(
|
|
||||||
fileSystem: FileSystem,
|
|
||||||
filePath: Path,
|
|
||||||
start: Long,
|
|
||||||
length: Long,
|
|
||||||
channel: io.ktor.utils.io.ByteWriteChannel) {
|
|
||||||
fileSystem.source(filePath).buffer().use { source ->
|
|
||||||
source.skip(start)
|
|
||||||
val chunk = ByteArray(STREAM_CHUNK_BYTES)
|
|
||||||
var remaining = length
|
|
||||||
while (remaining > 0) {
|
|
||||||
val read = source.read(chunk, 0, minOf(chunk.size.toLong(), remaining).toInt())
|
|
||||||
if (read == -1) break
|
|
||||||
channel.writeFully(chunk, 0, read)
|
|
||||||
remaining -= read
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,7 +46,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@ -81,7 +80,6 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
@ -98,7 +96,6 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import org.spotube.spotube.core.ui.misc.SkeletonTree
|
import org.spotube.spotube.core.ui.misc.SkeletonTree
|
||||||
import org.spotube.spotube.core.ui.misc.uniqueLazyKeys
|
|
||||||
import org.spotube.spotube.core.ui.misc.TextWithShimmer
|
import org.spotube.spotube.core.ui.misc.TextWithShimmer
|
||||||
import org.spotube.spotube.core.ui.misc.shimmerApply
|
import org.spotube.spotube.core.ui.misc.shimmerApply
|
||||||
import org.spotube.spotube.getPlatform
|
import org.spotube.spotube.getPlatform
|
||||||
@ -122,42 +119,6 @@ private val CompactTrackListBreakpoint = 600.dp
|
|||||||
private val LargeTrackListBreakpoint = 840.dp
|
private val LargeTrackListBreakpoint = 840.dp
|
||||||
private const val ShimmerRowCount = 6
|
private const val ShimmerRowCount = 6
|
||||||
|
|
||||||
private val TrackCardOuterPadding = 8.dp
|
|
||||||
private val TrackCardInnerPadding = 12.dp
|
|
||||||
private const val TrackCardTopKey = "track-card-top"
|
|
||||||
private const val TrackCardRowKeyPrefix = "track-card-row:"
|
|
||||||
private const val TrackCardFooterKey = "track-card-footer"
|
|
||||||
private const val TrackCardBottomKey = "track-card-bottom"
|
|
||||||
|
|
||||||
private fun Any.isTrackCardKey() = this is String && startsWith("track-card-")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sizes the card drawn behind a [TrackList]'s rows to the rows the list has laid out. Edges that
|
|
||||||
* are scrolled away are pushed a few viewports past the screen, far enough that the card's rounded
|
|
||||||
* corners, shadow and edge gradient stay off-screen, while the height stays bounded for huge lists.
|
|
||||||
*/
|
|
||||||
private fun Modifier.trackCardBounds(listState: LazyListState, edgeInsetPx: Int): Modifier =
|
|
||||||
layout { measurable, constraints ->
|
|
||||||
val info = listState.layoutInfo
|
|
||||||
val cardItems = info.visibleItemsInfo.filter { it.key.isTrackCardKey() }
|
|
||||||
if (cardItems.isEmpty()) return@layout layout(constraints.maxWidth, constraints.maxHeight) {}
|
|
||||||
|
|
||||||
// Item offsets are relative to the viewport start, which is negative by the top padding.
|
|
||||||
val toLocal = -info.viewportStartOffset
|
|
||||||
val viewport = info.viewportEndOffset - info.viewportStartOffset
|
|
||||||
val overscan = viewport * 3
|
|
||||||
val first = cardItems.first()
|
|
||||||
val last = cardItems.last()
|
|
||||||
val top = if (first.key == TrackCardTopKey) first.offset + toLocal + edgeInsetPx else -overscan
|
|
||||||
val bottom = if (last.key == TrackCardBottomKey) {
|
|
||||||
last.offset + last.size + toLocal - edgeInsetPx
|
|
||||||
} else viewport + overscan
|
|
||||||
val height = (bottom - top).coerceAtLeast(0)
|
|
||||||
val placeable = measurable.measure(
|
|
||||||
constraints.copy(minHeight = height, maxHeight = height))
|
|
||||||
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, top) }
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class TrackSortOption(val label: StringResource) {
|
enum class TrackSortOption(val label: StringResource) {
|
||||||
None(Res.string.track_list_sort_original),
|
None(Res.string.track_list_sort_original),
|
||||||
Title(Res.string.track_list_sort_title),
|
Title(Res.string.track_list_sort_title),
|
||||||
@ -237,7 +198,7 @@ fun TrackList(
|
|||||||
val shouldLoadMore = remember(density) {
|
val shouldLoadMore = remember(density) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val loadMoreThreshold = with(density) { 200.dp.toPx() }
|
val loadMoreThreshold = with(density) { 200.dp.toPx() }
|
||||||
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == TrackCardBottomKey }
|
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" }
|
||||||
// Trigger when the bottom of the track card is close to/inside the viewport,
|
// Trigger when the bottom of the track card is close to/inside the viewport,
|
||||||
// which means the user has scrolled through (or is about to scroll through)
|
// which means the user has scrolled through (or is about to scroll through)
|
||||||
// all loaded tracks.
|
// all loaded tracks.
|
||||||
@ -258,124 +219,115 @@ fun TrackList(
|
|||||||
val showAlbum = !isCompact
|
val showAlbum = !isCompact
|
||||||
val useDropdownForOptions = isDesktop && !isCompact
|
val useDropdownForOptions = isDesktop && !isCompact
|
||||||
|
|
||||||
val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track ->
|
|
||||||
TrackListRow(
|
|
||||||
index = displayedIndex + 1,
|
|
||||||
track = track,
|
|
||||||
showIndex = showIndex,
|
|
||||||
showAlbum = showAlbum,
|
|
||||||
useDropdownForOptions = useDropdownForOptions,
|
|
||||||
isCurrentTrack = track.id == currentTrackId,
|
|
||||||
// Only the current row depends on it, so play/pause/buffering flips don't recompose every row.
|
|
||||||
isCurrentTrackPlaying = isCurrentTrackPlaying && track.id == currentTrackId,
|
|
||||||
isSelectionMode = isSelectionMode,
|
|
||||||
isSelected = selectedTrackIds.contains(track.id),
|
|
||||||
onTrackClick = {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
selectedTrackIds =
|
|
||||||
if (selectedTrackIds.contains(track.id)) {
|
|
||||||
selectedTrackIds - track.id
|
|
||||||
} else {
|
|
||||||
selectedTrackIds + track.id
|
|
||||||
}
|
|
||||||
} else if (isJamGuest) {
|
|
||||||
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
|
|
||||||
} else {
|
|
||||||
onTrackClick(track)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongClick = {
|
|
||||||
if (!useDropdownForOptions && !isSelectionMode) {
|
|
||||||
isSelectionMode = true
|
|
||||||
selectedTrackIds = setOf(track.id)
|
|
||||||
} else if (!useDropdownForOptions) {
|
|
||||||
selectedTrackForOptions = track
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSelectionToggle = { checked ->
|
|
||||||
isSelectionMode = true
|
|
||||||
selectedTrackIds = if (checked) {
|
|
||||||
selectedTrackIds + track.id
|
|
||||||
} else {
|
|
||||||
selectedTrackIds - track.id
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onTrackOptionsAction = { action ->
|
|
||||||
onTrackOptionsAction(
|
|
||||||
track,
|
|
||||||
action
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trackOptionsState = trackOptionsState(track),
|
|
||||||
isInJam = isInJam,
|
|
||||||
isJamGuest = isJamGuest,
|
|
||||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
|
||||||
onArtistClick = onArtistClick,
|
|
||||||
onAlbumClick = onAlbumClick,
|
|
||||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shimmer, error, empty and next-page rows at the end of the card.
|
|
||||||
val trackCardFooter: @Composable () -> Unit = {
|
|
||||||
if (isLoading && tracks.isEmpty()) {
|
|
||||||
repeat(ShimmerRowCount) { shimmerIndex ->
|
|
||||||
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
|
||||||
MillenniumSeparator(
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp))
|
|
||||||
}
|
|
||||||
ShimmerTrackListRow(
|
|
||||||
index = shimmerIndex + 1,
|
|
||||||
showIndex = showIndex,
|
|
||||||
showAlbum = showAlbum,
|
|
||||||
useDropdownForOptions = useDropdownForOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error != null) {
|
|
||||||
if (visibleTracks.isNotEmpty()) {
|
|
||||||
MillenniumSeparator(
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp))
|
|
||||||
}
|
|
||||||
TrackListFeedbackRow(
|
|
||||||
message = error,
|
|
||||||
isError = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
|
||||||
TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoadingNextPage) {
|
|
||||||
if (visibleTracks.isNotEmpty()) {
|
|
||||||
MillenniumSeparator(
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp))
|
|
||||||
}
|
|
||||||
ShimmerTrackListRow(
|
|
||||||
index = visibleTracks.size + 1,
|
|
||||||
showIndex = showIndex,
|
|
||||||
showAlbum = showAlbum,
|
|
||||||
useDropdownForOptions = useDropdownForOptions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val trackCardContent: @Composable () -> Unit = {
|
val trackCardContent: @Composable () -> Unit = {
|
||||||
MillenniumCard(
|
MillenniumCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = TrackCardOuterPadding),
|
.padding(vertical = 8.dp),
|
||||||
contentPadding = PaddingValues(0.dp)) {
|
contentPadding = PaddingValues(0.dp)) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding)
|
.padding(top = 12.dp, bottom = 12.dp)
|
||||||
) {
|
) {
|
||||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||||
if (displayedIndex > 0) {
|
if (displayedIndex > 0) {
|
||||||
MillenniumSeparator()
|
MillenniumSeparator()
|
||||||
}
|
}
|
||||||
trackRow(displayedIndex, track)
|
TrackListRow(
|
||||||
|
index = displayedIndex + 1,
|
||||||
|
track = track,
|
||||||
|
showIndex = showIndex,
|
||||||
|
showAlbum = showAlbum,
|
||||||
|
useDropdownForOptions = useDropdownForOptions,
|
||||||
|
isCurrentTrack = track.id == currentTrackId,
|
||||||
|
isCurrentTrackPlaying = isCurrentTrackPlaying,
|
||||||
|
isSelectionMode = isSelectionMode,
|
||||||
|
isSelected = selectedTrackIds.contains(track.id),
|
||||||
|
onTrackClick = {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
selectedTrackIds =
|
||||||
|
if (selectedTrackIds.contains(track.id)) {
|
||||||
|
selectedTrackIds - track.id
|
||||||
|
} else {
|
||||||
|
selectedTrackIds + track.id
|
||||||
|
}
|
||||||
|
} else if (isJamGuest) {
|
||||||
|
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
|
||||||
|
} else {
|
||||||
|
onTrackClick(track)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
if (!useDropdownForOptions && !isSelectionMode) {
|
||||||
|
isSelectionMode = true
|
||||||
|
selectedTrackIds = setOf(track.id)
|
||||||
|
} else if (!useDropdownForOptions) {
|
||||||
|
selectedTrackForOptions = track
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSelectionToggle = { checked ->
|
||||||
|
isSelectionMode = true
|
||||||
|
selectedTrackIds = if (checked) {
|
||||||
|
selectedTrackIds + track.id
|
||||||
|
} else {
|
||||||
|
selectedTrackIds - track.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTrackOptionsAction = { action ->
|
||||||
|
onTrackOptionsAction(
|
||||||
|
track,
|
||||||
|
action
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trackOptionsState = trackOptionsState(track),
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||||
|
onArtistClick = onArtistClick,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && tracks.isEmpty()) {
|
||||||
|
repeat(ShimmerRowCount) { shimmerIndex ->
|
||||||
|
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
||||||
|
MillenniumSeparator(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
|
}
|
||||||
|
ShimmerTrackListRow(
|
||||||
|
index = shimmerIndex + 1,
|
||||||
|
showIndex = showIndex,
|
||||||
|
showAlbum = showAlbum,
|
||||||
|
useDropdownForOptions = useDropdownForOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != null) {
|
||||||
|
if (visibleTracks.isNotEmpty()) {
|
||||||
|
MillenniumSeparator(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
|
}
|
||||||
|
TrackListFeedbackRow(
|
||||||
|
message = error,
|
||||||
|
isError = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
||||||
|
TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingNextPage) {
|
||||||
|
if (visibleTracks.isNotEmpty()) {
|
||||||
|
MillenniumSeparator(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
|
}
|
||||||
|
ShimmerTrackListRow(
|
||||||
|
index = visibleTracks.size + 1,
|
||||||
|
showIndex = showIndex,
|
||||||
|
showAlbum = showAlbum,
|
||||||
|
useDropdownForOptions = useDropdownForOptions)
|
||||||
}
|
}
|
||||||
trackCardFooter()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -502,57 +454,29 @@ fun TrackList(
|
|||||||
|
|
||||||
if (scrollable) {
|
if (scrollable) {
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 1280.dp)
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp)) {
|
|
||||||
// Rows are separate lazy items (only on-screen ones are composed, #3200), so the card
|
|
||||||
// they sit on is drawn once behind the list, spanning the card's laid-out items.
|
|
||||||
MillenniumCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.matchParentSize()
|
|
||||||
.trackCardBounds(listState, with(density) { TrackCardOuterPadding.roundToPx() }),
|
|
||||||
contentPadding = PaddingValues(0.dp)) {}
|
|
||||||
// No arrangement spacing: it would also open gaps between the card's rows. The 2.dp
|
|
||||||
// gaps around the card are applied to the items before and after it instead.
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
contentPadding = contentPadding) {
|
modifier = Modifier
|
||||||
|
.widthIn(max = 1280.dp)
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
if (headerContent != null) {
|
if (headerContent != null) {
|
||||||
item {
|
item {
|
||||||
Box(Modifier.padding(bottom = 2.dp)) { headerContent() }
|
headerContent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!simplified) {
|
if (!simplified) {
|
||||||
item { Box(Modifier.padding(bottom = 2.dp)) { filterSortRow() } }
|
item { filterSortRow() }
|
||||||
}
|
|
||||||
item(key = TrackCardTopKey) {
|
|
||||||
Spacer(Modifier.fillMaxWidth().height(TrackCardOuterPadding + TrackCardInnerPadding))
|
|
||||||
}
|
|
||||||
// Plugin ids can repeat (the same track twice in a playlist).
|
|
||||||
val rowKeys = uniqueLazyKeys(visibleTracks) { it.id }
|
|
||||||
items(visibleTracks.size, key = { TrackCardRowKeyPrefix + rowKeys[it] }) { displayedIndex ->
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
|
||||||
if (displayedIndex > 0) {
|
|
||||||
MillenniumSeparator()
|
|
||||||
}
|
|
||||||
trackRow(displayedIndex, visibleTracks[displayedIndex])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item(key = TrackCardFooterKey) {
|
|
||||||
Column(Modifier.fillMaxWidth()) { trackCardFooter() }
|
|
||||||
}
|
|
||||||
item(key = TrackCardBottomKey) {
|
|
||||||
Spacer(Modifier.fillMaxWidth().height(TrackCardInnerPadding + TrackCardOuterPadding))
|
|
||||||
}
|
}
|
||||||
|
item(key = "track-card") { trackCardContent() }
|
||||||
if (footerContent != null) {
|
if (footerContent != null) {
|
||||||
item {
|
item {
|
||||||
Box(Modifier.padding(top = 2.dp)) { footerContent() }
|
footerContent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!useDropdownForOptions) {
|
if (!useDropdownForOptions) {
|
||||||
selectedTrackForOptions?.let { track ->
|
selectedTrackForOptions?.let { track ->
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.spotube.spotube.core.ui.misc
|
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Lazy lists crash when two items share a key ("Key ... was already used", #3196). Ids that come
|
|
||||||
* from plugins can be blank or repeat (the same playlist twice in a library page, a track added
|
|
||||||
* twice), so lists keyed on them use these instead of `items(list, key = { it.id })`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
private const val DUPLICATE_SEPARATOR = '\u0000'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One key per item: an item keeps its id the first time it appears, so scroll position and
|
|
||||||
* animations stay stable; blank ids and repeats get an occurrence suffix that can't collide with
|
|
||||||
* a real id.
|
|
||||||
*/
|
|
||||||
fun <T> uniqueLazyKeys(items: List<T>, id: (T) -> String): List<String> {
|
|
||||||
val occurrences = HashMap<String, Int>(items.size)
|
|
||||||
return items.map { item ->
|
|
||||||
val base = id(item)
|
|
||||||
val occurrence = occurrences[base] ?: 0
|
|
||||||
occurrences[base] = occurrence + 1
|
|
||||||
if (occurrence == 0 && base.isNotEmpty()) base else "$base$DUPLICATE_SEPARATOR$occurrence"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> LazyListScope.itemsWithUniqueKeys(
|
|
||||||
items: List<T>,
|
|
||||||
id: (T) -> String,
|
|
||||||
keyPrefix: String = "",
|
|
||||||
itemContent: @Composable LazyItemScope.(T) -> Unit
|
|
||||||
) {
|
|
||||||
val keys = uniqueLazyKeys(items, id)
|
|
||||||
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> LazyGridScope.itemsWithUniqueKeys(
|
|
||||||
items: List<T>,
|
|
||||||
id: (T) -> String,
|
|
||||||
keyPrefix: String = "",
|
|
||||||
itemContent: @Composable LazyGridItemScope.(T) -> Unit
|
|
||||||
) {
|
|
||||||
val keys = uniqueLazyKeys(items, id)
|
|
||||||
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
|
|
||||||
}
|
|
||||||
@ -18,7 +18,6 @@
|
|||||||
package org.spotube.spotube.modules.artist
|
package org.spotube.spotube.modules.artist
|
||||||
|
|
||||||
|
|
||||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
import org.spotube.millennium.composables.MillenniumButton
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
@ -639,7 +638,7 @@ private fun AlbumsSection(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
itemsWithUniqueKeys(albums, id = { it.id }) { album ->
|
items(albums, key = { it.id }) { album ->
|
||||||
AlbumCard(album = album)
|
AlbumCard(album = album)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -677,7 +676,7 @@ private fun RelatedArtistsSection(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
itemsWithUniqueKeys(artists, id = { it.id }) { artist ->
|
items(artists, key = { it.id }) { artist ->
|
||||||
ArtistCard(artist = artist)
|
ArtistCard(artist = artist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -715,7 +714,7 @@ private fun FeaturedPlaylistsSection(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
|
items(playlists, key = { it.id }) { playlist ->
|
||||||
PlaylistCard(playlist = playlist)
|
PlaylistCard(playlist = playlist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
package org.spotube.spotube.modules.library
|
package org.spotube.spotube.modules.library
|
||||||
|
|
||||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -59,7 +58,7 @@ internal fun <T> LibraryMediaCollection(
|
|||||||
if (loading) items(8) { SkeletonTree(true) { skeleton(true) } }
|
if (loading) items(8) { SkeletonTree(true) { skeleton(true) } }
|
||||||
else {
|
else {
|
||||||
if (header != null) item(key = "library_header") { header(true) }
|
if (header != null) item(key = "library_header") { header(true) }
|
||||||
itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, true) }
|
items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, true) }
|
||||||
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } }
|
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,7 +71,7 @@ internal fun <T> LibraryMediaCollection(
|
|||||||
if (loading) items(8) { SkeletonTree(true) { skeleton(false) } }
|
if (loading) items(8) { SkeletonTree(true) { skeleton(false) } }
|
||||||
else {
|
else {
|
||||||
if (header != null) item(key = "library_header") { header(false) }
|
if (header != null) item(key = "library_header") { header(false) }
|
||||||
itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, false) }
|
items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, false) }
|
||||||
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } }
|
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
package org.spotube.spotube.modules.library.playlist
|
package org.spotube.spotube.modules.library.playlist
|
||||||
|
|
||||||
|
|
||||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
|
||||||
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
import org.spotube.millennium.composables.MillenniumButton
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
@ -196,7 +195,7 @@ private fun PlaylistPickerContent(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
|
items(playlists, key = { it.id }) { playlist ->
|
||||||
PlaylistPickerRow(
|
PlaylistPickerRow(
|
||||||
playlist = playlist,
|
playlist = playlist,
|
||||||
onClick = { onPlaylistSelected(playlist.id) })
|
onClick = { onPlaylistSelected(playlist.id) })
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
package org.spotube.spotube.modules.playlist
|
package org.spotube.spotube.modules.playlist
|
||||||
|
|
||||||
|
|
||||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
|
||||||
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||||
import org.spotube.millennium.composables.MillenniumCircularProgress
|
import org.spotube.millennium.composables.MillenniumCircularProgress
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
@ -139,7 +138,7 @@ fun AddTracksToPlaylistDialog(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
itemsWithUniqueKeys(state.results, id = { it.track.id }) { result ->
|
items(state.results, key = { it.track.id }) { result ->
|
||||||
TrackSearchResultRow(
|
TrackSearchResultRow(
|
||||||
track = result.track,
|
track = result.track,
|
||||||
isAdding = result.isAdding,
|
isAdding = result.isAdding,
|
||||||
|
|||||||
@ -50,14 +50,6 @@ class SavedTracksRepository(
|
|||||||
|
|
||||||
val savedTracksIdsFlow : StateFlow<Set<String>> = savedTrackIds.asStateFlow()
|
val savedTracksIdsFlow : StateFlow<Set<String>> = savedTrackIds.asStateFlow()
|
||||||
|
|
||||||
/**
|
|
||||||
* Bumped only when the library itself changes (save, remove, re-login). [savedTracksIdsFlow]
|
|
||||||
* also grows while pages and saved-states are fetched, so reloading the list on it restarted
|
|
||||||
* paging on every page (#3200).
|
|
||||||
*/
|
|
||||||
private val _libraryChanges = MutableStateFlow(0)
|
|
||||||
val libraryChanges: StateFlow<Int> = _libraryChanges.asStateFlow()
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
pluginManager.selectedMetadataPlugin
|
pluginManager.selectedMetadataPlugin
|
||||||
@ -79,7 +71,6 @@ class SavedTracksRepository(
|
|||||||
savedTracksCache.invalidateAll()
|
savedTracksCache.invalidateAll()
|
||||||
totalCountCache.invalidateAll()
|
totalCountCache.invalidateAll()
|
||||||
savedTrackIds.value = emptySet()
|
savedTrackIds.value = emptySet()
|
||||||
_libraryChanges.value++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) =
|
suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) =
|
||||||
@ -118,7 +109,6 @@ class SavedTracksRepository(
|
|||||||
savedTracksCache.invalidateAll()
|
savedTracksCache.invalidateAll()
|
||||||
totalCountCache.invalidateAll()
|
totalCountCache.invalidateAll()
|
||||||
savedTrackIds.value += ids.toSet()
|
savedTrackIds.value += ids.toSet()
|
||||||
_libraryChanges.value++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +122,6 @@ class SavedTracksRepository(
|
|||||||
savedTracksCache.invalidateAll()
|
savedTracksCache.invalidateAll()
|
||||||
totalCountCache.invalidateAll()
|
totalCountCache.invalidateAll()
|
||||||
savedTrackIds.value -= ids.toSet()
|
savedTrackIds.value -= ids.toSet()
|
||||||
_libraryChanges.value++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -152,7 +152,7 @@ class SavedTracksViewModel(
|
|||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.flatMapLatest { it.loggedInFlow }
|
.flatMapLatest { it.loggedInFlow }
|
||||||
.distinctUntilChanged(),
|
.distinctUntilChanged(),
|
||||||
repository.libraryChanges
|
repository.savedTracksIdsFlow
|
||||||
) { _, _ -> }.collect {
|
) { _, _ -> }.collect {
|
||||||
loadInitialData()
|
loadInitialData()
|
||||||
loadCurrentUser()
|
loadCurrentUser()
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
package org.spotube.spotube.modules.shell.alternative_track
|
package org.spotube.spotube.modules.shell.alternative_track
|
||||||
|
|
||||||
|
|
||||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
|
||||||
import org.spotube.millennium.composables.MillenniumSpokeSpinner
|
import org.spotube.millennium.composables.MillenniumSpokeSpinner
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
import org.spotube.millennium.composables.MillenniumListTile
|
import org.spotube.millennium.composables.MillenniumListTile
|
||||||
@ -120,9 +119,9 @@ fun AlternativeTrackContent(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
itemsWithUniqueKeys(
|
items(
|
||||||
items = uiState.alternatives,
|
items = uiState.alternatives,
|
||||||
id = { source -> source.id }) { source ->
|
key = { source -> source.id }) { source ->
|
||||||
AlternativeSourceRow(
|
AlternativeSourceRow(
|
||||||
source = source,
|
source = source,
|
||||||
isActive = source.id == uiState.activeSourceId,
|
isActive = source.id == uiState.activeSourceId,
|
||||||
|
|||||||
@ -8,10 +8,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.core.context.stopKoin
|
import org.koin.core.context.stopKoin
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
@ -425,10 +423,7 @@ class DeviceAudioPlayerQueueTest {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val queue = createQueue()
|
val queue = createQueue()
|
||||||
// Restore runs on Dispatchers.IO; wait for it on real time rather than assuming one yield is enough.
|
withContext(Dispatchers.Default) { } // yield to let init coroutine run
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
withTimeout(5_000) { while (fakePlayer.loadCallCount == 0) delay(10) }
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(1, fakePlayer.loadCallCount)
|
assertEquals(1, fakePlayer.loadCallCount)
|
||||||
assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry())
|
assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry())
|
||||||
|
|||||||
@ -19,13 +19,6 @@ package org.spotube.spotube.modules.library.local_tracks.media
|
|||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jetbrains.skia.EncodedImageFormat
|
|
||||||
import org.jetbrains.skia.FilterMipmap
|
|
||||||
import org.jetbrains.skia.FilterMode
|
|
||||||
import org.jetbrains.skia.Image
|
|
||||||
import org.jetbrains.skia.MipmapMode
|
|
||||||
import org.jetbrains.skia.Rect
|
|
||||||
import org.jetbrains.skia.Surface
|
|
||||||
import java.nio.file.FileSystems
|
import java.nio.file.FileSystems
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
@ -77,7 +70,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
|
|||||||
artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(),
|
artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(),
|
||||||
durationMs = metadata?.durationMs ?: 0L,
|
durationMs = metadata?.durationMs ?: 0L,
|
||||||
album = metadata?.album,
|
album = metadata?.album,
|
||||||
coverBytes = metadata?.coverBytes?.let(::coverThumbnail))
|
coverBytes = metadata?.coverBytes)
|
||||||
folders.getOrPut(parent) { mutableListOf() }.add(track)
|
folders.getOrPut(parent) { mutableListOf() }.add(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -194,30 +187,3 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
|
|||||||
"wma")
|
"wma")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Embedded covers can be several MB each and the index is serialised to JSON, so keep a small
|
|
||||||
* JPEG like Android does (#3193). Returns null for unreadable art.
|
|
||||||
*/
|
|
||||||
internal fun coverThumbnail(bytes: ByteArray, size: Int = COVER_THUMBNAIL_SIZE): ByteArray? = runCatching {
|
|
||||||
Image.makeFromEncoded(bytes).use { source ->
|
|
||||||
val scale = size.toFloat() / minOf(source.width, source.height)
|
|
||||||
if (scale >= 1f && bytes.size <= MAX_SMALL_COVER_BYTES) return@runCatching bytes
|
|
||||||
val width = (source.width * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1)
|
|
||||||
val height = (source.height * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1)
|
|
||||||
Surface.makeRasterN32Premul(width, height).use { surface ->
|
|
||||||
surface.canvas.drawImageRect(
|
|
||||||
source,
|
|
||||||
Rect.makeWH(source.width.toFloat(), source.height.toFloat()),
|
|
||||||
Rect.makeWH(width.toFloat(), height.toFloat()),
|
|
||||||
FilterMipmap(FilterMode.LINEAR, MipmapMode.NONE),
|
|
||||||
null,
|
|
||||||
true)
|
|
||||||
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.JPEG, 85)?.bytes }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
private const val COVER_THUMBNAIL_SIZE = 160
|
|
||||||
private const val MAX_SMALL_COVER_BYTES = 64 * 1024
|
|
||||||
|
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.spotube.spotube.core.server
|
|
||||||
|
|
||||||
import io.ktor.utils.io.ByteChannel
|
|
||||||
import io.ktor.utils.io.readRemaining
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.io.readByteArray
|
|
||||||
import okio.FileSystem
|
|
||||||
import okio.Path.Companion.toOkioPath
|
|
||||||
import java.nio.file.Files
|
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertContentEquals
|
|
||||||
|
|
||||||
class StreamFileRangeTest {
|
|
||||||
// Several chunks plus a partial one.
|
|
||||||
private val data = Random(42).nextBytes(64 * 1024 * 3 + 1234)
|
|
||||||
|
|
||||||
private fun copy(start: Long, length: Long): ByteArray = runBlocking {
|
|
||||||
val file = Files.createTempFile("spotube-cache", ".bin").also { it.toFile().deleteOnExit() }
|
|
||||||
Files.write(file, data)
|
|
||||||
val channel = ByteChannel()
|
|
||||||
launch {
|
|
||||||
streamFileRange(FileSystem.SYSTEM, file.toOkioPath(), start, length, channel)
|
|
||||||
channel.flushAndClose()
|
|
||||||
}
|
|
||||||
channel.readRemaining().readByteArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun wholeFile() = assertContentEquals(data, copy(0, data.size.toLong()))
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rangeAcrossChunks() {
|
|
||||||
val start = 70_000L
|
|
||||||
val length = 120_000L
|
|
||||||
assertContentEquals(data.copyOfRange(start.toInt(), (start + length).toInt()), copy(start, length))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun lengthPastEndStopsAtEof() = assertContentEquals(data.copyOfRange(100, data.size), copy(100, data.size.toLong()))
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.spotube.spotube.core.ui.component
|
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
|
||||||
import androidx.compose.ui.ImageComposeScene
|
|
||||||
import androidx.compose.ui.semantics.SemanticsNode
|
|
||||||
import androidx.compose.ui.semantics.SemanticsProperties
|
|
||||||
import androidx.compose.ui.semantics.getOrNull
|
|
||||||
import androidx.compose.ui.use
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
|
||||||
import org.spotube.spotube.core.ui.theming.SpotubeTheme
|
|
||||||
import org.spotube.spotube.modules.settings.Theme
|
|
||||||
import org.spotube.spotube.modules.settings.UserSettings
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
/** #3200: big track lists must only compose the rows on screen. */
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
|
||||||
class TrackListVirtualizationTest {
|
|
||||||
private fun track(index: Int) = MetadataTrack(
|
|
||||||
// Every 100th id repeats: playlists can hold the same track twice.
|
|
||||||
id = if (index % 100 == 0) "dup" else "t$index",
|
|
||||||
title = "Song $index",
|
|
||||||
durationMs = 200_000L,
|
|
||||||
trackNumber = 1,
|
|
||||||
discNumber = 1,
|
|
||||||
artists = listOf(MetadataArtist.Basic(id = "a", name = "Artist", thumbnails = emptyList(), externalUri = null)),
|
|
||||||
album = null,
|
|
||||||
thumbnails = emptyList(),
|
|
||||||
explicit = false,
|
|
||||||
popularity = 0,
|
|
||||||
isrcCode = null,
|
|
||||||
externalUri = null)
|
|
||||||
|
|
||||||
private fun allNodes(scene: ImageComposeScene): List<SemanticsNode> {
|
|
||||||
fun flatten(node: SemanticsNode): List<SemanticsNode> = listOf(node) + node.children.flatMap(::flatten)
|
|
||||||
return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun songTitles(scene: ImageComposeScene): Set<String> = allNodes(scene)
|
|
||||||
.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() }
|
|
||||||
.map { it.text }
|
|
||||||
.filter { it.startsWith("Song ") }
|
|
||||||
.toSet()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun onlyVisibleRowsAreComposedAndTheEndLoadsMore() {
|
|
||||||
val tracks = (0 until 2000).map(::track)
|
|
||||||
val listState = LazyListState()
|
|
||||||
var scope: CoroutineScope? = null
|
|
||||||
var nextPageRequests = 0
|
|
||||||
|
|
||||||
ImageComposeScene(1000, 800) {
|
|
||||||
scope = rememberCoroutineScope()
|
|
||||||
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
|
||||||
TrackList(
|
|
||||||
tracks = tracks,
|
|
||||||
hasMore = true,
|
|
||||||
state = listState,
|
|
||||||
onLoadNextPage = { nextPageRequests++ })
|
|
||||||
}
|
|
||||||
}.use { scene ->
|
|
||||||
repeat(3) { scene.render().close() }
|
|
||||||
|
|
||||||
val visible = songTitles(scene)
|
|
||||||
assertTrue("Song 1" in visible, "first rows render: $visible")
|
|
||||||
assertTrue(visible.size in 3..60, "expected only on-screen rows, composed ${visible.size}")
|
|
||||||
assertEquals(0, nextPageRequests)
|
|
||||||
|
|
||||||
scope!!.launch { listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) }
|
|
||||||
repeat(3) { scene.render().close() }
|
|
||||||
|
|
||||||
val atEnd = songTitles(scene)
|
|
||||||
assertTrue("Song 1999" in atEnd, "last rows render: ${atEnd.sorted().takeLast(5)}")
|
|
||||||
assertTrue("Song 1" !in atEnd)
|
|
||||||
assertEquals(1, nextPageRequests)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.spotube.spotube.core.ui.misc
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
class UniqueLazyKeysTest {
|
|
||||||
@Test
|
|
||||||
fun blankAndRepeatedIdsGetDistinctKeys() {
|
|
||||||
val ids = listOf("a", "", "b", "a", "", "a")
|
|
||||||
val keys = uniqueLazyKeys(ids) { it }
|
|
||||||
|
|
||||||
assertEquals(ids.size, keys.toSet().size)
|
|
||||||
// First occurrences keep their id so scroll position stays stable.
|
|
||||||
assertEquals("a", keys[0])
|
|
||||||
assertEquals("b", keys[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun suffixesCannotCollideWithRealIds() {
|
|
||||||
val ids = listOf("a", "a", "a#1", "a1", "a_1")
|
|
||||||
assertEquals(ids.size, uniqueLazyKeys(ids) { it }.toSet().size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.spotube.spotube.modules.library.local_tracks.media
|
|
||||||
|
|
||||||
import org.jetbrains.skia.EncodedImageFormat
|
|
||||||
import org.jetbrains.skia.Image
|
|
||||||
import org.jetbrains.skia.Paint
|
|
||||||
import org.jetbrains.skia.Rect
|
|
||||||
import org.jetbrains.skia.Surface
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class CoverThumbnailTest {
|
|
||||||
private fun png(width: Int, height: Int): ByteArray = Surface.makeRasterN32Premul(width, height).use { surface ->
|
|
||||||
// Noise-free but non-uniform so the PNG isn't trivially small.
|
|
||||||
for (x in 0 until width step 7) {
|
|
||||||
surface.canvas.drawRect(Rect.makeXYWH(x.toFloat(), 0f, 3f, height.toFloat()), Paint().apply { color = (0xFF000000.toInt() or (x * 2654435761L).toInt()) })
|
|
||||||
}
|
|
||||||
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.PNG)!!.bytes }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun largeCoverIsShrunkToASmallJpeg() {
|
|
||||||
val original = png(1600, 1200)
|
|
||||||
val thumbnail = assertNotNull(coverThumbnail(original))
|
|
||||||
|
|
||||||
Image.makeFromEncoded(thumbnail).use { image ->
|
|
||||||
assertEquals(160, minOf(image.width, image.height))
|
|
||||||
assertEquals(213, image.width)
|
|
||||||
}
|
|
||||||
assertTrue(thumbnail.size < 32 * 1024, "thumbnail is ${thumbnail.size} bytes")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun unreadableCoverIsDropped() {
|
|
||||||
assertNull(coverThumbnail(byteArrayOf(1, 2, 3, 4)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -73,7 +73,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
|
|||||||
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
|
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
|
||||||
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
|
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
|
||||||
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
||||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs" }
|
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||||
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
|
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
|
||||||
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||||
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user