mirror of
https://github.com/project-dorado/dorado-hd.git
synced 2026-10-11 21:10:01 +00:00
Xune-HD: the Zune HD UI reborn as an Android music player
Initial build implementing phases 0-6: - AGP 9 built-in Kotlin toolchain (KGP 2.4.20), Media3/ExoPlayer, Room, Coil3 - Zune HD design system: edge-crop typography, cropped-header back nav, 480x272 device-mode canvas + adaptive reflow, decelerating motion - Home menu + Quickplay (now playing / pins / history / new) - Crossbar library (albums, artists, playlists, songs, genres) with alphabet rail - Now Playing: swipe-to-skip, transport overlay, swipe volume, tri-state heart rating, idle screensaver over artist photography (MBID-keyed) - Accent themes, palette wash, context menus, playlists - Unit tests incl. design-invariant audit; Selawik (OFL) stand-in font
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
name: Build & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Assemble debug
|
||||
run: ./gradlew assembleDebug --stacktrace
|
||||
|
||||
- name: Unit tests + design-invariant audit
|
||||
run: ./gradlew test --stacktrace
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xune-hd-debug
|
||||
path: app/build/outputs/apk/debug/app-debug.apk
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
|
||||
# Android
|
||||
captures/
|
||||
*.apk.idsig
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -0,0 +1,51 @@
|
||||
# AGENTS.md — Xune-HD repository rules
|
||||
|
||||
Xune-HD is the Zune HD on-device UI, reborn as an Android music player.
|
||||
Sister project of Not-Zune (the Zune desktop re-implementation).
|
||||
|
||||
## The canon is law
|
||||
|
||||
`docs/zune-hd-ui-canon.md` defines every screen and gesture; `docs/design-tokens.md`
|
||||
defines every color, size and duration. If a change contradicts the canon, change
|
||||
the canon first (with a source citation), then the code.
|
||||
|
||||
## Design invariants (audited by tests — `DesignInvariantTest`)
|
||||
|
||||
1. **Zero corner radius.** Never write `RoundedCornerShape` or set any nonzero
|
||||
corner radius on UI. Squares only, like the device.
|
||||
2. **No Material chrome leakage.** Screens compose our tokens
|
||||
(`LocalXuneColors`, `XuneTokens`, `Selawik`). Material primitives (Icon,
|
||||
IconButton, TextField) are permitted as behaviorless shells — never for
|
||||
their styling.
|
||||
3. **Typography is navigation.** New navigation is text-first (the Zune HD had
|
||||
no icon nav). Icons exist only for transport/rating glyphs.
|
||||
4. **Hearts, not stars.** Rating is the tri-state heart: heart / broken / none.
|
||||
5. **Back = the cropped header.** Detail screens use `CroppedHeader` /
|
||||
`DetailScaffold`; Now Playing uses the explicit back arrow. Do not add
|
||||
chrome back buttons elsewhere.
|
||||
6. **Motion decelerates.** Use `XuneMotion` easings/durations. No springs in
|
||||
navigation.
|
||||
|
||||
## Toolchain notes (AGP 9)
|
||||
|
||||
- `com.android.application` provides **built-in Kotlin**; never apply
|
||||
`org.jetbrains.kotlin.android`.
|
||||
- KGP is force-aligned to 2.4.20 through the root `buildscript` block because
|
||||
2026 androidx libraries ship stdlib 2.4 metadata. Keep
|
||||
`libs.versions.kotlinComposePlugin` equal to that KGP version.
|
||||
- Kotlin jvmTarget follows `compileOptions` (17).
|
||||
|
||||
## Code layout
|
||||
|
||||
- `design/` — tokens, theme, motion, reusable components. Screens may not
|
||||
hardcode colors/sizes; consume tokens.
|
||||
- `data/` — Room database, MediaStore scanner, repositories.
|
||||
- `media/` — Media3 session service + app-side `PlaybackController`.
|
||||
- `ui/` — navigation, screens, shared widgets.
|
||||
- `net/` — optional network providers (artist images via `{mbid}` templates).
|
||||
|
||||
## Licensing
|
||||
|
||||
MIT for code. MedTune attribution lives in NOTICE.md. MusicIn2001 is a
|
||||
Research-Only-licensed project — **never copy code from it**; it is a
|
||||
behavioral spec only. Never bundle Microsoft fonts or firmware.
|
||||
@@ -0,0 +1,24 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Heretek AI and contributors
|
||||
|
||||
Portions of the playback and library-scan logic are derived from MedTune by
|
||||
Rizqi Tri Antomi, used under the MIT License (see NOTICE.md).
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,28 @@
|
||||
# NOTICE — third-party materials in Xune-HD
|
||||
|
||||
## Code
|
||||
- **MedTune** (https://github.com/rizqitri3at/MedTune) by Rizqi Tri Antomi —
|
||||
MIT License. Library-scan concepts and the starting project structure are
|
||||
derived from MedTune. Modifications are extensive and specific to Xune-HD.
|
||||
- **Not-Zune** (https://github.com/Heretek-AI/not-zune) — MIT License. Design
|
||||
tokens and the design-invariant audit approach are ported from its
|
||||
zune-design-system skill.
|
||||
- **MusicIn2001** (https://github.com/BillyOutlast/MusicIn2001) — Research-Only
|
||||
License. Used exclusively as a behavioral specification (features, gesture
|
||||
set, EQ/sync model). No source code, assets, or text from MusicIn2001 are
|
||||
included in this repository.
|
||||
|
||||
## Fonts
|
||||
- **Selawik** — Copyright 2015 Microsoft Corporation, licensed under the SIL
|
||||
Open Font License 1.1 (see docs/OFL-Selawik.txt). Selawik is a trademark of
|
||||
Microsoft Corporation; it is redistributed under the OFL as permitted.
|
||||
|
||||
## Trademarks
|
||||
- Zune, Zegoe, Zune HD and Microsoft are trademarks of Microsoft Corporation.
|
||||
Xune-HD is an independent, non-affiliated homage. No Microsoft software,
|
||||
fonts, firmware, or artwork are bundled.
|
||||
|
||||
## Concept sources (no code)
|
||||
- ZuneRedux/zune-hd-apps — original Zune HD app archive, design reference only.
|
||||
- spidersandmoths/ZuneArtistImages and zuneupdate.com — the artist-background
|
||||
concept; no endpoints or data are hardcoded (user-configured).
|
||||
@@ -0,0 +1,87 @@
|
||||
# Xune-HD
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 📱 Xune-HD
|
||||
**The Zune HD, reborn as an Android music player — sister app to Not-Zune**
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Xune-HD is a faithful re-creation of the **Microsoft Zune HD on-device
|
||||
interface** (2009) for modern Android phones: white-on-black typography-first
|
||||
Metro design, a text menu cropped at the screen edge, Quickplay parked "left
|
||||
and rear", crossbar navigation, swipe-to-skip Now Playing floating over artist
|
||||
photography, and the tri-state heart rating.
|
||||
|
||||
It is the **device-side sibling** of [Not-Zune](https://github.com/Heretek-AI/not-zune)
|
||||
(the Zune *desktop* re-implementation). Just as the Zune HD was the device and
|
||||
Zune 4.8 was the desktop, Xune-HD is the phone and Not-Zune is the desktop.
|
||||
|
||||
## ✨ What's implemented
|
||||
|
||||
- **Home menu** — giant lowercase items cropped at the right screen edge;
|
||||
slide right to reveal **Quickplay**.
|
||||
- **Quickplay** — Now Playing card, **Pins** (long-press anything to pin),
|
||||
**History**, **New**.
|
||||
- **Music crossbar** — `albums · artists · playlists · songs · genres`, flick
|
||||
horizontally to switch pivots.
|
||||
- **Alphabet rail** — tap/drag the faint letters to jump through lists.
|
||||
- **Now Playing** — floats over artist photography (MusicBrainz-MBID keyed,
|
||||
user-configurable catalog server), swipe ← → to skip, tap for the transport
|
||||
overlay (play/pause center, volume top/bottom, prev/next sides, swipe ↑ ↓
|
||||
for volume), tri-state heart rating, and the idle **screensaver** with
|
||||
slow-drifting metadata.
|
||||
- **Tap-the-cut-off-header to go back** — the Zune HD's signature navigation,
|
||||
on every screen.
|
||||
- **Device mode** — the entire UI re-rendered inside a letterboxed 480×272
|
||||
canvas, the authentic Zune HD screen (hold the phone in landscape).
|
||||
- **Accent themes** — zune pink, orange, electric cyan, vivid lime, deep
|
||||
purple. Palette-driven color wash on Now Playing.
|
||||
|
||||
## 📄 Design canon
|
||||
|
||||
`docs/zune-hd-ui-canon.md` is the authoritative interaction spec, compiled
|
||||
from 2009 reviews and community resources. `docs/design-tokens.md` holds the
|
||||
portable token set. The unit-test suite audits screens against the invariant
|
||||
list (zero corner radius, no Material leakage, heart-not-stars).
|
||||
|
||||
## 🔒 Licensing posture
|
||||
|
||||
- **Not-Zune** (MIT) — design tokens, concepts. Thank you.
|
||||
- **MedTune** (MIT) — starting skeleton; see NOTICE.md.
|
||||
- **MusicIn2001** — Research-Only license: used strictly as a behavioral
|
||||
specification. **No code was copied.**
|
||||
- **Selawik** (SIL OFL 1.1) — Segoe-metric stand-in for Zegoe. Import your
|
||||
own Zegoe if you have it.
|
||||
- Zune, Zegoe and the Zune HD are Microsoft trademarks. Xune-HD is an
|
||||
independent homage; nothing Microsoft is bundled.
|
||||
|
||||
## 🛠️ Building
|
||||
|
||||
```bash
|
||||
# Android SDK + JDK 17+ required; local.properties points at your SDK
|
||||
./gradlew assembleDebug # debug APK
|
||||
./gradlew test # unit tests + design-invariant audit
|
||||
./gradlew lint # android lint
|
||||
```
|
||||
|
||||
The release build minifies with R8; keep rules in `app/proguard-rules.pro`.
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
- Wi-Fi sync with Not-Zune: the phone enrolls as a Zune-HD-like device in
|
||||
Not-Zune's sync engine (JSON manifest, ZMDB-style database).
|
||||
- Podcasts & video pivots, home-screen widget, Zune-style lock shade.
|
||||
- Real MTP/MTPZ sync to a physical Zune HD over USB host (stretch).
|
||||
|
||||
---
|
||||
|
||||
*"The Zune HD's UI is everything but an example of Apple minimalism."* —
|
||||
Gizmodo, 2009
|
||||
@@ -0,0 +1,82 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.heretek.xunehd"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.heretek.xunehd"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.coroutines.guava)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.animation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.common)
|
||||
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
implementation(libs.palette.ktx)
|
||||
implementation(libs.datastore.preferences)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
# Keep Media3 session service wiring.
|
||||
-keep class androidx.media3.** { *; }
|
||||
-keep interface androidx.media3.** { *; }
|
||||
# Room entities are accessed reflectively by generated code.
|
||||
-keep class com.heretek.xunehd.data.db.** { *; }
|
||||
-dontwarn org.checkerframework.**
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name=".XuneApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.XuneHd"
|
||||
tools:targetApi="tiramisu">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||
android:theme="@style/Theme.XuneHd">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".media.XunePlaybackService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.heretek.xunehd
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import com.heretek.xunehd.ui.XuneRoot
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val graph = (application as XuneApp).graph
|
||||
setContent {
|
||||
CompositionLocalProvider(
|
||||
com.heretek.xunehd.ui.LocalXuneGraph provides graph,
|
||||
) {
|
||||
XuneRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.heretek.xunehd
|
||||
|
||||
import android.app.Application
|
||||
import com.heretek.xunehd.data.db.XuneDatabase
|
||||
import com.heretek.xunehd.data.repo.LibraryRepository
|
||||
import com.heretek.xunehd.data.repo.QuickplayRepository
|
||||
import com.heretek.xunehd.data.repo.SettingsRepository
|
||||
import com.heretek.xunehd.media.PlaybackController
|
||||
import com.heretek.xunehd.net.ArtistImageService
|
||||
import com.heretek.xunehd.ui.nav.XuneNav
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The application graph. Deliberately small and hand-wired: no DI framework,
|
||||
* mirroring the lean structure of the Zune-era codebase we emulate.
|
||||
*/
|
||||
class XuneGraph(
|
||||
val library: LibraryRepository,
|
||||
val quickplay: QuickplayRepository,
|
||||
val settings: SettingsRepository,
|
||||
val settingsFlow: kotlinx.coroutines.flow.Flow<com.heretek.xunehd.data.repo.XuneSettings>,
|
||||
val controller: PlaybackController,
|
||||
val nav: XuneNav,
|
||||
val artistImages: ArtistImageService,
|
||||
)
|
||||
|
||||
class XuneApp : Application() {
|
||||
|
||||
lateinit var graph: XuneGraph
|
||||
private set
|
||||
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val db = XuneDatabase.get(this)
|
||||
val library = LibraryRepository(this, db)
|
||||
val quickplay = QuickplayRepository(db)
|
||||
val settings = SettingsRepository(this)
|
||||
val controller = PlaybackController(this, library, quickplay)
|
||||
val nav = XuneNav()
|
||||
val artistImages = ArtistImageService(this, db, settings)
|
||||
|
||||
graph = XuneGraph(library, quickplay, settings, settings.settings, controller, nav, artistImages)
|
||||
|
||||
appScope.launch {
|
||||
controller.connect()
|
||||
}
|
||||
|
||||
// Keep the artist-image service aware of the user's settings.
|
||||
settings.settings
|
||||
.onEach { artistImages.settingsSnapshot = it }
|
||||
.launchIn(appScope)
|
||||
|
||||
// First launch: build the collection from MediaStore.
|
||||
appScope.launch {
|
||||
if (settings.settings.first().libraryScanned.not()) {
|
||||
library.refresh()
|
||||
settings.setLibraryScanned(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.heretek.xunehd.data.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.heretek.xunehd.data.model.Album
|
||||
import com.heretek.xunehd.data.model.Artist
|
||||
import com.heretek.xunehd.data.model.Genre
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
data class AlbumRow(
|
||||
val albumId: Long,
|
||||
val album: String,
|
||||
val artist: String,
|
||||
val artistId: Long,
|
||||
val year: String,
|
||||
val trackCount: Int,
|
||||
val totalDurationMs: Long,
|
||||
val dateAdded: Long,
|
||||
) {
|
||||
fun toAlbum() = Album(albumId, album, artist, artistId, year, trackCount, totalDurationMs, dateAdded)
|
||||
}
|
||||
|
||||
data class ArtistRow(
|
||||
val artistId: Long,
|
||||
val artist: String,
|
||||
val albumCount: Int,
|
||||
val trackCount: Int,
|
||||
) {
|
||||
fun toArtist() = Artist(artistId, artist, albumCount, trackCount)
|
||||
}
|
||||
|
||||
data class GenreRow(val genre: String, val trackCount: Int) {
|
||||
fun toGenre() = Genre(genre, trackCount)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface TrackDao {
|
||||
@Query("SELECT * FROM tracks ORDER BY title COLLATE NOCASE")
|
||||
fun tracks(): Flow<List<TrackEntity>>
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE mediaId = :id")
|
||||
suspend fun track(id: Long): TrackEntity?
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE mediaId IN (:ids)")
|
||||
suspend fun tracksByIds(ids: List<Long>): List<TrackEntity>
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE albumId = :albumId ORDER BY trackNumber, title COLLATE NOCASE")
|
||||
suspend fun tracksByAlbum(albumId: Long): List<TrackEntity>
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE artistId = :artistId ORDER BY album COLLATE NOCASE, trackNumber")
|
||||
suspend fun tracksByArtist(artistId: Long): List<TrackEntity>
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE genre = :genre ORDER BY album COLLATE NOCASE, trackNumber")
|
||||
suspend fun tracksByGenre(genre: String): List<TrackEntity>
|
||||
|
||||
@Query("SELECT * FROM tracks ORDER BY dateAdded DESC LIMIT :limit")
|
||||
suspend fun recentlyAdded(limit: Int): List<TrackEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM tracks WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"OR artist LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"OR album LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY title COLLATE NOCASE LIMIT 100"
|
||||
)
|
||||
suspend fun search(q: String): List<TrackEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT albumId AS albumId, album AS album, artist AS artist, artistId AS artistId, " +
|
||||
"MAX(year) AS year, COUNT(*) AS trackCount, SUM(durationMs) AS totalDurationMs, " +
|
||||
"MAX(dateAdded) AS dateAdded FROM tracks GROUP BY albumId ORDER BY album COLLATE NOCASE"
|
||||
)
|
||||
fun albumRows(): Flow<List<AlbumRow>>
|
||||
|
||||
@Query(
|
||||
"SELECT albumId AS albumId, album AS album, artist AS artist, artistId AS artistId, " +
|
||||
"MAX(year) AS year, COUNT(*) AS trackCount, SUM(durationMs) AS totalDurationMs, " +
|
||||
"MAX(dateAdded) AS dateAdded FROM tracks WHERE artistId = :artistId " +
|
||||
"GROUP BY albumId ORDER BY year, album COLLATE NOCASE"
|
||||
)
|
||||
suspend fun albumRowsByArtist(artistId: Long): List<AlbumRow>
|
||||
|
||||
@Query(
|
||||
"SELECT artistId AS artistId, artist AS artist, COUNT(DISTINCT albumId) AS albumCount, " +
|
||||
"COUNT(*) AS trackCount FROM tracks GROUP BY artistId ORDER BY artist COLLATE NOCASE"
|
||||
)
|
||||
fun artistRows(): Flow<List<ArtistRow>>
|
||||
|
||||
@Query("SELECT genre AS genre, COUNT(*) AS trackCount FROM tracks GROUP BY genre ORDER BY genre COLLATE NOCASE")
|
||||
fun genreRows(): Flow<List<GenreRow>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(tracks: List<TrackEntity>)
|
||||
|
||||
@Query("DELETE FROM tracks WHERE mediaId IN (:ids)")
|
||||
suspend fun deleteByIds(ids: List<Long>)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM tracks")
|
||||
suspend fun count(): Int
|
||||
|
||||
@Query("SELECT DISTINCT artist FROM tracks ORDER BY artist COLLATE NOCASE")
|
||||
suspend fun distinctArtistNames(): List<String>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface RatingDao {
|
||||
@Query("SELECT rating FROM ratings WHERE mediaId = :id")
|
||||
fun ratingOf(id: Long): Flow<Int?>
|
||||
|
||||
@Query("SELECT * FROM ratings WHERE rating = :rating")
|
||||
suspend fun tracksWithRating(rating: Int): List<RatingEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun set(entity: RatingEntity)
|
||||
|
||||
@Query("DELETE FROM ratings WHERE mediaId = :id")
|
||||
suspend fun clear(id: Long)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface PlaylistDao {
|
||||
@Query("SELECT id, name, dateCreated, (SELECT COUNT(*) FROM playlist_tracks pt WHERE pt.playlistId = p.id) AS trackCount FROM playlists p ORDER BY name COLLATE NOCASE")
|
||||
fun playlists(): Flow<List<PlaylistRow>>
|
||||
|
||||
@Query("SELECT * FROM playlists WHERE id = :id")
|
||||
suspend fun playlist(id: Long): PlaylistEntity?
|
||||
|
||||
@Query(
|
||||
"SELECT t.* FROM tracks t JOIN playlist_tracks pt ON pt.mediaId = t.mediaId " +
|
||||
"WHERE pt.playlistId = :playlistId ORDER BY pt.position"
|
||||
)
|
||||
suspend fun tracks(playlistId: Long): List<TrackEntity>
|
||||
|
||||
@Insert
|
||||
suspend fun create(playlist: PlaylistEntity): Long
|
||||
|
||||
@Query("DELETE FROM playlists WHERE id = :id")
|
||||
suspend fun delete(id: Long)
|
||||
|
||||
@Query("DELETE FROM playlist_tracks WHERE playlistId = :playlistId")
|
||||
suspend fun deleteMemberships(playlistId: Long)
|
||||
|
||||
@Query("SELECT MAX(position) FROM playlist_tracks WHERE playlistId = :playlistId")
|
||||
suspend fun maxPosition(playlistId: Long): Int?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun addToPlaylist(item: PlaylistTrackEntity)
|
||||
|
||||
@Query("DELETE FROM playlist_tracks WHERE playlistId = :playlistId AND mediaId = :mediaId")
|
||||
suspend fun removeFromPlaylist(playlistId: Long, mediaId: Long)
|
||||
|
||||
@Transaction
|
||||
suspend fun deletePlaylistAndMembers(id: Long) {
|
||||
deleteMemberships(id)
|
||||
delete(id)
|
||||
}
|
||||
|
||||
data class PlaylistRow(val id: Long, val name: String, val dateCreated: Long, val trackCount: Int) {
|
||||
fun toPlaylist() = com.heretek.xunehd.data.model.Playlist(id, name, dateCreated, trackCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface PinDao {
|
||||
@Query("SELECT * FROM pins ORDER BY pinnedAt DESC")
|
||||
fun pins(): Flow<List<PinEntity>>
|
||||
|
||||
@Insert
|
||||
suspend fun pin(entity: PinEntity)
|
||||
|
||||
@Query("DELETE FROM pins WHERE kind = :kind AND refId = :refId")
|
||||
suspend fun unpin(kind: String, refId: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pins WHERE kind = :kind AND refId = :refId")
|
||||
suspend fun isPinned(kind: String, refId: Long): Int
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface HistoryDao {
|
||||
@Query("SELECT * FROM history ORDER BY playedAt DESC LIMIT :limit")
|
||||
fun recent(limit: Int): Flow<List<HistoryEntity>>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(entity: HistoryEntity)
|
||||
|
||||
@Query("DELETE FROM history WHERE id NOT IN (SELECT id FROM history ORDER BY playedAt DESC LIMIT :keep)")
|
||||
suspend fun trim(keep: Int)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface ArtistImageDao {
|
||||
@Query("SELECT * FROM artist_images WHERE artistKey = :key")
|
||||
suspend fun get(key: String): ArtistImageEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun put(entity: ArtistImageEntity)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.heretek.xunehd.data.db
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "tracks", indices = [Index("albumId"), Index("artistId"), Index("title")])
|
||||
data class TrackEntity(
|
||||
@PrimaryKey val mediaId: Long,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val artistId: Long,
|
||||
val album: String,
|
||||
val albumId: Long,
|
||||
val genre: String,
|
||||
val durationMs: Long,
|
||||
val dateAdded: Long,
|
||||
val trackNumber: Int,
|
||||
val year: String,
|
||||
val uri: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "playlists")
|
||||
data class PlaylistEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val name: String,
|
||||
val dateCreated: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "playlist_tracks", primaryKeys = ["playlistId", "mediaId"])
|
||||
data class PlaylistTrackEntity(
|
||||
val playlistId: Long,
|
||||
val mediaId: Long,
|
||||
val position: Int,
|
||||
)
|
||||
|
||||
@Entity(tableName = "ratings")
|
||||
data class RatingEntity(
|
||||
@PrimaryKey val mediaId: Long,
|
||||
val rating: Int,
|
||||
)
|
||||
|
||||
@Entity(tableName = "pins")
|
||||
data class PinEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val kind: String,
|
||||
val refId: Long,
|
||||
val label: String,
|
||||
val subLabel: String,
|
||||
val artAlbumId: Long,
|
||||
val pinnedAt: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "history")
|
||||
data class HistoryEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val kind: String,
|
||||
val refId: Long,
|
||||
val label: String,
|
||||
val subLabel: String,
|
||||
val artAlbumId: Long,
|
||||
val playedAt: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "artist_images")
|
||||
data class ArtistImageEntity(
|
||||
@PrimaryKey val artistKey: String,
|
||||
val imagePath: String,
|
||||
val fetchedAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.heretek.xunehd.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
TrackEntity::class,
|
||||
PlaylistEntity::class,
|
||||
PlaylistTrackEntity::class,
|
||||
RatingEntity::class,
|
||||
PinEntity::class,
|
||||
HistoryEntity::class,
|
||||
ArtistImageEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class XuneDatabase : RoomDatabase() {
|
||||
abstract fun trackDao(): TrackDao
|
||||
abstract fun ratingDao(): RatingDao
|
||||
abstract fun playlistDao(): PlaylistDao
|
||||
abstract fun pinDao(): PinDao
|
||||
abstract fun historyDao(): HistoryDao
|
||||
abstract fun artistImageDao(): ArtistImageDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: XuneDatabase? = null
|
||||
|
||||
fun get(context: Context): XuneDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(context, XuneDatabase::class.java, "xune.db")
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.heretek.xunehd.data.model
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
enum class Rating(val value: Int) {
|
||||
NONE(0), HEART(1), BROKEN(2);
|
||||
|
||||
companion object {
|
||||
fun from(value: Int) = entries.firstOrNull { it.value == value } ?: NONE
|
||||
}
|
||||
}
|
||||
|
||||
enum class RepeatMode { OFF, ALL, ONE }
|
||||
|
||||
enum class PinKind { TRACK, ALBUM, ARTIST, PLAYLIST }
|
||||
|
||||
data class Track(
|
||||
val mediaId: Long,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val artistId: Long,
|
||||
val album: String,
|
||||
val albumId: Long,
|
||||
val genre: String,
|
||||
val durationMs: Long,
|
||||
val dateAdded: Long,
|
||||
val trackNumber: Int,
|
||||
val year: String,
|
||||
val uri: Uri,
|
||||
) {
|
||||
val albumArtUri: Uri
|
||||
get() = Uri.parse("content://media/external/audio/albumart/$albumId")
|
||||
}
|
||||
|
||||
data class Album(
|
||||
val albumId: Long,
|
||||
val title: String,
|
||||
val artist: String,
|
||||
val artistId: Long,
|
||||
val year: String,
|
||||
val trackCount: Int,
|
||||
val totalDurationMs: Long,
|
||||
val dateAdded: Long,
|
||||
) {
|
||||
val albumArtUri: Uri
|
||||
get() = Uri.parse("content://media/external/audio/albumart/$albumId")
|
||||
}
|
||||
|
||||
data class Artist(
|
||||
val artistId: Long,
|
||||
val name: String,
|
||||
val albumCount: Int,
|
||||
val trackCount: Int,
|
||||
)
|
||||
|
||||
data class Genre(
|
||||
val name: String,
|
||||
val trackCount: Int,
|
||||
)
|
||||
|
||||
data class Playlist(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val dateCreated: Long,
|
||||
val trackCount: Int,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.heretek.xunehd.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import com.heretek.xunehd.data.db.XuneDatabase
|
||||
import com.heretek.xunehd.data.model.Album
|
||||
import com.heretek.xunehd.data.model.Artist
|
||||
import com.heretek.xunehd.data.model.Genre
|
||||
import com.heretek.xunehd.data.model.Playlist
|
||||
import com.heretek.xunehd.data.model.Track
|
||||
import com.heretek.xunehd.data.scan.MediaLibraryScanner
|
||||
import com.heretek.xunehd.data.scan.toModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class LibraryRepository(
|
||||
private val context: Context,
|
||||
private val db: XuneDatabase,
|
||||
) {
|
||||
val scanning = MutableStateFlow(false)
|
||||
val lastScanResult = MutableStateFlow<MediaLibraryScanner.ScanResult?>(null)
|
||||
|
||||
fun tracks(): Flow<List<Track>> = db.trackDao().tracks().map { list -> list.map { it.toModel() } }
|
||||
fun albums(): Flow<List<Album>> = db.trackDao().albumRows().map { rows -> rows.map { it.toAlbum() } }
|
||||
fun artists(): Flow<List<Artist>> = db.trackDao().artistRows().map { rows -> rows.map { it.toArtist() } }
|
||||
fun genres(): Flow<List<Genre>> = db.trackDao().genreRows().map { rows -> rows.map { it.toGenre() } }
|
||||
fun playlists(): Flow<List<Playlist>> = db.playlistDao().playlists().map { rows -> rows.map { it.toPlaylist() } }
|
||||
|
||||
suspend fun track(id: Long): Track? = db.trackDao().track(id)?.toModel()
|
||||
suspend fun tracksByAlbum(albumId: Long): List<Track> = db.trackDao().tracksByAlbum(albumId).map { it.toModel() }
|
||||
suspend fun tracksByArtist(artistId: Long): List<Track> = db.trackDao().tracksByArtist(artistId).map { it.toModel() }
|
||||
suspend fun tracksByGenre(genre: String): List<Track> = db.trackDao().tracksByGenre(genre).map { it.toModel() }
|
||||
suspend fun tracksInPlaylist(playlistId: Long): List<Track> = db.playlistDao().tracks(playlistId).map { it.toModel() }
|
||||
suspend fun recentlyAdded(limit: Int = 24): List<Track> = db.trackDao().recentlyAdded(limit).map { it.toModel() }
|
||||
suspend fun search(q: String): List<Track> = db.trackDao().search(q).map { it.toModel() }
|
||||
suspend fun albumsByArtist(artistId: Long): List<Album> = db.trackDao().albumRowsByArtist(artistId).map { it.toAlbum() }
|
||||
suspend fun trackCount(): Int = db.trackDao().count()
|
||||
|
||||
suspend fun refresh(): MediaLibraryScanner.ScanResult {
|
||||
scanning.value = true
|
||||
try {
|
||||
return MediaLibraryScanner(context, db).scan().also { lastScanResult.value = it }
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createPlaylist(name: String): Long = db.playlistDao().create(
|
||||
com.heretek.xunehd.data.db.PlaylistEntity(name = name, dateCreated = System.currentTimeMillis()),
|
||||
)
|
||||
|
||||
suspend fun addToPlaylist(playlistId: Long, track: Track) {
|
||||
val max = db.playlistDao().maxPosition(playlistId) ?: -1
|
||||
db.playlistDao().addToPlaylist(
|
||||
com.heretek.xunehd.data.db.PlaylistTrackEntity(playlistId, track.mediaId, max + 1),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun removeFromPlaylist(playlistId: Long, mediaId: Long) = db.playlistDao().removeFromPlaylist(playlistId, mediaId)
|
||||
suspend fun deletePlaylist(playlistId: Long) = db.playlistDao().deletePlaylistAndMembers(playlistId)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.heretek.xunehd.data.repo
|
||||
|
||||
import com.heretek.xunehd.data.db.HistoryEntity
|
||||
import com.heretek.xunehd.data.db.PinEntity
|
||||
import com.heretek.xunehd.data.db.RatingEntity
|
||||
import com.heretek.xunehd.data.db.XuneDatabase
|
||||
import com.heretek.xunehd.data.model.PinKind
|
||||
import com.heretek.xunehd.data.model.Rating
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
data class QuickplayCard(
|
||||
val kind: PinKind,
|
||||
val refId: Long,
|
||||
val label: String,
|
||||
val subLabel: String,
|
||||
val artAlbumId: Long,
|
||||
)
|
||||
|
||||
class QuickplayRepository(private val db: XuneDatabase) {
|
||||
fun pins(): Flow<List<QuickplayCard>> = db.pinDao().pins().map { list ->
|
||||
list.map { it.toCard() }
|
||||
}
|
||||
|
||||
fun history(limit: Int = 24): Flow<List<QuickplayCard>> = db.historyDao().recent(limit).map { list ->
|
||||
list.map { it.toCard() }
|
||||
}
|
||||
|
||||
suspend fun pin(kind: PinKind, refId: Long, label: String, subLabel: String, artAlbumId: Long) {
|
||||
db.pinDao().pin(
|
||||
PinEntity(
|
||||
kind = kind.name,
|
||||
refId = refId,
|
||||
label = label,
|
||||
subLabel = subLabel,
|
||||
artAlbumId = artAlbumId,
|
||||
pinnedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun unpin(kind: PinKind, refId: Long) = db.pinDao().unpin(kind.name, refId)
|
||||
suspend fun isPinned(kind: PinKind, refId: Long) = db.pinDao().isPinned(kind.name, refId) > 0
|
||||
|
||||
suspend fun recordHistory(kind: PinKind, refId: Long, label: String, subLabel: String, artAlbumId: Long) {
|
||||
db.historyDao().insert(
|
||||
HistoryEntity(
|
||||
kind = kind.name,
|
||||
refId = refId,
|
||||
label = label,
|
||||
subLabel = subLabel,
|
||||
artAlbumId = artAlbumId,
|
||||
playedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
db.historyDao().trim(keep = 30)
|
||||
}
|
||||
|
||||
fun ratingOf(mediaId: Long): Flow<Rating> = db.ratingDao().ratingOf(mediaId).map { Rating.from(it ?: 0) }
|
||||
|
||||
suspend fun setRating(mediaId: Long, rating: Rating) {
|
||||
if (rating == Rating.NONE) {
|
||||
db.ratingDao().clear(mediaId)
|
||||
} else {
|
||||
db.ratingDao().set(RatingEntity(mediaId, rating.value))
|
||||
}
|
||||
}
|
||||
|
||||
private fun PinEntity.toCard() = QuickplayCard(
|
||||
kind = PinKind.valueOf(kind),
|
||||
refId = refId,
|
||||
label = label,
|
||||
subLabel = subLabel,
|
||||
artAlbumId = artAlbumId,
|
||||
)
|
||||
|
||||
private fun HistoryEntity.toCard() = QuickplayCard(
|
||||
kind = PinKind.valueOf(kind),
|
||||
refId = refId,
|
||||
label = label,
|
||||
subLabel = subLabel,
|
||||
artAlbumId = artAlbumId,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.heretek.xunehd.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.heretek.xunehd.design.XuneAccent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore by preferencesDataStore(name = "xune_settings")
|
||||
|
||||
data class XuneSettings(
|
||||
val accent: XuneAccent = XuneAccent.PINK,
|
||||
val deviceMode: Boolean = false,
|
||||
val artistImagesEnabled: Boolean = true,
|
||||
val artistImageTemplate: String = "",
|
||||
val libraryScanned: Boolean = false,
|
||||
)
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
private object Keys {
|
||||
val ACCENT = intPreferencesKey("accent")
|
||||
val DEVICE_MODE = booleanPreferencesKey("device_mode")
|
||||
val ARTIST_IMAGES = booleanPreferencesKey("artist_images")
|
||||
val ARTIST_IMAGE_TEMPLATE = stringPreferencesKey("artist_image_template")
|
||||
val LIBRARY_SCANNED = booleanPreferencesKey("library_scanned")
|
||||
}
|
||||
|
||||
val settings: Flow<XuneSettings> = context.dataStore.data.map { p ->
|
||||
XuneSettings(
|
||||
accent = XuneAccent.entries.firstOrNull { it.id == p[Keys.ACCENT] } ?: XuneAccent.PINK,
|
||||
deviceMode = p[Keys.DEVICE_MODE] ?: false,
|
||||
artistImagesEnabled = p[Keys.ARTIST_IMAGES] ?: true,
|
||||
artistImageTemplate = p[Keys.ARTIST_IMAGE_TEMPLATE] ?: "",
|
||||
libraryScanned = p[Keys.LIBRARY_SCANNED] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setAccent(accent: XuneAccent) = context.dataStore.edit { it[Keys.ACCENT] = accent.id }
|
||||
suspend fun setDeviceMode(enabled: Boolean) = context.dataStore.edit { it[Keys.DEVICE_MODE] = enabled }
|
||||
suspend fun setArtistImages(enabled: Boolean) = context.dataStore.edit { it[Keys.ARTIST_IMAGES] = enabled }
|
||||
suspend fun setArtistImageTemplate(template: String) = context.dataStore.edit { it[Keys.ARTIST_IMAGE_TEMPLATE] = template }
|
||||
suspend fun setLibraryScanned(scanned: Boolean) = context.dataStore.edit { it[Keys.LIBRARY_SCANNED] = scanned }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.heretek.xunehd.data.scan
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import com.heretek.xunehd.data.db.TrackEntity
|
||||
import com.heretek.xunehd.data.db.XuneDatabase
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* MediaStore-backed library scanner. Mirrors the sync model of the Zune
|
||||
* family: the device library is rebuilt from the host's collection
|
||||
* (here: MediaStore instead of the desktop sync engine).
|
||||
*/
|
||||
class MediaLibraryScanner(
|
||||
private val context: Context,
|
||||
private val db: XuneDatabase,
|
||||
) {
|
||||
suspend fun scan(): ScanResult = withContext(Dispatchers.IO) {
|
||||
val found = mutableListOf<TrackEntity>()
|
||||
|
||||
// ARTIST_ID landed in API 29 and GENRE in API 30; projecting unknown
|
||||
// columns throws on older devices, so the projection is built
|
||||
// conditionally and the missing data is derived instead.
|
||||
val sdk = android.os.Build.VERSION.SDK_INT
|
||||
val projection = mutableListOf(
|
||||
MediaStore.Audio.Media._ID,
|
||||
MediaStore.Audio.Media.TITLE,
|
||||
MediaStore.Audio.Media.ARTIST,
|
||||
MediaStore.Audio.Media.ALBUM,
|
||||
MediaStore.Audio.Media.ALBUM_ID,
|
||||
MediaStore.Audio.Media.DURATION,
|
||||
MediaStore.Audio.Media.DATE_ADDED,
|
||||
MediaStore.Audio.Media.TRACK,
|
||||
MediaStore.Audio.Media.YEAR,
|
||||
)
|
||||
if (sdk >= 29) projection += MediaStore.Audio.Media.ARTIST_ID
|
||||
if (sdk >= 30) projection += MediaStore.Audio.Media.GENRE
|
||||
|
||||
context.contentResolver.query(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
projection.toTypedArray(),
|
||||
"${MediaStore.Audio.Media.IS_MUSIC} != 0 AND ${MediaStore.Audio.Media.DURATION} >= 30000",
|
||||
null,
|
||||
null,
|
||||
)?.use { c ->
|
||||
val iId = c.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||
val iTitle = c.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
|
||||
val iArtist = c.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
|
||||
val iArtistId = if (sdk >= 29) c.getColumnIndex(MediaStore.Audio.Media.ARTIST_ID) else -1
|
||||
val iAlbum = c.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
|
||||
val iAlbumId = c.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
|
||||
val iGenre = if (sdk >= 30) c.getColumnIndex(MediaStore.Audio.Media.GENRE) else -1
|
||||
val iDur = c.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||
val iDate = c.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
|
||||
val iTrack = c.getColumnIndexOrThrow(MediaStore.Audio.Media.TRACK)
|
||||
val iYear = c.getColumnIndex(MediaStore.Audio.Media.YEAR)
|
||||
|
||||
while (c.moveToNext()) {
|
||||
val mediaId = c.getLong(iId)
|
||||
val title = c.getString(iTitle)?.trim().takeUnless { it.isNullOrEmpty() } ?: "unknown title"
|
||||
val artist = c.getString(iArtist)?.trim().takeUnless { it.isNullOrEmpty() } ?: "unknown artist"
|
||||
val album = c.getString(iAlbum)?.trim().takeUnless { it.isNullOrEmpty() } ?: "unknown album"
|
||||
val genre = if (iGenre >= 0) c.getString(iGenre)?.trim().takeUnless { it.isNullOrEmpty() } ?: "unknown" else "unknown"
|
||||
val year = if (iYear >= 0) c.getString(iYear)?.takeUnless { it.isNullOrEmpty() } ?: "unknown" else "unknown"
|
||||
val trackNo = c.getInt(iTrack) % 1000
|
||||
|
||||
// Below API 29 group artists by name with a stable derived id.
|
||||
val artistId = when {
|
||||
iArtistId >= 0 -> c.getLong(iArtistId)
|
||||
else -> -kotlin.math.abs(artist.lowercase().hashCode().toLong())
|
||||
}
|
||||
|
||||
found += TrackEntity(
|
||||
mediaId = mediaId,
|
||||
title = title,
|
||||
artist = artist,
|
||||
artistId = artistId,
|
||||
album = album,
|
||||
albumId = c.getLong(iAlbumId),
|
||||
genre = genre,
|
||||
durationMs = c.getLong(iDur),
|
||||
dateAdded = c.getLong(iDate),
|
||||
trackNumber = trackNo,
|
||||
year = year,
|
||||
uri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, mediaId).toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val removed: Int
|
||||
val foundIds = found.map { it.mediaId }.toHashSet()
|
||||
db.openHelper.readableDatabase.query("SELECT mediaId FROM tracks").use { cursor ->
|
||||
val stale = mutableListOf<Long>()
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
if (id !in foundIds) stale += id
|
||||
}
|
||||
if (stale.isNotEmpty()) db.trackDao().deleteByIds(stale)
|
||||
removed = stale.size
|
||||
}
|
||||
|
||||
db.trackDao().upsertAll(found)
|
||||
ScanResult(found.size, removed)
|
||||
}
|
||||
|
||||
data class ScanResult(val scanned: Int, val removed: Int)
|
||||
}
|
||||
|
||||
fun TrackEntity.toModel(): com.heretek.xunehd.data.model.Track =
|
||||
com.heretek.xunehd.data.model.Track(
|
||||
mediaId = mediaId,
|
||||
title = title,
|
||||
artist = artist,
|
||||
artistId = artistId,
|
||||
album = album,
|
||||
albumId = albumId,
|
||||
genre = genre,
|
||||
durationMs = durationMs,
|
||||
dateAdded = dateAdded,
|
||||
trackNumber = trackNumber,
|
||||
year = year,
|
||||
uri = Uri.parse(uri),
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.heretek.xunehd.design
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
enum class XuneAccent(val id: Int, val label: String, val primary: Color, val bright: Color) {
|
||||
PINK(0, "zune pink", Color(0xFFFA2A55), Color(0xFFFF4D79)),
|
||||
ORANGE(1, "zune orange", Color(0xFFF09609), Color(0xFFFFA726)),
|
||||
CYAN(2, "electric cyan", Color(0xFF1BA1E2), Color(0xFF33B5E5)),
|
||||
LIME(3, "vivid lime", Color(0xFF339933), Color(0xFF4CAF50)),
|
||||
PURPLE(4, "deep purple", Color(0xFFA200FF), Color(0xFFB388FF)),
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class XuneColors(
|
||||
val background: Color = Color(0xFF111111),
|
||||
val elevated: Color = Color(0xFF181818),
|
||||
val tile: Color = Color(0xFF202020),
|
||||
val tilePressed: Color = Color(0xFF2A2A2A),
|
||||
val border: Color = Color(0xFF2C2C2C),
|
||||
val accent: Color,
|
||||
val accentBright: Color,
|
||||
val textPrimary: Color = Color(0xFFFFFFFF),
|
||||
val textSecondary: Color = Color(0xFFFFFFFF).copy(alpha = 0.60f),
|
||||
val textInactive: Color = Color(0xFFFFFFFF).copy(alpha = 0.40f),
|
||||
val textWatermark: Color = Color(0xFFFFFFFF).copy(alpha = 0.08f),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.heretek.xunehd.design
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
|
||||
/**
|
||||
* Motion spec: movement starts fast and decelerates gently — the signature
|
||||
* Zune "kinetic" curve. All navigation slides use these durations.
|
||||
*/
|
||||
object XuneMotion {
|
||||
val Decelerate: Easing = CubicBezierEasing(0.1f, 0.9f, 0.2f, 1.0f)
|
||||
|
||||
const val PIVOT_SLIDE_MS = 380
|
||||
const val QUICKPLAY_MS = 420
|
||||
const val DEPTH_MS = 340
|
||||
const val STAGGER_MS = 20L
|
||||
|
||||
fun <T> pivot() = tween<T>(PIVOT_SLIDE_MS, easing = Decelerate)
|
||||
fun <T> quickplay() = tween<T>(QUICKPLAY_MS, easing = Decelerate)
|
||||
fun <T> depth() = tween<T>(DEPTH_MS, easing = Decelerate)
|
||||
fun <T> instant() = tween<T>(1, easing = LinearEasing)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.heretek.xunehd.design
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.heretek.xunehd.R
|
||||
|
||||
/**
|
||||
* Selawik — the OFL Segoe-metric font family that stands in for Zegoe UI.
|
||||
* Users may import Zegoe in a later release; Selawik ships by default.
|
||||
*/
|
||||
val Selawik = FontFamily(
|
||||
Font(R.font.selawkl, FontWeight.Light),
|
||||
Font(R.font.selawksl, FontWeight(350)),
|
||||
Font(R.font.selawk, FontWeight.Normal),
|
||||
Font(R.font.selawksb, FontWeight.SemiBold),
|
||||
Font(R.font.selawkb, FontWeight.Bold),
|
||||
)
|
||||
|
||||
val LocalXuneColors = staticCompositionLocalOf<XuneColors> {
|
||||
error("XuneColors not provided")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun XuneTheme(
|
||||
accent: XuneAccent = XuneAccent.PINK,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = XuneColors(accent = accent.primary, accentBright = accent.bright)
|
||||
CompositionLocalProvider(LocalXuneColors provides colors) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.heretek.xunehd.design
|
||||
|
||||
/**
|
||||
* Design tokens from docs/design-tokens.md. Screens must consume these;
|
||||
* raw literals in screen code violate the design-invariant audit.
|
||||
*/
|
||||
object XuneTokens {
|
||||
// Canvas
|
||||
const val CANVAS_WIDTH = 480
|
||||
const val CANVAS_HEIGHT = 272
|
||||
|
||||
// Spacing
|
||||
const val EDGE = 16
|
||||
const val CROSSBAR_LEAD = 24
|
||||
const val CROSSBAR_HEIGHT = 34
|
||||
const val ROW_HEIGHT = 40
|
||||
const val ALBUM_TILE = 92
|
||||
const val GRID_GUTTER = 8
|
||||
const val MINI_PLAYER_HEIGHT = 32
|
||||
|
||||
// Type (design-unit dp)
|
||||
const val TYPE_MENU_ITEM = 34
|
||||
const val TYPE_HEADER_CROPPED = 40
|
||||
const val TYPE_HEADER_CROP_VISIBLE = 22
|
||||
const val TYPE_CROSSBAR = 18
|
||||
const val TYPE_NOW_TITLE = 26
|
||||
const val TYPE_NOW_META = 15
|
||||
const val TYPE_LIST = 14
|
||||
const val TYPE_LIST_SECONDARY = 11
|
||||
const val TYPE_CAPTION = 9
|
||||
const val TYPE_ALPHABET = 10
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.heretek.xunehd.design.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.XuneMotion
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Square album art tile. No corner radius — a hard square with a hairline
|
||||
* border on the placeholder, exactly like the Zune collection grid.
|
||||
*/
|
||||
@Composable
|
||||
fun AlbumArt(
|
||||
model: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalXuneColors.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(colors.tile)
|
||||
.border(0.5.dp, colors.border)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Staggered Metro cascade entrance: each item fades/slides in slightly after
|
||||
* the previous one (15-25 ms stagger per the design tokens).
|
||||
*/
|
||||
@Composable
|
||||
fun StaggerEntrance(
|
||||
index: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var appeared by remember { mutableStateOf(false) }
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = if (appeared) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 220, easing = XuneMotion.Decelerate),
|
||||
label = "stagger",
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
delay((index.coerceAtMost(30) * XuneMotion.STAGGER_MS).coerceAtMost(300))
|
||||
appeared = true
|
||||
}
|
||||
Box(
|
||||
modifier = modifier.graphicsLayer {
|
||||
alpha = progress
|
||||
translationX = (1f - progress) * 24f
|
||||
},
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.heretek.xunehd.design.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.Selawik
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
|
||||
/**
|
||||
* The Zune crossbar: lowercase pivot labels across the top of the screen.
|
||||
* Active pivot is white; the rest dim to 40%. Tapping selects; horizontal
|
||||
* content drags page between pivots (CrossbarScreen wires a pager to [selected]).
|
||||
*/
|
||||
@Composable
|
||||
fun CrossbarBar(
|
||||
labels: List<String>,
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colors = LocalXuneColors.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(XuneTokens.CROSSBAR_HEIGHT.dp)
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(start = XuneTokens.CROSSBAR_LEAD.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
labels.forEachIndexed { index, label ->
|
||||
val target = if (index == selected) colors.textPrimary else colors.textInactive
|
||||
val color by animateColorAsState(target, label = "crossbar")
|
||||
BasicText(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = XuneTokens.TYPE_CROSSBAR.sp,
|
||||
color = color,
|
||||
letterSpacing = (-0.3).sp,
|
||||
),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { onSelect(index) })
|
||||
.padding(end = 22.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(0.5.dp)
|
||||
.background(colors.border),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.heretek.xunehd.design.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* The 480x272 Zune HD canvas.
|
||||
*
|
||||
* In device mode the entire UI is laid out at the authentic design size and
|
||||
* scaled to fit (letterboxed on matte black) — hold the phone in landscape
|
||||
* for the full-device feel. In adaptive mode the same components reflow to
|
||||
* the natural screen size.
|
||||
*/
|
||||
@Composable
|
||||
fun DeviceCanvas(
|
||||
deviceMode: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (canvasWidth: Dp, canvasHeight: Dp) -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(LocalXuneColors.current.background),
|
||||
) {
|
||||
if (!deviceMode) {
|
||||
content(maxWidth, maxHeight)
|
||||
} else {
|
||||
val scale = min(
|
||||
maxWidth.value / XuneTokens.CANVAS_WIDTH,
|
||||
maxHeight.value / XuneTokens.CANVAS_HEIGHT,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(XuneTokens.CANVAS_WIDTH.dp, XuneTokens.CANVAS_HEIGHT.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.clipToBounds(),
|
||||
) {
|
||||
content(XuneTokens.CANVAS_WIDTH.dp, XuneTokens.CANVAS_HEIGHT.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.heretek.xunehd.design.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.Selawik
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
|
||||
/**
|
||||
* Text that deliberately overflows and is cropped at the right edge — the
|
||||
* signature Zune typography gesture ("marketplace" cut off mid-letter).
|
||||
*/
|
||||
@Composable
|
||||
fun EdgeCropText(
|
||||
text: String,
|
||||
fontSize: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = LocalXuneColors.current.textPrimary,
|
||||
fontWeight: FontWeight = FontWeight.Light,
|
||||
alpha: Float = 1f,
|
||||
) {
|
||||
Box(modifier = modifier.clipToBounds()) {
|
||||
BasicText(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize.value.sp,
|
||||
color = color.copy(alpha = alpha),
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Clip,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The oversized cropped screen header. Only the bottom sliver of the text is
|
||||
* visible (the bottom of "SETT" on Settings) — tapping it navigates back.
|
||||
* This is the Zune HD's substitute for a hardware back button.
|
||||
*/
|
||||
@Composable
|
||||
fun CroppedHeader(
|
||||
text: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
visibleHeight: Dp = XuneTokens.TYPE_HEADER_CROP_VISIBLE.dp,
|
||||
fontSize: Dp = XuneTokens.TYPE_HEADER_CROPPED.dp,
|
||||
) {
|
||||
val colors = LocalXuneColors.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(visibleHeight)
|
||||
.clipToBounds()
|
||||
.background(Color.Transparent)
|
||||
.clickable(onClick = onBack),
|
||||
) {
|
||||
BasicText(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = fontSize.value.sp,
|
||||
color = colors.textPrimary.copy(alpha = 0.85f),
|
||||
letterSpacing = (-1).sp,
|
||||
),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Clip,
|
||||
modifier = Modifier.offset(y = (visibleHeight - fontSize).coerceAtLeast(0.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Home-menu item: giant lowercase text, cropped at the right edge.
|
||||
*/
|
||||
@Composable
|
||||
fun HomeMenuItem(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
alpha: Float = 1f,
|
||||
) {
|
||||
EdgeCropText(
|
||||
text = label,
|
||||
fontSize = XuneTokens.TYPE_MENU_ITEM.dp,
|
||||
alpha = alpha,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = XuneTokens.EDGE.dp, top = 10.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.heretek.xunehd.design.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.Selawik
|
||||
import com.heretek.xunehd.design.XuneMotion
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Kinetic list with the Zune alphabet rail: faint letters alongside the list;
|
||||
* dragging or tapping the rail jumps the list and shows the big letter
|
||||
* overlay — the Zune HD "tap any of the letters ... and that pops up the
|
||||
* full alphabet" behavior, adapted to a direct-manipulation rail.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> KineticList(
|
||||
items: List<T>,
|
||||
key: (T) -> Any,
|
||||
letter: (T) -> Char?,
|
||||
rowContent: @Composable (item: T, index: Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
bottomPadding: Dp = 0.dp,
|
||||
) {
|
||||
val colors = LocalXuneColors.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val letters = remember(items) {
|
||||
items.mapNotNull { letter(it) }.distinct().sorted()
|
||||
}
|
||||
|
||||
var draggingLetter by remember { mutableStateOf<Char?>(null) }
|
||||
val overlayAlpha = remember { Animatable(0f) }
|
||||
|
||||
Box(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = bottomPadding),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items,
|
||||
key = { _, item -> key(item) },
|
||||
) { index, item ->
|
||||
rowContent(item, index)
|
||||
}
|
||||
}
|
||||
|
||||
if (letters.isNotEmpty()) {
|
||||
AlphabetRail(
|
||||
letters = letters,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(18.dp),
|
||||
onLetterFocus = { letterChar ->
|
||||
draggingLetter = letterChar
|
||||
scope.launch { overlayAlpha.snapTo(1f) }
|
||||
val index = items.indexOfFirst { letter(it) == letterChar }
|
||||
if (index >= 0) scope.launch { listState.scrollToItem(index) }
|
||||
},
|
||||
onDragEnd = {
|
||||
scope.launch {
|
||||
delay(350)
|
||||
overlayAlpha.animateTo(0f, XuneMotion.pivot())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = overlayAlpha.value > 0.01f,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
) {
|
||||
BasicText(
|
||||
text = draggingLetter?.toString() ?: "",
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 120.sp,
|
||||
color = colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier.graphicsLayer { alpha = overlayAlpha.value },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlphabetRail(
|
||||
letters: List<Char>,
|
||||
modifier: Modifier = Modifier,
|
||||
onLetterFocus: (Char) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
) {
|
||||
val colors = LocalXuneColors.current
|
||||
Column(modifier = modifier) {
|
||||
letters.forEach { letterChar ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = true)
|
||||
.clickable { onLetterFocus(letterChar) }
|
||||
.draggable(
|
||||
state = rememberDraggableState { },
|
||||
orientation = Orientation.Vertical,
|
||||
onDragStarted = { onLetterFocus(letterChar) },
|
||||
onDragStopped = { onDragEnd() },
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BasicText(
|
||||
text = letterChar.toString(),
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontSize = XuneTokens.TYPE_ALPHABET.sp,
|
||||
color = colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Uppercase first letter used by alphabet grouping. */
|
||||
fun firstLetterOf(text: String): Char =
|
||||
text.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() ?: '#'
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.heretek.xunehd.media
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.heretek.xunehd.data.model.RepeatMode
|
||||
import com.heretek.xunehd.data.model.Rating
|
||||
import com.heretek.xunehd.data.model.Track
|
||||
import com.heretek.xunehd.data.repo.QuickplayRepository
|
||||
import com.heretek.xunehd.data.repo.LibraryRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.guava.await
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* App-side playback coordinator. Bridges the Compose UI to the Media3 session
|
||||
* service and owns queue ordering (Zune-style shuffle), repeat, ratings and
|
||||
* Quickplay history.
|
||||
*/
|
||||
class PlaybackController(
|
||||
private val context: Context,
|
||||
private val library: LibraryRepository,
|
||||
private val quickplay: QuickplayRepository,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var controller: MediaController? = null
|
||||
|
||||
private val _nowPlaying = MutableStateFlow<Track?>(null)
|
||||
val nowPlaying: StateFlow<Track?> = _nowPlaying.asStateFlow()
|
||||
|
||||
private val _queue = MutableStateFlow<List<Track>>(emptyList())
|
||||
val queue: StateFlow<List<Track>> = _queue.asStateFlow()
|
||||
|
||||
private val _currentIndex = MutableStateFlow(-1)
|
||||
val currentIndex: StateFlow<Int> = _currentIndex.asStateFlow()
|
||||
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
|
||||
|
||||
private val _positionMs = MutableStateFlow(0L)
|
||||
val positionMs: StateFlow<Long> = _positionMs.asStateFlow()
|
||||
|
||||
private val _durationMs = MutableStateFlow(0L)
|
||||
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
|
||||
|
||||
private val _shuffle = MutableStateFlow(false)
|
||||
val shuffle: StateFlow<Boolean> = _shuffle.asStateFlow()
|
||||
|
||||
private val _repeat = MutableStateFlow(RepeatMode.OFF)
|
||||
val repeat: StateFlow<RepeatMode> = _repeat.asStateFlow()
|
||||
|
||||
private val _currentRating = MutableStateFlow(Rating.NONE)
|
||||
val currentRating: StateFlow<Rating> = _currentRating.asStateFlow()
|
||||
|
||||
private var baseOrder: List<Track> = emptyList()
|
||||
|
||||
suspend fun connect() {
|
||||
if (controller != null) return
|
||||
val token = SessionToken(context, ComponentName(context, XunePlaybackService::class.java))
|
||||
controller = MediaController.Builder(context, token).buildAsync().await()
|
||||
controller?.addListener(listener)
|
||||
positionTicker()
|
||||
}
|
||||
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_isPlaying.value = isPlaying
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
_durationMs.value = controller?.duration ?: 0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
val index = controller?.currentMediaItemIndex ?: return
|
||||
_currentIndex.value = index
|
||||
val track = _queue.value.getOrNull(index)
|
||||
_nowPlaying.value = track
|
||||
_durationMs.value = controller?.duration ?: 0L
|
||||
if (track != null) {
|
||||
scope.launch {
|
||||
_currentRating.value = quickplay.ratingOf(track.mediaId).first()
|
||||
quickplay.recordHistory(
|
||||
kind = com.heretek.xunehd.data.model.PinKind.TRACK,
|
||||
refId = track.mediaId,
|
||||
label = track.title,
|
||||
subLabel = track.artist,
|
||||
artAlbumId = track.albumId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun positionTicker() {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
val player = controller
|
||||
if (player != null && _isPlaying.value) {
|
||||
_positionMs.value = player.currentPosition.coerceAtLeast(0)
|
||||
}
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(tracks: List<Track>, startIndex: Int = 0) {
|
||||
if (tracks.isEmpty()) return
|
||||
baseOrder = tracks
|
||||
_queue.value = tracks
|
||||
val items = tracks.map { it.toMediaItem() }
|
||||
val player = controller ?: return
|
||||
player.setMediaItems(items, startIndex, 0L)
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
|
||||
fun enqueue(track: Track) {
|
||||
val player = controller ?: return
|
||||
player.addMediaItem(track.toMediaItem())
|
||||
_queue.value = _queue.value + track
|
||||
if (baseOrder.isEmpty()) baseOrder = _queue.value
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
val player = controller ?: return
|
||||
if (player.isPlaying) player.pause() else player.play()
|
||||
}
|
||||
|
||||
fun next() {
|
||||
controller?.seekToNextMediaItem()
|
||||
}
|
||||
|
||||
fun previous() {
|
||||
controller?.seekToPreviousMediaItem()
|
||||
}
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
controller?.seekTo(positionMs)
|
||||
_positionMs.value = positionMs
|
||||
}
|
||||
|
||||
fun seekToIndex(index: Int) {
|
||||
controller?.seekTo(index, 0L)
|
||||
}
|
||||
|
||||
fun setShuffle(enabled: Boolean) {
|
||||
_shuffle.value = enabled
|
||||
val player = controller ?: return
|
||||
val current = _nowPlaying.value
|
||||
val queueNow = _queue.value
|
||||
if (queueNow.isEmpty()) return
|
||||
|
||||
val newOrder = if (enabled) {
|
||||
val rest = baseOrder.filter { it.mediaId != current?.mediaId }.shuffled()
|
||||
listOfNotNull(current) + rest
|
||||
} else {
|
||||
val rest = baseOrder.filter { it.mediaId != current?.mediaId }
|
||||
if (current != null && baseOrder.contains(current)) {
|
||||
baseOrder
|
||||
} else {
|
||||
listOfNotNull(current) + rest
|
||||
}
|
||||
}
|
||||
|
||||
_queue.value = newOrder
|
||||
player.setMediaItems(newOrder.map { it.toMediaItem() }, 0, 0L)
|
||||
player.prepare()
|
||||
if (_isPlaying.value) player.play()
|
||||
}
|
||||
|
||||
fun cycleRepeat() {
|
||||
_repeat.value = when (_repeat.value) {
|
||||
RepeatMode.OFF -> RepeatMode.ALL
|
||||
RepeatMode.ALL -> RepeatMode.ONE
|
||||
RepeatMode.ONE -> RepeatMode.OFF
|
||||
}
|
||||
controller?.repeatMode = when (_repeat.value) {
|
||||
RepeatMode.OFF -> Player.REPEAT_MODE_OFF
|
||||
RepeatMode.ALL -> Player.REPEAT_MODE_ALL
|
||||
RepeatMode.ONE -> Player.REPEAT_MODE_ONE
|
||||
}
|
||||
}
|
||||
|
||||
fun setRating(rating: Rating) {
|
||||
val track = _nowPlaying.value ?: return
|
||||
_currentRating.value = rating
|
||||
scope.launch { quickplay.setRating(track.mediaId, rating) }
|
||||
}
|
||||
|
||||
suspend fun currentTrackListSnapshot(): List<Track> = _queue.value
|
||||
|
||||
fun release() {
|
||||
controller?.release()
|
||||
controller = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun audioManager(context: Context): AudioManager =
|
||||
context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.heretek.xunehd.media
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.heretek.xunehd.MainActivity
|
||||
import com.heretek.xunehd.XuneApp
|
||||
import com.heretek.xunehd.data.model.Track
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.guava.future
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Foreground Media3 session service. Holds the ExoPlayer instance and the
|
||||
* MediaSession that powers the system notification / lock screen controls.
|
||||
*/
|
||||
class XunePlaybackService : MediaSessionService() {
|
||||
|
||||
private var session: MediaSession? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val player = ExoPlayer.Builder(this)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
val sessionActivity = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
session = MediaSession.Builder(this, player)
|
||||
.setSessionActivity(sessionActivity)
|
||||
.setCallback(object : MediaSession.Callback {
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
): com.google.common.util.concurrent.ListenableFuture<MutableList<MediaItem>> =
|
||||
resolveMediaItems(mediaItems)
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* External controllers (lock screen, Wear, Auto) may hand back items with
|
||||
* only a mediaId. Resolve them against the library, mirroring the Zune
|
||||
* sync model where the device database is the source of truth.
|
||||
*/
|
||||
private fun resolveMediaItems(requested: List<MediaItem>): com.google.common.util.concurrent.ListenableFuture<MutableList<MediaItem>> =
|
||||
GlobalScope.future(Dispatchers.IO) {
|
||||
val repo = (applicationContext as XuneApp).graph.library
|
||||
requested.map { item ->
|
||||
if (item.localConfiguration != null) {
|
||||
item
|
||||
} else {
|
||||
val id = item.mediaId.toLongOrNull()
|
||||
val track = id?.let { repo.track(it) }
|
||||
if (track != null) track.toMediaItem()
|
||||
else item.buildUpon().setUri(android.net.Uri.EMPTY).build()
|
||||
}
|
||||
}.toMutableList()
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = session?.player
|
||||
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
session?.run {
|
||||
player.release()
|
||||
release()
|
||||
session = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun Track.toMediaItem(): MediaItem = MediaItem.Builder()
|
||||
.setMediaId(mediaId.toString())
|
||||
.setUri(uri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setArtist(artist)
|
||||
.setAlbumTitle(album)
|
||||
.setArtworkUri(albumArtUri)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.heretek.xunehd.net
|
||||
|
||||
import android.content.Context
|
||||
import com.heretek.xunehd.data.db.ArtistImageEntity
|
||||
import com.heretek.xunehd.data.db.XuneDatabase
|
||||
import com.heretek.xunehd.data.repo.SettingsRepository
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Artist background photography, replicating the Zune HD's signature Now
|
||||
* Playing wallpaper. The original device fetched
|
||||
* `catalog.zune.net/v3.0/{locale}/music/artist/{mbid}/deviceBackgroundImage`
|
||||
* over USB-PPP; community recreations (ZuneArtistImages, zuneupdate.com)
|
||||
* keep that catalog alive. We keep the same concept user-configurable:
|
||||
* a URL template with `{mbid}` is resolved after a MusicBrainz lookup.
|
||||
*/
|
||||
class ArtistImageService(
|
||||
private val context: Context,
|
||||
private val db: XuneDatabase,
|
||||
private val settings: SettingsRepository,
|
||||
) {
|
||||
private val mbidCache = mutableMapOf<String, String>()
|
||||
|
||||
suspend fun backgroundFor(artist: String): File? = withContext(Dispatchers.IO) {
|
||||
val config = currentTemplate() ?: return@withContext null
|
||||
val key = artist.trim().lowercase()
|
||||
val dir = File(context.filesDir, "artist_images").apply { mkdirs() }
|
||||
val cached = db.artistImageDao().get(key)
|
||||
if (cached != null) {
|
||||
val file = File(cached.imagePath)
|
||||
if (file.exists() && System.currentTimeMillis() - cached.fetchedAt < SEVEN_DAYS) {
|
||||
return@withContext file
|
||||
}
|
||||
}
|
||||
|
||||
return@withContext try {
|
||||
val mbid = lookupMbid(artist) ?: return@withContext null
|
||||
val url = config.replace("{mbid}", mbid)
|
||||
val file = File(dir, mbid + ".img")
|
||||
download(url, file) ?: return@withContext null
|
||||
db.artistImageDao().put(
|
||||
ArtistImageEntity(artistKey = key, imagePath = file.absolutePath, fetchedAt = System.currentTimeMillis()),
|
||||
)
|
||||
file
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun currentTemplate(): String? {
|
||||
val s = settingsSnapshot ?: return null
|
||||
if (!s.artistImagesEnabled) return null
|
||||
return s.artistImageTemplate.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var settingsSnapshot: com.heretek.xunehd.data.repo.XuneSettings? = null
|
||||
|
||||
private suspend fun lookupMbid(artist: String): String? {
|
||||
mbidCache[artist.lowercase()]?.let { return it }
|
||||
val url = "https://musicbrainz.org/ws/2/artist/?query=artist:%22" +
|
||||
java.net.URLEncoder.encode(artist, "UTF-8") + "%22&fmt=json&limit=1"
|
||||
val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
|
||||
try {
|
||||
connection.connectTimeout = 8000
|
||||
connection.readTimeout = 8000
|
||||
connection.setRequestProperty("User-Agent", USER_AGENT)
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
val body = connection.inputStream.bufferedReader().use { it.readText() }
|
||||
val id = Regex("\"id\"\\s*:\\s*\"([0-9a-f-]{36})\"").find(body)?.groupValues?.get(1)
|
||||
if (id != null) mbidCache[artist.lowercase()] = id
|
||||
return id
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun download(url: String, target: File): File? {
|
||||
val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
|
||||
try {
|
||||
connection.connectTimeout = 10000
|
||||
connection.readTimeout = 15000
|
||||
connection.setRequestProperty("User-Agent", USER_AGENT)
|
||||
if (connection.responseCode !in 200..299) return null
|
||||
connection.inputStream.use { input ->
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
return if (target.length() > 1024) target else null
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SEVEN_DAYS = 7L * 24 * 60 * 60 * 1000
|
||||
private const val USER_AGENT = "XuneHD/0.1 (android; sister-app to not-zune)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.heretek.xunehd.ui
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.heretek.xunehd.XuneGraph
|
||||
|
||||
val LocalXuneGraph = staticCompositionLocalOf<XuneGraph> {
|
||||
error("XuneGraph not provided")
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.heretek.xunehd.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.heretek.xunehd.R
|
||||
import com.heretek.xunehd.data.repo.XuneSettings
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.XuneMotion
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
import com.heretek.xunehd.design.components.AlbumArt
|
||||
import com.heretek.xunehd.design.components.DeviceCanvas
|
||||
import com.heretek.xunehd.design.components.EdgeCropText
|
||||
import com.heretek.xunehd.ui.components.MenuController
|
||||
import com.heretek.xunehd.ui.nav.XuneDestination
|
||||
import com.heretek.xunehd.ui.screens.AlbumDetailScreen
|
||||
import com.heretek.xunehd.ui.screens.ArtistDetailScreen
|
||||
import com.heretek.xunehd.ui.screens.GenreScreen
|
||||
import com.heretek.xunehd.ui.screens.HomePages
|
||||
import com.heretek.xunehd.ui.screens.MusicScreen
|
||||
import com.heretek.xunehd.ui.screens.NowPlayingScreen
|
||||
import com.heretek.xunehd.ui.screens.PlaylistDetailScreen
|
||||
import com.heretek.xunehd.ui.screens.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun XuneRoot() {
|
||||
val graph = LocalXuneGraph.current
|
||||
val settings by graph.settingsFlow.collectAsState(initial = XuneSettings())
|
||||
val menus = remember { MenuController() }
|
||||
val activity = androidx.activity.compose.LocalActivity.current
|
||||
|
||||
com.heretek.xunehd.design.XuneTheme(accent = settings.accent) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
com.heretek.xunehd.ui.components.LocalContextMenu provides menus,
|
||||
) {
|
||||
DeviceCanvas(deviceMode = settings.deviceMode) { canvasWidth, canvasHeight ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
NavHost(canvasWidth, canvasHeight)
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
MiniPlayer(canvasWidth)
|
||||
}
|
||||
com.heretek.xunehd.ui.components.ContextMenuOverlay(menus)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
val nav = graph.nav
|
||||
if (nav.current == XuneDestination.Home) {
|
||||
// Let the system move the task to background.
|
||||
activity?.moveTaskToBack(false)
|
||||
} else {
|
||||
nav.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavHost(canvasWidth: Dp, canvasHeight: Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
val nav = graph.nav
|
||||
val target = nav.current to nav.depth
|
||||
|
||||
AnimatedContent(
|
||||
targetState = target,
|
||||
transitionSpec = {
|
||||
val pushing = targetState.second > initialState.second
|
||||
if (pushing) {
|
||||
(slideInHorizontally(XuneMotion.pivot()) { it / 2 } + fadeIn(XuneMotion.pivot())) togetherWith
|
||||
(slideOutHorizontally(XuneMotion.pivot()) { -it / 8 } + fadeOut(XuneMotion.pivot()))
|
||||
} else {
|
||||
(slideInHorizontally(XuneMotion.pivot()) { -it / 8 } + fadeIn(XuneMotion.pivot())) togetherWith
|
||||
(slideOutHorizontally(XuneMotion.pivot()) { it / 2 } + fadeOut(XuneMotion.pivot()))
|
||||
}
|
||||
},
|
||||
label = "nav",
|
||||
) { (destination, _) ->
|
||||
when (destination) {
|
||||
XuneDestination.Home, XuneDestination.Quickplay -> HomePages(canvasWidth)
|
||||
XuneDestination.Music -> MusicScreen(canvasWidth)
|
||||
is XuneDestination.Album -> AlbumDetailScreen(destination.albumId, canvasWidth)
|
||||
is XuneDestination.Artist -> ArtistDetailScreen(destination.artistId, canvasWidth)
|
||||
is XuneDestination.Genre -> GenreScreen(destination.genre, canvasWidth)
|
||||
is XuneDestination.PlaylistDetail -> PlaylistDetailScreen(destination.playlistId, canvasWidth)
|
||||
XuneDestination.NowPlaying -> NowPlayingScreen(canvasWidth)
|
||||
XuneDestination.Settings -> SettingsScreen(canvasWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniPlayer(canvasWidth: Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
val nav = graph.nav
|
||||
val controller = graph.controller
|
||||
val nowPlaying by controller.nowPlaying.collectAsState()
|
||||
val isPlaying by controller.isPlaying.collectAsState()
|
||||
val colors = LocalXuneColors.current
|
||||
|
||||
if (nav.current == XuneDestination.NowPlaying || nowPlaying == null) return
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(XuneTokens.MINI_PLAYER_HEIGHT.dp)
|
||||
.background(colors.elevated.copy(alpha = 0.95f)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(0.5.dp)
|
||||
.background(colors.border),
|
||||
)
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { nav.push(XuneDestination.NowPlaying) }
|
||||
.padding(horizontal = XuneTokens.EDGE.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AlbumArt(
|
||||
model = nowPlaying?.albumArtUri,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
EdgeCropText(
|
||||
text = "${nowPlaying?.title ?: ""} — ${nowPlaying?.artist ?: ""}",
|
||||
fontSize = XuneTokens.TYPE_LIST_SECONDARY.dp,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 8.dp, end = 8.dp),
|
||||
color = colors.textSecondary,
|
||||
)
|
||||
IconButton(
|
||||
onClick = { controller.toggle() },
|
||||
modifier = Modifier.width(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play),
|
||||
contentDescription = if (isPlaying) "pause" else "play",
|
||||
tint = colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package com.heretek.xunehd.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.Selawik
|
||||
import com.heretek.xunehd.design.XuneMotion
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
import com.heretek.xunehd.design.components.AlbumArt
|
||||
import com.heretek.xunehd.design.components.EdgeCropText
|
||||
import com.heretek.xunehd.data.model.Track
|
||||
import com.heretek.xunehd.ui.LocalXuneGraph
|
||||
|
||||
/** One action in a context menu. */
|
||||
data class MenuAction(val label: String, val action: () -> Unit)
|
||||
|
||||
class MenuRequest(
|
||||
val title: String,
|
||||
val actions: List<MenuAction>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Zune-style long-press context menu: a dimmed scrim plus a bottom panel of
|
||||
* text actions sliding up. No Material chrome.
|
||||
*/
|
||||
class MenuController {
|
||||
var menu by mutableStateOf<MenuRequest?>(null)
|
||||
var prompt by mutableStateOf<PromptRequest?>(null)
|
||||
|
||||
fun show(title: String, actions: List<MenuAction>) {
|
||||
menu = MenuRequest(title, actions)
|
||||
}
|
||||
|
||||
fun showPrompt(title: String, placeholder: String, onConfirm: (String) -> Unit) {
|
||||
prompt = PromptRequest(title, placeholder, onConfirm)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
menu = null
|
||||
prompt = null
|
||||
}
|
||||
|
||||
class PromptRequest(val title: String, val placeholder: String, val onConfirm: (String) -> Unit)
|
||||
}
|
||||
|
||||
val LocalContextMenu = androidx.compose.runtime.staticCompositionLocalOf<MenuController> {
|
||||
error("MenuController not provided")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContextMenuOverlay(controller: MenuController) {
|
||||
val colors = LocalXuneColors.current
|
||||
val request = controller.menu
|
||||
AnimatedVisibility(
|
||||
visible = request != null,
|
||||
enter = fadeIn(XuneMotion.pivot()),
|
||||
exit = fadeOut(XuneMotion.pivot()),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.55f))
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { controller.dismiss() }
|
||||
},
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = request != null,
|
||||
enter = slideInVertically(XuneMotion.pivot()) { it } + fadeIn(XuneMotion.pivot()),
|
||||
exit = slideOutVertically(XuneMotion.pivot()) { it } + fadeOut(XuneMotion.pivot()),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.elevated)
|
||||
.border(0.5.dp, colors.border),
|
||||
) {
|
||||
EdgeCropText(
|
||||
text = request?.title ?: "",
|
||||
fontSize = XuneTokens.TYPE_NOW_META.dp,
|
||||
modifier = Modifier.padding(horizontal = XuneTokens.EDGE.dp, vertical = 10.dp),
|
||||
color = colors.textSecondary,
|
||||
)
|
||||
request?.actions?.forEach { action ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
controller.dismiss()
|
||||
action.action()
|
||||
}
|
||||
.padding(horizontal = XuneTokens.EDGE.dp, vertical = 12.dp),
|
||||
) {
|
||||
EdgeCropText(text = action.label, fontSize = XuneTokens.TYPE_LIST.dp)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(XuneTokens.EDGE.dp))
|
||||
}
|
||||
}
|
||||
|
||||
val prompt = controller.prompt
|
||||
AnimatedVisibility(
|
||||
visible = prompt != null,
|
||||
enter = slideInVertically(XuneMotion.pivot()) { it } + fadeIn(XuneMotion.pivot()),
|
||||
exit = slideOutVertically(XuneMotion.pivot()) { it } + fadeOut(XuneMotion.pivot()),
|
||||
) {
|
||||
PromptPanel(controller)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PromptPanel(controller: MenuController) {
|
||||
val colors = LocalXuneColors.current
|
||||
var text by remember { mutableStateOf("") }
|
||||
val request = controller.prompt ?: return
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
.background(colors.elevated)
|
||||
.border(0.5.dp, colors.border)
|
||||
.padding(XuneTokens.EDGE.dp),
|
||||
) {
|
||||
EdgeCropText(
|
||||
text = request.title,
|
||||
fontSize = XuneTokens.TYPE_NOW_META.dp,
|
||||
color = colors.textSecondary,
|
||||
)
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontSize = XuneTokens.TYPE_NOW_META.sp,
|
||||
color = colors.textPrimary,
|
||||
),
|
||||
cursorBrush = SolidColor(colors.accent),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (text.isNotBlank()) {
|
||||
val value = text
|
||||
controller.dismiss()
|
||||
request.onConfirm(value)
|
||||
}
|
||||
},
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
.border(0.5.dp, colors.border)
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard detail screen scaffold: the cropped header (back button) plus
|
||||
* content beneath.
|
||||
*/
|
||||
@Composable
|
||||
fun DetailScaffold(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val graph = LocalXuneGraph.current
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
com.heretek.xunehd.design.components.CroppedHeader(
|
||||
text = title,
|
||||
onBack = { graph.nav.pop() },
|
||||
)
|
||||
Box(Modifier.weight(1f)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A track list row: title over artist — album. The currently playing track
|
||||
* renders in the accent color.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TrackRow(
|
||||
track: Track,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
playing: Boolean = false,
|
||||
showSubLabel: Boolean = true,
|
||||
) {
|
||||
val colors = LocalXuneColors.current
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(XuneTokens.ROW_HEIGHT.dp)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(horizontal = XuneTokens.EDGE.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
EdgeCropText(
|
||||
text = track.title,
|
||||
fontSize = XuneTokens.TYPE_LIST.dp,
|
||||
color = if (playing) colors.accent else colors.textPrimary,
|
||||
fontWeight = if (playing) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
if (showSubLabel) {
|
||||
EdgeCropText(
|
||||
text = "${track.artist} — ${track.album}",
|
||||
fontSize = XuneTokens.TYPE_LIST_SECONDARY.dp,
|
||||
color = colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Small section label used across quickplay and details. */
|
||||
@Composable
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
val colors = LocalXuneColors.current
|
||||
EdgeCropText(
|
||||
text = text,
|
||||
fontSize = XuneTokens.TYPE_CROSSBAR.dp,
|
||||
alpha = 0.6f,
|
||||
modifier = modifier.padding(horizontal = XuneTokens.EDGE.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtTile(
|
||||
model: Any?,
|
||||
size: androidx.compose.ui.unit.Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AlbumArt(
|
||||
model = model,
|
||||
contentDescription = null,
|
||||
modifier = modifier.size(size),
|
||||
)
|
||||
}
|
||||
|
||||
/** Truncation helper shared by row labels. */
|
||||
fun String.ellipsize(max: Int = 60): String =
|
||||
if (length <= max) this else take(max - 1) + "…"
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.heretek.xunehd.ui.nav
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Lightweight hierarchical navigation. The Zune HD has no hardware back:
|
||||
* going back is "tap the cut-off header", which calls [pop]. Slides are
|
||||
* horizontal with the Zune deceleration curve.
|
||||
*/
|
||||
sealed interface XuneDestination {
|
||||
data object Home : XuneDestination
|
||||
data object Quickplay : XuneDestination
|
||||
data object Music : XuneDestination
|
||||
data class Album(val albumId: Long) : XuneDestination
|
||||
data class Artist(val artistId: Long) : XuneDestination
|
||||
data class Genre(val genre: String) : XuneDestination
|
||||
data class PlaylistDetail(val playlistId: Long) : XuneDestination
|
||||
data object NowPlaying : XuneDestination
|
||||
data object Settings : XuneDestination
|
||||
}
|
||||
|
||||
class XuneNav {
|
||||
private val stack = mutableStateListOf<XuneDestination>(XuneDestination.Home)
|
||||
|
||||
val current: XuneDestination
|
||||
get() = stack.lastOrNull() ?: XuneDestination.Home
|
||||
|
||||
val depth: Int
|
||||
get() = stack.size
|
||||
|
||||
fun push(destination: XuneDestination) {
|
||||
stack.add(destination)
|
||||
}
|
||||
|
||||
fun pop() {
|
||||
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
|
||||
fun toHome() {
|
||||
stack.clear()
|
||||
stack.add(XuneDestination.Home)
|
||||
}
|
||||
|
||||
fun replaceTop(destination: XuneDestination) {
|
||||
if (stack.isNotEmpty()) stack[stack.lastIndex] = destination else stack.add(destination)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.heretek.xunehd.ui.screens
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
import com.heretek.xunehd.design.components.AlbumArt
|
||||
import com.heretek.xunehd.design.components.CrossbarBar
|
||||
import com.heretek.xunehd.design.components.EdgeCropText
|
||||
import com.heretek.xunehd.design.components.KineticList
|
||||
import com.heretek.xunehd.design.components.firstLetterOf
|
||||
import com.heretek.xunehd.data.model.Album
|
||||
import com.heretek.xunehd.data.model.PinKind
|
||||
import com.heretek.xunehd.data.model.Track
|
||||
import com.heretek.xunehd.ui.LocalXuneGraph
|
||||
import com.heretek.xunehd.ui.components.DetailScaffold
|
||||
import com.heretek.xunehd.ui.components.LocalContextMenu
|
||||
import com.heretek.xunehd.ui.components.MenuAction
|
||||
import com.heretek.xunehd.ui.components.TrackRow
|
||||
import com.heretek.xunehd.ui.nav.XuneDestination
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Album detail: art, year, then the track list. Tapping a track plays the
|
||||
* album from that point, exactly as the device does.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AlbumDetailScreen(albumId: Long, canvasWidth: androidx.compose.ui.unit.Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val menus = LocalContextMenu.current
|
||||
var album by remember { mutableStateOf<Album?>(null) }
|
||||
var tracks by remember { mutableStateOf<List<Track>>(emptyList()) }
|
||||
val nowPlayingId by graph.controller.nowPlaying.collectAsState()
|
||||
|
||||
LaunchedEffect(albumId) {
|
||||
tracks = graph.library.tracksByAlbum(albumId)
|
||||
album = tracks.firstOrNull()?.let {
|
||||
com.heretek.xunehd.data.model.Album(
|
||||
albumId = it.albumId,
|
||||
title = it.album,
|
||||
artist = it.artist,
|
||||
artistId = it.artistId,
|
||||
year = it.year,
|
||||
trackCount = tracks.size,
|
||||
totalDurationMs = tracks.sumOf { t -> t.durationMs },
|
||||
dateAdded = it.dateAdded,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DetailScaffold(title = album?.title ?: "album") {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = XuneTokens.EDGE.dp),
|
||||
) {
|
||||
AlbumArt(
|
||||
model = album?.albumArtUri,
|
||||
contentDescription = album?.title,
|
||||
modifier = Modifier.size(72.dp),
|
||||
)
|
||||
Column(Modifier.padding(start = 12.dp)) {
|
||||
EdgeCropText(
|
||||
text = album?.artist ?: "",
|
||||
fontSize = XuneTokens.TYPE_NOW_META.dp,
|
||||
)
|
||||
EdgeCropText(
|
||||
text = listOfNotNull(album?.year?.takeIf { it != "unknown" }, "${album?.trackCount ?: 0} songs").joinToString(" — "),
|
||||
fontSize = XuneTokens.TYPE_LIST.dp,
|
||||
color = LocalXuneColors.current.textSecondary,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row {
|
||||
EdgeCropText(
|
||||
text = "play",
|
||||
fontSize = XuneTokens.TYPE_LIST.dp,
|
||||
color = LocalXuneColors.current.accent,
|
||||
modifier = Modifier
|
||||
.combinedClickable(onClick = {
|
||||
tracks.firstOrNull()?.let { graph.controller.play(tracks, 0) }
|
||||
}, onLongClick = {})
|
||||
.padding(end = 16.dp),
|
||||
)
|
||||
EdgeCropText(
|
||||
text = "shuffle",
|
||||
fontSize = XuneTokens.TYPE_LIST.dp,
|
||||
color = LocalXuneColors.current.accent,
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = {
|
||||
tracks.firstOrNull()?.let {
|
||||
graph.controller.play(tracks, 0)
|
||||
graph.controller.setShuffle(true)
|
||||
}
|
||||
},
|
||||
onLongClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
KineticList(
|
||||
items = tracks,
|
||||
key = { it.mediaId },
|
||||
letter = { firstLetterOf(it.title) },
|
||||
rowContent = { track, index ->
|
||||
TrackRow(
|
||||
track = track,
|
||||
playing = nowPlayingId?.mediaId == track.mediaId,
|
||||
onClick = { graph.controller.play(tracks, index) },
|
||||
onLongClick = {
|
||||
menus.show(
|
||||
title = track.title,
|
||||
actions = listOf(
|
||||
MenuAction("pin to quickplay") {
|
||||
scope.launch {
|
||||
graph.quickplay.pin(PinKind.TRACK, track.mediaId, track.title, track.artist, track.albumId)
|
||||
}
|
||||
},
|
||||
MenuAction("view artist") { graph.nav.push(XuneDestination.Artist(track.artistId)) },
|
||||
),
|
||||
)
|
||||
},
|
||||
showSubLabel = false,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Artist detail: a crossbar of their albums and songs, mirroring the device's
|
||||
* artist page.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ArtistDetailScreen(artistId: Long, canvasWidth: androidx.compose.ui.unit.Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val menus = LocalContextMenu.current
|
||||
var albums by remember { mutableStateOf<List<Album>>(emptyList()) }
|
||||
var tracks by remember { mutableStateOf<List<Track>>(emptyList()) }
|
||||
var loaded by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(artistId) {
|
||||
tracks = graph.library.tracksByArtist(artistId)
|
||||
albums = graph.library.albumsByArtist(artistId)
|
||||
loaded = true
|
||||
}
|
||||
|
||||
val title = tracks.firstOrNull()?.artist ?: "artist"
|
||||
|
||||
DetailScaffold(title = title) {
|
||||
if (!loaded) return@DetailScaffold
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
val pagerState = androidx.compose.foundation.pager.rememberPagerState(
|
||||
initialPage = 0,
|
||||
pageCount = { 2 },
|
||||
)
|
||||
CrossbarBar(
|
||||
labels = listOf("albums", "songs"),
|
||||
selected = pagerState.currentPage,
|
||||
onSelect = { index -> scope.launch { pagerState.animateScrollToPage(index) } },
|
||||
)
|
||||
androidx.compose.foundation.pager.HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> ArtistAlbums(albums)
|
||||
else -> ArtistSongs(tracks, menus, graph)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ArtistAlbums(albums: List<Album>) {
|
||||
val graph = LocalXuneGraph.current
|
||||
LazyRow(
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(XuneTokens.EDGE.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(XuneTokens.GRID_GUTTER.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(albums, key = { it.albumId }) { album ->
|
||||
Column(
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onClick = { graph.nav.push(XuneDestination.Album(album.albumId)) },
|
||||
onLongClick = {},
|
||||
)
|
||||
.width(92.dp),
|
||||
) {
|
||||
AlbumArt(
|
||||
model = album.albumArtUri,
|
||||
contentDescription = album.title,
|
||||
modifier = Modifier.size(92.dp),
|
||||
)
|
||||
EdgeCropText(text = album.title, fontSize = XuneTokens.TYPE_CAPTION.dp, modifier = Modifier.fillMaxWidth())
|
||||
EdgeCropText(
|
||||
text = album.year,
|
||||
fontSize = XuneTokens.TYPE_CAPTION.dp,
|
||||
color = LocalXuneColors.current.textSecondary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ArtistSongs(
|
||||
tracks: List<Track>,
|
||||
menus: com.heretek.xunehd.ui.components.MenuController,
|
||||
graph: com.heretek.xunehd.XuneGraph,
|
||||
) {
|
||||
val nowPlayingId by graph.controller.nowPlaying.collectAsState()
|
||||
KineticList(
|
||||
items = tracks,
|
||||
key = { it.mediaId },
|
||||
letter = { firstLetterOf(it.title) },
|
||||
rowContent = { track, index ->
|
||||
TrackRow(
|
||||
track = track,
|
||||
playing = nowPlayingId?.mediaId == track.mediaId,
|
||||
onClick = { graph.controller.play(tracks, index) },
|
||||
onLongClick = {
|
||||
menus.show(
|
||||
title = track.title,
|
||||
actions = listOf(
|
||||
MenuAction("view album") { graph.nav.push(XuneDestination.Album(track.albumId)) },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Genre detail: every track in a genre. */
|
||||
@Composable
|
||||
fun GenreScreen(genre: String, canvasWidth: androidx.compose.ui.unit.Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
var tracks by remember { mutableStateOf<List<Track>>(emptyList()) }
|
||||
val nowPlayingId by graph.controller.nowPlaying.collectAsState()
|
||||
|
||||
LaunchedEffect(genre) {
|
||||
tracks = graph.library.tracksByGenre(genre)
|
||||
}
|
||||
|
||||
DetailScaffold(title = genre) {
|
||||
KineticList(
|
||||
items = tracks,
|
||||
key = { it.mediaId },
|
||||
letter = { firstLetterOf(it.title) },
|
||||
rowContent = { track, index ->
|
||||
TrackRow(
|
||||
track = track,
|
||||
playing = nowPlayingId?.mediaId == track.mediaId,
|
||||
onClick = { graph.controller.play(tracks, index) },
|
||||
onLongClick = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Playlist detail: tracks with removal. */
|
||||
@Composable
|
||||
fun PlaylistDetailScreen(playlistId: Long, canvasWidth: androidx.compose.ui.unit.Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var tracks by remember { mutableStateOf<List<Track>>(emptyList()) }
|
||||
val nowPlayingId by graph.controller.nowPlaying.collectAsState()
|
||||
|
||||
LaunchedEffect(playlistId) {
|
||||
tracks = graph.library.tracksInPlaylist(playlistId)
|
||||
}
|
||||
|
||||
DetailScaffold(title = "playlist") {
|
||||
if (tracks.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
EdgeCropText(
|
||||
text = "empty playlist",
|
||||
fontSize = XuneTokens.TYPE_NOW_META.dp,
|
||||
alpha = 0.4f,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
KineticList(
|
||||
items = tracks,
|
||||
key = { it.mediaId },
|
||||
letter = { firstLetterOf(it.title) },
|
||||
rowContent = { track, index ->
|
||||
TrackRow(
|
||||
track = track,
|
||||
playing = nowPlayingId?.mediaId == track.mediaId,
|
||||
onClick = { graph.controller.play(tracks, index) },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
graph.library.removeFromPlaylist(playlistId, track.mediaId)
|
||||
tracks = graph.library.tracksInPlaylist(playlistId)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package com.heretek.xunehd.ui.screens
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.heretek.xunehd.XuneGraph
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
import com.heretek.xunehd.design.components.AlbumArt
|
||||
import com.heretek.xunehd.design.components.EdgeCropText
|
||||
import com.heretek.xunehd.design.components.HomeMenuItem
|
||||
import com.heretek.xunehd.design.components.StaggerEntrance
|
||||
import com.heretek.xunehd.data.model.PinKind
|
||||
import com.heretek.xunehd.data.repo.QuickplayCard
|
||||
import com.heretek.xunehd.ui.LocalXuneGraph
|
||||
import com.heretek.xunehd.ui.components.LocalContextMenu
|
||||
import com.heretek.xunehd.ui.components.SectionLabel
|
||||
import com.heretek.xunehd.ui.nav.XuneDestination
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The Zune HD home: two pages side by side. The home text menu sits on the
|
||||
* right; Quickplay is parked to its left ("left and to the rear") and is
|
||||
* revealed by sliding the menu rightward.
|
||||
*/
|
||||
@Composable
|
||||
fun HomePages(canvasWidth: Dp) {
|
||||
val pagerState = rememberPagerState(initialPage = 1, pageCount = { 2 })
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
|
||||
if (page == 0) QuickplayScreen(canvasWidth) else HomeMenuScreen(canvasWidth)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeMenuScreen(canvasWidth: Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Spacer(Modifier.height(48.dp))
|
||||
StaggerEntrance(index = 0) {
|
||||
HomeMenuItem(label = "music", onClick = { graph.nav.push(XuneDestination.Music) })
|
||||
}
|
||||
StaggerEntrance(index = 1) {
|
||||
HomeMenuItem(label = "settings", onClick = { graph.nav.push(XuneDestination.Settings) })
|
||||
}
|
||||
// The Zune HD carries entries for services we have not built; render
|
||||
// them dim and inert, as the device does for missing services.
|
||||
StaggerEntrance(index = 2) {
|
||||
EdgeCropText(
|
||||
text = "podcasts",
|
||||
fontSize = XuneTokens.TYPE_MENU_ITEM.dp,
|
||||
alpha = 0.4f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = XuneTokens.EDGE.dp, top = 10.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
EdgeCropText(
|
||||
text = "xune hd",
|
||||
fontSize = XuneTokens.TYPE_CROSSBAR.dp,
|
||||
alpha = 0.4f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = XuneTokens.EDGE.dp, bottom = XuneTokens.EDGE.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun QuickplayScreen(canvasWidth: Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
val controller = graph.controller
|
||||
val scope = rememberCoroutineScope()
|
||||
val menus = LocalContextMenu.current
|
||||
|
||||
val nowPlaying by controller.nowPlaying.collectAsState()
|
||||
val pins by graph.quickplay.pins().collectAsState(initial = emptyList())
|
||||
val history by graph.quickplay.history(12).collectAsState(initial = emptyList())
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Spacer(Modifier.height(XuneTokens.EDGE.dp))
|
||||
SectionLabel("now playing")
|
||||
|
||||
val track = nowPlaying
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(72.dp)
|
||||
.combinedClickable(
|
||||
onClick = { if (track != null) graph.nav.push(XuneDestination.NowPlaying) },
|
||||
onLongClick = {},
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (track != null) {
|
||||
AlbumArt(
|
||||
model = track.albumArtUri,
|
||||
contentDescription = track.album,
|
||||
modifier = Modifier
|
||||
.padding(start = XuneTokens.EDGE.dp)
|
||||
.size(64.dp),
|
||||
)
|
||||
Column(Modifier.padding(start = 12.dp)) {
|
||||
EdgeCropText(text = track.title, fontSize = XuneTokens.TYPE_NOW_META.dp)
|
||||
EdgeCropText(
|
||||
text = track.artist,
|
||||
fontSize = XuneTokens.TYPE_LIST.dp,
|
||||
color = LocalXuneColors.current.textSecondary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
EdgeCropText(
|
||||
text = "nothing playing",
|
||||
fontSize = XuneTokens.TYPE_NOW_META.dp,
|
||||
alpha = 0.4f,
|
||||
modifier = Modifier.padding(start = XuneTokens.EDGE.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionLabel("pins")
|
||||
QuickplayRow(graph, scope, cards = pins)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionLabel("history")
|
||||
QuickplayRow(graph, scope, cards = history)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionLabel("new")
|
||||
NewRow()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun QuickplayRow(graph: XuneGraph, scope: CoroutineScope, cards: List<QuickplayCard>) {
|
||||
val menus = LocalContextMenu.current
|
||||
if (cards.isEmpty()) {
|
||||
EdgeCropText(
|
||||
text = "empty",
|
||||
fontSize = XuneTokens.TYPE_LIST.dp,
|
||||
alpha = 0.4f,
|
||||
modifier = Modifier.padding(horizontal = XuneTokens.EDGE.dp, vertical = 4.dp),
|
||||
)
|
||||
return
|
||||
}
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = XuneTokens.EDGE.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(XuneTokens.GRID_GUTTER.dp),
|
||||
) {
|
||||
items(cards, key = { "${it.kind}:${it.refId}:${it.label}" }) { card ->
|
||||
Column(
|
||||
Modifier
|
||||
.width(72.dp)
|
||||
.combinedClickable(
|
||||
onClick = { openCard(graph, scope, card) },
|
||||
onLongClick = {
|
||||
menus.show(
|
||||
title = card.label,
|
||||
actions = listOf(
|
||||
com.heretek.xunehd.ui.components.MenuAction("unpin") {
|
||||
scope.launch { graph.quickplay.unpin(card.kind, card.refId) }
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
) {
|
||||
AlbumArt(
|
||||
model = card.artAlbumId.takeIf { it > 0 }
|
||||
?.let { android.net.Uri.parse("content://media/external/audio/albumart/$it") },
|
||||
contentDescription = card.label,
|
||||
modifier = Modifier.size(72.dp),
|
||||
)
|
||||
EdgeCropText(
|
||||
text = card.label,
|
||||
fontSize = XuneTokens.TYPE_CAPTION.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun NewRow() {
|
||||
val graph = LocalXuneGraph.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val albums by graph.library.albums().collectAsState(initial = emptyList())
|
||||
val recent = albums.sortedByDescending { it.dateAdded }.take(12)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = XuneTokens.EDGE.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(XuneTokens.GRID_GUTTER.dp),
|
||||
) {
|
||||
items(recent, key = { it.albumId }) { album ->
|
||||
Column(
|
||||
Modifier
|
||||
.width(72.dp)
|
||||
.combinedClickable(
|
||||
onClick = { graph.nav.push(XuneDestination.Album(album.albumId)) },
|
||||
onLongClick = {},
|
||||
),
|
||||
) {
|
||||
AlbumArt(
|
||||
model = album.albumArtUri,
|
||||
contentDescription = album.title,
|
||||
modifier = Modifier.size(72.dp),
|
||||
)
|
||||
EdgeCropText(
|
||||
text = album.title,
|
||||
fontSize = XuneTokens.TYPE_CAPTION.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCard(graph: XuneGraph, scope: CoroutineScope, card: QuickplayCard) {
|
||||
when (card.kind) {
|
||||
PinKind.TRACK -> scope.launch {
|
||||
graph.library.track(card.refId)?.let { graph.controller.play(listOf(it)) }
|
||||
}
|
||||
PinKind.ALBUM -> graph.nav.push(XuneDestination.Album(card.refId))
|
||||
PinKind.ARTIST -> graph.nav.push(XuneDestination.Artist(card.refId))
|
||||
PinKind.PLAYLIST -> graph.nav.push(XuneDestination.PlaylistDetail(card.refId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package com.heretek.xunehd.ui.screens
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
import com.heretek.xunehd.design.components.AlbumArt
|
||||
import com.heretek.xunehd.design.components.CrossbarBar
|
||||
import com.heretek.xunehd.design.components.EdgeCropText
|
||||
import com.heretek.xunehd.data.model.PinKind
|
||||
import com.heretek.xunehd.design.components.KineticList
|
||||
import com.heretek.xunehd.design.components.firstLetterOf
|
||||
import com.heretek.xunehd.ui.LocalXuneGraph
|
||||
import com.heretek.xunehd.ui.components.LocalContextMenu
|
||||
import com.heretek.xunehd.ui.components.MenuAction
|
||||
import com.heretek.xunehd.ui.components.TrackRow
|
||||
import com.heretek.xunehd.ui.nav.XuneDestination
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Real Zune HD music crossbar order. */
|
||||
private val PIVOTS = listOf("albums", "artists", "playlists", "songs", "genres")
|
||||
|
||||
@Composable
|
||||
fun MusicScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(initialPage = 0, pageCount = { PIVOTS.size })
|
||||
val selected = pagerState.currentPage
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
CrossbarBar(
|
||||
labels = PIVOTS,
|
||||
selected = selected,
|
||||
onSelect = { index -> scope.launch { pagerState.animateScrollToPage(index) } },
|
||||
)
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
beyondViewportPageCount = 1,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> AlbumsTab()
|
||||
1 -> ArtistsTab()
|
||||
2 -> PlaylistsTab()
|
||||
3 -> SongsTab()
|
||||
else -> GenresTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AlbumsTab() {
|
||||
val graph = LocalXuneGraph.current
|
||||
val menus = LocalContextMenu.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val albums by graph.library.albums().collectAsState(initial = emptyList())
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = XuneTokens.ALBUM_TILE.dp),
|
||||
contentPadding = PaddingValues(XuneTokens.EDGE.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(XuneTokens.GRID_GUTTER.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(albums, key = { it.albumId }) { album ->
|
||||
Column(
|
||||
Modifier.combinedClickable(
|
||||
onClick = { graph.nav.push(XuneDestination.Album(album.albumId)) },
|
||||
onLongClick = {
|
||||
menus.show(
|
||||
title = album.title,
|
||||
actions = listOf(
|
||||
MenuAction("pin to quickplay") {
|
||||
scope.launch {
|
||||
graph.quickplay.pin(
|
||||
PinKind.ALBUM, album.albumId, album.title, album.artist, album.albumId,
|
||||
)
|
||||
}
|
||||
},
|
||||
MenuAction("view artist") {
|
||||
graph.nav.push(XuneDestination.Artist(album.artistId))
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
) {
|
||||
AlbumArt(
|
||||
model = album.albumArtUri,
|
||||
contentDescription = album.title,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
EdgeCropText(
|
||||
text = album.title,
|
||||
fontSize = XuneTokens.TYPE_CAPTION.dp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
EdgeCropText(
|
||||
text = album.artist,
|
||||
fontSize = XuneTokens.TYPE_CAPTION.dp,
|
||||
color = LocalXuneColors.current.textSecondary,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ArtistsTab() {
|
||||
val graph = LocalXuneGraph.current
|
||||
val menus = LocalContextMenu.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val artists by graph.library.artists().collectAsState(initial = emptyList())
|
||||
|
||||
KineticList(
|
||||
items = artists,
|
||||
key = { it.artistId },
|
||||
letter = { firstLetterOf(it.name) },
|
||||
rowContent = { artist, _ ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(XuneTokens.ROW_HEIGHT.dp)
|
||||
.combinedClickable(
|
||||
onClick = { graph.nav.push(XuneDestination.Artist(artist.artistId)) },
|
||||
onLongClick = {
|
||||
menus.show(
|
||||
title = artist.name,
|
||||
actions = listOf(
|
||||
MenuAction("pin to quickplay") {
|
||||
scope.launch {
|
||||
graph.quickplay.pin(PinKind.ARTIST, artist.artistId, artist.name, "${artist.albumCount} albums", 0)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
.padding(horizontal = XuneTokens.EDGE.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
EdgeCropText(text = artist.name, fontSize = XuneTokens.TYPE_LIST.dp)
|
||||
EdgeCropText(
|
||||
text = "${artist.albumCount} albums — ${artist.trackCount} songs",
|
||||
fontSize = XuneTokens.TYPE_LIST_SECONDARY.dp,
|
||||
color = LocalXuneColors.current.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PlaylistsTab() {
|
||||
val graph = LocalXuneGraph.current
|
||||
val menus = LocalContextMenu.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val playlists by graph.library.playlists().collectAsState(initial = emptyList())
|
||||
|
||||
androidx.compose.foundation.layout.Box(Modifier.fillMaxSize()) {
|
||||
com.heretek.xunehd.design.components.KineticList(
|
||||
items = playlists,
|
||||
key = { it.id },
|
||||
letter = { firstLetterOf(it.name) },
|
||||
rowContent = { playlist, _ ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(XuneTokens.ROW_HEIGHT.dp)
|
||||
.combinedClickable(
|
||||
onClick = { graph.nav.push(XuneDestination.PlaylistDetail(playlist.id)) },
|
||||
onLongClick = {
|
||||
menus.show(
|
||||
title = playlist.name,
|
||||
actions = listOf(
|
||||
MenuAction("delete playlist") {
|
||||
scope.launch { graph.library.deletePlaylist(playlist.id) }
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
.padding(horizontal = XuneTokens.EDGE.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
EdgeCropText(text = playlist.name, fontSize = XuneTokens.TYPE_LIST.dp)
|
||||
EdgeCropText(
|
||||
text = "${playlist.trackCount} songs",
|
||||
fontSize = XuneTokens.TYPE_LIST_SECONDARY.dp,
|
||||
color = LocalXuneColors.current.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Create playlist affordance, styled as plain text like everything else.
|
||||
EdgeCropText(
|
||||
text = "+ new playlist",
|
||||
fontSize = XuneTokens.TYPE_LIST.dp,
|
||||
alpha = 0.6f,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
menus.showPrompt("new playlist", "name") { name ->
|
||||
scope.launch { graph.library.createPlaylist(name) }
|
||||
}
|
||||
},
|
||||
onLongClick = {},
|
||||
)
|
||||
.padding(horizontal = XuneTokens.EDGE.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SongsTab() {
|
||||
val graph = LocalXuneGraph.current
|
||||
val menus = LocalContextMenu.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val songs by graph.library.tracks().collectAsState(initial = emptyList())
|
||||
val nowPlayingId by graph.controller.nowPlaying.collectAsState()
|
||||
val playlists by graph.library.playlists().collectAsState(initial = emptyList())
|
||||
|
||||
KineticList(
|
||||
items = songs,
|
||||
key = { it.mediaId },
|
||||
letter = { firstLetterOf(it.title) },
|
||||
bottomPadding = 96.dp,
|
||||
rowContent = { track, index ->
|
||||
TrackRow(
|
||||
track = track,
|
||||
playing = nowPlayingId?.mediaId == track.mediaId,
|
||||
onClick = { graph.controller.play(songs, index) },
|
||||
onLongClick = {
|
||||
menus.show(
|
||||
title = track.title,
|
||||
actions = listOf(
|
||||
MenuAction("pin to quickplay") {
|
||||
scope.launch {
|
||||
graph.quickplay.pin(PinKind.TRACK, track.mediaId, track.title, track.artist, track.albumId)
|
||||
}
|
||||
},
|
||||
MenuAction("add to playlist") {
|
||||
menus.show(
|
||||
title = "add to playlist",
|
||||
actions = playlists.map { playlist ->
|
||||
MenuAction(playlist.name) {
|
||||
scope.launch { graph.library.addToPlaylist(playlist.id, track) }
|
||||
}
|
||||
} + MenuAction("new playlist…") {
|
||||
menus.showPrompt("new playlist", "name") { name ->
|
||||
scope.launch {
|
||||
val id = graph.library.createPlaylist(name)
|
||||
graph.library.addToPlaylist(id, track)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
MenuAction("view album") { graph.nav.push(XuneDestination.Album(track.albumId)) },
|
||||
MenuAction("view artist") { graph.nav.push(XuneDestination.Artist(track.artistId)) },
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun GenresTab() {
|
||||
val graph = LocalXuneGraph.current
|
||||
val genres by graph.library.genres().collectAsState(initial = emptyList())
|
||||
|
||||
KineticList(
|
||||
items = genres,
|
||||
key = { it.name },
|
||||
letter = { firstLetterOf(it.name) },
|
||||
rowContent = { genre, _ ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(XuneTokens.ROW_HEIGHT.dp)
|
||||
.combinedClickable(
|
||||
onClick = { graph.nav.push(XuneDestination.Genre(genre.name)) },
|
||||
onLongClick = {},
|
||||
)
|
||||
.padding(horizontal = XuneTokens.EDGE.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
EdgeCropText(text = genre.name, fontSize = XuneTokens.TYPE_LIST.dp)
|
||||
EdgeCropText(
|
||||
text = "${genre.trackCount} songs",
|
||||
fontSize = XuneTokens.TYPE_LIST_SECONDARY.dp,
|
||||
color = LocalXuneColors.current.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
package com.heretek.xunehd.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.media.AudioManager
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.RepeatMode as AnimRepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.allowHardware
|
||||
import com.heretek.xunehd.R
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.Selawik
|
||||
import com.heretek.xunehd.design.XuneMotion
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
import com.heretek.xunehd.design.components.AlbumArt
|
||||
import com.heretek.xunehd.design.components.EdgeCropText
|
||||
import com.heretek.xunehd.data.model.RepeatMode
|
||||
import com.heretek.xunehd.data.model.Rating
|
||||
import com.heretek.xunehd.data.model.Track
|
||||
import com.heretek.xunehd.ui.LocalXuneGraph
|
||||
import com.heretek.xunehd.ui.nav.XuneDestination
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val IDLE_SCREENSAVER_MS = 5_000L
|
||||
private const val SKIP_DRAG_THRESHOLD = 90f
|
||||
|
||||
/**
|
||||
* The Zune HD Now Playing: a metadata card floating over artist photography.
|
||||
* Swipe sideways to skip; tap for the transport overlay; idle and it becomes
|
||||
* a slow-scrolling screensaver — the device behavior, verified against the
|
||||
* period reviews in docs/zune-hd-ui-canon.md.
|
||||
*/
|
||||
@Composable
|
||||
fun NowPlayingScreen(canvasWidth: Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
val controller = graph.controller
|
||||
val colors = LocalXuneColors.current
|
||||
|
||||
val track by controller.nowPlaying.collectAsState()
|
||||
val isPlaying by controller.isPlaying.collectAsState()
|
||||
val positionMs by controller.positionMs.collectAsState()
|
||||
val durationMs by controller.durationMs.collectAsState()
|
||||
val shuffle by controller.shuffle.collectAsState()
|
||||
val repeat by controller.repeat.collectAsState()
|
||||
val rating by controller.currentRating.collectAsState()
|
||||
|
||||
var overlay by remember { mutableStateOf(false) }
|
||||
var screensaver by remember { mutableStateOf(false) }
|
||||
var interactionKey by remember { mutableStateOf(0) }
|
||||
|
||||
val current = track
|
||||
|
||||
// Idle → screensaver, exactly as the device does after a few seconds.
|
||||
LaunchedEffect(interactionKey, current?.mediaId, isPlaying) {
|
||||
if (current == null) return@LaunchedEffect
|
||||
delay(IDLE_SCREENSAVER_MS)
|
||||
if (!overlay) screensaver = true
|
||||
}
|
||||
|
||||
fun poke() {
|
||||
interactionKey++
|
||||
screensaver = false
|
||||
}
|
||||
|
||||
if (current == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
EdgeCropText(
|
||||
text = "nothing playing",
|
||||
fontSize = XuneTokens.TYPE_NOW_META.dp,
|
||||
alpha = 0.4f,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val backdropFile by rememberArtistBackdropFile(current)
|
||||
val washColors by rememberArtWash(current)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clipToBounds()
|
||||
.pointerInput(overlay, screensaver) {
|
||||
if (!overlay && !screensaver) {
|
||||
detectTapGestures(onTap = { overlay = true })
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
if (!overlay && !screensaver) {
|
||||
var totalDrag = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { totalDrag = 0f },
|
||||
onHorizontalDrag = { change, amount ->
|
||||
totalDrag += amount
|
||||
change.consume()
|
||||
},
|
||||
onDragEnd = {
|
||||
if (totalDrag < -SKIP_DRAG_THRESHOLD) controller.next()
|
||||
else if (totalDrag > SKIP_DRAG_THRESHOLD) controller.previous()
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
// Layer 0: the backdrop — artist photography or a palette wash.
|
||||
BackdropLayer(current, backdropFile, washColors)
|
||||
|
||||
// The floating card.
|
||||
AnimatedVisibility(
|
||||
visible = !screensaver,
|
||||
enter = fadeIn(XuneMotion.pivot()),
|
||||
exit = fadeOut(XuneMotion.pivot()),
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Explicit back arrow, per the canon.
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = XuneTokens.EDGE.dp, top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_back),
|
||||
contentDescription = "back",
|
||||
tint = colors.textPrimary.copy(alpha = 0.85f),
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clickable { graph.nav.pop() },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = XuneTokens.EDGE.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
AlbumArt(
|
||||
model = current.albumArtUri,
|
||||
contentDescription = current.album,
|
||||
modifier = Modifier.size(96.dp),
|
||||
)
|
||||
Column(Modifier.padding(start = 14.dp)) {
|
||||
EdgeCropText(
|
||||
text = current.title,
|
||||
fontSize = XuneTokens.TYPE_NOW_TITLE.dp,
|
||||
)
|
||||
EdgeCropText(
|
||||
text = current.artist,
|
||||
fontSize = XuneTokens.TYPE_NOW_META.dp,
|
||||
alpha = 0.85f,
|
||||
modifier = Modifier.clickable {
|
||||
poke()
|
||||
graph.nav.push(XuneDestination.Artist(current.artistId))
|
||||
},
|
||||
)
|
||||
EdgeCropText(
|
||||
text = current.album,
|
||||
fontSize = XuneTokens.TYPE_NOW_META.dp,
|
||||
alpha = 0.85f,
|
||||
modifier = Modifier.clickable {
|
||||
poke()
|
||||
graph.nav.push(XuneDestination.Album(current.albumId))
|
||||
},
|
||||
)
|
||||
EdgeCropText(
|
||||
text = "${formatTime(positionMs)} / ${formatTime(durationMs)}",
|
||||
fontSize = XuneTokens.TYPE_CAPTION.dp,
|
||||
alpha = 0.6f,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// Bottom row: shuffle, repeat, and the tri-state heart.
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = XuneTokens.EDGE.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_shuffle),
|
||||
contentDescription = "shuffle",
|
||||
tint = if (shuffle) colors.accent else colors.textInactive,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clickable { controller.setShuffle(!shuffle) },
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
if (repeat == RepeatMode.ONE) R.drawable.ic_repeat_one else R.drawable.ic_repeat,
|
||||
),
|
||||
contentDescription = "repeat",
|
||||
tint = if (repeat != RepeatMode.OFF) colors.accent else colors.textInactive,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clickable { controller.cycleRepeat() },
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_heart_broken),
|
||||
contentDescription = "dislike",
|
||||
tint = if (rating == Rating.BROKEN) colors.accent else colors.textInactive,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clickable {
|
||||
controller.setRating(if (rating == Rating.BROKEN) Rating.NONE else Rating.BROKEN)
|
||||
},
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_heart),
|
||||
contentDescription = "favorite",
|
||||
tint = if (rating == Rating.HEART) colors.accent else colors.textInactive,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clickable {
|
||||
controller.setRating(if (rating == Rating.HEART) Rating.NONE else Rating.HEART)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The screensaver: slowly drifting metadata over the photography.
|
||||
AnimatedVisibility(
|
||||
visible = screensaver,
|
||||
enter = fadeIn(XuneMotion.pivot()),
|
||||
exit = fadeOut(XuneMotion.pivot()),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
screensaver = false
|
||||
interactionKey++
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
ScreensaverLayer(current, positionMs, durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
// Transport overlay.
|
||||
AnimatedVisibility(
|
||||
visible = overlay,
|
||||
enter = fadeIn(XuneMotion.pivot()),
|
||||
exit = fadeOut(XuneMotion.pivot()),
|
||||
) {
|
||||
TransportOverlay(
|
||||
isPlaying = isPlaying,
|
||||
onDismiss = {
|
||||
overlay = false
|
||||
interactionKey++
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Artist photography with the Zune Ken-Burns drift, or a palette wash. */
|
||||
@Composable
|
||||
private fun BackdropLayer(
|
||||
track: Track,
|
||||
backdropFile: File?,
|
||||
washColors: Pair<Color, Color>?,
|
||||
) {
|
||||
val colors = LocalXuneColors.current
|
||||
if (backdropFile != null) {
|
||||
val transition = rememberInfiniteTransition(label = "kenburns")
|
||||
val scale by transition.animateFloat(
|
||||
initialValue = 1.0f,
|
||||
targetValue = 1.06f,
|
||||
animationSpec = infiniteRepeatable(tween(20_000), AnimRepeatMode.Reverse),
|
||||
label = "kb-scale",
|
||||
)
|
||||
val pan by transition.animateFloat(
|
||||
initialValue = -8f,
|
||||
targetValue = 8f,
|
||||
animationSpec = infiniteRepeatable(tween(26_000), AnimRepeatMode.Reverse),
|
||||
label = "kb-pan",
|
||||
)
|
||||
AsyncImage(
|
||||
model = android.net.Uri.fromFile(backdropFile),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = pan
|
||||
},
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.15f),
|
||||
0.55f to Color.Black.copy(alpha = 0.35f),
|
||||
1f to Color.Black.copy(alpha = 0.75f),
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val top = washColors?.first ?: colors.tile
|
||||
val bottom = washColors?.second ?: colors.background
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.verticalGradient(0f to top, 1f to bottom)),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.25f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScreensaverLayer(track: Track, positionMs: Long, durationMs: Long) {
|
||||
val colors = LocalXuneColors.current
|
||||
val transition = rememberInfiniteTransition(label = "saver-drift")
|
||||
val drift by transition.animateFloat(
|
||||
initialValue = 30f,
|
||||
targetValue = -110f,
|
||||
animationSpec = infiniteRepeatable(tween(18_000), AnimRepeatMode.Reverse),
|
||||
label = "saver-y",
|
||||
)
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer { translationY = drift }
|
||||
.padding(start = XuneTokens.EDGE.dp, end = 80.dp),
|
||||
) {
|
||||
EdgeCropText(text = track.title, fontSize = 32.dp)
|
||||
EdgeCropText(
|
||||
text = track.artist,
|
||||
fontSize = 20.dp,
|
||||
alpha = 0.9f,
|
||||
)
|
||||
EdgeCropText(
|
||||
text = track.album,
|
||||
fontSize = 16.dp,
|
||||
alpha = 0.7f,
|
||||
)
|
||||
EdgeCropText(
|
||||
text = formatTime(durationMs),
|
||||
fontSize = XuneTokens.TYPE_CAPTION.dp,
|
||||
alpha = 0.5f,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
AlbumArt(
|
||||
model = track.albumArtUri,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(XuneTokens.EDGE.dp)
|
||||
.size(56.dp)
|
||||
.graphicsLayer { alpha = 0.92f },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The transport overlay: play/pause center, volume up/down at top/bottom,
|
||||
* previous/next at the sides; vertical swipes adjust volume.
|
||||
*/
|
||||
@Composable
|
||||
private fun TransportOverlay(
|
||||
isPlaying: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val controller = LocalXuneGraph.current.controller
|
||||
val colors = LocalXuneColors.current
|
||||
val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
var volumePulse by remember { mutableStateOf(0) }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.68f))
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onTap = { onDismiss() })
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
var accumulated = 0f
|
||||
detectVerticalDragGestures(
|
||||
onVerticalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
accumulated += dragAmount
|
||||
if (abs2(accumulated) > 18f) {
|
||||
val steps = (abs2(accumulated) / 18f).roundToInt()
|
||||
accumulated = 0f
|
||||
val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val now = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
// Swipe up = louder, per the device behavior.
|
||||
val target = (now + (if (dragAmount < 0) steps else -steps)).coerceIn(0, max)
|
||||
audio.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
|
||||
volumePulse = target
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Volume up (top)
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 18.dp)
|
||||
.clickable {
|
||||
val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val now = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
if (now < max) {
|
||||
audio.setStreamVolume(AudioManager.STREAM_MUSIC, now + 1, 0)
|
||||
volumePulse = now + 1
|
||||
}
|
||||
},
|
||||
) {
|
||||
OverlayGlyph(text = "+")
|
||||
}
|
||||
// Volume down (bottom)
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 18.dp)
|
||||
.clickable {
|
||||
val now = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
if (now > 0) {
|
||||
audio.setStreamVolume(AudioManager.STREAM_MUSIC, now - 1, 0)
|
||||
volumePulse = now - 1
|
||||
}
|
||||
},
|
||||
) {
|
||||
OverlayGlyph(text = "–")
|
||||
}
|
||||
// Previous (left)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_prev),
|
||||
contentDescription = "previous",
|
||||
tint = colors.textPrimary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 26.dp)
|
||||
.size(34.dp)
|
||||
.clickable { controller.previous() },
|
||||
)
|
||||
// Next (right)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_next),
|
||||
contentDescription = "next",
|
||||
tint = colors.textPrimary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 26.dp)
|
||||
.size(34.dp)
|
||||
.clickable { controller.next() },
|
||||
)
|
||||
// Play/pause (center)
|
||||
Icon(
|
||||
painter = painterResource(if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play),
|
||||
contentDescription = "play pause",
|
||||
tint = colors.textPrimary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(58.dp)
|
||||
.clickable { controller.toggle() },
|
||||
)
|
||||
// Transient volume readout
|
||||
if (volumePulse > 0) {
|
||||
androidx.compose.foundation.text.BasicText(
|
||||
text = "volume $volumePulse",
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontSize = XuneTokens.TYPE_LIST.sp,
|
||||
color = colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverlayGlyph(text: String) {
|
||||
val colors = LocalXuneColors.current
|
||||
androidx.compose.foundation.text.BasicText(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontFamily = Selawik,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 34.sp,
|
||||
color = colors.textPrimary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Cached artist background photography fetch. */
|
||||
@Composable
|
||||
private fun rememberArtistBackdropFile(track: Track): androidx.compose.runtime.State<File?> {
|
||||
val graph = LocalXuneGraph.current
|
||||
val file = androidx.compose.runtime.produceState<File?>(initialValue = null, key1 = track.artist) {
|
||||
val enabled = graph.artistImages.settingsSnapshot?.artistImagesEnabled ?: true
|
||||
if (!enabled) {
|
||||
value = null
|
||||
} else {
|
||||
value = graph.artistImages.backgroundFor(track.artist)
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
/** Palette wash extracted from the album art when no photo is available. */
|
||||
@Composable
|
||||
private fun rememberArtWash(track: Track): androidx.compose.runtime.State<Pair<Color, Color>?> {
|
||||
val context = LocalContext.current
|
||||
return androidx.compose.runtime.produceState<Pair<Color, Color>?>(initialValue = null, key1 = track.albumId) {
|
||||
value = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bitmap: Bitmap? = context.contentResolver.openInputStream(track.albumArtUri)?.use { input ->
|
||||
android.graphics.BitmapFactory.decodeStream(
|
||||
input,
|
||||
null,
|
||||
android.graphics.BitmapFactory.Options().apply { inSampleSize = 8 },
|
||||
)
|
||||
}
|
||||
if (bitmap == null) {
|
||||
null
|
||||
} else {
|
||||
val palette = androidx.palette.graphics.Palette.from(bitmap).generate()
|
||||
val vibrant = palette.getVibrantColor(
|
||||
palette.getMutedColor(android.graphics.Color.parseColor("#22303C")),
|
||||
)
|
||||
val vibrantBright = palette.getLightVibrantColor(vibrant)
|
||||
val a = android.graphics.Color.valueOf(
|
||||
androidx.core.graphics.ColorUtils.blendARGB(vibrant, android.graphics.Color.WHITE, 0.15f),
|
||||
)
|
||||
val b = android.graphics.Color.valueOf(
|
||||
androidx.core.graphics.ColorUtils.blendARGB(
|
||||
if (vibrantBright != vibrant) vibrantBright else vibrant,
|
||||
android.graphics.Color.BLACK,
|
||||
0.72f,
|
||||
),
|
||||
)
|
||||
Color(a.red(), a.green(), a.blue()) to Color(b.red(), b.green(), b.blue())
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun abs2(v: Float): Float = if (v < 0) -v else v
|
||||
|
||||
fun formatTime(ms: Long): String {
|
||||
val totalSeconds = (ms / 1000).coerceAtLeast(0)
|
||||
val minutes = totalSeconds / 60
|
||||
val seconds = totalSeconds % 60
|
||||
return "%d:%02d".format(minutes, seconds)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.heretek.xunehd.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.heretek.xunehd.BuildConfig
|
||||
import com.heretek.xunehd.design.LocalXuneColors
|
||||
import com.heretek.xunehd.design.XuneAccent
|
||||
import com.heretek.xunehd.design.XuneTokens
|
||||
import com.heretek.xunehd.design.components.EdgeCropText
|
||||
import com.heretek.xunehd.ui.LocalXuneGraph
|
||||
import com.heretek.xunehd.ui.components.DetailScaffold
|
||||
import com.heretek.xunehd.ui.components.SectionLabel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(canvasWidth: androidx.compose.ui.unit.Dp) {
|
||||
val graph = LocalXuneGraph.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val settings by graph.settingsFlow.collectAsState(initial = com.heretek.xunehd.data.repo.XuneSettings())
|
||||
val colors = LocalXuneColors.current
|
||||
val scanState by graph.library.scanning.collectAsState()
|
||||
val menus = com.heretek.xunehd.ui.components.LocalContextMenu.current
|
||||
|
||||
DetailScaffold(title = "settings") {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 48.dp),
|
||||
) {
|
||||
SectionLabel("accent")
|
||||
Row(
|
||||
Modifier.padding(horizontal = XuneTokens.EDGE.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
XuneAccent.entries.forEach { accent ->
|
||||
val selected = accent == settings.accent
|
||||
Box(
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.background(accent.primary)
|
||||
.border(
|
||||
width = if (selected) 2.dp else 0.5.dp,
|
||||
color = if (selected) colors.textPrimary else colors.border,
|
||||
)
|
||||
.clickable { scope.launch { graph.settings.setAccent(accent) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SectionLabel("display")
|
||||
SettingsToggle(
|
||||
label = "device mode",
|
||||
subLabel = "authentic 480x272 canvas (best in landscape)",
|
||||
value = settings.deviceMode,
|
||||
) { enabled ->
|
||||
scope.launch { graph.settings.setDeviceMode(enabled) }
|
||||
}
|
||||
SettingsToggle(
|
||||
label = "artist photos",
|
||||
subLabel = "now playing backdrop, via musicbrainz",
|
||||
value = settings.artistImagesEnabled,
|
||||
) { enabled ->
|
||||
scope.launch { graph.settings.setArtistImages(enabled) }
|
||||
}
|
||||
SettingsRow(
|
||||
label = "artist photo source",
|
||||
subLabel = settings.artistImageTemplate.ifBlank { "not configured" },
|
||||
onClick = {
|
||||
menus.showPrompt("artist photo source", "url template with {mbid}") { template ->
|
||||
scope.launch { graph.settings.setArtistImageTemplate(template) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
SectionLabel("collection")
|
||||
SettingsRow(
|
||||
label = if (scanState) "scanning…" else "refresh collection",
|
||||
subLabel = "rebuild the library from device media",
|
||||
onClick = { scope.launch { graph.library.refresh() } },
|
||||
)
|
||||
|
||||
SectionLabel("about")
|
||||
SettingsRow(
|
||||
label = "xune hd ${BuildConfig.VERSION_NAME}",
|
||||
subLabel = "sister to not-zune — the zune hd, reborn on android",
|
||||
onClick = {},
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
EdgeCropText(
|
||||
text = "selawik stands in for zegoe (sil ofl). zune, zegoe and the zune hd are trademarks of microsoft; this is an independent homage.",
|
||||
fontSize = XuneTokens.TYPE_CAPTION.dp,
|
||||
alpha = 0.4f,
|
||||
modifier = Modifier.padding(horizontal = XuneTokens.EDGE.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(
|
||||
label: String,
|
||||
subLabel: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colors = LocalXuneColors.current
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = XuneTokens.EDGE.dp, vertical = 8.dp),
|
||||
) {
|
||||
EdgeCropText(text = label, fontSize = XuneTokens.TYPE_LIST.dp)
|
||||
EdgeCropText(
|
||||
text = subLabel,
|
||||
fontSize = XuneTokens.TYPE_LIST_SECONDARY.dp,
|
||||
color = colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsToggle(
|
||||
label: String,
|
||||
subLabel: String,
|
||||
value: Boolean,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val colors = LocalXuneColors.current
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onChange(!value) }
|
||||
.padding(horizontal = XuneTokens.EDGE.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
EdgeCropText(text = label, fontSize = XuneTokens.TYPE_LIST.dp)
|
||||
EdgeCropText(
|
||||
text = subLabel,
|
||||
fontSize = XuneTokens.TYPE_LIST_SECONDARY.dp,
|
||||
color = colors.textSecondary,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier
|
||||
.background(if (value) colors.accent else colors.tile)
|
||||
.border(0.5.dp, colors.border)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
EdgeCropText(
|
||||
text = if (value) "on" else "off",
|
||||
fontSize = XuneTokens.TYPE_LIST.dp,
|
||||
color = if (value) colors.textPrimary else colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M11.3,5.09 L9.86,3.85 C8.9,3.28 8.2,3 7.5,3 4.42,3 2,5.42 2,8.5c0,3.78 3.4,6.86 8.55,11.53L12,21.35l-1.2,-1.85 1.5,-2.5 -2,-2.5 1.7,-2 -1.6,-2 1.4,-2.11 -1.5,-2.3z" />
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M12.7,5.09 L14.14,3.85 C15.1,3.28 15.8,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.53L12,21.35l1.2,-1.85 -1.5,-2.5 2,-2.5 -1.7,-2 1.6,-2 -1.4,-2.11 1.5,-2.3z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2L18,6h-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M16,9V4h1c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1H8C7.45,2 7,2.45 7,3s0.45,1 1,1h1v5c0,1.66 -1.34,3 -3,3v2h5.97v7l1,1 1,-1v-7H19v-2c-1.66,0 -3,-1.34 -3,-3z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M6,6h2v12L6,18zM8.5,12l8.5,6L17,6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 3,13.09 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M41.5,41.5 L66.5,41.5 L66.5,50.25 L50.25,61.5 L66.5,61.5 L66.5,69 L41.5,69 L41.5,60.25 L57.75,49 L41.5,49 Z"
|
||||
android:strokeColor="#FA2A55"
|
||||
android:strokeWidth="3"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000" />
|
||||
</vector>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/xune_mark" />
|
||||
<monochrome android:drawable="@drawable/xune_mark" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="xune_black">#FF111111</color>
|
||||
<color name="xune_accent">#FFFA2A55</color>
|
||||
<color name="ic_launcher_background">#FF111111</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Xune HD</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<style name="Theme.XuneHd" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/xune_black</item>
|
||||
<item name="android:statusBarColor">@color/xune_black</item>
|
||||
<item name="android:navigationBarColor">@color/xune_black</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.heretek.xunehd
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Design-invariant audit, ported from Not-Zune's approach. Scans the UI
|
||||
* sources for violations of the Zune HD canon (docs/zune-hd-ui-canon.md §7).
|
||||
*/
|
||||
class DesignInvariantTest {
|
||||
|
||||
private val appDir: File
|
||||
get() = if (File("src/main/java").exists()) File(".") else File("..")
|
||||
|
||||
private val repoRoot: File
|
||||
get() = appDir.absoluteFile.normalize().parentFile
|
||||
|
||||
private fun sources(dir: String): List<File> {
|
||||
val root = File(appDir, "src/main/java/com/heretek/xunehd/$dir")
|
||||
return root.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no corner radius anywhere`() {
|
||||
val offenders = sources("")
|
||||
.filter { it.readText().contains("RoundedCornerShape") }
|
||||
.map { it.path }
|
||||
assertTrue(
|
||||
"RoundedCornerShape is banned (canon §7, invariant 1): $offenders",
|
||||
offenders.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `screens consume tokens not raw colors`() {
|
||||
val offenders = (sources("ui") + sources("design/components"))
|
||||
.flatMap { file ->
|
||||
val text = file.readText()
|
||||
Regex("Color\\(0x[0-9A-Fa-f]{8}\\)").findAll(text)
|
||||
.map { "${file.name}: ${it.value}" }
|
||||
}
|
||||
.toList()
|
||||
assertTrue(
|
||||
"Screens must use LocalXuneColors tokens, not raw colors: $offenders",
|
||||
offenders.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigation uses motion tokens not springs`() {
|
||||
val offenders = sources("ui")
|
||||
.filter { it.readText().contains("spring") }
|
||||
.map { it.name }
|
||||
assertTrue(
|
||||
"Navigation must use XuneMotion deceleration (canon §6, invariant 6): $offenders",
|
||||
offenders.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canon documents exist`() {
|
||||
assertTrue(File(repoRoot, "docs/zune-hd-ui-canon.md").exists())
|
||||
assertTrue(File(repoRoot, "docs/design-tokens.md").exists())
|
||||
assertTrue(File(repoRoot, "NOTICE.md").exists())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.heretek.xunehd
|
||||
|
||||
import com.heretek.xunehd.data.model.PinKind
|
||||
import com.heretek.xunehd.data.model.Rating
|
||||
import com.heretek.xunehd.design.XuneAccent
|
||||
import com.heretek.xunehd.design.components.firstLetterOf
|
||||
import com.heretek.xunehd.ui.screens.formatTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LogicTest {
|
||||
|
||||
@Test
|
||||
fun `rating roundtrips through persistence values`() {
|
||||
assertEquals(Rating.NONE, Rating.from(0))
|
||||
assertEquals(Rating.HEART, Rating.from(1))
|
||||
assertEquals(Rating.BROKEN, Rating.from(2))
|
||||
assertEquals(Rating.NONE, Rating.from(99))
|
||||
assertEquals(3, Rating.entries.size) // tri-state: heart / broken / none
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alphabet grouping handles junk`() {
|
||||
assertEquals('A', firstLetterOf("abba"))
|
||||
assertEquals('T', firstLetterOf(" The "))
|
||||
assertEquals('9', firstLetterOf("99 Luftballons"))
|
||||
assertEquals('#', firstLetterOf("!!!"))
|
||||
assertEquals('#', firstLetterOf(""))
|
||||
assertFalse(firstLetterOf("abba").isLowerCase())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `time formats like the device`() {
|
||||
assertEquals("0:00", formatTime(0))
|
||||
assertEquals("0:59", formatTime(59_000))
|
||||
assertEquals("3:42", formatTime(222_000))
|
||||
assertEquals("61:02", formatTime(3_662_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accent ids are unique and pink is default`() {
|
||||
val ids = XuneAccent.entries.map { it.id }
|
||||
assertEquals(ids.size, ids.toSet().size)
|
||||
assertEquals(0, XuneAccent.PINK.id)
|
||||
assertEquals(5, XuneAccent.entries.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pin kinds cover quickplay surfaces`() {
|
||||
assertTrue(PinKind.TRACK.name == "TRACK")
|
||||
assertTrue(PinKind.entries.size == 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Root build file. AGP 9+ provides built-in Kotlin support —
|
||||
// the org.jetbrains.kotlin.android plugin must NOT be applied.
|
||||
//
|
||||
// Built-in Kotlin ships KGP 2.2.10; 2026-era androidx libraries pull
|
||||
// kotlin-stdlib 2.4.x whose metadata the 2.2.0 compiler cannot read.
|
||||
// Per the official guidance, force the newer KGP on the buildscript
|
||||
// classpath (upgrades only) so the whole toolchain aligns on 2.4.20.
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.20")
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
Copyright 2015, Microsoft Corporation (www.microsoft.com), with Reserved Font Name Selawik. All Rights Reserved. Selawik is a trademark of Microsoft Corporation in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Xune-HD Design Tokens
|
||||
|
||||
Ported from the Not-Zune `zune-design-system` skill (MIT) and adapted for the
|
||||
Zune HD device UI. All Compose code must consume these tokens; raw hex literals
|
||||
in screens are an invariant violation.
|
||||
|
||||
## Surfaces
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| `SurfaceBackground` | `#111111` | App canvas |
|
||||
| `SurfaceElevated` | `#181818` | Panels, crossbar strip |
|
||||
| `SurfaceTile` | `#202020` | Album art placeholder, cards |
|
||||
| `SurfaceTileHover` | `#2A2A2A` | Pressed/hovered tile |
|
||||
| `SurfaceBorderSubtle` | `#2C2C2C` | Hairline dividers (0.5-1 dp) |
|
||||
|
||||
## Accents (user-selectable)
|
||||
| Name | Primary | Bright |
|
||||
|---|---|---|
|
||||
| Zune Pink (default) | `#FA2A55` | `#FF4D79` |
|
||||
| Zune Orange | `#F09609` | `#FFA726` |
|
||||
| Zune Cyan | `#1BA1E2` | `#33B5E5` |
|
||||
| Zune Lime | `#339933` | `#4CAF50` |
|
||||
| Zune Purple | `#A200FF` | `#B388FF` |
|
||||
|
||||
## Text opacities (white only)
|
||||
| State | Opacity |
|
||||
|---|---|
|
||||
| Active / primary | 1.0 |
|
||||
| Hover / pressed | 0.85 |
|
||||
| Secondary metadata | 0.60 |
|
||||
| Inactive crossbar/menu | 0.40 |
|
||||
| Watermark type | 0.08 |
|
||||
|
||||
## Type scale (device-mode design units, 480x272 canvas)
|
||||
| Role | Size | Weight | Case |
|
||||
|---|---|---|---|
|
||||
| Home menu item | 34 | Light | lowercase |
|
||||
| Cropped screen header (back-tap) | 40 | Light | lowercase |
|
||||
| Crossbar pivot | 18 | Light | lowercase |
|
||||
| Now Playing title | 26 | Light | lowercase |
|
||||
| Now Playing artist/album | 15 | Light | lowercase |
|
||||
| List primary | 14 | Normal | — |
|
||||
| List secondary | 11 | Normal | — |
|
||||
| Caption / time | 9 | Normal | — |
|
||||
| Alphabet index | 10 | Normal | — |
|
||||
|
||||
## Motion
|
||||
- Deceleration: cubic ease-out; pivot slides 320-420 ms.
|
||||
- List stagger entrance: 15-25 ms/item.
|
||||
- Now Playing screensaver text drift: continuous, ~40 px/s, opacity 0.9.
|
||||
- Quickplay reveal: 420 ms with parallax at 0.6x.
|
||||
|
||||
## Geometry
|
||||
- Device canvas: 480 x 272 design units.
|
||||
- Screen margins: 16 (edges), 24 (crossbar leading).
|
||||
- Crossbar strip height: 34.
|
||||
- List row height: 40; album grid tile: 92 with 8 gutter.
|
||||
- Corner radius: **0 everywhere.**
|
||||
@@ -0,0 +1,121 @@
|
||||
# Zune HD UI Canon
|
||||
|
||||
The authoritative description of the Microsoft Zune HD on-device interface that
|
||||
Xune-HD replicates. Compiled from contemporaneous reviews (CNET, Gizmodo,
|
||||
SlashGear, TechCrunch, Windows Central, ZDNet, PCMag, ITPro Today, Sept 2009),
|
||||
the ZuneDev/Not-Zune decompilation corpus, and ZuneRedux community resources.
|
||||
Every screen in Xune-HD must be checkable against this document.
|
||||
|
||||
## 1. Hardware & canvas
|
||||
|
||||
- Display: 3.3" capacitive multi-touch OLED, **480x272** (wide), Tegra APX.
|
||||
- Buttons: power/hold (top), home (below screen), media/quickplay (left edge).
|
||||
- On Android we re-render this canvas at `480x272` design units (see
|
||||
`DeviceCanvas`) for **device mode**, and reflow the same components for
|
||||
**adaptive mode** on tall phones.
|
||||
|
||||
## 2. Core design language (Metro / Zegoe)
|
||||
|
||||
- White text on matte black. Typography **is** the UI; chrome is banned.
|
||||
- Font: Zegoe UI (Microsoft-modified Segoe). We ship **Selawik** (OFL,
|
||||
Segoe-metric) by default; users may import Zegoe themselves.
|
||||
- Menu labels are lowercase, oversized, and **deliberately cropped at the
|
||||
right screen edge** (Gizmodo: "the word marketplace is cut off at the
|
||||
penultimate letter").
|
||||
- Opacity communicates state: active 100%, neighbors ~40%, metadata ~60%.
|
||||
- Zero corner radius, zero drop shadows, no skeuomorphism. Ever.
|
||||
- Accent color themes: Zune pink `#FA2A55` (default), orange `#F09609`,
|
||||
cyan `#1BA1E2`, lime `#339933`, purple `#A200FF`.
|
||||
- Kinetic scrolling with strong deceleration (cubic/exponential ease-out).
|
||||
- Lists stagger their entrance (~15-25 ms per item cascading slide).
|
||||
|
||||
## 3. Navigation model
|
||||
|
||||
### 3.1 Home menu (default view)
|
||||
A vertical text list: `music · videos · pictures · radio · marketplace ·
|
||||
social · podcasts · internet · settings` (Zune HD firmware 4.x). In Xune-HD
|
||||
the functional entries are `music` and `settings`; future pivots may join.
|
||||
- Flick vertically to scroll (kinetic).
|
||||
- Tap an entry to enter. The whole entry is the button — text only.
|
||||
- Items are cropped at the right edge as a signature.
|
||||
|
||||
### 3.2 Quickplay (the "left screen")
|
||||
- **Slide the home menu right** to reveal Quickplay parked "left and rear"
|
||||
with a parallax 3D slide (Windows Central: "left and to the rear, in a bit
|
||||
of visual 3D trickery").
|
||||
- Quickplay contains: **Now Playing** (current track card: art, title,
|
||||
artist), **Pins** (user-pinned items — long-press anything to pin),
|
||||
**History** (recently played), **New** (recently added).
|
||||
- Purpose: bypass collection drilling; reach relevant content instantly.
|
||||
- On device the left-edge hardware button summons it; in Xune-HD it is the
|
||||
home screen's left page.
|
||||
|
||||
### 3.3 Crossbar ("sub-menus arrayed left to right across the top")
|
||||
- Inside `music`: top crossbar row is `albums · artists · playlists · songs ·
|
||||
genres` (real Zune order).
|
||||
- Flick horizontally on the crossbar (or content) to switch pivots; content
|
||||
slides horizontally beneath the fixed crossbar.
|
||||
- Lists scroll vertically: textual (artists, genres) or grid (albums).
|
||||
|
||||
### 3.4 Back gesture = tap the cut-off header
|
||||
- There is no hardware back. The **partially visible top-of-screen heading**
|
||||
(e.g. the bottom of "SETT" on the Settings screen) is the back button
|
||||
(ZDNet Quick Start Guide discovery; Gizmodo confirms).
|
||||
- Now Playing is the exception: it shows an explicit left-arrow back button
|
||||
(Gizmodo).
|
||||
|
||||
### 3.5 Alphabet jump
|
||||
- In long lists, faint letters run alongside the list (SlashGear).
|
||||
- Tap any letter → a full A–Z index pops up → pick a letter to jump.
|
||||
|
||||
## 4. Now Playing (the signature screen)
|
||||
|
||||
Layout (from ITPro Today's walkthrough):
|
||||
- Explicit **back arrow** top-left.
|
||||
- **Artist** name and **album** name (tappable: artist → their crossbar page
|
||||
with albums/songs/bio/photos/related; song title → the album's track list).
|
||||
- **Album art** prominent.
|
||||
- Bottom row: **shuffle**, **repeat**, **rating** (heart / broken heart).
|
||||
- The card **floats over artist photography** fetched from zune.net keyed by
|
||||
MusicBrainz ID (see `net/` in Xune-HD; recreated by ZuneArtistImages).
|
||||
|
||||
Interactions:
|
||||
- **Idle for a few seconds → screensaver**: metadata (artist, track, album,
|
||||
length, art) slowly scrolls over the artist photo, "super-smooth".
|
||||
- **Swipe left/right → skip** to next/previous track.
|
||||
- **Tap empty space → transport overlay**: play/pause center, volume up /
|
||||
volume down at top/bottom, previous/next at left/right (ITPro Today).
|
||||
Also summoned by the device's media button.
|
||||
- **Swipe up/down (overlay visible) → volume** up/down.
|
||||
|
||||
Ratings (tri-state heart, from the Zune desktop/HD family):
|
||||
- Heart = favorite (prioritized by Smart DJ/Quickplay).
|
||||
- Broken heart = dislike (skipped in shuffle).
|
||||
- Unrated = neutral.
|
||||
|
||||
## 5. Lock/wake behavior
|
||||
- Wake shows the user wallpaper behind a "software shade"; **slide the shade
|
||||
up** to reveal the home screen (ITPro Today). Xune-HD v1 treats the Android
|
||||
lock screen as the wake surface; the in-app shade is a stretch goal.
|
||||
|
||||
## 6. Motion rules
|
||||
- Navigation: content slides horizontally with deceleration; deeper screens
|
||||
slide in from the right while the parent dims slightly leftward.
|
||||
- Crossbar pivot switch: horizontal slide, no bounce.
|
||||
- Kinetic lists: fling, then long deceleration to rest.
|
||||
- Everything fast: Metro is "designed to feel fast and responsive".
|
||||
|
||||
## 7. Banned in Xune-HD (invariant list, mirrors Not-Zune)
|
||||
- `RoundedCornerShape` / any nonzero corner radius on UI chrome.
|
||||
- Drop shadows on text/buttons; gradient chrome; skeuomorphic textures.
|
||||
- Icon-only navigation in the main hierarchy (text is navigation).
|
||||
- Material default colors/typography leaking into screens.
|
||||
- Star ratings (Zune is heart-based, tri-state).
|
||||
|
||||
## 8. Reference library
|
||||
- not-zune (desktop) design-system skill + extracted Zune assets (MIT).
|
||||
- ZuneRedux/zune-hd-apps — original HD app archive (design reference only).
|
||||
- spidersandmoths/ZuneArtistImages — recreated catalog.zune.net semantics.
|
||||
- zuneupdate.com — community resource server (resources.zune.net).
|
||||
- BillyOutlast/MusicIn2001 — behavioral spec only (Research-Only license).
|
||||
- Period reviews, Sept 2009 (listed above) for interaction ground truth.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Project-wide Gradle settings.
|
||||
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -XX:+UseParallelGC
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
# AndroidX
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# AGP 9: built-in Kotlin is the supported path.
|
||||
android.builtInKotlin=true
|
||||
android.newDsl=true
|
||||
@@ -0,0 +1,57 @@
|
||||
[versions]
|
||||
agp = "9.4.0"
|
||||
# Built-in Kotlin (AGP 9) is upgraded to this KGP version via the
|
||||
# buildscript classpath (see root build.gradle.kts). The compose compiler
|
||||
# plugin version must match the effective Kotlin compiler version.
|
||||
kotlinComposePlugin = "2.4.20"
|
||||
ksp = "2.3.12"
|
||||
coreKtx = "1.19.0"
|
||||
activityCompose = "1.13.0"
|
||||
lifecycle = "2.11.0"
|
||||
composeBom = "2026.09.00"
|
||||
media3 = "1.11.0"
|
||||
room = "2.8.5"
|
||||
coil = "3.6.2"
|
||||
palette = "1.0.0"
|
||||
datastore = "1.2.1"
|
||||
coroutines = "1.10.2"
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
|
||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||
compose-animation = { group = "androidx.compose.animation", name = "animation" }
|
||||
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
|
||||
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||
media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
||||
media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" }
|
||||
|
||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||
|
||||
palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
|
||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutines" }
|
||||
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlinComposePlugin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,24 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "xune-hd"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user