mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
7 Commits
54f9af00ff
...
e8794b49a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8794b49a7 | ||
|
|
33a8fdb8c2 | ||
|
|
03aaffeca8 | ||
|
|
1233967982 | ||
|
|
17f2068950 | ||
|
|
75ced1285e | ||
|
|
c767b8dcd6 |
4
.github/workflows/maven-publish.yml
vendored
4
.github/workflows/maven-publish.yml
vendored
@ -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
|
||||
|
||||
92
.github/workflows/spotube-release-binary.yml
vendored
92
.github/workflows/spotube-release-binary.yml
vendored
@ -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: |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
package org.spotube.spotube.modules.library.local_tracks
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composeunstyled.Text
|
||||
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
||||
|
||||
@Composable
|
||||
fun LocalFolderScreen(viewModel: LocalFolderViewModel, onBack: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
Column {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text(state.currentFolder?.name.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
)
|
||||
LocalTracksContent(
|
||||
state,
|
||||
onOpenFolder = {},
|
||||
onPlayTrack = viewModel::playTrack,
|
||||
onNavigateUp = onBack,
|
||||
onPlayFolder = viewModel::playFolder,
|
||||
onRequestPermission = {},
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
/* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
package org.spotube.spotube.modules.library.local_tracks
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
|
||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaTrack
|
||||
|
||||
class LocalFolderViewModel(
|
||||
private val folderPath: String,
|
||||
coordinator: LocalMediaLibraryCoordinator,
|
||||
private val audioPlayerQueue: AudioPlayerQueue
|
||||
) : ViewModel() {
|
||||
val state: StateFlow<LibraryLocalTracksState> = coordinator.cacheState
|
||||
.map { cache ->
|
||||
localTracksUiState(
|
||||
LibraryLocalTracksState(folders = cache.folders, currentFolderPath = folderPath),
|
||||
""
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
viewModelScope, SharingStarted.WhileSubscribed(5_000),
|
||||
localTracksUiState(LibraryLocalTracksState(currentFolderPath = folderPath), "")
|
||||
)
|
||||
|
||||
fun playFolder() = play(0)
|
||||
|
||||
fun playTrack(trackPath: String) {
|
||||
val index = tracks().indexOfFirst { it.path == trackPath }
|
||||
if (index >= 0) play(index)
|
||||
}
|
||||
|
||||
private fun tracks(): List<LocalMediaTrack> =
|
||||
state.value.currentFolder?.tracks.orEmpty()
|
||||
|
||||
private fun play(startPosition: Int) {
|
||||
val tracks = tracks()
|
||||
if (tracks.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
audioPlayerQueue.load(
|
||||
entries = tracks.map {
|
||||
QueueEntry.LocalTrack(
|
||||
name = it.name,
|
||||
artists = it.artists,
|
||||
duration = it.durationMs,
|
||||
album = it.album,
|
||||
coverBytes = it.coverBytes,
|
||||
url = it.path
|
||||
)
|
||||
},
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -211,7 +211,7 @@ fun AppExpandedPlayer(
|
||||
val snackbarHostState = remember { MillenniumSnackbarHostState() }
|
||||
val 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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user