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:
John Smith
2026-09-09 18:41:44 -04:00
commit f125324641
75 changed files with 5308 additions and 0 deletions
+33
View File
@@ -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
View File
@@ -0,0 +1,17 @@
# Gradle
.gradle/
build/
app/build/
local.properties
# IDE
.idea/
*.iml
.DS_Store
# Android
captures/
*.apk.idsig
# Logs
*.log
+51
View File
@@ -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.
+24
View File
@@ -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.
+28
View File
@@ -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).
+87
View File
@@ -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**
[![Android](https://img.shields.io/badge/Platform-Android%209%2B-3DDC84)]()
[![UI](https://img.shields.io/badge/UI-Jetpack%20Compose-4285F4)]()
[![Audio](https://img.shields.io/badge/Audio-Media3%20%2F%20ExoPlayer-FA2A55)]()
[![License](https://img.shields.io/badge/License-MIT-111111)]()
</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
+82
View File
@@ -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)
}
+6
View File
@@ -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.**
+41
View File
@@ -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,
)
}
}
}
+3
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>
+3
View File
@@ -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>
+13
View File
@@ -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>
+6
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Xune HD</string>
</resources>
+8
View File
@@ -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)
}
}
+22
View File
@@ -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
}
+91
View File
@@ -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.
+58
View File
@@ -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.**
+121
View File
@@ -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.
+13
View File
@@ -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
+57
View File
@@ -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" }
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+252
View File
@@ -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" "$@"
+24
View File
@@ -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")