mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(local-tracks): implement LocalFolder screen and view model for folder navigation
This commit is contained in:
parent
c767b8dcd6
commit
75ced1285e
@ -202,7 +202,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
|
|||||||
.setArtist(artist)
|
.setArtist(artist)
|
||||||
.setAlbumTitle(album)
|
.setAlbumTitle(album)
|
||||||
.apply {
|
.apply {
|
||||||
if (coverURL.isNotBlank()) {
|
if (coverBytes != null) {
|
||||||
|
setArtworkData(coverBytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
||||||
|
} else if (coverURL.isNotBlank()) {
|
||||||
setArtworkUri(android.net.Uri.parse(coverURL))
|
setArtworkUri(android.net.Uri.parse(coverURL))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,18 @@ import android.os.Build
|
|||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import org.spotube.spotube.core.di.injectLogger
|
import org.spotube.spotube.core.di.injectLogger
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -35,14 +46,39 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
|
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
|
||||||
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
||||||
|
|
||||||
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
|
private class TagInfo(
|
||||||
|
val title: String?,
|
||||||
|
val artists: List<String>,
|
||||||
|
val album: String?,
|
||||||
|
val durationMs: Long,
|
||||||
|
val cover: ByteArray?)
|
||||||
|
|
||||||
|
private class PendingTrack(
|
||||||
|
val uri: String,
|
||||||
|
val filePath: String?,
|
||||||
|
val modifiedAt: Long,
|
||||||
|
val folderPath: String,
|
||||||
|
val fallbackName: String,
|
||||||
|
val mediaStoreArtists: List<String>,
|
||||||
|
val mediaStoreAlbum: String?,
|
||||||
|
val mediaStoreDurationMs: Long)
|
||||||
|
|
||||||
|
// Parsing tags + cover with lofty is the expensive part, so keep results across rescans.
|
||||||
|
private val tagCache = ConcurrentHashMap<String, TagInfo>()
|
||||||
|
|
||||||
|
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> = withContext(Dispatchers.IO) {
|
||||||
|
discoverFoldersBlocking(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun discoverFoldersBlocking(roots: List<String>): List<LocalMediaFolder> {
|
||||||
logger.i { "discoverFolders: start roots=${roots.size}" }
|
logger.i { "discoverFolders: start roots=${roots.size}" }
|
||||||
if (!hasReadPermission()) {
|
if (!hasReadPermission()) {
|
||||||
logger.w { "discoverFolders: missing media permission, returning empty result" }
|
logger.w { "discoverFolders: missing media permission, returning empty result" }
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
val tracksByFolder = linkedMapOf<String, MutableList<LocalMediaTrack>>()
|
val pending = mutableListOf<PendingTrack>()
|
||||||
|
val knownFilePaths = hashSetOf<String>()
|
||||||
val resolver = context.contentResolver
|
val resolver = context.contentResolver
|
||||||
val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
var totalRows = 0
|
var totalRows = 0
|
||||||
@ -58,7 +94,9 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
MediaStore.Audio.Media.ARTIST,
|
MediaStore.Audio.Media.ARTIST,
|
||||||
MediaStore.Audio.Media.ALBUM,
|
MediaStore.Audio.Media.ALBUM,
|
||||||
MediaStore.Audio.Media.DURATION,
|
MediaStore.Audio.Media.DURATION,
|
||||||
MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) {
|
MediaStore.Audio.Media.MIME_TYPE,
|
||||||
|
MediaStore.Audio.Media.DATA,
|
||||||
|
MediaStore.Audio.Media.DATE_MODIFIED) + if (Build.VERSION.SDK_INT >= 29) {
|
||||||
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
||||||
} else {
|
} else {
|
||||||
emptyArray()
|
emptyArray()
|
||||||
@ -77,6 +115,8 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||||
val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE)
|
val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE)
|
||||||
val relativePathCol = cursor.getColumnIndex(MediaStore.Audio.Media.RELATIVE_PATH)
|
val relativePathCol = cursor.getColumnIndex(MediaStore.Audio.Media.RELATIVE_PATH)
|
||||||
|
val dataCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATA)
|
||||||
|
val modifiedCol = cursor.getColumnIndex(MediaStore.Audio.Media.DATE_MODIFIED)
|
||||||
|
|
||||||
logger.d {
|
logger.d {
|
||||||
"discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)"
|
"discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)"
|
||||||
@ -118,20 +158,23 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
.takeUnless { it.equals("<unknown>", true) }
|
.takeUnless { it.equals("<unknown>", true) }
|
||||||
val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() }
|
val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() }
|
||||||
val durationMs = cursor.getLong(durationCol).coerceAtLeast(0L)
|
val durationMs = cursor.getLong(durationCol).coerceAtLeast(0L)
|
||||||
|
val filePath = if (dataCol >= 0) cursor.getString(dataCol)?.takeIf { it.isNotBlank() } else null
|
||||||
|
val modifiedAt = if (modifiedCol >= 0) cursor.getLong(modifiedCol) else 0L
|
||||||
|
filePath?.let(knownFilePaths::add)
|
||||||
|
|
||||||
val name = rawTitle.ifBlank {
|
val name = rawTitle.ifBlank {
|
||||||
displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName
|
displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName
|
||||||
}
|
}
|
||||||
|
|
||||||
val track = LocalMediaTrack(
|
pending += PendingTrack(
|
||||||
path = uri,
|
uri = uri,
|
||||||
name = name.ifBlank { "Unknown track" },
|
filePath = filePath,
|
||||||
artists = listOfNotNull(artist),
|
modifiedAt = modifiedAt,
|
||||||
durationMs = durationMs,
|
folderPath = folderPath,
|
||||||
album = album,
|
fallbackName = name.ifBlank { "Unknown track" },
|
||||||
coverBytes = null)
|
mediaStoreArtists = listOfNotNull(artist),
|
||||||
|
mediaStoreAlbum = album,
|
||||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
mediaStoreDurationMs = durationMs)
|
||||||
acceptedRows += 1
|
acceptedRows += 1
|
||||||
}
|
}
|
||||||
} ?: run {
|
} ?: run {
|
||||||
@ -142,20 +185,13 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
logger.w { "discoverFolders: MediaStore query returned null cursor" }
|
logger.w { "discoverFolders: MediaStore query returned null cursor" }
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaStoreFolders = tracksByFolder.entries
|
// Files MediaStore already indexed must not be listed a second time by the filesystem scan.
|
||||||
.map { (folderPath, tracks) ->
|
discoverFileSystemFallback(roots, knownFilePaths, pending)
|
||||||
LocalMediaFolder(
|
|
||||||
path = folderPath,
|
|
||||||
name = normalizeFolderName(folderPath),
|
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
|
||||||
}
|
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
|
||||||
|
|
||||||
val fallbackFolders = discoverFileSystemFallback(roots)
|
val merged = buildFolders(pending)
|
||||||
val merged = mergeFolders(mediaStoreFolders, fallbackFolders)
|
|
||||||
|
|
||||||
logger.i {
|
logger.i {
|
||||||
"discoverFolders: done folders=${merged.size} mediaStoreFolders=${mediaStoreFolders.size} fallbackFolders=${fallbackFolders.size} tracks=$acceptedRows totalRows=$totalRows skippedNonAudio=$skippedNonAudioRows unknownFolderRows=$unknownFolderRows"
|
"discoverFolders: done folders=${merged.size} mediaStoreTracks=$acceptedRows tracks=${pending.size} totalRows=$totalRows skippedNonAudio=$skippedNonAudioRows unknownFolderRows=$unknownFolderRows"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (merged.isNotEmpty()) {
|
if (merged.isNotEmpty()) {
|
||||||
@ -221,56 +257,109 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
return extension in SUPPORTED_EXTENSIONS
|
return extension in SUPPORTED_EXTENSIONS
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun discoverFileSystemFallback(roots: List<String>): List<LocalMediaFolder> {
|
private fun discoverFileSystemFallback(
|
||||||
|
roots: List<String>,
|
||||||
|
knownFilePaths: Set<String>,
|
||||||
|
out: MutableList<PendingTrack>) {
|
||||||
val candidateRoots = resolveCandidateRoots(roots)
|
val candidateRoots = resolveCandidateRoots(roots)
|
||||||
if (candidateRoots.isEmpty()) {
|
if (candidateRoots.isEmpty()) {
|
||||||
logger.d { "discoverFileSystemFallback: no candidate roots" }
|
logger.d { "discoverFileSystemFallback: no candidate roots" }
|
||||||
return emptyList()
|
return
|
||||||
}
|
}
|
||||||
|
val storageRoot = Environment.getExternalStorageDirectory().absolutePath.trimEnd('/') + "/"
|
||||||
logger.d {
|
val seen = hashSetOf<String>()
|
||||||
"discoverFileSystemFallback: scanning roots=${candidateRoots.joinToString()}"
|
var added = 0
|
||||||
}
|
|
||||||
|
|
||||||
val tracksByFolder = linkedMapOf<String, MutableList<LocalMediaTrack>>()
|
|
||||||
var scannedFiles = 0
|
|
||||||
var acceptedFiles = 0
|
|
||||||
|
|
||||||
candidateRoots.forEach { root ->
|
candidateRoots.forEach { root ->
|
||||||
scanDirectory(root) { file ->
|
scanDirectory(root) { file ->
|
||||||
scannedFiles += 1
|
if (file.extension.lowercase(Locale.getDefault()) !in SUPPORTED_EXTENSIONS) return@scanDirectory
|
||||||
val extension = file.extension.lowercase(Locale.getDefault())
|
val absolute = file.absolutePath
|
||||||
if (extension !in SUPPORTED_EXTENSIONS) return@scanDirectory
|
if (absolute in knownFilePaths || !seen.add(absolute)) return@scanDirectory
|
||||||
acceptedFiles += 1
|
|
||||||
|
|
||||||
val folderPath = file.parentFile?.absolutePath ?: "Unknown"
|
// Same key shape as MediaStore's RELATIVE_PATH ("Download/") so folders merge.
|
||||||
val name = file.nameWithoutExtension.ifBlank { file.name }
|
val parent = file.parentFile?.absolutePath.orEmpty().trimEnd('/') + "/"
|
||||||
val track = LocalMediaTrack(
|
val folderPath = if (parent.startsWith(storageRoot)) parent.removePrefix(storageRoot) else parent
|
||||||
path = file.absolutePath,
|
out += PendingTrack(
|
||||||
name = name,
|
uri = absolute,
|
||||||
artists = emptyList(),
|
filePath = absolute,
|
||||||
durationMs = 0L,
|
modifiedAt = file.lastModified() / 1000,
|
||||||
album = null,
|
folderPath = folderPath,
|
||||||
coverBytes = null)
|
fallbackName = file.nameWithoutExtension.ifBlank { file.name },
|
||||||
|
mediaStoreArtists = emptyList(),
|
||||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
mediaStoreAlbum = null,
|
||||||
|
mediaStoreDurationMs = 0L)
|
||||||
|
added += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.i { "discoverFileSystemFallback: extra files not in MediaStore=$added" }
|
||||||
|
}
|
||||||
|
|
||||||
val folders = tracksByFolder.entries
|
private suspend fun buildFolders(pending: List<PendingTrack>): List<LocalMediaFolder> {
|
||||||
.map { (folderPath, tracks) ->
|
val permits = Semaphore(4)
|
||||||
|
val tracks = coroutineScope {
|
||||||
|
pending.map { item ->
|
||||||
|
async(Dispatchers.IO) {
|
||||||
|
val tag = permits.withPermit { readTags(item) }
|
||||||
|
item.folderPath to LocalMediaTrack(
|
||||||
|
path = item.uri,
|
||||||
|
name = tag?.title?.takeIf { it.isNotBlank() } ?: item.fallbackName,
|
||||||
|
artists = tag?.artists?.takeIf { it.isNotEmpty() } ?: item.mediaStoreArtists,
|
||||||
|
durationMs = tag?.durationMs?.takeIf { it > 0 } ?: item.mediaStoreDurationMs,
|
||||||
|
album = tag?.album?.takeIf { it.isNotBlank() } ?: item.mediaStoreAlbum,
|
||||||
|
coverBytes = tag?.cover)
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
return tracks.groupBy({ it.first }, { it.second })
|
||||||
|
.map { (path, list) ->
|
||||||
LocalMediaFolder(
|
LocalMediaFolder(
|
||||||
path = folderPath,
|
path = path,
|
||||||
name = normalizeFolderName(folderPath),
|
name = normalizeFolderName(path),
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
tracks = list.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||||
}
|
}
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||||
|
}
|
||||||
|
|
||||||
logger.i {
|
private fun readTags(item: PendingTrack): TagInfo? {
|
||||||
"discoverFileSystemFallback: scannedFiles=$scannedFiles acceptedFiles=$acceptedFiles folders=${folders.size}"
|
val path = item.filePath ?: return null
|
||||||
|
val key = "$path:${item.modifiedAt}"
|
||||||
|
tagCache[key]?.let { return it }
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.isFile || !file.canRead()) return null
|
||||||
|
val info = try {
|
||||||
|
val meta = uniffi.compose_app.readAudioMetadata(path)
|
||||||
|
TagInfo(
|
||||||
|
title = meta.title,
|
||||||
|
artists = meta.artists,
|
||||||
|
album = meta.album,
|
||||||
|
durationMs = meta.durationMs,
|
||||||
|
cover = meta.coverBytes?.let(::thumbnail))
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
logger.d { "readTags: failed path=$path reason=${t.message}" }
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
if (info != null) tagCache[key] = info
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
return folders
|
// Embedded covers can be several MB; the cache is serialised as JSON, so keep a small JPEG.
|
||||||
|
private fun thumbnail(bytes: ByteArray): ByteArray? {
|
||||||
|
if (bytes.isEmpty()) return null
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
var sample = 1
|
||||||
|
while (bounds.outWidth / (sample * 2) >= THUMBNAIL_SIZE && bounds.outHeight / (sample * 2) >= THUMBNAIL_SIZE) sample *= 2
|
||||||
|
val decoded = BitmapFactory.decodeByteArray(
|
||||||
|
bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null
|
||||||
|
val scale = THUMBNAIL_SIZE.toFloat() / minOf(decoded.width, decoded.height)
|
||||||
|
val bitmap = if (scale < 1f) Bitmap.createScaledBitmap(
|
||||||
|
decoded, (decoded.width * scale).toInt().coerceAtLeast(1),
|
||||||
|
(decoded.height * scale).toInt().coerceAtLeast(1), true) else decoded
|
||||||
|
return ByteArrayOutputStream().use {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it)
|
||||||
|
it.toByteArray()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveCandidateRoots(roots: List<String>): List<File> {
|
private fun resolveCandidateRoots(roots: List<String>): List<File> {
|
||||||
@ -309,35 +398,8 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeFolders(
|
|
||||||
mediaStoreFolders: List<LocalMediaFolder>,
|
|
||||||
fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> {
|
|
||||||
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
|
||||||
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
|
||||||
|
|
||||||
val merged = linkedMapOf<String, MutableList<LocalMediaTrack>>()
|
|
||||||
val seenTrackPaths = mutableSetOf<String>()
|
|
||||||
|
|
||||||
(mediaStoreFolders + fallbackFolders).forEach { folder ->
|
|
||||||
val bucket = merged.getOrPut(folder.path) { mutableListOf() }
|
|
||||||
folder.tracks.forEach { track ->
|
|
||||||
if (seenTrackPaths.add(track.path)) {
|
|
||||||
bucket.add(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.entries
|
|
||||||
.map { (path, tracks) ->
|
|
||||||
LocalMediaFolder(
|
|
||||||
path = path,
|
|
||||||
name = normalizeFolderName(path),
|
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
|
||||||
}
|
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val THUMBNAIL_SIZE = 160
|
||||||
private val SUPPORTED_EXTENSIONS = setOf(
|
private val SUPPORTED_EXTENSIONS = setOf(
|
||||||
"mp3",
|
"mp3",
|
||||||
"m4a",
|
"m4a",
|
||||||
|
|||||||
@ -30,7 +30,8 @@ data class MediaItem(
|
|||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val coverURL: String,
|
val coverURL: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE)
|
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||||
|
val coverBytes: ByteArray? = null)
|
||||||
|
|
||||||
enum class LoopState {
|
enum class LoopState {
|
||||||
NONE, ONE, ALL;
|
NONE, ONE, ALL;
|
||||||
@ -53,13 +54,28 @@ sealed interface QueueEntry {
|
|||||||
val url: String
|
val url: String
|
||||||
val addedBy: String
|
val addedBy: String
|
||||||
|
|
||||||
|
/** Remote artwork URL, for consumers that can only take a link (remote control, media session). */
|
||||||
|
val artworkUrl: String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for a queue entry's artwork, in a form Coil can load directly:
|
||||||
|
* a URL `String` for streaming tracks, embedded cover `ByteArray` for local ones.
|
||||||
|
* Every UI surface (players, queue, lists) must use this instead of reading thumbnails itself.
|
||||||
|
*/
|
||||||
|
val artworkModel: Any?
|
||||||
|
get() = (this as? LocalTrack)?.coverBytes?.takeIf { it.isNotEmpty() } ?: artworkUrl
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("streaming")
|
@SerialName("streaming")
|
||||||
data class StreamingTrack(
|
data class StreamingTrack(
|
||||||
val track: MetadataTrack,
|
val track: MetadataTrack,
|
||||||
override val url: String,
|
override val url: String,
|
||||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||||
override val addedBy: String = "") : QueueEntry
|
override val addedBy: String = "") : QueueEntry {
|
||||||
|
override val artworkUrl: String?
|
||||||
|
get() = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
|
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("local")
|
@SerialName("local")
|
||||||
@ -71,6 +87,8 @@ sealed interface QueueEntry {
|
|||||||
val coverBytes: ByteArray?,
|
val coverBytes: ByteArray?,
|
||||||
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 fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (other == null || this::class != other::class) return false
|
if (other == null || this::class != other::class) return false
|
||||||
|
|||||||
@ -463,7 +463,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
artist = track.artists.joinToString(", ") { artist -> artist.name },
|
artist = track.artists.joinToString(", ") { artist -> artist.name },
|
||||||
album = track.album?.title.orEmpty(),
|
album = track.album?.title.orEmpty(),
|
||||||
duration = track.durationMs.milliseconds,
|
duration = track.durationMs.milliseconds,
|
||||||
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
|
coverURL = artworkUrl.orEmpty(),
|
||||||
url = resolvedUrl,
|
url = resolvedUrl,
|
||||||
protocol = protocol)
|
protocol = protocol)
|
||||||
}
|
}
|
||||||
@ -476,7 +476,8 @@ class DeviceAudioPlayerQueue(
|
|||||||
album = album.orEmpty(),
|
album = album.orEmpty(),
|
||||||
duration = duration.milliseconds,
|
duration = duration.milliseconds,
|
||||||
coverURL = "",
|
coverURL = "",
|
||||||
url = url)
|
url = url,
|
||||||
|
coverBytes = coverBytes?.takeIf { it.isNotEmpty() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,6 +60,7 @@ import org.spotube.spotube.modules.library.LibraryState
|
|||||||
import org.spotube.spotube.modules.library.album.LibraryAlbumsViewModel
|
import org.spotube.spotube.modules.library.album.LibraryAlbumsViewModel
|
||||||
import org.spotube.spotube.modules.library.artist.LibraryArtistsViewModel
|
import org.spotube.spotube.modules.library.artist.LibraryArtistsViewModel
|
||||||
import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel
|
import org.spotube.spotube.modules.library.local_tracks.LibraryLocalTracksViewModel
|
||||||
|
import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel
|
||||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
||||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersConfig
|
||||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
|
||||||
@ -133,6 +134,7 @@ val sharedModules = module {
|
|||||||
viewModelOf(::LibraryAlbumsViewModel)
|
viewModelOf(::LibraryAlbumsViewModel)
|
||||||
viewModelOf(::LibraryArtistsViewModel)
|
viewModelOf(::LibraryArtistsViewModel)
|
||||||
viewModelOf(::LibraryLocalTracksViewModel)
|
viewModelOf(::LibraryLocalTracksViewModel)
|
||||||
|
viewModel { (folderPath: String) -> LocalFolderViewModel(folderPath, get(), get()) }
|
||||||
|
|
||||||
// Plugin system
|
// Plugin system
|
||||||
singleOf(::PluginManager) {
|
singleOf(::PluginManager) {
|
||||||
|
|||||||
@ -26,6 +26,8 @@ import org.spotube.spotube.modules.devices.RemoteControlScreen
|
|||||||
import org.spotube.spotube.modules.jam.JamScreen
|
import org.spotube.spotube.modules.jam.JamScreen
|
||||||
import org.spotube.spotube.modules.home.HomeScreen
|
import org.spotube.spotube.modules.home.HomeScreen
|
||||||
import org.spotube.spotube.modules.library.LibraryScreen
|
import org.spotube.spotube.modules.library.LibraryScreen
|
||||||
|
import org.spotube.spotube.modules.library.local_tracks.LocalFolderScreen
|
||||||
|
import org.spotube.spotube.modules.library.local_tracks.LocalFolderViewModel
|
||||||
import org.spotube.spotube.modules.lyrics.LyricsScreen
|
import org.spotube.spotube.modules.lyrics.LyricsScreen
|
||||||
import org.spotube.spotube.modules.playlist.PlaylistScreen
|
import org.spotube.spotube.modules.playlist.PlaylistScreen
|
||||||
import org.spotube.spotube.modules.plugin.PluginScreen
|
import org.spotube.spotube.modules.plugin.PluginScreen
|
||||||
@ -96,6 +98,9 @@ sealed interface Routes : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Welcome : Routes
|
data object Welcome : Routes
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LocalFolder(val folderPath: String) : Routes
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(KoinExperimentalAPI::class)
|
@OptIn(KoinExperimentalAPI::class)
|
||||||
@ -115,6 +120,14 @@ val navigationModule = module {
|
|||||||
// Not guarded as a whole: Local tracks and Downloads work without a plugin.
|
// Not guarded as a whole: Local tracks and Downloads work without a plugin.
|
||||||
LibraryScreen()
|
LibraryScreen()
|
||||||
}
|
}
|
||||||
|
navigation<Routes.LocalFolder> {
|
||||||
|
LocalFolderScreen(
|
||||||
|
viewModel = koinViewModel(
|
||||||
|
key = it.folderPath,
|
||||||
|
parameters = { parametersOf(it.folderPath) }
|
||||||
|
),
|
||||||
|
onBack = { get<NavigationCommands>().pop() })
|
||||||
|
}
|
||||||
navigation<Routes.Settings> {
|
navigation<Routes.Settings> {
|
||||||
SettingsScreen(settingsViewModel = koinViewModel())
|
SettingsScreen(settingsViewModel = koinViewModel())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,7 @@ val serializersConfig = SavedStateConfiguration {
|
|||||||
subclass(Routes.Devices::class, Routes.Devices.serializer())
|
subclass(Routes.Devices::class, Routes.Devices.serializer())
|
||||||
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
||||||
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
|
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
|
||||||
|
subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -354,7 +354,7 @@ class RemoteControlHandler(
|
|||||||
currentTrackTitle = current?.titleOrNull(),
|
currentTrackTitle = current?.titleOrNull(),
|
||||||
currentTrackArtists = current?.artistsOrNull(),
|
currentTrackArtists = current?.artistsOrNull(),
|
||||||
currentTrackAlbum = current?.albumOrNull(),
|
currentTrackAlbum = current?.albumOrNull(),
|
||||||
currentTrackCoverUrl = current?.coverUrlOrNull())
|
currentTrackCoverUrl = current?.artworkUrl)
|
||||||
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
||||||
session.send(Frame.Text(text))
|
session.send(Frame.Text(text))
|
||||||
}
|
}
|
||||||
@ -395,7 +395,7 @@ class RemoteControlHandler(
|
|||||||
title = track.title,
|
title = track.title,
|
||||||
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
||||||
album = track.album?.title,
|
album = track.album?.title,
|
||||||
coverUrl = coverUrlOrNull(),
|
coverUrl = artworkUrl,
|
||||||
durationMs = track.durationMs)
|
durationMs = track.durationMs)
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
||||||
@ -427,12 +427,6 @@ class RemoteControlHandler(
|
|||||||
is QueueEntry.StreamingTrack -> track.album?.title
|
is QueueEntry.StreamingTrack -> track.album?.title
|
||||||
is QueueEntry.LocalTrack -> album
|
is QueueEntry.LocalTrack -> album
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun QueueEntry.coverUrlOrNull(): String? = when (this) {
|
|
||||||
is QueueEntry.StreamingTrack -> track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
|
||||||
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
|
||||||
is QueueEntry.LocalTrack -> null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@ -61,7 +61,8 @@ fun PlayableListTile(
|
|||||||
placeholderIcon: ImageVector = Iconsax.IconsaxMusic,
|
placeholderIcon: ImageVector = Iconsax.IconsaxMusic,
|
||||||
artwork: (@Composable () -> Unit)? = null,
|
artwork: (@Composable () -> Unit)? = null,
|
||||||
trailingContent: (@Composable () -> Unit)? = null,
|
trailingContent: (@Composable () -> Unit)? = null,
|
||||||
isBlacklisted: Boolean = false
|
isBlacklisted: Boolean = false,
|
||||||
|
compact: Boolean = false
|
||||||
) {
|
) {
|
||||||
val colors = MillenniumTheme.colors
|
val colors = MillenniumTheme.colors
|
||||||
MillenniumListTile(
|
MillenniumListTile(
|
||||||
@ -73,12 +74,12 @@ fun PlayableListTile(
|
|||||||
borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border,
|
borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border,
|
||||||
titleForeground = if (isBlacklisted) colors.danger else null,
|
titleForeground = if (isBlacklisted) colors.danger else null,
|
||||||
borderWidth = 1.dp,
|
borderWidth = 1.dp,
|
||||||
contentPadding = PaddingValues(12.dp),
|
contentPadding = PaddingValues(if (compact) 8.dp else 12.dp),
|
||||||
minimumHeight = 76.dp,
|
minimumHeight = if (compact) 52.dp else 76.dp,
|
||||||
leading = {
|
leading = {
|
||||||
MediaArtwork(
|
MediaArtwork(
|
||||||
imageModel, title,
|
imageModel, title,
|
||||||
Modifier.size(52.dp).sharedElementOrNone(
|
Modifier.size(if (compact) 36.dp else 52.dp).sharedElementOrNone(
|
||||||
sharedElementKey,
|
sharedElementKey,
|
||||||
LocalSharedTransitionScope.current,
|
LocalSharedTransitionScope.current,
|
||||||
LocalAnimatedVisibilityScope.current
|
LocalAnimatedVisibilityScope.current
|
||||||
@ -114,7 +115,7 @@ fun PlayableListTile(
|
|||||||
if (onAddToQueue != null && !isPlaying) {
|
if (onAddToQueue != null && !isPlaying) {
|
||||||
MillenniumIconButton(
|
MillenniumIconButton(
|
||||||
onClick = onAddToQueue,
|
onClick = onAddToQueue,
|
||||||
modifier = Modifier.size(40.dp),
|
modifier = Modifier.size(if (compact) 32.dp else 40.dp),
|
||||||
style = MillenniumIconButtonStyle.Secondary
|
style = MillenniumIconButtonStyle.Secondary
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
MillenniumIcon(
|
||||||
@ -126,7 +127,7 @@ fun PlayableListTile(
|
|||||||
if (onPlay != null) {
|
if (onPlay != null) {
|
||||||
MillenniumIconButton(
|
MillenniumIconButton(
|
||||||
onClick = onPlay,
|
onClick = onPlay,
|
||||||
modifier = Modifier.size(40.dp),
|
modifier = Modifier.size(if (compact) 32.dp else 40.dp),
|
||||||
style = MillenniumIconButtonStyle.Primary
|
style = MillenniumIconButtonStyle.Primary
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
MillenniumIcon(
|
||||||
|
|||||||
@ -47,7 +47,10 @@ import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen
|
|||||||
import org.spotube.spotube.resources.iconsax.IconsaxPlay
|
import org.spotube.spotube.resources.iconsax.IconsaxPlay
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.jetbrains.compose.resources.pluralStringResource
|
import org.jetbrains.compose.resources.pluralStringResource
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.spotube.spotube.core.navigation.NavigationCommands
|
||||||
|
import org.spotube.spotube.core.navigation.Routes
|
||||||
import org.spotube.millennium.composables.MillenniumButton
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
@ -63,15 +66,18 @@ import spotube.composeapp.generated.resources.*
|
|||||||
fun LibraryLocalTracksScreen(
|
fun LibraryLocalTracksScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: LibraryLocalTracksViewModel = koinViewModel(),
|
viewModel: LibraryLocalTracksViewModel = koinViewModel(),
|
||||||
foldersViewModel: LocalMediaFoldersViewModel = koinViewModel()
|
foldersViewModel: LocalMediaFoldersViewModel = koinViewModel(),
|
||||||
|
navigationCommands: NavigationCommands = koinInject()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val permission = rememberLocalMediaPermissionState()
|
val permission = rememberLocalMediaPermissionState()
|
||||||
// Platform permission events only; the ViewModel decides whether/when to index media.
|
// Platform permission events only; the ViewModel decides whether/when to index media.
|
||||||
LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) }
|
LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) }
|
||||||
LocalTracksContent(
|
LocalTracksContent(
|
||||||
state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp,
|
state,
|
||||||
viewModel::playCurrentFolder, permission.requestPermission, modifier,
|
onOpenFolder = { navigationCommands.navigateTo(Routes.LocalFolder(it)) },
|
||||||
|
onPlayTrack = {}, onNavigateUp = {}, onPlayFolder = {},
|
||||||
|
onRequestPermission = permission.requestPermission, modifier = modifier,
|
||||||
onManageFolders = foldersViewModel::open
|
onManageFolders = foldersViewModel::open
|
||||||
)
|
)
|
||||||
LocalMediaFoldersDialogHost(foldersViewModel)
|
LocalMediaFoldersDialogHost(foldersViewModel)
|
||||||
@ -120,7 +126,7 @@ internal fun LocalTracksContent(
|
|||||||
state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
|
state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
|
||||||
Modifier.weight(1f).fillMaxWidth(),
|
Modifier.weight(1f).fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(bottom = inset + 16.dp),
|
contentPadding = PaddingValues(bottom = inset + 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
items(state.visibleFolders, key = { it.path }) { folder ->
|
items(state.visibleFolders, key = { it.path }) { folder ->
|
||||||
LocalFolderRow(folder) { onOpenFolder(folder.path) }
|
LocalFolderRow(folder) { onOpenFolder(folder.path) }
|
||||||
@ -130,7 +136,7 @@ internal fun LocalTracksContent(
|
|||||||
state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn(
|
state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn(
|
||||||
Modifier.weight(1f).fillMaxWidth(),
|
Modifier.weight(1f).fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(bottom = inset + 16.dp),
|
contentPadding = PaddingValues(bottom = inset + 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
items(state.visibleTracks, key = { it.path }) { track ->
|
items(state.visibleTracks, key = { it.path }) { track ->
|
||||||
PlayableListTile(
|
PlayableListTile(
|
||||||
@ -139,6 +145,7 @@ internal fun LocalTracksContent(
|
|||||||
imageModel = track.artwork,
|
imageModel = track.artwork,
|
||||||
onClick = { onPlayTrack(track.path) },
|
onClick = { onPlayTrack(track.path) },
|
||||||
onPlay = { onPlayTrack(track.path) },
|
onPlay = { onPlayTrack(track.path) },
|
||||||
|
compact = true,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
track.durationLabel?.let {
|
track.durationLabel?.let {
|
||||||
Text(
|
Text(
|
||||||
@ -195,7 +202,7 @@ internal fun LocalTracksBreadcrumb(
|
|||||||
) {
|
) {
|
||||||
val backDescription = stringResource(Res.string.library_local_back)
|
val backDescription = stringResource(Res.string.library_local_back)
|
||||||
Row(
|
Row(
|
||||||
modifier.fillMaxWidth().height(32.dp),
|
modifier.fillMaxWidth().height(40.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
@ -268,22 +275,22 @@ internal fun LocalTracksBreadcrumb(
|
|||||||
if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp)
|
if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp)
|
||||||
if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton(
|
if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton(
|
||||||
onClick = onManageFolders,
|
onClick = onManageFolders,
|
||||||
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet
|
modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
MillenniumIcon(
|
||||||
Iconsax.IconsaxFolderAdd,
|
Iconsax.IconsaxFolderAdd,
|
||||||
stringResource(Res.string.library_local_add_folder),
|
stringResource(Res.string.library_local_add_folder),
|
||||||
modifier = Modifier.size(14.dp)
|
modifier = Modifier.size(22.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.currentFolderPath != null) MillenniumIconButton(
|
if (state.currentFolderPath != null) MillenniumIconButton(
|
||||||
onClick = onPlayFolder, enabled = state.canPlayFolder,
|
onClick = onPlayFolder, enabled = state.canPlayFolder,
|
||||||
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet
|
modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
MillenniumIcon(
|
||||||
Iconsax.IconsaxPlay,
|
Iconsax.IconsaxPlay,
|
||||||
stringResource(Res.string.library_local_play_folder),
|
stringResource(Res.string.library_local_play_folder),
|
||||||
modifier = Modifier.size(14.dp)
|
modifier = Modifier.size(22.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -297,13 +304,13 @@ internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) {
|
|||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
borderColor = MillenniumTheme.colors.border,
|
borderColor = MillenniumTheme.colors.border,
|
||||||
borderWidth = 1.dp,
|
borderWidth = 1.dp,
|
||||||
contentPadding = PaddingValues(12.dp),
|
contentPadding = PaddingValues(8.dp),
|
||||||
minimumHeight = 76.dp,
|
minimumHeight = 52.dp,
|
||||||
leading = {
|
leading = {
|
||||||
MediaArtwork(
|
MediaArtwork(
|
||||||
folder.artwork,
|
folder.artwork,
|
||||||
folder.name,
|
folder.name,
|
||||||
Modifier.size(52.dp),
|
Modifier.size(36.dp),
|
||||||
placeholderIcon = Iconsax.IconsaxFolderOpen
|
placeholderIcon = Iconsax.IconsaxFolderOpen
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -328,7 +335,7 @@ internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) {
|
|||||||
MillenniumIcon(
|
MillenniumIcon(
|
||||||
FeatherIcons.ChevronRight,
|
FeatherIcons.ChevronRight,
|
||||||
null,
|
null,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -19,8 +19,6 @@ package org.spotube.spotube.modules.library.local_tracks
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
|
||||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
|
||||||
import org.spotube.spotube.modules.library.LibraryState
|
import org.spotube.spotube.modules.library.LibraryState
|
||||||
import org.spotube.spotube.modules.library.LibraryTab
|
import org.spotube.spotube.modules.library.LibraryTab
|
||||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder
|
||||||
@ -120,7 +118,6 @@ private fun localTrackMatches(track: LocalMediaTrack, query: String): Boolean =
|
|||||||
|
|
||||||
class LibraryLocalTracksViewModel(
|
class LibraryLocalTracksViewModel(
|
||||||
private val coordinator: LocalMediaLibraryCoordinator,
|
private val coordinator: LocalMediaLibraryCoordinator,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
|
||||||
private val libraryState: LibraryState
|
private val libraryState: LibraryState
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val requiresPermission = getPlatform().type == PlatformType.Android
|
private val requiresPermission = getPlatform().type == PlatformType.Android
|
||||||
@ -155,14 +152,6 @@ class LibraryLocalTracksViewModel(
|
|||||||
libraryState.setQuery(LibraryTab.LocalTracks, query)
|
libraryState.setQuery(LibraryTab.LocalTracks, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openFolder(folderPath: String) {
|
|
||||||
_state.update { it.copy(currentFolderPath = folderPath) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun navigateUp() {
|
|
||||||
_state.update { it.copy(currentFolderPath = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onPermissionChanged(granted: Boolean) {
|
fun onPermissionChanged(granted: Boolean) {
|
||||||
val accessible = !requiresPermission || granted
|
val accessible = !requiresPermission || granted
|
||||||
val wasAccessible = _state.value.canAccessMedia
|
val wasAccessible = _state.value.canAccessMedia
|
||||||
@ -170,10 +159,6 @@ class LibraryLocalTracksViewModel(
|
|||||||
if (accessible && !wasAccessible) refresh()
|
if (accessible && !wasAccessible) refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playCurrentFolder() {
|
|
||||||
_state.value.currentFolderPath?.let(::playFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
if (!_state.value.canAccessMedia) return
|
if (!_state.value.canAccessMedia) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -191,41 +176,6 @@ class LibraryLocalTracksViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playFolder(folderPath: String) {
|
|
||||||
if (!_state.value.canAccessMedia) return
|
|
||||||
val folder = _state.value.folders.firstOrNull { it.path == folderPath } ?: return
|
|
||||||
val tracks = filterTracks(folder.tracks, state.value.query)
|
|
||||||
if (tracks.isEmpty()) return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
audioPlayerQueue.load(
|
|
||||||
entries = tracks.map { it.toQueueEntry() },
|
|
||||||
autoPlay = true,
|
|
||||||
startPosition = 0,
|
|
||||||
collectionEntry = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playTrack(trackPath: String) {
|
|
||||||
if (!_state.value.canAccessMedia) return
|
|
||||||
val folder = currentFolder() ?: return
|
|
||||||
val tracks = filterTracks(folder.tracks, state.value.query)
|
|
||||||
val selectedIndex = tracks.indexOfFirst { it.path == trackPath }
|
|
||||||
if (selectedIndex < 0) return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
audioPlayerQueue.load(
|
|
||||||
entries = tracks.map { it.toQueueEntry() },
|
|
||||||
autoPlay = true,
|
|
||||||
startPosition = selectedIndex,
|
|
||||||
collectionEntry = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun currentFolder(): LocalMediaFolder? = state.value.currentFolder
|
|
||||||
|
|
||||||
private fun refreshIfEmpty() {
|
private fun refreshIfEmpty() {
|
||||||
if (_state.value.folders.isNotEmpty()) return
|
if (_state.value.folders.isNotEmpty()) return
|
||||||
refresh()
|
refresh()
|
||||||
@ -239,22 +189,4 @@ class LibraryLocalTracksViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun filterTracks(tracks: List<LocalMediaTrack>, query: String): List<LocalMediaTrack> {
|
|
||||||
val normalizedQuery = query.trim()
|
|
||||||
if (normalizedQuery.isBlank()) return tracks
|
|
||||||
|
|
||||||
return tracks.filter { localTrackMatches(it, normalizedQuery) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LocalMediaTrack.toQueueEntry(): QueueEntry.LocalTrack {
|
|
||||||
return QueueEntry.LocalTrack(
|
|
||||||
name = name,
|
|
||||||
artists = artists,
|
|
||||||
duration = durationMs,
|
|
||||||
album = album,
|
|
||||||
coverBytes = coverBytes,
|
|
||||||
url = path
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package org.spotube.spotube.modules.library.local_tracks
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composeunstyled.Text
|
||||||
|
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LocalFolderScreen(viewModel: LocalFolderViewModel, onBack: () -> Unit) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
Column {
|
||||||
|
ApplicationMainBar(
|
||||||
|
backButton = true,
|
||||||
|
title = { Text(state.currentFolder?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
|
)
|
||||||
|
LocalTracksContent(
|
||||||
|
state,
|
||||||
|
onOpenFolder = {},
|
||||||
|
onPlayTrack = viewModel::playTrack,
|
||||||
|
onNavigateUp = onBack,
|
||||||
|
onPlayFolder = viewModel::playFolder,
|
||||||
|
onRequestPermission = {},
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package org.spotube.spotube.modules.library.local_tracks
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||||
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
|
||||||
|
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||||
|
|
||||||
|
class LocalFolderViewModel(
|
||||||
|
private val folderPath: String,
|
||||||
|
coordinator: LocalMediaLibraryCoordinator,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue
|
||||||
|
) : ViewModel() {
|
||||||
|
val state: StateFlow<LibraryLocalTracksState> = coordinator.cacheState
|
||||||
|
.map { cache ->
|
||||||
|
localTracksUiState(
|
||||||
|
LibraryLocalTracksState(folders = cache.folders, currentFolderPath = folderPath),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
viewModelScope, SharingStarted.WhileSubscribed(5_000),
|
||||||
|
localTracksUiState(LibraryLocalTracksState(currentFolderPath = folderPath), "")
|
||||||
|
)
|
||||||
|
|
||||||
|
fun playFolder() = play(0)
|
||||||
|
|
||||||
|
fun playTrack(trackPath: String) {
|
||||||
|
val index = tracks().indexOfFirst { it.path == trackPath }
|
||||||
|
if (index >= 0) play(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tracks(): List<LocalMediaTrack> =
|
||||||
|
state.value.currentFolder?.tracks.orEmpty()
|
||||||
|
|
||||||
|
private fun play(startPosition: Int) {
|
||||||
|
val tracks = tracks()
|
||||||
|
if (tracks.isEmpty()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
audioPlayerQueue.load(
|
||||||
|
entries = tracks.map {
|
||||||
|
QueueEntry.LocalTrack(
|
||||||
|
name = it.name,
|
||||||
|
artists = it.artists,
|
||||||
|
duration = it.durationMs,
|
||||||
|
album = it.album,
|
||||||
|
coverBytes = it.coverBytes,
|
||||||
|
url = it.path
|
||||||
|
)
|
||||||
|
},
|
||||||
|
autoPlay = true,
|
||||||
|
startPosition = startPosition,
|
||||||
|
collectionEntry = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -211,7 +211,7 @@ fun AppExpandedPlayer(
|
|||||||
val snackbarHostState = remember { MillenniumSnackbarHostState() }
|
val snackbarHostState = remember { MillenniumSnackbarHostState() }
|
||||||
val albumArtModifier =
|
val albumArtModifier =
|
||||||
rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope)
|
rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope)
|
||||||
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
|
val coverModel = playerUiState.coverModel
|
||||||
var isSeeking by remember { mutableStateOf(false) }
|
var isSeeking by remember { mutableStateOf(false) }
|
||||||
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
|
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
|
||||||
val moreOptionsSheetState = rememberModalBottomSheetState(
|
val moreOptionsSheetState = rememberModalBottomSheetState(
|
||||||
|
|||||||
@ -131,7 +131,7 @@ internal fun FloatingPlayerContent(
|
|||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
heartButton: @Composable () -> Unit
|
heartButton: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
|
val coverModel = playerUiState.coverModel
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|||||||
@ -164,7 +164,7 @@ fun AppLargePlayer(
|
|||||||
var isSeeking by remember { mutableStateOf(false) }
|
var isSeeking by remember { mutableStateOf(false) }
|
||||||
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
|
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
|
||||||
var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) }
|
var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) }
|
||||||
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
|
val coverModel = playerUiState.coverModel
|
||||||
|
|
||||||
PlayerOptionDialogs(viewModel = playerOptionsViewModel)
|
PlayerOptionDialogs(viewModel = playerOptionsViewModel)
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,6 @@ internal data class PlayerUiState(
|
|||||||
val artists: String = "No-op artists",
|
val artists: String = "No-op artists",
|
||||||
val artistList: List<PlayerArtist> = emptyList(),
|
val artistList: List<PlayerArtist> = emptyList(),
|
||||||
val album: String = "",
|
val album: String = "",
|
||||||
val coverUrl: String = "",
|
|
||||||
val position: Duration = 0.milliseconds,
|
val position: Duration = 0.milliseconds,
|
||||||
val actualDuration: Duration = 0.milliseconds,
|
val actualDuration: Duration = 0.milliseconds,
|
||||||
val metadataDuration: Duration = 0.milliseconds,
|
val metadataDuration: Duration = 0.milliseconds,
|
||||||
@ -55,6 +54,8 @@ internal data class PlayerUiState(
|
|||||||
val volume: Float = 0f
|
val volume: Float = 0f
|
||||||
) {
|
) {
|
||||||
val isPlaying: Boolean get() = playerState == PlayerState.PLAYING
|
val isPlaying: Boolean get() = playerState == PlayerState.PLAYING
|
||||||
|
|
||||||
|
val coverModel: Any? get() = currentQueueEntry?.artworkModel
|
||||||
val displayDuration: Duration
|
val displayDuration: Duration
|
||||||
get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration
|
get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration
|
||||||
val seekDuration: Duration
|
val seekDuration: Duration
|
||||||
@ -72,7 +73,7 @@ internal data class PlayerUiState(
|
|||||||
queue=${queue.map { it.displayTitle() }},
|
queue=${queue.map { it.displayTitle() }},
|
||||||
currentQueueEntry=${currentQueueEntry?.displayTitle()},
|
currentQueueEntry=${currentQueueEntry?.displayTitle()},
|
||||||
title='$title', artists='$artists',
|
title='$title', artists='$artists',
|
||||||
album='$album', coverUrl='$coverUrl',
|
album='$album', coverModel=${coverModel?.let { it::class.simpleName }},
|
||||||
position=$position, actualDuration=$actualDuration, metadataDuration=$metadataDuration,
|
position=$position, actualDuration=$actualDuration, metadataDuration=$metadataDuration,
|
||||||
playerState=$playerState, loopState=$loopState, isShuffling=$isShuffling, volume=$volume
|
playerState=$playerState, loopState=$loopState, isShuffling=$isShuffling, volume=$volume
|
||||||
)
|
)
|
||||||
@ -105,7 +106,6 @@ internal fun rememberPlayerUiState(
|
|||||||
artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists },
|
artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists },
|
||||||
artistList = currentQueueEntry?.displayArtistList().orEmpty(),
|
artistList = currentQueueEntry?.displayArtistList().orEmpty(),
|
||||||
album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(),
|
album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(),
|
||||||
coverUrl = currentQueueEntry?.coverUrl().orEmpty(),
|
|
||||||
position = position,
|
position = position,
|
||||||
actualDuration = duration,
|
actualDuration = duration,
|
||||||
metadataDuration = metadataDuration,
|
metadataDuration = metadataDuration,
|
||||||
@ -149,15 +149,6 @@ private fun QueueEntry.displayAlbum(unknownAlbum: String): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun QueueEntry.coverUrl(): String {
|
|
||||||
return when (this) {
|
|
||||||
is QueueEntry.StreamingTrack -> (track.album?.thumbnails
|
|
||||||
?: track.thumbnails)?.firstOrNull()?.url.orEmpty()
|
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun QueueEntry.durationInMilliseconds(): Long {
|
private fun QueueEntry.durationInMilliseconds(): Long {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is QueueEntry.StreamingTrack -> track.durationMs
|
is QueueEntry.StreamingTrack -> track.durationMs
|
||||||
|
|||||||
@ -350,9 +350,9 @@ private fun QueueItemRow(
|
|||||||
.background(AppTheme.colors.surfaceVariant),
|
.background(AppTheme.colors.surfaceVariant),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (item.imageUrl != null) {
|
if (item.artwork != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = item.imageUrl,
|
model = item.artwork,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
|
|||||||
@ -39,7 +39,7 @@ data class QueueItemUi(
|
|||||||
val subtitle: String,
|
val subtitle: String,
|
||||||
val durationLabel: String,
|
val durationLabel: String,
|
||||||
val isCurrent: Boolean,
|
val isCurrent: Boolean,
|
||||||
val imageUrl: String?,
|
val artwork: Any?,
|
||||||
val originalIndex: Int,
|
val originalIndex: Int,
|
||||||
/** Participant who added this item to the jam queue, if any. */
|
/** Participant who added this item to the jam queue, if any. */
|
||||||
val addedByParticipant: JamParticipant? = null)
|
val addedByParticipant: JamParticipant? = null)
|
||||||
@ -77,22 +77,18 @@ class PlayerQueueContentViewModel(
|
|||||||
val title: String
|
val title: String
|
||||||
val subtitle: String
|
val subtitle: String
|
||||||
val durationMs: Long
|
val durationMs: Long
|
||||||
val imageUrl: String?
|
|
||||||
|
|
||||||
when (entry) {
|
when (entry) {
|
||||||
is QueueEntry.StreamingTrack -> {
|
is QueueEntry.StreamingTrack -> {
|
||||||
title = entry.track.title
|
title = entry.track.title
|
||||||
subtitle = entry.track.artists.joinToString(", ") { it.name }
|
subtitle = entry.track.artists.joinToString(", ") { it.name }
|
||||||
durationMs = entry.track.durationMs
|
durationMs = entry.track.durationMs
|
||||||
imageUrl = entry.track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
|
||||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> {
|
is QueueEntry.LocalTrack -> {
|
||||||
title = entry.name
|
title = entry.name
|
||||||
subtitle = entry.artists.joinToString(", ")
|
subtitle = entry.artists.joinToString(", ")
|
||||||
durationMs = entry.duration
|
durationMs = entry.duration
|
||||||
imageUrl = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,7 +100,7 @@ class PlayerQueueContentViewModel(
|
|||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
durationLabel = durationMs.toDurationLabel(),
|
durationLabel = durationMs.toDurationLabel(),
|
||||||
isCurrent = index == currentIndex,
|
isCurrent = index == currentIndex,
|
||||||
imageUrl = imageUrl,
|
artwork = entry.artworkModel,
|
||||||
originalIndex = index,
|
originalIndex = index,
|
||||||
addedByParticipant = addedByParticipant)
|
addedByParticipant = addedByParticipant)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -158,7 +158,7 @@ class MediaLibraryUiTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun breadcrumbIsThinAndItsParentLinkNavigatesUp() {
|
fun breadcrumbIsCompactAndItsParentLinkNavigatesUp() {
|
||||||
var height = 0
|
var height = 0
|
||||||
var navigatedUp = 0
|
var navigatedUp = 0
|
||||||
val state = localTracksUiState(
|
val state = localTracksUiState(
|
||||||
@ -167,7 +167,7 @@ class MediaLibraryUiTest {
|
|||||||
currentFolderPath = "/music"
|
currentFolderPath = "/music"
|
||||||
), ""
|
), ""
|
||||||
)
|
)
|
||||||
ImageComposeScene(360, 40) {
|
ImageComposeScene(360, 48) {
|
||||||
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
||||||
LocalTracksBreadcrumb(
|
LocalTracksBreadcrumb(
|
||||||
state,
|
state,
|
||||||
@ -177,7 +177,7 @@ class MediaLibraryUiTest {
|
|||||||
}
|
}
|
||||||
}.use { scene ->
|
}.use { scene ->
|
||||||
repeat(6) { scene.render().close() }
|
repeat(6) { scene.render().close() }
|
||||||
assertEquals(32, height)
|
assertEquals(40, height)
|
||||||
val parent = allNodes(scene).first {
|
val parent = allNodes(scene).first {
|
||||||
it.config.getOrNull(SemanticsActions.OnClick) != null &&
|
it.config.getOrNull(SemanticsActions.OnClick) != null &&
|
||||||
it.config.getOrNull(SemanticsProperties.ContentDescription)
|
it.config.getOrNull(SemanticsProperties.ContentDescription)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user