Compare commits

..

7 Commits

27 changed files with 431 additions and 278 deletions

View File

@ -10,9 +10,9 @@ jobs:
runs-on: macOS-latest
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: 'zulu'
java-version: 21

View File

@ -31,6 +31,8 @@ env:
COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d
GRADLE_PLUGIN_REPO: team-spotube/gradle-plugin
VLCJ_BUNDLER_REPO: team-spotube/vlcj-bundler-gradle-plugin
# Nucleus lists GraalVM CE releases via the GitHub API; unauthenticated calls hit the 60/hour limit on shared runners
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
prepare-deps:
@ -38,7 +40,7 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout ComposeNativeWebview
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
repository: ${{ env.COMPOSE_WEBVIEW_REPO }}
ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }}
@ -46,33 +48,33 @@ jobs:
path: compose-webview
- name: Checkout gradle-plugin
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
repository: ${{ env.GRADLE_PLUGIN_REPO }}
token: ${{ secrets.GITHUB_TOKEN }}
path: gradle-plugin
- name: Checkout vlcj-bundler
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
repository: ${{ env.VLCJ_BUNDLER_REPO }}
token: ${{ secrets.GITHUB_TOKEN }}
path: vlcj-bundler
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
- name: Set up Android SDK
uses: android-actions/setup-android@v3
uses: android-actions/setup-android@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Publish compose-webview to mavenLocal
working-directory: compose-webview
@ -93,7 +95,7 @@ jobs:
./gradlew publishToMavenLocal
- name: Upload maven local
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: maven-local
path: ~/.m2/repository/
@ -103,16 +105,16 @@ jobs:
needs: prepare-deps
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
@ -121,7 +123,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Make gradlew executable
run: chmod +x gradlew
@ -154,7 +156,7 @@ jobs:
cp "$APK_FILE" Spotube-android-all-arch.apk
- name: Upload APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: android-apk
path: Spotube-android-all-arch.apk
@ -170,10 +172,10 @@ jobs:
needs: prepare-deps
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
@ -184,7 +186,7 @@ jobs:
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
@ -193,13 +195,13 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Make gradlew executable
run: chmod +x gradlew
- name: Cache GraalVM toolchain
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.gradle/nucleus/graalvm
key: graalvm-${{ runner.os }}-${{ runner.arch }}
@ -215,13 +217,13 @@ jobs:
cp "$RPM_FILE" Spotube-linux-x86_64.rpm
- name: Upload DEB
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-deb
path: Spotube-linux-x86_64.deb
- name: Upload RPM
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-rpm
path: Spotube-linux-x86_64.rpm
@ -237,10 +239,14 @@ jobs:
needs: prepare-deps
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# The arm64 runner image ships without an Android SDK, but :composeApp configuration requires one
- name: Set up Android SDK
uses: android-actions/setup-android@v4
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
@ -251,7 +257,7 @@ jobs:
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
@ -260,13 +266,13 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Make gradlew executable
run: chmod +x gradlew
- name: Cache GraalVM toolchain
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.gradle/nucleus/graalvm
key: graalvm-${{ runner.os }}-${{ runner.arch }}
@ -282,13 +288,13 @@ jobs:
cp "$RPM_FILE" Spotube-linux-aarch64.rpm
- name: Upload DEB
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-deb-arm64
path: Spotube-linux-aarch64.deb
- name: Upload RPM
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-rpm-arm64
path: Spotube-linux-aarch64.rpm
@ -304,16 +310,16 @@ jobs:
needs: prepare-deps
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
@ -322,14 +328,14 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
# native-image links with MSVC
- name: Set up MSVC
uses: ilammy/msvc-dev-cmd@v1
- name: Cache GraalVM toolchain
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.gradle/nucleus/graalvm
key: graalvm-${{ runner.os }}-${{ runner.arch }}
@ -346,7 +352,7 @@ jobs:
Copy-Item $EXE_FILE.FullName Spotube-windows-x86_64-setup.exe
- name: Upload EXE
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: windows-exe
path: Spotube-windows-x86_64-setup.exe
@ -362,16 +368,16 @@ jobs:
needs: prepare-deps
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
@ -380,13 +386,13 @@ jobs:
uses: dtolnay/rust-toolchain@stable
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Make gradlew executable
run: chmod +x gradlew
- name: Cache GraalVM toolchain
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.gradle/nucleus/graalvm
key: graalvm-${{ runner.os }}-${{ runner.arch }}
@ -400,7 +406,7 @@ jobs:
cp "$DMG_FILE" Spotube-macos-universal.dmg
- name: Upload DMG
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: macos-dmg
path: Spotube-macos-universal.dmg
@ -422,23 +428,23 @@ jobs:
- build-macos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# The build resolves its plugins from mavenLocal, so Gradle needs the same setup as the build jobs
- name: Download maven local
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: maven-local
path: ~/.m2/repository/
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v6
- name: Set release variables
id: vars
@ -460,7 +466,7 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
- name: Generate release notes
run: |

View File

@ -32,7 +32,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
This project and everyone participating in it is governed by the
[Spotube Code of Conduct](https://github.com/team-spotube/spotube/blob/master/CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report unacceptable behavior
to krtirtho@gmail.com.
to team.spotube@proton.me.
## I Have a Question

View File

@ -59,4 +59,4 @@ This policy is effective as of 2022-04-01
**Contact Us**
If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at krtirtho@gmail.com.
If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at team.spotube@proton.me

View File

@ -202,7 +202,9 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
.setArtist(artist)
.setAlbumTitle(album)
.apply {
if (coverURL.isNotBlank()) {
if (coverBytes != null) {
setArtworkData(coverBytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
} else if (coverURL.isNotBlank()) {
setArtworkUri(android.net.Uri.parse(coverURL))
}
}

View File

@ -25,7 +25,18 @@ import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.graphics.BitmapFactory
import android.graphics.Bitmap
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.koin.core.component.KoinComponent
import java.io.File
@ -35,14 +46,39 @@ class AndroidLocalMediaDiscoveryService(
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
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}" }
if (!hasReadPermission()) {
logger.w { "discoverFolders: missing media permission, returning empty result" }
return emptyList()
}
val tracksByFolder = linkedMapOf<String, MutableList<LocalMediaTrack>>()
val pending = mutableListOf<PendingTrack>()
val knownFilePaths = hashSetOf<String>()
val resolver = context.contentResolver
val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
var totalRows = 0
@ -58,7 +94,9 @@ class AndroidLocalMediaDiscoveryService(
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
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)
} else {
emptyArray()
@ -77,6 +115,8 @@ class AndroidLocalMediaDiscoveryService(
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val mimeTypeCol = cursor.getColumnIndex(MediaStore.Audio.Media.MIME_TYPE)
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 {
"discoverFolders: query ok columns(mimeType=$mimeTypeCol, relativePath=$relativePathCol)"
@ -118,20 +158,23 @@ class AndroidLocalMediaDiscoveryService(
.takeUnless { it.equals("<unknown>", true) }
val album = cursor.getString(albumCol).orEmpty().takeUnless { it.isBlank() }
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 {
displayName.substringBeforeLast('.').takeIf { it.isNotBlank() } ?: displayName
}
val track = LocalMediaTrack(
path = uri,
name = name.ifBlank { "Unknown track" },
artists = listOfNotNull(artist),
durationMs = durationMs,
album = album,
coverBytes = null)
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
pending += PendingTrack(
uri = uri,
filePath = filePath,
modifiedAt = modifiedAt,
folderPath = folderPath,
fallbackName = name.ifBlank { "Unknown track" },
mediaStoreArtists = listOfNotNull(artist),
mediaStoreAlbum = album,
mediaStoreDurationMs = durationMs)
acceptedRows += 1
}
} ?: run {
@ -142,20 +185,13 @@ class AndroidLocalMediaDiscoveryService(
logger.w { "discoverFolders: MediaStore query returned null cursor" }
}
val mediaStoreFolders = tracksByFolder.entries
.map { (folderPath, tracks) ->
LocalMediaFolder(
path = folderPath,
name = normalizeFolderName(folderPath),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
}
.sortedBy { it.name.lowercase(Locale.getDefault()) }
// Files MediaStore already indexed must not be listed a second time by the filesystem scan.
discoverFileSystemFallback(roots, knownFilePaths, pending)
val fallbackFolders = discoverFileSystemFallback(roots)
val merged = mergeFolders(mediaStoreFolders, fallbackFolders)
val merged = buildFolders(pending)
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()) {
@ -221,56 +257,109 @@ class AndroidLocalMediaDiscoveryService(
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)
if (candidateRoots.isEmpty()) {
logger.d { "discoverFileSystemFallback: no candidate roots" }
return emptyList()
return
}
logger.d {
"discoverFileSystemFallback: scanning roots=${candidateRoots.joinToString()}"
}
val tracksByFolder = linkedMapOf<String, MutableList<LocalMediaTrack>>()
var scannedFiles = 0
var acceptedFiles = 0
val storageRoot = Environment.getExternalStorageDirectory().absolutePath.trimEnd('/') + "/"
val seen = hashSetOf<String>()
var added = 0
candidateRoots.forEach { root ->
scanDirectory(root) { file ->
scannedFiles += 1
val extension = file.extension.lowercase(Locale.getDefault())
if (extension !in SUPPORTED_EXTENSIONS) return@scanDirectory
acceptedFiles += 1
if (file.extension.lowercase(Locale.getDefault()) !in SUPPORTED_EXTENSIONS) return@scanDirectory
val absolute = file.absolutePath
if (absolute in knownFilePaths || !seen.add(absolute)) return@scanDirectory
val folderPath = file.parentFile?.absolutePath ?: "Unknown"
val name = file.nameWithoutExtension.ifBlank { file.name }
val track = LocalMediaTrack(
path = file.absolutePath,
name = name,
artists = emptyList(),
durationMs = 0L,
album = null,
coverBytes = null)
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
// Same key shape as MediaStore's RELATIVE_PATH ("Download/") so folders merge.
val parent = file.parentFile?.absolutePath.orEmpty().trimEnd('/') + "/"
val folderPath = if (parent.startsWith(storageRoot)) parent.removePrefix(storageRoot) else parent
out += PendingTrack(
uri = absolute,
filePath = absolute,
modifiedAt = file.lastModified() / 1000,
folderPath = folderPath,
fallbackName = file.nameWithoutExtension.ifBlank { file.name },
mediaStoreArtists = emptyList(),
mediaStoreAlbum = null,
mediaStoreDurationMs = 0L)
added += 1
}
}
logger.i { "discoverFileSystemFallback: extra files not in MediaStore=$added" }
}
val folders = tracksByFolder.entries
.map { (folderPath, tracks) ->
private suspend fun buildFolders(pending: List<PendingTrack>): List<LocalMediaFolder> {
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(
path = folderPath,
name = normalizeFolderName(folderPath),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
path = path,
name = normalizeFolderName(path),
tracks = list.sortedBy { it.name.lowercase(Locale.getDefault()) })
}
.sortedBy { it.name.lowercase(Locale.getDefault()) }
}
logger.i {
"discoverFileSystemFallback: scannedFiles=$scannedFiles acceptedFiles=$acceptedFiles folders=${folders.size}"
private fun readTags(item: PendingTrack): TagInfo? {
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> {
@ -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 {
private const val THUMBNAIL_SIZE = 160
private val SUPPORTED_EXTENSIONS = setOf(
"mp3",
"m4a",

View File

@ -30,7 +30,8 @@ data class MediaItem(
val duration: Duration,
val coverURL: String,
val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE)
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
val coverBytes: ByteArray? = null)
enum class LoopState {
NONE, ONE, ALL;
@ -53,13 +54,28 @@ sealed interface QueueEntry {
val url: 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
@SerialName("streaming")
data class StreamingTrack(
val track: MetadataTrack,
override val url: String,
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
@SerialName("local")
@ -71,6 +87,8 @@ sealed interface QueueEntry {
val coverBytes: ByteArray?,
override val url: String,
override val addedBy: String = "") : QueueEntry {
override val artworkUrl: String? get() = null
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false

View File

@ -463,7 +463,7 @@ class DeviceAudioPlayerQueue(
artist = track.artists.joinToString(", ") { artist -> artist.name },
album = track.album?.title.orEmpty(),
duration = track.durationMs.milliseconds,
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
coverURL = artworkUrl.orEmpty(),
url = resolvedUrl,
protocol = protocol)
}
@ -476,7 +476,8 @@ class DeviceAudioPlayerQueue(
album = album.orEmpty(),
duration = duration.milliseconds,
coverURL = "",
url = url)
url = url,
coverBytes = coverBytes?.takeIf { it.isNotEmpty() })
}
}
}

View File

@ -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.artist.LibraryArtistsViewModel
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.LocalMediaFoldersConfig
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
@ -133,6 +134,7 @@ val sharedModules = module {
viewModelOf(::LibraryAlbumsViewModel)
viewModelOf(::LibraryArtistsViewModel)
viewModelOf(::LibraryLocalTracksViewModel)
viewModel { (folderPath: String) -> LocalFolderViewModel(folderPath, get(), get()) }
// Plugin system
singleOf(::PluginManager) {

View File

@ -25,6 +25,7 @@ import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
@ -33,7 +34,10 @@ import androidx.navigation3.runtime.NavKey
import androidx.navigation3.scene.Scene
private const val TRANSITION_DURATION_MILLIS = 300
private const val SLIDE_FRACTION = 0.08f
private const val FADE_THROUGH_OUT_MILLIS = 90
private const val FADE_THROUGH_SCALE = 0.94f
private const val SLIDE_FRACTION = 0.25f
private const val BEHIND_SLIDE_FRACTION = 0.1f
private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
// NavEntry.contentKey defaults to key.toString()
@ -49,28 +53,49 @@ private fun Scene<NavKey>.tabRoot(): Any? =
private fun AnimatedContentTransitionScope<Scene<NavKey>>.isTabSwitch(): Boolean =
initialState.tabRoot() != targetState.tabRoot()
/** Material shared-axis (X) style: a short horizontal slide combined with a cross-fade. */
private fun AnimatedContentTransitionScope<Scene<NavKey>>.sharedAxisX(
/** Material fade-through: the old tab fades out, the new one fades in while scaling up slightly. */
private fun fadeThrough(): ContentTransform {
val enter = fadeIn(
tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate)
) + scaleIn(
tween(TRANSITION_DURATION_MILLIS - FADE_THROUGH_OUT_MILLIS, FADE_THROUGH_OUT_MILLIS, EmphasizedDecelerate),
initialScale = FADE_THROUGH_SCALE
)
return enter togetherWith fadeOut(tween(FADE_THROUGH_OUT_MILLIS))
}
/**
* Pushed/popped pages inside a tab: the page on top slides + fades while the page behind
* drifts slightly the other way. On pop the leaving page must stay on top (negative target
* z-index), otherwise the page being revealed is drawn over it and its exit is never seen.
*/
private fun AnimatedContentTransitionScope<Scene<NavKey>>.stackTransition(
forward: Boolean
): ContentTransform {
if (isTabSwitch()) return ContentTransform(EnterTransition.None, ExitTransition.None)
if (isTabSwitch()) return fadeThrough()
val direction = if (forward) 1 else -1
val slideSpec = tween<IntOffset>(
TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate
)
val enter = slideInHorizontally(slideSpec) { (it * SLIDE_FRACTION).toInt() * direction } +
fadeIn(tween(TRANSITION_DURATION_MILLIS / 2, delayMillis = TRANSITION_DURATION_MILLIS / 6))
val exit = slideOutHorizontally(slideSpec) { -(it * SLIDE_FRACTION).toInt() * direction } +
fadeOut(tween(TRANSITION_DURATION_MILLIS / 3))
return enter togetherWith exit
val slide = tween<IntOffset>(TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate)
val enter: EnterTransition
val exit: ExitTransition
if (forward) {
enter = slideInHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } +
fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3))
exit = slideOutHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } +
fadeOut(tween(TRANSITION_DURATION_MILLIS / 2))
} else {
enter = slideInHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } +
fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3))
exit = slideOutHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } +
fadeOut(tween(TRANSITION_DURATION_MILLIS * 2 / 3))
}
return ContentTransform(enter, exit, targetContentZIndex = if (forward) 0f else -1f)
}
val navPushTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform =
{ sharedAxisX(forward = true) }
{ stackTransition(forward = true) }
val navPopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform =
{ sharedAxisX(forward = false) }
{ stackTransition(forward = false) }
val navPredictivePopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.(Int) -> ContentTransform =
{ sharedAxisX(forward = false) }
{ stackTransition(forward = false) }

View File

@ -26,6 +26,8 @@ import org.spotube.spotube.modules.devices.RemoteControlScreen
import org.spotube.spotube.modules.jam.JamScreen
import org.spotube.spotube.modules.home.HomeScreen
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.playlist.PlaylistScreen
import org.spotube.spotube.modules.plugin.PluginScreen
@ -96,6 +98,9 @@ sealed interface Routes : NavKey {
@Serializable
data object Welcome : Routes
@Serializable
data class LocalFolder(val folderPath: String) : Routes
}
@OptIn(KoinExperimentalAPI::class)
@ -115,6 +120,14 @@ val navigationModule = module {
// Not guarded as a whole: Local tracks and Downloads work without a plugin.
LibraryScreen()
}
navigation<Routes.LocalFolder> {
LocalFolderScreen(
viewModel = koinViewModel(
key = it.folderPath,
parameters = { parametersOf(it.folderPath) }
),
onBack = { get<NavigationCommands>().pop() })
}
navigation<Routes.Settings> {
SettingsScreen(settingsViewModel = koinViewModel())
}

View File

@ -64,6 +64,7 @@ val serializersConfig = SavedStateConfiguration {
subclass(Routes.Devices::class, Routes.Devices.serializer())
subclass(Routes.Jam::class, Routes.Jam.serializer())
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
subclass(Routes.LocalFolder::class, Routes.LocalFolder.serializer())
}
}
}

View File

@ -354,7 +354,7 @@ class RemoteControlHandler(
currentTrackTitle = current?.titleOrNull(),
currentTrackArtists = current?.artistsOrNull(),
currentTrackAlbum = current?.albumOrNull(),
currentTrackCoverUrl = current?.coverUrlOrNull())
currentTrackCoverUrl = current?.artworkUrl)
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
session.send(Frame.Text(text))
}
@ -395,7 +395,7 @@ class RemoteControlHandler(
title = track.title,
artists = track.artists.joinToString(", ") { artist -> artist.name },
album = track.album?.title,
coverUrl = coverUrlOrNull(),
coverUrl = artworkUrl,
durationMs = track.durationMs)
is QueueEntry.LocalTrack -> RemoteQueueEntry(
@ -427,12 +427,6 @@ class RemoteControlHandler(
is QueueEntry.StreamingTrack -> track.album?.title
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

View File

@ -61,7 +61,8 @@ fun PlayableListTile(
placeholderIcon: ImageVector = Iconsax.IconsaxMusic,
artwork: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
isBlacklisted: Boolean = false
isBlacklisted: Boolean = false,
compact: Boolean = false
) {
val colors = MillenniumTheme.colors
MillenniumListTile(
@ -73,12 +74,12 @@ fun PlayableListTile(
borderColor = if (isBlacklisted) colors.danger.copy(alpha = 0.5f) else if (isPlaying) null else colors.border,
titleForeground = if (isBlacklisted) colors.danger else null,
borderWidth = 1.dp,
contentPadding = PaddingValues(12.dp),
minimumHeight = 76.dp,
contentPadding = PaddingValues(if (compact) 8.dp else 12.dp),
minimumHeight = if (compact) 52.dp else 76.dp,
leading = {
MediaArtwork(
imageModel, title,
Modifier.size(52.dp).sharedElementOrNone(
Modifier.size(if (compact) 36.dp else 52.dp).sharedElementOrNone(
sharedElementKey,
LocalSharedTransitionScope.current,
LocalAnimatedVisibilityScope.current
@ -114,7 +115,7 @@ fun PlayableListTile(
if (onAddToQueue != null && !isPlaying) {
MillenniumIconButton(
onClick = onAddToQueue,
modifier = Modifier.size(40.dp),
modifier = Modifier.size(if (compact) 32.dp else 40.dp),
style = MillenniumIconButtonStyle.Secondary
) {
MillenniumIcon(
@ -126,7 +127,7 @@ fun PlayableListTile(
if (onPlay != null) {
MillenniumIconButton(
onClick = onPlay,
modifier = Modifier.size(40.dp),
modifier = Modifier.size(if (compact) 32.dp else 40.dp),
style = MillenniumIconButtonStyle.Primary
) {
MillenniumIcon(

View File

@ -47,7 +47,10 @@ import org.spotube.spotube.resources.iconsax.IconsaxFolderOpen
import org.spotube.spotube.resources.iconsax.IconsaxPlay
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.pluralStringResource
import org.koin.compose.koinInject
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.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIcon
@ -63,15 +66,18 @@ import spotube.composeapp.generated.resources.*
fun LibraryLocalTracksScreen(
modifier: Modifier = Modifier,
viewModel: LibraryLocalTracksViewModel = koinViewModel(),
foldersViewModel: LocalMediaFoldersViewModel = koinViewModel()
foldersViewModel: LocalMediaFoldersViewModel = koinViewModel(),
navigationCommands: NavigationCommands = koinInject()
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val permission = rememberLocalMediaPermissionState()
// Platform permission events only; the ViewModel decides whether/when to index media.
LaunchedEffect(permission.isGranted) { viewModel.onPermissionChanged(permission.isGranted) }
LocalTracksContent(
state, viewModel::openFolder, viewModel::playTrack, viewModel::navigateUp,
viewModel::playCurrentFolder, permission.requestPermission, modifier,
state,
onOpenFolder = { navigationCommands.navigateTo(Routes.LocalFolder(it)) },
onPlayTrack = {}, onNavigateUp = {}, onPlayFolder = {},
onRequestPermission = permission.requestPermission, modifier = modifier,
onManageFolders = foldersViewModel::open
)
LocalMediaFoldersDialogHost(foldersViewModel)
@ -120,7 +126,7 @@ internal fun LocalTracksContent(
state.currentFolderPath == null && state.visibleFolders.isNotEmpty() -> LazyColumn(
Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(bottom = inset + 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(state.visibleFolders, key = { it.path }) { folder ->
LocalFolderRow(folder) { onOpenFolder(folder.path) }
@ -130,7 +136,7 @@ internal fun LocalTracksContent(
state.currentFolderPath != null && state.visibleTracks.isNotEmpty() -> LazyColumn(
Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(bottom = inset + 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(state.visibleTracks, key = { it.path }) { track ->
PlayableListTile(
@ -139,6 +145,7 @@ internal fun LocalTracksContent(
imageModel = track.artwork,
onClick = { onPlayTrack(track.path) },
onPlay = { onPlayTrack(track.path) },
compact = true,
trailingContent = {
track.durationLabel?.let {
Text(
@ -195,7 +202,7 @@ internal fun LocalTracksBreadcrumb(
) {
val backDescription = stringResource(Res.string.library_local_back)
Row(
modifier.fillMaxWidth().height(32.dp),
modifier.fillMaxWidth().height(40.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
@ -268,22 +275,22 @@ internal fun LocalTracksBreadcrumb(
if (state.isRefreshing) MillenniumSpokeSpinner(size = 14.dp)
if (state.currentFolderPath == null && onManageFolders != null) MillenniumIconButton(
onClick = onManageFolders,
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet
modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
Iconsax.IconsaxFolderAdd,
stringResource(Res.string.library_local_add_folder),
modifier = Modifier.size(14.dp)
modifier = Modifier.size(22.dp)
)
}
if (state.currentFolderPath != null) MillenniumIconButton(
onClick = onPlayFolder, enabled = state.canPlayFolder,
modifier = Modifier.size(28.dp), style = MillenniumIconButtonStyle.Quiet
modifier = Modifier.size(40.dp), style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
Iconsax.IconsaxPlay,
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),
borderColor = MillenniumTheme.colors.border,
borderWidth = 1.dp,
contentPadding = PaddingValues(12.dp),
minimumHeight = 76.dp,
contentPadding = PaddingValues(8.dp),
minimumHeight = 52.dp,
leading = {
MediaArtwork(
folder.artwork,
folder.name,
Modifier.size(52.dp),
Modifier.size(36.dp),
placeholderIcon = Iconsax.IconsaxFolderOpen
)
},
@ -328,7 +335,7 @@ internal fun LocalFolderRow(folder: LocalFolderUi, onClick: () -> Unit) {
MillenniumIcon(
FeatherIcons.ChevronRight,
null,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(16.dp)
)
}
)

View File

@ -19,8 +19,6 @@ package org.spotube.spotube.modules.library.local_tracks
import androidx.lifecycle.ViewModel
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.LibraryTab
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFolder
@ -120,7 +118,6 @@ private fun localTrackMatches(track: LocalMediaTrack, query: String): Boolean =
class LibraryLocalTracksViewModel(
private val coordinator: LocalMediaLibraryCoordinator,
private val audioPlayerQueue: AudioPlayerQueue,
private val libraryState: LibraryState
) : ViewModel() {
private val requiresPermission = getPlatform().type == PlatformType.Android
@ -155,14 +152,6 @@ class LibraryLocalTracksViewModel(
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) {
val accessible = !requiresPermission || granted
val wasAccessible = _state.value.canAccessMedia
@ -170,10 +159,6 @@ class LibraryLocalTracksViewModel(
if (accessible && !wasAccessible) refresh()
}
fun playCurrentFolder() {
_state.value.currentFolderPath?.let(::playFolder)
}
fun refresh() {
if (!_state.value.canAccessMedia) return
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() {
if (_state.value.folders.isNotEmpty()) return
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
)
}
}

View File

@ -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)
)
}
}

View File

@ -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
)
}
}
}

View File

@ -211,7 +211,7 @@ fun AppExpandedPlayer(
val snackbarHostState = remember { MillenniumSnackbarHostState() }
val albumArtModifier =
rememberSharedAlbumArtModifier(sharedTransitionScope, animatedVisibilityScope)
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
val coverModel = playerUiState.coverModel
var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
val moreOptionsSheetState = rememberModalBottomSheetState(

View File

@ -131,7 +131,7 @@ internal fun FloatingPlayerContent(
onPlayPause: () -> Unit,
heartButton: @Composable () -> Unit
) {
val coverModel = playerUiState.coverUrl.takeIf { it.isNotBlank() }
val coverModel = playerUiState.coverModel
Box(
modifier = modifier

View File

@ -164,7 +164,7 @@ fun AppLargePlayer(
var isSeeking by remember { mutableStateOf(false) }
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
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)

View File

@ -45,7 +45,6 @@ internal data class PlayerUiState(
val artists: String = "No-op artists",
val artistList: List<PlayerArtist> = emptyList(),
val album: String = "",
val coverUrl: String = "",
val position: Duration = 0.milliseconds,
val actualDuration: Duration = 0.milliseconds,
val metadataDuration: Duration = 0.milliseconds,
@ -55,6 +54,8 @@ internal data class PlayerUiState(
val volume: Float = 0f
) {
val isPlaying: Boolean get() = playerState == PlayerState.PLAYING
val coverModel: Any? get() = currentQueueEntry?.artworkModel
val displayDuration: Duration
get() = if (actualDuration.inWholeMilliseconds > 0L) actualDuration else metadataDuration
val seekDuration: Duration
@ -72,7 +73,7 @@ internal data class PlayerUiState(
queue=${queue.map { it.displayTitle() }},
currentQueueEntry=${currentQueueEntry?.displayTitle()},
title='$title', artists='$artists',
album='$album', coverUrl='$coverUrl',
album='$album', coverModel=${coverModel?.let { it::class.simpleName }},
position=$position, actualDuration=$actualDuration, metadataDuration=$metadataDuration,
playerState=$playerState, loopState=$loopState, isShuffling=$isShuffling, volume=$volume
)
@ -105,7 +106,6 @@ internal fun rememberPlayerUiState(
artists = currentQueueEntry?.displayArtists().orEmpty().ifBlank { fallbackArtists },
artistList = currentQueueEntry?.displayArtistList().orEmpty(),
album = currentQueueEntry?.displayAlbum(unknownAlbum).orEmpty(),
coverUrl = currentQueueEntry?.coverUrl().orEmpty(),
position = position,
actualDuration = duration,
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 {
return when (this) {
is QueueEntry.StreamingTrack -> track.durationMs

View File

@ -350,9 +350,9 @@ private fun QueueItemRow(
.background(AppTheme.colors.surfaceVariant),
contentAlignment = Alignment.Center
) {
if (item.imageUrl != null) {
if (item.artwork != null) {
AsyncImage(
model = item.imageUrl,
model = item.artwork,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop

View File

@ -39,7 +39,7 @@ data class QueueItemUi(
val subtitle: String,
val durationLabel: String,
val isCurrent: Boolean,
val imageUrl: String?,
val artwork: Any?,
val originalIndex: Int,
/** Participant who added this item to the jam queue, if any. */
val addedByParticipant: JamParticipant? = null)
@ -77,22 +77,18 @@ class PlayerQueueContentViewModel(
val title: String
val subtitle: String
val durationMs: Long
val imageUrl: String?
when (entry) {
is QueueEntry.StreamingTrack -> {
title = entry.track.title
subtitle = entry.track.artists.joinToString(", ") { it.name }
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 -> {
title = entry.name
subtitle = entry.artists.joinToString(", ")
durationMs = entry.duration
imageUrl = null
}
}
@ -104,7 +100,7 @@ class PlayerQueueContentViewModel(
subtitle = subtitle,
durationLabel = durationMs.toDurationLabel(),
isCurrent = index == currentIndex,
imageUrl = imageUrl,
artwork = entry.artworkModel,
originalIndex = index,
addedByParticipant = addedByParticipant)
}

View File

@ -158,7 +158,7 @@ class MediaLibraryUiTest {
}
@Test
fun breadcrumbIsThinAndItsParentLinkNavigatesUp() {
fun breadcrumbIsCompactAndItsParentLinkNavigatesUp() {
var height = 0
var navigatedUp = 0
val state = localTracksUiState(
@ -167,7 +167,7 @@ class MediaLibraryUiTest {
currentFolderPath = "/music"
), ""
)
ImageComposeScene(360, 40) {
ImageComposeScene(360, 48) {
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
LocalTracksBreadcrumb(
state,
@ -177,7 +177,7 @@ class MediaLibraryUiTest {
}
}.use { scene ->
repeat(6) { scene.render().close() }
assertEquals(32, height)
assertEquals(40, height)
val parent = allNodes(scene).first {
it.config.getOrNull(SemanticsActions.OnClick) != null &&
it.config.getOrNull(SemanticsProperties.ContentDescription)

View File

@ -41,7 +41,7 @@ kotlin = "2.3.21"
kotlinx-coroutines = "1.11.0"
kotlinx-io = "0.9.1"
kotlinx-serialization-json = "1.11.0"
millennium = "1.0.0"
millennium = "0.1.0"
mqttClient = "2.1.1"
mqttBuffer = "6.30.8"
multiplatformMarkdownRenderer = "0.45.0"
@ -69,7 +69,7 @@ kotlinx-datetime = "0.8.0"
media3 = "1.10.1"
androidx-datastore = "1.2.1"
ksp = "2.3.5"
spotube-gradle = "0.1.1"
spotube-gradle = "0.1.2"
cryptography = "0.6.0"
reorderable = "3.1.0"
vlcjBundler = "0.1.0"

View File

@ -115,9 +115,10 @@ mavenPublishing {
}
developers {
developer {
id = "KRTirtho"
name = "Kingkor Roy Tirtho"
id = "team-spotube"
name = "Team Spotube"
url = "https://github.com/team-spotube/"
email = "team.spotube@proton.me"
}
}
scm {