Compare commits

...

8 Commits

Author SHA1 Message Date
dependabot[bot]
4101c113f6
Merge 5e65508fed into 33a8fdb8c2 2026-10-18 14:35:02 +00:00
Kingkor Roy Tirtho
33a8fdb8c2 cd: add GitHub token for API calls and set up Android SDK in workflow 2026-10-18 20:33:51 +06:00
Kingkor Roy Tirtho
03aaffeca8 chore: update spotube gradle plugin version 2026-10-18 20:07:20 +06:00
Kingkor Roy Tirtho
1233967982 chore: update contact information and library version in project files 2026-10-18 19:23:18 +06:00
Kingkor Roy Tirtho
17f2068950 feat(navigation): implement fade-through and stack transition animations for navigation 2026-10-18 18:31:51 +06:00
Kingkor Roy Tirtho
75ced1285e feat(local-tracks): implement LocalFolder screen and view model for folder navigation 2026-10-18 18:04:53 +06:00
Kingkor Roy Tirtho
c767b8dcd6 chore: update GitHub Actions to use latest versions of checkout, setup-java, setup-gradle, and upload-artifact 2026-10-17 19:21:42 +06:00
dependabot[bot]
5e65508fed
chore(deps): bump android-actions/setup-android from 3 to 4
Bumps [android-actions/setup-android](https://github.com/android-actions/setup-android) from 3 to 4.
- [Release notes](https://github.com/android-actions/setup-android/releases)
- [Commits](https://github.com/android-actions/setup-android/compare/v3...v4)

---
updated-dependencies:
- dependency-name: android-actions/setup-android
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:42:30 +00:00
27 changed files with 432 additions and 279 deletions

View File

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

View File

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

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 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). [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 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 ## I Have a Question

View File

@ -59,4 +59,4 @@ This policy is effective as of 2022-04-01
**Contact Us** **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) .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))
} }
} }

View File

@ -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",

View File

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

View File

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

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.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) {

View File

@ -25,6 +25,7 @@ import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
@ -33,7 +34,10 @@ import androidx.navigation3.runtime.NavKey
import androidx.navigation3.scene.Scene import androidx.navigation3.scene.Scene
private const val TRANSITION_DURATION_MILLIS = 300 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) private val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
// NavEntry.contentKey defaults to key.toString() // NavEntry.contentKey defaults to key.toString()
@ -49,28 +53,49 @@ private fun Scene<NavKey>.tabRoot(): Any? =
private fun AnimatedContentTransitionScope<Scene<NavKey>>.isTabSwitch(): Boolean = private fun AnimatedContentTransitionScope<Scene<NavKey>>.isTabSwitch(): Boolean =
initialState.tabRoot() != targetState.tabRoot() initialState.tabRoot() != targetState.tabRoot()
/** Material shared-axis (X) style: a short horizontal slide combined with a cross-fade. */ /** Material fade-through: the old tab fades out, the new one fades in while scaling up slightly. */
private fun AnimatedContentTransitionScope<Scene<NavKey>>.sharedAxisX( 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 forward: Boolean
): ContentTransform { ): ContentTransform {
if (isTabSwitch()) return ContentTransform(EnterTransition.None, ExitTransition.None) if (isTabSwitch()) return fadeThrough()
val direction = if (forward) 1 else -1 val slide = tween<IntOffset>(TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate)
val slideSpec = tween<IntOffset>( val enter: EnterTransition
TRANSITION_DURATION_MILLIS, easing = EmphasizedDecelerate val exit: ExitTransition
) if (forward) {
val enter = slideInHorizontally(slideSpec) { (it * SLIDE_FRACTION).toInt() * direction } + enter = slideInHorizontally(slide) { (it * SLIDE_FRACTION).toInt() } +
fadeIn(tween(TRANSITION_DURATION_MILLIS / 2, delayMillis = TRANSITION_DURATION_MILLIS / 6)) fadeIn(tween(TRANSITION_DURATION_MILLIS * 2 / 3))
val exit = slideOutHorizontally(slideSpec) { -(it * SLIDE_FRACTION).toInt() * direction } + exit = slideOutHorizontally(slide) { -(it * BEHIND_SLIDE_FRACTION).toInt() } +
fadeOut(tween(TRANSITION_DURATION_MILLIS / 3)) fadeOut(tween(TRANSITION_DURATION_MILLIS / 2))
return enter togetherWith exit } 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 = val navPushTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform =
{ sharedAxisX(forward = true) } { stackTransition(forward = true) }
val navPopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform = val navPopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.() -> ContentTransform =
{ sharedAxisX(forward = false) } { stackTransition(forward = false) }
val navPredictivePopTransitionSpec: AnimatedContentTransitionScope<Scene<NavKey>>.(Int) -> ContentTransform = 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.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())
} }

View File

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

View File

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

View File

@ -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(

View File

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

View File

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

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 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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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