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