mirror of
https://github.com/Heretek-AI/not-zune.git
synced 2026-10-11 21:19:55 +00:00
feat: Initial implementation of Not-Zune cross-platform desktop player
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"not-zune-dev-tools": {
|
||||||
|
"command": "python3",
|
||||||
|
"args": ["scripts/mcp_tools.py"],
|
||||||
|
"description": "Not-Zune development tools: physical Zune USB hardware prober and Zune Metro design invariant auditor."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
name: zune-design-system
|
||||||
|
description: >-
|
||||||
|
Use this skill when designing, implementing, styling, or auditing user interface components,
|
||||||
|
themes, layouts, typography, and motion in Not-Zune to ensure complete fidelity to the Microsoft
|
||||||
|
Zune "Metro" and Iris design language.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Zune "Metro" / Iris Design System Guide
|
||||||
|
|
||||||
|
This guide defines the immutable rules, tokens, typography scales, layout geometries, and motion
|
||||||
|
principles that govern all visual and interactive presentation in Not-Zune.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core Principles
|
||||||
|
|
||||||
|
### Authentically Digital & Content Before Chrome
|
||||||
|
- **No Skeuomorphism:** No fake leather, drop shadows, glossy plastic buttons, or glass bevels.
|
||||||
|
- **Zero Corner Radius:** All elements (buttons, cards, album art, text boxes, flyouts, sliders) have `CornerRadius="0"`.
|
||||||
|
- **Borderless Geometry:** Visual structure is achieved through spatial layout, alignment, and typography weight rather than border lines.
|
||||||
|
- **Horizontal Bleed (Panorama):** Horizontal layouts bleed off the right edge of the viewport, visually inviting the user to explore further.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Color Palette & Tokens
|
||||||
|
|
||||||
|
### Base Surfaces
|
||||||
|
| Token Name | Hex Code | Purpose |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `SurfaceBackground` | `#111111` | Primary matte black canvas |
|
||||||
|
| `SurfaceElevated` | `#181818` | Panels, drawers, navigation strips |
|
||||||
|
| `SurfaceTile` | `#202020` | Album art placeholder, media cards |
|
||||||
|
| `SurfaceTileHover` | `#2A2A2A` | Hovered card state |
|
||||||
|
| `SurfaceBorderSubtle`| `#2C2C2C` | Hairline dividers (0.5px - 1px) |
|
||||||
|
|
||||||
|
### Signature Accent Colors
|
||||||
|
The user can select an accent color in Settings. All accent-colored UI controls must bind to the dynamic accent resource:
|
||||||
|
|
||||||
|
| Accent Name | Primary Hex | Hover / Bright | Purpose |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Zune Magenta / Pink (Default)** | `#E51400` / `#FA2A55` | `#FF4D79` | Signature default brand accent |
|
||||||
|
| **Zune Orange** | `#F09609` | `#FFA726` | Classic Zune 30 / Warm accent |
|
||||||
|
| **Zune Cyan / Electric Blue** | `#1BA1E2` | `#33B5E5` | Fresh modern accent |
|
||||||
|
| **Zune Lime / Green** | `#339933` | `#4CAF50` | Eco / Vivid accent |
|
||||||
|
| **Zune Purple** | `#A200FF` | `#B388FF` | Deep vibrant accent |
|
||||||
|
|
||||||
|
### Typography Hierarchy & Opacities
|
||||||
|
Text color is always pure white (`#FFFFFF`) on dark theme, modulated by opacity:
|
||||||
|
- **Active / Primary Text:** `Opacity="1.0"` (`#FFFFFF`)
|
||||||
|
- **Hover Text:** `Opacity="0.85"`
|
||||||
|
- **Secondary / Metadata Text:** `Opacity="0.60"` (Artist name under track, duration, year)
|
||||||
|
- **Inactive / Dimmed Pivot:** `Opacity="0.40"` (Unselected pivot titles)
|
||||||
|
- **Watermark / Background Big Type:** `Opacity="0.08"` (Oversized subtle background numerals/letters)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typographic Scales
|
||||||
|
|
||||||
|
Font family must be `Segoe UI`, `Segoe WP`, `Zegoe UI`, or bundled open-source metric-compatible `Selawik` / `Inter`.
|
||||||
|
|
||||||
|
| Role | Font Size | Weight | Case | Usage |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **Hero Display** | `56pt - 72pt` | Light (300) | Normal | Now Playing track title, Quickplay clock/stats |
|
||||||
|
| **Pivot Main** | `34pt` | Light (300) | UPPERCASE | `QUICKPLAY`, `COLLECTION`, `DEVICE`, `SETTINGS` |
|
||||||
|
| **Sub-Pivot** | `20pt` | Light (300) | lowercase | `music`, `videos`, `podcasts`, `artists`, `albums` |
|
||||||
|
| **Section Header**| `16pt` | SemiBold (600)| Normal | Playlist title, Album header |
|
||||||
|
| **Body Primary** | `12pt` | Normal (400) | Normal | Track title in list, table row |
|
||||||
|
| **Body Secondary**| `10pt` | Normal (400) | Normal | Artist, Album subtitle |
|
||||||
|
| **Caption / Time** | `9pt` | Normal (400) | Normal | Elapsed/Total time, track numbers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key Component Blueprints
|
||||||
|
|
||||||
|
### A. The Pivot Header
|
||||||
|
```xml
|
||||||
|
<!-- Example Avalonia XAML structure for Zune Pivot -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="28" Margin="40,24,0,16">
|
||||||
|
<TextBlock Text="QUICKPLAY" Classes="pivot-header active" />
|
||||||
|
<TextBlock Text="COLLECTION" Classes="pivot-header" />
|
||||||
|
<TextBlock Text="DEVICE" Classes="pivot-header" />
|
||||||
|
<TextBlock Text="SETTINGS" Classes="pivot-header" />
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
- Inactive pivots must have a smooth fade to 100% opacity on hover, and clicking switches the active view with a horizontal content slide.
|
||||||
|
|
||||||
|
### B. The Tri-State Heart Rating
|
||||||
|
Zune used an iconic binary/tri-state heart rating rather than 5-star ratings:
|
||||||
|
1. **Heart (Favorite):** Solid accent-colored heart icon. Song is prioritized in Smart DJ and Quickplay Favorites.
|
||||||
|
2. **Broken Heart (Dislike):** Cracked/broken heart icon. Song is skipped during shuffle and Smart DJ generation.
|
||||||
|
3. **Unrated (Neutral):** Transparent or subtle outline shown on hover.
|
||||||
|
|
||||||
|
### C. The Quickplay Hub
|
||||||
|
- Split screen:
|
||||||
|
- **Left Wing:** Smart DJ quick-mix seed panel.
|
||||||
|
- **Right Wing:** 3-slot horizontal sliding carousel containing:
|
||||||
|
- `Pins`: User-pinned artists, albums, or playlists.
|
||||||
|
- `History`: Grid of recently played album covers.
|
||||||
|
- `New`: Grid of newly added album covers.
|
||||||
|
- Clicking on one of the decks slides it into center stage with deceleration easing.
|
||||||
|
|
||||||
|
### D. Now Playing Modes
|
||||||
|
1. **Dynamic Artist Backdrop:**
|
||||||
|
- Full-bleed artist photography slideshow.
|
||||||
|
- Smooth, slow Ken-Burns effect (gentle pan and 1.0 -> 1.05 scale transition over 20 seconds).
|
||||||
|
- Track metadata floats over subtle bottom/left linear gradient vignette.
|
||||||
|
2. **Album Art Mosaic Wall:**
|
||||||
|
- Vast, scrolling tapestry of square album art from the entire collection.
|
||||||
|
- Active album cover is scaled up and highlighted in the center.
|
||||||
|
|
||||||
|
### E. Player HUD (Bottom Bar)
|
||||||
|
- Height: `64px` - `72px`.
|
||||||
|
- Ultra-thin seek bar (2px normal, 4px on hover) spanning the top edge of the HUD with the active accent color.
|
||||||
|
- Transport controls: Previous, Play/Pause, Next, Shuffle, Repeat, Volume.
|
||||||
|
- Left side: Album thumbnail, track title, artist, heart rating.
|
||||||
|
- Right side: Now Playing toggle button, Mini-Player toggle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Animation & Motion Rules
|
||||||
|
|
||||||
|
- **Deceleration Curve:** Use Cubic Ease Out or Exponential Ease Out for pan transitions. Movement should start fast and decelerate gently.
|
||||||
|
- **Staggered Entrance:** When loading lists or grids, stagger tile entrances by 15-25ms per item to create the classic Metro cascading entrance effect.
|
||||||
|
- **Depth Transitions:** When navigating deeper into an album or artist, the parent view slides slightly left and dims, while the child view slides in from the right.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
name: zune-hardware-sync
|
||||||
|
description: >-
|
||||||
|
Use this skill when developing, debugging, testing, or maintaining the physical Zune device
|
||||||
|
synchronization subsystem, including USB MTP/MTPZ protocols, ZMDB database parsing,
|
||||||
|
USB-PPP network interception for artist metadata, and wireless sync.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Zune Hardware Synchronization Architecture Guide
|
||||||
|
|
||||||
|
This guide details the technical protocols, binary formats, and hardware communication procedures
|
||||||
|
required to interface with physical Microsoft Zune media players (Zune 30, Zune 4/8/16, Zune 80/120, and Zune HD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Hardware Identifiers & USB Enumeration
|
||||||
|
|
||||||
|
All physical Zune devices share Microsoft's USB Vendor ID (`0x045E`):
|
||||||
|
|
||||||
|
| Device Model | USB Product ID | Storage | Display / Notes |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Zune 30** | `0x063E` | 30 GB HDD | 240x320 LCD, mechanical d-pad |
|
||||||
|
| **Zune 4 / 8 / 16** | `0x0710` | 4, 8, 16 GB Flash | 240x320 LCD, Zune Pad |
|
||||||
|
| **Zune 80 / 120** | `0x0715` | 80, 120 GB HDD | 240x320 LCD, Zune Pad |
|
||||||
|
| **Zune HD (16/32/64)**| `0x0723` | 16, 32, 64 GB Flash| 480x272 OLED, Capacitive Multi-touch, Tegra APX |
|
||||||
|
|
||||||
|
### OS USB Backends
|
||||||
|
- **Linux (`libusb-1.0`):**
|
||||||
|
Requires udev rules in `/etc/udev/rules.d/51-zune.rules`:
|
||||||
|
```udev
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="045e", ATTR{idProduct}=="063e", MODE="0666"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="045e", ATTR{idProduct}=="0710", MODE="0666"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="045e", ATTR{idProduct}=="0715", MODE="0666"
|
||||||
|
SUBSYSTEM=="usb", ATTR{idVendor}=="045e", ATTR{idProduct}=="0723", MODE="0666"
|
||||||
|
```
|
||||||
|
- **Windows (`WinUSB`):**
|
||||||
|
Bound using the WinUSB driver so userspace software has direct access to bulk and interrupt pipes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. MTP & MTPZ Protocol Handshake
|
||||||
|
|
||||||
|
Zunes communicate using the Media Transfer Protocol (MTP) with proprietary Microsoft extensions (MTPZ).
|
||||||
|
|
||||||
|
1. **Standard MTP Session:**
|
||||||
|
- Open Session (`OpenSession(1)`).
|
||||||
|
- Query Device Capabilities and Storage IDs (`GetStorageIDs`).
|
||||||
|
2. **MTPZ Authentication Challenge:**
|
||||||
|
- The device requires an MTPZ handshake to unlock full read/write capabilities and prevent firmware lockup.
|
||||||
|
- Requires `.mtpz-data` certificate and key validation.
|
||||||
|
- Handshake sequence exchanges encrypted tokens over vendor-specific MTP commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fast ZMDB (Zune Media Database) Parser
|
||||||
|
|
||||||
|
Iterating objects one-by-one via standard MTP commands (`GetObjectHandles` + `GetObjectInfo`) takes hours for large collections (10,000+ tracks).
|
||||||
|
|
||||||
|
Instead, Not-Zune utilizes **fast ZMDB binary parsing**:
|
||||||
|
1. Retrieve the device's internal binary database file (`zune.zmdb` located in the root media directory) via a single MTP streaming download.
|
||||||
|
2. Parse the binary table structure using the **F-marker record extractor algorithm**:
|
||||||
|
- The database stores fixed-length record headers preceded by signature sync markers (F-markers).
|
||||||
|
- Fast sequential memory scan extracts Track ID, Title, Artist string, Album string, Duration, Bitrate, Rating (Heart state), and File path.
|
||||||
|
3. The entire device library of 30,000 tracks is indexed in under 2 seconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. USB PPP / HTTP Reverse Interceptor
|
||||||
|
|
||||||
|
The Zune HD (and Zune 30/80/120 firmware 3.0+) automatically fetches artist backgrounds and biography XML over USB when connected to a host:
|
||||||
|
|
||||||
|
### How it Works:
|
||||||
|
1. **USB Transport Layer:** The host and Zune exchange raw network frames using MTP vendor commands:
|
||||||
|
- `0x922C`: Send packet to Zune.
|
||||||
|
- `0x922D`: Poll/receive packet from Zune.
|
||||||
|
2. **PPP / IPCP Handshake:**
|
||||||
|
- The host implements a lightweight Point-to-Point Protocol (PPP) and Link Control Protocol (LCP).
|
||||||
|
- Negotiates IP addresses via IPCP:
|
||||||
|
- **Host IP:** `192.168.55.100`
|
||||||
|
- **Zune Device IP:** `192.168.55.101`
|
||||||
|
3. **Embedded DNS Server:**
|
||||||
|
- Listens on UDP port 53 on `192.168.55.100`.
|
||||||
|
- Any query for `catalog.zune.net`, `image.catalog.zune.net`, or `resources.zune.net` resolves to `192.168.55.100`.
|
||||||
|
4. **Embedded HTTP Server:**
|
||||||
|
- Listens on TCP port 80.
|
||||||
|
- Serves Zune XML catalog format:
|
||||||
|
- `/v3.0/en-US/music/artist/{mbid}`
|
||||||
|
- `/v3.0/en-US/music/artist/{mbid}/biography`
|
||||||
|
- `/v3.0/en-US/music/artist/{mbid}/deviceBackgroundImage`
|
||||||
|
- The Zune immediately renders the high-res artist wallpaper and bio on the device screen!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Wireless Sync (SSDP & PTP/IP)
|
||||||
|
|
||||||
|
- **SSDP Discovery:** The Zune broadcasts SSDP `M-SEARCH` packets on UDP port 1900 with device type `urn:schemas-microsoft-com:device:zune-sync:1`.
|
||||||
|
- **PTP/IP Connection:** Host connects back to the Zune's wireless IP address on TCP port 15740 to perform sync over Wi-Fi without needing a USB cable.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
---
|
||||||
|
name: zune-plugins-protocol
|
||||||
|
description: >-
|
||||||
|
Use this skill when developing, testing, or integrating out-of-process plugins for Not-Zune,
|
||||||
|
including JSON-RPC messaging contracts, event handling (playback, scrobbling, rich presence),
|
||||||
|
and packaging .znp extensions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Not-Zune Plugin Architecture & Wire Protocol
|
||||||
|
|
||||||
|
This guide defines the out-of-process plugin architecture, JSON-RPC communication specification,
|
||||||
|
event subscriptions, and packaging standards for Not-Zune.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Process Isolation Architecture
|
||||||
|
|
||||||
|
To ensure player stability, security, and cross-platform flexibility, Not-Zune executes all plugins
|
||||||
|
in **isolated out-of-process worker sandboxes**:
|
||||||
|
|
||||||
|
- Plugins do **not** run inside the main player UI process. A crashed or hung plugin cannot crash or freeze audio playback.
|
||||||
|
- Communication takes place over standard bidirectional **JSON-RPC 2.0** over Standard I/O pipes (stdin/stdout) or local domain sockets (`unix:` on Linux, named pipes `\\.\pipe\` on Windows).
|
||||||
|
- The host process monitors plugin health, handles automatic restarts, and regulates resource limits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Plugin Manifest (`plugin.json`)
|
||||||
|
|
||||||
|
Every plugin package includes a `plugin.json` descriptor:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/Heretek-AI/not-zune/main/plugin-schema.json",
|
||||||
|
"id": "com.notzune.discord",
|
||||||
|
"name": "Discord Rich Presence",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Not-Zune Team",
|
||||||
|
"description": "Displays current track, artist, album art, and play status on your Discord profile.",
|
||||||
|
"sdkVersion": "1.0",
|
||||||
|
"entryPoint": "NotZune.Plugin.Discord.dll",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"type": "event-listening",
|
||||||
|
"scopes": ["playback", "metadata"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "network-access",
|
||||||
|
"scopes": ["ipc"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. JSON-RPC Protocol & Methods
|
||||||
|
|
||||||
|
### Lifecycle Methods
|
||||||
|
- `initialize`: Host sends player version, OS info, and user preferences.
|
||||||
|
- `shutdown`: Graceful termination notification before process termination.
|
||||||
|
|
||||||
|
### Event Subscriptions
|
||||||
|
Plugins declare interest in specific event scopes and receive push notifications from the host:
|
||||||
|
|
||||||
|
#### `playback/trackChanged`
|
||||||
|
Fires when the active track changes or starts playing:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "playback/trackChanged",
|
||||||
|
"params": {
|
||||||
|
"track": {
|
||||||
|
"id": "uuid-here",
|
||||||
|
"title": "Subdivisions",
|
||||||
|
"artist": "Rush",
|
||||||
|
"album": "Signals",
|
||||||
|
"albumArtist": "Rush",
|
||||||
|
"durationMs": 334000,
|
||||||
|
"trackNumber": 1,
|
||||||
|
"discNumber": 1,
|
||||||
|
"year": 1982,
|
||||||
|
"genre": "Progressive Rock",
|
||||||
|
"rating": "Favorite",
|
||||||
|
"artworkUrl": "local://art/album-rush-signals.jpg",
|
||||||
|
"musicBrainzTrackId": "...",
|
||||||
|
"musicBrainzArtistId": "..."
|
||||||
|
},
|
||||||
|
"positionMs": 0,
|
||||||
|
"isPlaying": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `playback/stateChanged`
|
||||||
|
Fires on play, pause, stop, or seek:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "playback/stateChanged",
|
||||||
|
"params": {
|
||||||
|
"state": "Playing",
|
||||||
|
"positionMs": 45120
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `rating/changed`
|
||||||
|
Fires when the user updates the heart rating:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "rating/changed",
|
||||||
|
"params": {
|
||||||
|
"trackId": "uuid-here",
|
||||||
|
"rating": "Favorite"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Host Services Provided to Plugins
|
||||||
|
|
||||||
|
Plugins can invoke methods on the host via standard JSON-RPC requests:
|
||||||
|
- `logger/log`: Log info, warning, or error messages into the central player log.
|
||||||
|
- `storage/get` & `storage/set`: Encrypted key-value persistence for credentials (e.g. Last.fm session tokens).
|
||||||
|
- `library/queryTracks`: Query tracks by artist, album, or search text.
|
||||||
|
- `ui/showToast`: Display a native Zune-styled sliding toast notification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Packaging & Distribution (`.znp`)
|
||||||
|
|
||||||
|
- Plugin packages use the `.znp` extension (Zune Native Plugin), which is a standard zip archive.
|
||||||
|
- Contents:
|
||||||
|
- `plugin.json` (at root)
|
||||||
|
- Compiled plugin binaries and dependent DLLs / native libraries
|
||||||
|
- Optional assets (icons, licenses)
|
||||||
|
- Installation: Drag-and-drop into Settings > Software > Plugins, or placing into the `~/.local/share/not-zune/plugins` (Linux) or `%LOCALAPPDATA%\NotZune\plugins` (Windows) folder.
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio / Rider
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Local database and caches
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
notzune.db
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
<Authors>Heretek AI / Not-Zune Contributors</Authors>
|
||||||
|
<Company>Not-Zune</Company>
|
||||||
|
<Product>Not-Zune Media Player</Product>
|
||||||
|
<Copyright>Copyright © 2026</Copyright>
|
||||||
|
<RepositoryUrl>https://github.com/Heretek-AI/not-zune</RepositoryUrl>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<AvaloniaVersion>11.2.0</AvaloniaVersion>
|
||||||
|
<CommunityToolkitMvvmVersion>8.3.2</CommunityToolkitMvvmVersion>
|
||||||
|
<MicrosoftEfCoreVersion>8.0.10</MicrosoftEfCoreVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Not-Zune: Repository Guidelines & Directives
|
||||||
|
|
||||||
|
Welcome to **Not-Zune**, the modern cross-platform spiritual successor to Microsoft Zune Desktop and player.
|
||||||
|
This repository is self-contained and adheres to strict design, architecture, and engineering principles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design Invariants: The Zune "Metro" / Iris UI/UX System
|
||||||
|
|
||||||
|
All user interfaces in this project MUST strictly follow the authentic Zune design language:
|
||||||
|
|
||||||
|
1. **Content Before Chrome:**
|
||||||
|
- Eliminate unnecessary borders, frames, and drop shadows.
|
||||||
|
- **Zero Corner Radius:** All surfaces, cards, buttons, and tiles must have `CornerRadius = 0`. No rounded corners.
|
||||||
|
- Avoid skeuomorphic bevels, glossy highlights, or synthetic depth.
|
||||||
|
2. **Typography as Art:**
|
||||||
|
- Typography is the primary visual element. Use **Segoe UI**, **Segoe WP**, **Zegoe UI**, or bundled **Selawik** metrics.
|
||||||
|
- Strict hierarchical scale:
|
||||||
|
- Hero / Display: 48pt – 72pt Light (`FontWeight.Light`), tight tracking.
|
||||||
|
- Primary Pivot: 34pt Light, uppercase (`QUICKPLAY`, `COLLECTION`, `DEVICE`, `SETTINGS`).
|
||||||
|
- Sub-Pivot: 20pt Light, lowercase (`music`, `videos`, `podcasts`, `artists`, `albums`).
|
||||||
|
- Content Header: 16pt SemiBold.
|
||||||
|
- Body: 11pt Regular.
|
||||||
|
- Subtitle / Caption: 9pt Regular, 60% opacity.
|
||||||
|
- Opacity establishes depth: Active text is 100% white (`#FFFFFF`), hover is 85%, inactive/unselected is 35–40%.
|
||||||
|
3. **Canvas & Surfaces:**
|
||||||
|
- Background Canvas: Matte Pitch Black (`#111111`) or Dark Charcoal (`#161616`).
|
||||||
|
- Card/Tile Surface: `#202020` default, `#282828` hover.
|
||||||
|
- Light Theme alternate: `#ECECEC` background with `#1A1A1A` text.
|
||||||
|
4. **Signature Accent Colors:**
|
||||||
|
- Zune Pink / Magenta (`#FA2A55` / `#E51400`) — signature default.
|
||||||
|
- Zune Orange (`#F09609`).
|
||||||
|
- Zune Cyan (`#1BA1E2`).
|
||||||
|
- Zune Lime / Green (`#339933`).
|
||||||
|
- Zune Purple (`#A200FF`).
|
||||||
|
5. **Iconic Navigation & Hubs:**
|
||||||
|
- **Pivot Header:** Horizontal strip of section titles with smooth deceleration pan.
|
||||||
|
- **Quickplay Hub:** Split layout with Smart DJ seed generator on the left, and an interactive horizontal deck of `Pins`, `History`, and `New` on the right.
|
||||||
|
- **Now Playing:** Dual modes:
|
||||||
|
- Dynamic Artist Canvas (Ken-Burns slow pan/zoom on high-res artist photography).
|
||||||
|
- Album Art Mosaic Wall (3D/2D grid of collection album covers with active track centered).
|
||||||
|
- **Heart Rating:** Tri-state rating: Heart (favorite), Broken Heart (dislike/skip), Neutral (unrated).
|
||||||
|
- **Player HUD:** Minimalist bottom bar with hairline seek line, transport controls, and volume.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Platform Targets & Compatibility
|
||||||
|
|
||||||
|
- **Desktop (Tier 1):** Windows (x64, arm64) and Linux (x64, arm64).
|
||||||
|
- **Mobile (Long-Term Milestone):** Android.
|
||||||
|
- All core business logic, domain models, persistence, and application use cases must reside in platform-agnostic .NET 8 class libraries so they can be consumed across Desktop and Android targets.
|
||||||
|
- GUI is built on **Avalonia UI** with hardware-accelerated SkiaSharp rendering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture & Code Organization
|
||||||
|
|
||||||
|
The codebase follows Clean Architecture with strict separation of concerns:
|
||||||
|
|
||||||
|
- `src/NotZune.Domain`: Entities, value objects, domain events, business invariants. (Zero external GUI/audio dependencies).
|
||||||
|
- `src/NotZune.Application`: Application use cases, playback coordinators, library services, sync orchestrators, plugin interfaces.
|
||||||
|
- `src/NotZune.Infrastructure.Persistence`: SQLite database, EF Core, FTS5 full-text search indexing.
|
||||||
|
- `src/NotZune.Infrastructure.Audio`: Audio playback pipeline, gapless voice transitions, ReplayGain normalization, FFT spectrum analyzer, system media controls (Linux MPRIS, Windows SMTC).
|
||||||
|
- `src/NotZune.Infrastructure.Devices`: Physical Zune USB synchronization:
|
||||||
|
- Transport backends: `libusb` on Linux, `WinUSB` on Windows.
|
||||||
|
- MTP / MTPZ security handshake.
|
||||||
|
- ZMDB fast binary parser (F-marker record extractor).
|
||||||
|
- USB PPP / TCP / DNS / HTTP reverse interceptor (`192.168.55.100`) for streaming artist biography XML and JPEG artwork directly to connected Zunes.
|
||||||
|
- SSDP / PTP/IP wireless synchronization listener.
|
||||||
|
- `src/NotZune.Infrastructure.External`: Metadata aggregators (MusicBrainz, Fanart.tv, Last.fm, ZuneNetApi).
|
||||||
|
- `src/NotZune.Plugins.Protocol` & `src/NotZune.Plugins.Sdk`: Out-of-process JSON-RPC sandboxed plugin architecture.
|
||||||
|
- `src/NotZune.UI`: Shared Avalonia XAML views, view models, controls, animations, and theme resources.
|
||||||
|
- `src/NotZune.Desktop`: Desktop host executable for Linux and Windows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Skills & Agent Tools
|
||||||
|
|
||||||
|
Specialized domain knowledge is codified in `.agents/skills/`:
|
||||||
|
- [`zune-design-system`](.agents/skills/zune-design-system/SKILL.md): Comprehensive UI design tokens, XAML templates, and layout specifications.
|
||||||
|
- [`zune-hardware-sync`](.agents/skills/zune-hardware-sync/SKILL.md): Complete protocol guide for Zune USB MTP/MTPZ, ZMDB parsing, and USB-PPP network stack.
|
||||||
|
- [`zune-plugins-protocol`](.agents/skills/zune-plugins-protocol/SKILL.md): Out-of-process plugin specification and JSON-RPC wire contracts.
|
||||||
|
|
||||||
|
Local developer tools are declared in `.agents/mcp_config.json`.
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{386DD2E6-7733-4758-9DEF-333286DDB86F}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Application", "src\NotZune.Application\NotZune.Application.csproj", "{9C687BB5-6AE3-4C94-A12F-6D34C5DAAFFD}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Desktop", "src\NotZune.Desktop\NotZune.Desktop.csproj", "{25AC0615-C4F1-4C77-BC58-60854E7311CD}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Domain", "src\NotZune.Domain\NotZune.Domain.csproj", "{9C9E9467-07D6-4E56-91EA-B7D968A170AB}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Infrastructure.Audio", "src\NotZune.Infrastructure.Audio\NotZune.Infrastructure.Audio.csproj", "{9E32997A-83D4-4E95-9716-C2A4FE443BE0}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Infrastructure.Devices", "src\NotZune.Infrastructure.Devices\NotZune.Infrastructure.Devices.csproj", "{4609E14C-7E25-44B0-A330-8AC1FFEAB51B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Infrastructure.External", "src\NotZune.Infrastructure.External\NotZune.Infrastructure.External.csproj", "{CF1E3D87-5A99-46F7-994E-3CE3359C1638}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Infrastructure.Persistence", "src\NotZune.Infrastructure.Persistence\NotZune.Infrastructure.Persistence.csproj", "{162CD64B-153A-4CA6-8934-E70C8CA022DF}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Plugins.Protocol", "src\NotZune.Plugins.Protocol\NotZune.Plugins.Protocol.csproj", "{9E042CB8-6DFE-4036-B4FF-A8F5B893973B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Plugins.Sdk", "src\NotZune.Plugins.Sdk\NotZune.Plugins.Sdk.csproj", "{CF28E88C-BCB3-43DF-BC90-F9808D4C2B4E}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.UI", "src\NotZune.UI\NotZune.UI.csproj", "{E0F28BF6-1159-41CC-BBEE-1FBAFED53447}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{89103A14-FD5B-4F9F-9AEF-803F5D97F55C}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Tests.Application", "tests\NotZune.Tests.Application\NotZune.Tests.Application.csproj", "{17B493BB-CFCB-423D-844F-3ECD6BA3D6CC}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotZune.Tests.Domain", "tests\NotZune.Tests.Domain\NotZune.Tests.Domain.csproj", "{275BFD54-E360-44EC-8A6C-03D6167413BF}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{9C687BB5-6AE3-4C94-A12F-6D34C5DAAFFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9C687BB5-6AE3-4C94-A12F-6D34C5DAAFFD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9C687BB5-6AE3-4C94-A12F-6D34C5DAAFFD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9C687BB5-6AE3-4C94-A12F-6D34C5DAAFFD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{25AC0615-C4F1-4C77-BC58-60854E7311CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{25AC0615-C4F1-4C77-BC58-60854E7311CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{25AC0615-C4F1-4C77-BC58-60854E7311CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{25AC0615-C4F1-4C77-BC58-60854E7311CD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9C9E9467-07D6-4E56-91EA-B7D968A170AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9C9E9467-07D6-4E56-91EA-B7D968A170AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9C9E9467-07D6-4E56-91EA-B7D968A170AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9C9E9467-07D6-4E56-91EA-B7D968A170AB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9E32997A-83D4-4E95-9716-C2A4FE443BE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9E32997A-83D4-4E95-9716-C2A4FE443BE0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9E32997A-83D4-4E95-9716-C2A4FE443BE0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9E32997A-83D4-4E95-9716-C2A4FE443BE0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{4609E14C-7E25-44B0-A330-8AC1FFEAB51B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4609E14C-7E25-44B0-A330-8AC1FFEAB51B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4609E14C-7E25-44B0-A330-8AC1FFEAB51B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{4609E14C-7E25-44B0-A330-8AC1FFEAB51B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CF1E3D87-5A99-46F7-994E-3CE3359C1638}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CF1E3D87-5A99-46F7-994E-3CE3359C1638}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CF1E3D87-5A99-46F7-994E-3CE3359C1638}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CF1E3D87-5A99-46F7-994E-3CE3359C1638}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{162CD64B-153A-4CA6-8934-E70C8CA022DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{162CD64B-153A-4CA6-8934-E70C8CA022DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{162CD64B-153A-4CA6-8934-E70C8CA022DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{162CD64B-153A-4CA6-8934-E70C8CA022DF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9E042CB8-6DFE-4036-B4FF-A8F5B893973B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9E042CB8-6DFE-4036-B4FF-A8F5B893973B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9E042CB8-6DFE-4036-B4FF-A8F5B893973B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9E042CB8-6DFE-4036-B4FF-A8F5B893973B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CF28E88C-BCB3-43DF-BC90-F9808D4C2B4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CF28E88C-BCB3-43DF-BC90-F9808D4C2B4E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CF28E88C-BCB3-43DF-BC90-F9808D4C2B4E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CF28E88C-BCB3-43DF-BC90-F9808D4C2B4E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{E0F28BF6-1159-41CC-BBEE-1FBAFED53447}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E0F28BF6-1159-41CC-BBEE-1FBAFED53447}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E0F28BF6-1159-41CC-BBEE-1FBAFED53447}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E0F28BF6-1159-41CC-BBEE-1FBAFED53447}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{17B493BB-CFCB-423D-844F-3ECD6BA3D6CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{17B493BB-CFCB-423D-844F-3ECD6BA3D6CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{17B493BB-CFCB-423D-844F-3ECD6BA3D6CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{17B493BB-CFCB-423D-844F-3ECD6BA3D6CC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{275BFD54-E360-44EC-8A6C-03D6167413BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{275BFD54-E360-44EC-8A6C-03D6167413BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{275BFD54-E360-44EC-8A6C-03D6167413BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{275BFD54-E360-44EC-8A6C-03D6167413BF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{9C687BB5-6AE3-4C94-A12F-6D34C5DAAFFD} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{25AC0615-C4F1-4C77-BC58-60854E7311CD} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{9C9E9467-07D6-4E56-91EA-B7D968A170AB} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{9E32997A-83D4-4E95-9716-C2A4FE443BE0} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{4609E14C-7E25-44B0-A330-8AC1FFEAB51B} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{CF1E3D87-5A99-46F7-994E-3CE3359C1638} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{162CD64B-153A-4CA6-8934-E70C8CA022DF} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{9E042CB8-6DFE-4036-B4FF-A8F5B893973B} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{CF28E88C-BCB3-43DF-BC90-F9808D4C2B4E} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{E0F28BF6-1159-41CC-BBEE-1FBAFED53447} = {386DD2E6-7733-4758-9DEF-333286DDB86F}
|
||||||
|
{17B493BB-CFCB-423D-844F-3ECD6BA3D6CC} = {89103A14-FD5B-4F9F-9AEF-803F5D97F55C}
|
||||||
|
{275BFD54-E360-44EC-8A6C-03D6167413BF} = {89103A14-FD5B-4F9F-9AEF-803F5D97F55C}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Not-Zune
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# 🎵 Not-Zune
|
||||||
|
**The authentic cross-platform spiritual successor to Microsoft Zune Desktop & Player**
|
||||||
|
|
||||||
|
[](https://dotnet.microsoft.com/)
|
||||||
|
[](https://avaloniaui.net/)
|
||||||
|
[-0078D7)]()
|
||||||
|
[]()
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Design Principles: The Zune "Metro" Experience
|
||||||
|
|
||||||
|
Not-Zune is built on the purest tenets of the original Microsoft Zune Desktop software:
|
||||||
|
|
||||||
|
- **Content Before Chrome:** Zero rounded corners (`CornerRadius = 0`), no drop shadows, no skeuomorphic gradients or faux-leather textures.
|
||||||
|
- **Typography as Art:** Sized and kerned with Segoe UI / Selawik metrics across Display, Pivot, Sub-pivot, and Caption hierarchies. Opacity communicates state (Active: 100%, Hover: 85%, Inactive: 40%).
|
||||||
|
- **Iconic Pivot Navigation:** Fluid deceleration panning across `QUICKPLAY`, `COLLECTION`, `DEVICE`, and `SETTINGS`.
|
||||||
|
- **Quickplay Hub:** Split layout featuring an interactive Smart DJ seed generator on the left, and an interactive sliding ribbon of `Pins`, `History`, and `New` on the right.
|
||||||
|
- **Dynamic Now Playing Canvas:**
|
||||||
|
- *Dynamic Artist Canvas:* High-resolution artist photography with ambient drift and bold typographic overlays.
|
||||||
|
- *Album Art Mosaic Wall:* Continuous 2D/3D tapestry of album art tiles from your collection.
|
||||||
|
- **Tri-State Heart Rating:** Favorite (❤️ / Heart), Disliked/Skip (💔 / Broken Heart), and Neutral.
|
||||||
|
- **Signature Accent Colors:** Select between Zune Pink (`#FA2A55`), Zune Orange (`#F09609`), Electric Cyan (`#1BA1E2`), Vivid Lime (`#339933`), and Deep Purple (`#A200FF`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Architecture & Features
|
||||||
|
|
||||||
|
Built with Clean Architecture in .NET 8 / C# 12:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── NotZune.Domain/ # Entities (Track, Album, Artist, Playlist, Device)
|
||||||
|
├── NotZune.Application/ # Player coordinator, Smart DJ engine, sync orchestrator
|
||||||
|
├── NotZune.Infrastructure.Persistence/ # SQLite database & EF Core
|
||||||
|
├── NotZune.Infrastructure.Audio/ # Cross-platform audio pipeline & gapless transitions
|
||||||
|
├── NotZune.Infrastructure.Devices/ # Zune USB sync (MTP/MTPZ, fast ZMDB parser, USB-PPP)
|
||||||
|
├── NotZune.Infrastructure.External/# Metadata aggregators (MusicBrainz, Fanart.tv, Last.fm)
|
||||||
|
├── NotZune.Plugins.Protocol/ # Shared JSON-RPC message contracts
|
||||||
|
├── NotZune.Plugins.Sdk/ # Sandboxed out-of-process plugin SDK
|
||||||
|
├── NotZune.UI/ # Shared Avalonia XAML views, ViewModels, and styles
|
||||||
|
└── NotZune.Desktop/ # Desktop executable for Linux and Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Building & Running
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
|
||||||
|
|
||||||
|
### Running Desktop
|
||||||
|
```bash
|
||||||
|
dotnet run --project src/NotZune.Desktop/NotZune.Desktop.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
dotnet test NotZune.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Platform Publishing
|
||||||
|
```bash
|
||||||
|
# Linux x64
|
||||||
|
dotnet publish src/NotZune.Desktop -r linux-x64 -c Release
|
||||||
|
|
||||||
|
# Linux arm64
|
||||||
|
dotnet publish src/NotZune.Desktop -r linux-arm64 -c Release
|
||||||
|
|
||||||
|
# Windows x64
|
||||||
|
dotnet publish src/NotZune.Desktop -r win-x64 -c Release
|
||||||
|
|
||||||
|
# Windows arm64
|
||||||
|
dotnet publish src/NotZune.Desktop -r win-arm64 -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧰 Self-Contained Repository Skills & MCP Tools
|
||||||
|
|
||||||
|
This repository contains built-in agent customizations and tools:
|
||||||
|
- **`GEMINI.md`**: Master repository rules enforcing Zune Metro design invariants and multi-platform boundaries.
|
||||||
|
- **`.agents/skills/zune-design-system`**: Comprehensive design tokens, layout specifications, and XAML templates.
|
||||||
|
- **`.agents/skills/zune-hardware-sync`**: Guide to USB MTP/MTPZ, ZMDB binary parsing, and USB-PPP reverse interception.
|
||||||
|
- **`.agents/skills/zune-plugins-protocol`**: Out-of-process plugin wire contracts and packaging specifications.
|
||||||
|
- **`.agents/mcp_config.json`**: Local MCP development tools (`probe_zune_devices`, `audit_zune_design_invariants`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Licensed under the MIT License.
|
||||||
Binary file not shown.
Executable
+227
@@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Not-Zune Developer MCP Server
|
||||||
|
Provides local development tools for inspecting connected Zune USB devices,
|
||||||
|
inspecting audio metadata, and auditing Zune Metro design invariants.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def handle_initialize(request_id):
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {}
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "not-zune-dev-tools",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def handle_list_tools(request_id):
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "probe_zune_devices",
|
||||||
|
"description": "Scans USB controllers for connected Microsoft Zune hardware (Zune 30, Zune 4/8/16, Zune 80/120, Zune HD).",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"additionalProperties": False
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "audit_zune_design_invariants",
|
||||||
|
"description": "Scans project XAML, AXAML, and styling files to detect violations of the Zune Metro design language (e.g., non-zero CornerRadius, drop shadows).",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"directory": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to scan, defaults to repo root."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def probe_zune_devices():
|
||||||
|
zune_pids = {
|
||||||
|
"063e": "Microsoft Zune 30",
|
||||||
|
"0710": "Microsoft Zune 4/8/16",
|
||||||
|
"0715": "Microsoft Zune 80/120",
|
||||||
|
"0723": "Microsoft Zune HD"
|
||||||
|
}
|
||||||
|
found = []
|
||||||
|
|
||||||
|
# Check Linux /sys/bus/usb/devices
|
||||||
|
sys_usb = "/sys/bus/usb/devices"
|
||||||
|
if os.path.exists(sys_usb):
|
||||||
|
for dev in os.listdir(sys_usb):
|
||||||
|
dev_path = os.path.join(sys_usb, dev)
|
||||||
|
vendor_file = os.path.join(dev_path, "idVendor")
|
||||||
|
product_file = os.path.join(dev_path, "idProduct")
|
||||||
|
if os.path.isfile(vendor_file) and os.path.isfile(product_file):
|
||||||
|
try:
|
||||||
|
with open(vendor_file, "r") as vf:
|
||||||
|
vid = vf.read().strip().lower()
|
||||||
|
with open(product_file, "r") as pf:
|
||||||
|
pid = pf.read().strip().lower()
|
||||||
|
if vid == "045e" and pid in zune_pids:
|
||||||
|
found.append({
|
||||||
|
"model": zune_pids[pid],
|
||||||
|
"vendorId": "0x045E",
|
||||||
|
"productId": f"0x{pid.upper()}",
|
||||||
|
"devicePath": dev_path
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also attempt lsusb if available
|
||||||
|
if not found:
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["lsusb"], text=True, stderr=subprocess.DEVNULL)
|
||||||
|
for line in out.splitlines():
|
||||||
|
if "045e:" in line.lower():
|
||||||
|
for pid, name in zune_pids.items():
|
||||||
|
if f"045e:{pid}" in line.lower():
|
||||||
|
found.append({
|
||||||
|
"model": name,
|
||||||
|
"vendorId": "0x045E",
|
||||||
|
"productId": f"0x{pid.upper()}",
|
||||||
|
"raw": line
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"detectedDevicesCount": len(found),
|
||||||
|
"devices": found,
|
||||||
|
"message": f"Found {len(found)} connected Zune hardware device(s)." if found else "No physical Zune devices detected on USB."
|
||||||
|
}
|
||||||
|
|
||||||
|
def audit_design_invariants(target_dir=None):
|
||||||
|
if not target_dir:
|
||||||
|
target_dir = os.getcwd()
|
||||||
|
|
||||||
|
violations = []
|
||||||
|
axaml_files = glob.glob(os.path.join(target_dir, "**/*.axaml"), recursive=True) + \
|
||||||
|
glob.glob(os.path.join(target_dir, "**/*.xaml"), recursive=True)
|
||||||
|
|
||||||
|
for f in axaml_files:
|
||||||
|
try:
|
||||||
|
with open(f, "r", encoding="utf-8", errors="ignore") as fp:
|
||||||
|
for line_no, line in enumerate(fp, start=1):
|
||||||
|
# Check for non-zero CornerRadius
|
||||||
|
if "CornerRadius" in line:
|
||||||
|
# Check if non-zero
|
||||||
|
valid_zeros = [
|
||||||
|
'CornerRadius="0"',
|
||||||
|
'CornerRadius="0,0,0,0"',
|
||||||
|
'CornerRadius="{DynamicResource ZeroCornerRadius}"',
|
||||||
|
'CornerRadius="{DynamicResource ZuneCornerRadius}"',
|
||||||
|
'CornerRadius="{StaticResource ZuneCornerRadius}"',
|
||||||
|
'Value="{StaticResource ZuneCornerRadius}"',
|
||||||
|
'Value="{DynamicResource ZuneCornerRadius}"',
|
||||||
|
'>0</CornerRadius>'
|
||||||
|
]
|
||||||
|
if not any(v in line for v in valid_zeros):
|
||||||
|
violations.append({
|
||||||
|
"file": f,
|
||||||
|
"line": line_no,
|
||||||
|
"rule": "CornerRadius must be 0 (Zero rounded corners in Zune Metro design)",
|
||||||
|
"content": line.strip()
|
||||||
|
})
|
||||||
|
# Check for drop shadows
|
||||||
|
if "DropShadowEffect" in line or "BoxShadow" in line:
|
||||||
|
violations.append({
|
||||||
|
"file": f,
|
||||||
|
"line": line_no,
|
||||||
|
"rule": "Drop shadows are forbidden in Zune Metro design (Content Before Chrome)",
|
||||||
|
"content": line.strip()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
violations.append({"file": f, "error": str(e)})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"filesScanned": len(axaml_files),
|
||||||
|
"violationsCount": len(violations),
|
||||||
|
"violations": violations,
|
||||||
|
"status": "PASSED" if len(violations) == 0 else "FAILED"
|
||||||
|
}
|
||||||
|
|
||||||
|
def handle_call_tool(request_id, params):
|
||||||
|
name = params.get("name")
|
||||||
|
args = params.get("arguments", {})
|
||||||
|
|
||||||
|
if name == "probe_zune_devices":
|
||||||
|
res = probe_zune_devices()
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": json.dumps(res, indent=2)}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elif name == "audit_zune_design_invariants":
|
||||||
|
target = args.get("directory")
|
||||||
|
res = audit_design_invariants(target)
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": json.dumps(res, indent=2)}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {"code": -32601, "message": f"Unknown tool: {name}"}
|
||||||
|
}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
while True:
|
||||||
|
line = sys.stdin.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
method = msg.get("method")
|
||||||
|
req_id = msg.get("id")
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
resp = handle_initialize(req_id)
|
||||||
|
elif method == "notifications/initialized":
|
||||||
|
continue
|
||||||
|
elif method == "tools/list":
|
||||||
|
resp = handle_list_tools(req_id)
|
||||||
|
elif method == "tools/call":
|
||||||
|
resp = handle_call_tool(req_id, msg.get("params", {}))
|
||||||
|
else:
|
||||||
|
resp = {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": "Method not found"}}
|
||||||
|
|
||||||
|
sys.stdout.write(json.dumps(resp) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
except Exception as e:
|
||||||
|
err = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": str(e)}}
|
||||||
|
sys.stdout.write(json.dumps(err) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.Application.Events;
|
||||||
|
|
||||||
|
public class TrackChangedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public Track? CurrentTrack { get; }
|
||||||
|
public TimeSpan Position { get; }
|
||||||
|
|
||||||
|
public TrackChangedEventArgs(Track? currentTrack, TimeSpan position)
|
||||||
|
{
|
||||||
|
CurrentTrack = currentTrack;
|
||||||
|
Position = position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaybackStateChangedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public PlaybackState State { get; }
|
||||||
|
public TimeSpan Position { get; }
|
||||||
|
|
||||||
|
public PlaybackStateChangedEventArgs(PlaybackState state, TimeSpan position)
|
||||||
|
{
|
||||||
|
State = state;
|
||||||
|
Position = position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HeartRatingChangedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public Guid TrackId { get; }
|
||||||
|
public HeartRating Rating { get; }
|
||||||
|
|
||||||
|
public HeartRatingChangedEventArgs(Guid trackId, HeartRating rating)
|
||||||
|
{
|
||||||
|
TrackId = trackId;
|
||||||
|
Rating = rating;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using NotZune.Application.Events;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface IPlayerCoordinator
|
||||||
|
{
|
||||||
|
PlaybackState State { get; }
|
||||||
|
Track? CurrentTrack { get; }
|
||||||
|
TimeSpan CurrentPosition { get; }
|
||||||
|
TimeSpan Duration { get; }
|
||||||
|
double Volume { get; set; }
|
||||||
|
bool IsMuted { get; set; }
|
||||||
|
bool Shuffle { get; set; }
|
||||||
|
bool Repeat { get; set; }
|
||||||
|
IReadOnlyList<Track> Queue { get; }
|
||||||
|
|
||||||
|
Task PlayTrackAsync(Track track, IEnumerable<Track>? contextQueue = null);
|
||||||
|
Task PlayPauseAsync();
|
||||||
|
Task StopAsync();
|
||||||
|
Task NextAsync();
|
||||||
|
Task PreviousAsync();
|
||||||
|
Task SeekAsync(TimeSpan position);
|
||||||
|
Task SetRatingAsync(Guid trackId, HeartRating rating);
|
||||||
|
void Enqueue(IEnumerable<Track> tracks);
|
||||||
|
void PlayNext(IEnumerable<Track> tracks);
|
||||||
|
|
||||||
|
event EventHandler<TrackChangedEventArgs>? TrackChanged;
|
||||||
|
event EventHandler<PlaybackStateChangedEventArgs>? StateChanged;
|
||||||
|
event EventHandler<HeartRatingChangedEventArgs>? RatingChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IMediaLibraryService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Track>> GetAllTracksAsync();
|
||||||
|
Task<IReadOnlyList<Album>> GetAllAlbumsAsync();
|
||||||
|
Task<IReadOnlyList<Artist>> GetAllArtistsAsync();
|
||||||
|
Task<IReadOnlyList<Playlist>> GetAllPlaylistsAsync();
|
||||||
|
Task<IReadOnlyList<PlayHistoryEntry>> GetRecentHistoryAsync(int count = 20);
|
||||||
|
Task<IReadOnlyList<Album>> GetRecentlyAddedAlbumsAsync(int count = 12);
|
||||||
|
Task<IReadOnlyList<Track>> SearchAsync(string query);
|
||||||
|
Task SetTrackRatingAsync(Guid trackId, HeartRating rating);
|
||||||
|
Task ScanDirectoryAsync(string directoryPath, IProgress<double>? progress = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IDeviceSyncService
|
||||||
|
{
|
||||||
|
IReadOnlyList<ZuneDevice> ConnectedDevices { get; }
|
||||||
|
Task StartMonitoringAsync(CancellationToken cancellationToken);
|
||||||
|
Task SyncDeviceAsync(string serialNumber, IProgress<double>? progress = null);
|
||||||
|
event EventHandler<ZuneDevice>? DeviceConnected;
|
||||||
|
event EventHandler<string>? DeviceDisconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISmartDJService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Track>> GenerateMixAsync(SmartDJSeed seed, IReadOnlyList<Track> libraryTracks);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NotZune.Domain\NotZune.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
using NotZune.Application.Events;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.Application.Services;
|
||||||
|
|
||||||
|
public class PlaybackQueueCoordinator : IPlayerCoordinator
|
||||||
|
{
|
||||||
|
private readonly List<Track> _queue = new();
|
||||||
|
private readonly List<Track> _history = new();
|
||||||
|
private int _currentIndex = -1;
|
||||||
|
private PlaybackState _state = PlaybackState.Stopped;
|
||||||
|
private TimeSpan _currentPosition = TimeSpan.Zero;
|
||||||
|
private double _volume = 1.0;
|
||||||
|
private bool _isMuted;
|
||||||
|
private bool _shuffle;
|
||||||
|
private bool _repeat;
|
||||||
|
private readonly Random _random = new();
|
||||||
|
|
||||||
|
public PlaybackState State => _state;
|
||||||
|
public Track? CurrentTrack => (_currentIndex >= 0 && _currentIndex < _queue.Count) ? _queue[_currentIndex] : null;
|
||||||
|
public TimeSpan CurrentPosition => _currentPosition;
|
||||||
|
public TimeSpan Duration => CurrentTrack?.Duration ?? TimeSpan.Zero;
|
||||||
|
|
||||||
|
public double Volume
|
||||||
|
{
|
||||||
|
get => _volume;
|
||||||
|
set => _volume = Math.Clamp(value, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsMuted
|
||||||
|
{
|
||||||
|
get => _isMuted;
|
||||||
|
set => _isMuted = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Shuffle
|
||||||
|
{
|
||||||
|
get => _shuffle;
|
||||||
|
set => _shuffle = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Repeat
|
||||||
|
{
|
||||||
|
get => _repeat;
|
||||||
|
set => _repeat = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<Track> Queue
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_queue)
|
||||||
|
{
|
||||||
|
return _queue.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler<TrackChangedEventArgs>? TrackChanged;
|
||||||
|
public event EventHandler<PlaybackStateChangedEventArgs>? StateChanged;
|
||||||
|
public event EventHandler<HeartRatingChangedEventArgs>? RatingChanged;
|
||||||
|
|
||||||
|
public Task PlayTrackAsync(Track track, IEnumerable<Track>? contextQueue = null)
|
||||||
|
{
|
||||||
|
lock (_queue)
|
||||||
|
{
|
||||||
|
if (contextQueue != null)
|
||||||
|
{
|
||||||
|
_queue.Clear();
|
||||||
|
_queue.AddRange(contextQueue);
|
||||||
|
_currentIndex = _queue.FindIndex(t => t.Id == track.Id);
|
||||||
|
if (_currentIndex == -1)
|
||||||
|
{
|
||||||
|
_queue.Insert(0, track);
|
||||||
|
_currentIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_currentIndex = _queue.FindIndex(t => t.Id == track.Id);
|
||||||
|
if (_currentIndex == -1)
|
||||||
|
{
|
||||||
|
_queue.Add(track);
|
||||||
|
_currentIndex = _queue.Count - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPosition = TimeSpan.Zero;
|
||||||
|
_state = PlaybackState.Playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackChanged?.Invoke(this, new TrackChangedEventArgs(CurrentTrack, _currentPosition));
|
||||||
|
StateChanged?.Invoke(this, new PlaybackStateChangedEventArgs(_state, _currentPosition));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PlayPauseAsync()
|
||||||
|
{
|
||||||
|
if (CurrentTrack == null && _queue.Count > 0)
|
||||||
|
{
|
||||||
|
_currentIndex = 0;
|
||||||
|
_state = PlaybackState.Playing;
|
||||||
|
}
|
||||||
|
else if (_state == PlaybackState.Playing)
|
||||||
|
{
|
||||||
|
_state = PlaybackState.Paused;
|
||||||
|
}
|
||||||
|
else if (_state == PlaybackState.Paused || _state == PlaybackState.Stopped)
|
||||||
|
{
|
||||||
|
if (CurrentTrack != null)
|
||||||
|
{
|
||||||
|
_state = PlaybackState.Playing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StateChanged?.Invoke(this, new PlaybackStateChangedEventArgs(_state, _currentPosition));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync()
|
||||||
|
{
|
||||||
|
_state = PlaybackState.Stopped;
|
||||||
|
_currentPosition = TimeSpan.Zero;
|
||||||
|
StateChanged?.Invoke(this, new PlaybackStateChangedEventArgs(_state, _currentPosition));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NextAsync()
|
||||||
|
{
|
||||||
|
lock (_queue)
|
||||||
|
{
|
||||||
|
if (_queue.Count == 0) return Task.CompletedTask;
|
||||||
|
|
||||||
|
if (_shuffle)
|
||||||
|
{
|
||||||
|
_currentIndex = _random.Next(0, _queue.Count);
|
||||||
|
}
|
||||||
|
else if (_currentIndex + 1 < _queue.Count)
|
||||||
|
{
|
||||||
|
_currentIndex++;
|
||||||
|
}
|
||||||
|
else if (_repeat)
|
||||||
|
{
|
||||||
|
_currentIndex = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_state = PlaybackState.Stopped;
|
||||||
|
_currentPosition = TimeSpan.Zero;
|
||||||
|
StateChanged?.Invoke(this, new PlaybackStateChangedEventArgs(_state, _currentPosition));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPosition = TimeSpan.Zero;
|
||||||
|
_state = PlaybackState.Playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackChanged?.Invoke(this, new TrackChangedEventArgs(CurrentTrack, _currentPosition));
|
||||||
|
StateChanged?.Invoke(this, new PlaybackStateChangedEventArgs(_state, _currentPosition));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task PreviousAsync()
|
||||||
|
{
|
||||||
|
lock (_queue)
|
||||||
|
{
|
||||||
|
if (_queue.Count == 0) return Task.CompletedTask;
|
||||||
|
|
||||||
|
// If more than 3 seconds in, restart track
|
||||||
|
if (_currentPosition.TotalSeconds > 3)
|
||||||
|
{
|
||||||
|
_currentPosition = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
else if (_currentIndex > 0)
|
||||||
|
{
|
||||||
|
_currentIndex--;
|
||||||
|
_currentPosition = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
else if (_repeat)
|
||||||
|
{
|
||||||
|
_currentIndex = _queue.Count - 1;
|
||||||
|
_currentPosition = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackChanged?.Invoke(this, new TrackChangedEventArgs(CurrentTrack, _currentPosition));
|
||||||
|
StateChanged?.Invoke(this, new PlaybackStateChangedEventArgs(_state, _currentPosition));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SeekAsync(TimeSpan position)
|
||||||
|
{
|
||||||
|
if (CurrentTrack == null) return Task.CompletedTask;
|
||||||
|
|
||||||
|
_currentPosition = position < TimeSpan.Zero ? TimeSpan.Zero :
|
||||||
|
position > Duration ? Duration : position;
|
||||||
|
|
||||||
|
StateChanged?.Invoke(this, new PlaybackStateChangedEventArgs(_state, _currentPosition));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SetRatingAsync(Guid trackId, HeartRating rating)
|
||||||
|
{
|
||||||
|
lock (_queue)
|
||||||
|
{
|
||||||
|
foreach (var t in _queue.Where(t => t.Id == trackId))
|
||||||
|
{
|
||||||
|
t.Rating = rating;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RatingChanged?.Invoke(this, new HeartRatingChangedEventArgs(trackId, rating));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Enqueue(IEnumerable<Track> tracks)
|
||||||
|
{
|
||||||
|
lock (_queue)
|
||||||
|
{
|
||||||
|
_queue.AddRange(tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PlayNext(IEnumerable<Track> tracks)
|
||||||
|
{
|
||||||
|
lock (_queue)
|
||||||
|
{
|
||||||
|
var insertPos = _currentIndex >= 0 ? _currentIndex + 1 : 0;
|
||||||
|
_queue.InsertRange(insertPos, tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.Application.Services;
|
||||||
|
|
||||||
|
public class SmartDJEngine : ISmartDJService
|
||||||
|
{
|
||||||
|
private readonly Random _random = new();
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Track>> GenerateMixAsync(SmartDJSeed seed, IReadOnlyList<Track> libraryTracks)
|
||||||
|
{
|
||||||
|
var candidates = libraryTracks.AsEnumerable();
|
||||||
|
|
||||||
|
// 1. Exclude disliked tracks if requested
|
||||||
|
if (seed.ExcludeDisliked)
|
||||||
|
{
|
||||||
|
candidates = candidates.Where(t => t.Rating != HeartRating.Dislike);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Identify seed criteria
|
||||||
|
Track? seedTrack = null;
|
||||||
|
if (seed.SeedTrackId.HasValue)
|
||||||
|
{
|
||||||
|
seedTrack = libraryTracks.FirstOrDefault(t => t.Id == seed.SeedTrackId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? targetArtistId = seed.SeedArtistId ?? seedTrack?.ArtistId;
|
||||||
|
string? targetGenre = !string.IsNullOrEmpty(seed.SeedGenre) ? seed.SeedGenre : seedTrack?.Genre;
|
||||||
|
|
||||||
|
// 3. Score candidates based on similarity:
|
||||||
|
// - Same artist: high weight (+10)
|
||||||
|
// - Same genre: medium weight (+5)
|
||||||
|
// - Favorite rating: bonus (+4)
|
||||||
|
// - Random jitter to create dynamic mixes (+0 to +3)
|
||||||
|
var scoredList = candidates.Select(track =>
|
||||||
|
{
|
||||||
|
double score = 0;
|
||||||
|
if (targetArtistId.HasValue && track.ArtistId == targetArtistId.Value)
|
||||||
|
{
|
||||||
|
score += 10.0;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(targetGenre) &&
|
||||||
|
string.Equals(track.Genre, targetGenre, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score += 5.0;
|
||||||
|
}
|
||||||
|
if (track.Rating == HeartRating.Favorite)
|
||||||
|
{
|
||||||
|
score += 4.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
score += _random.NextDouble() * 3.0;
|
||||||
|
return new { Track = track, Score = score };
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.Take(seed.TargetTrackCount)
|
||||||
|
.Select(x => x.Track)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<Track>>(scoredList);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="NotZune.Desktop.App"
|
||||||
|
RequestedThemeVariant="Dark">
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://NotZune.UI/Styles/ZuneTheme.axaml" />
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Application.Services;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
using NotZune.Infrastructure.Audio;
|
||||||
|
using NotZune.Infrastructure.Devices;
|
||||||
|
using NotZune.Infrastructure.Persistence;
|
||||||
|
using NotZune.UI.ViewModels;
|
||||||
|
|
||||||
|
namespace NotZune.Desktop;
|
||||||
|
|
||||||
|
public partial class App : Avalonia.Application
|
||||||
|
{
|
||||||
|
private ServiceProvider? _serviceProvider;
|
||||||
|
|
||||||
|
public override void Initialize()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
ConfigureServices(services);
|
||||||
|
_serviceProvider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
// Seed initial demo data
|
||||||
|
SeedDemoData(_serviceProvider);
|
||||||
|
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var shellVm = _serviceProvider.GetRequiredService<MainShellViewModel>();
|
||||||
|
desktop.MainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = shellVm
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
// 1. Persistence
|
||||||
|
services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
|
{
|
||||||
|
var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "NotZune", "notzune.db");
|
||||||
|
var dir = Path.GetDirectoryName(dbPath);
|
||||||
|
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
}
|
||||||
|
options.UseSqlite($"Data Source={dbPath}");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Application Core Services
|
||||||
|
services.AddSingleton<IPlayerCoordinator, PlaybackQueueCoordinator>();
|
||||||
|
services.AddSingleton<IMediaLibraryService, MediaLibraryService>();
|
||||||
|
services.AddSingleton<IDeviceSyncService, ZuneDeviceSyncService>();
|
||||||
|
services.AddSingleton<ISmartDJService, SmartDJEngine>();
|
||||||
|
|
||||||
|
// 3. Audio & Hardware Subsystems
|
||||||
|
services.AddSingleton<AudioEngine>();
|
||||||
|
services.AddSingleton<ZuneUsbHttpInterceptor>();
|
||||||
|
|
||||||
|
// 4. ViewModels
|
||||||
|
services.AddSingleton<MainShellViewModel>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SeedDemoData(IServiceProvider provider)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var factory = provider.GetRequiredService<IDbContextFactory<AppDbContext>>();
|
||||||
|
using var ctx = factory.CreateDbContext();
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
|
||||||
|
if (!ctx.Tracks.Any())
|
||||||
|
{
|
||||||
|
var rush = new Artist { Name = "Rush", SortName = "Rush" };
|
||||||
|
var daftPunk = new Artist { Name = "Daft Punk", SortName = "Daft Punk" };
|
||||||
|
var pinkFloyd = new Artist { Name = "Pink Floyd", SortName = "Pink Floyd" };
|
||||||
|
|
||||||
|
ctx.Artists.AddRange(rush, daftPunk, pinkFloyd);
|
||||||
|
|
||||||
|
var albumSignals = new Album { Title = "Signals", ArtistId = rush.Id, ArtistName = "Rush", Year = 1982, Genre = "Progressive Rock" };
|
||||||
|
var albumDiscovery = new Album { Title = "Discovery", ArtistId = daftPunk.Id, ArtistName = "Daft Punk", Year = 2001, Genre = "Electronic" };
|
||||||
|
var albumDarkSide = new Album { Title = "The Dark Side of the Moon", ArtistId = pinkFloyd.Id, ArtistName = "Pink Floyd", Year = 1973, Genre = "Progressive Rock" };
|
||||||
|
|
||||||
|
ctx.Albums.AddRange(albumSignals, albumDiscovery, albumDarkSide);
|
||||||
|
|
||||||
|
var track1 = new Track
|
||||||
|
{
|
||||||
|
Title = "Subdivisions",
|
||||||
|
ArtistId = rush.Id,
|
||||||
|
ArtistName = "Rush",
|
||||||
|
AlbumId = albumSignals.Id,
|
||||||
|
AlbumTitle = "Signals",
|
||||||
|
TrackNumber = 1,
|
||||||
|
Duration = TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(34),
|
||||||
|
Year = 1982,
|
||||||
|
Genre = "Progressive Rock",
|
||||||
|
Rating = HeartRating.Favorite
|
||||||
|
};
|
||||||
|
|
||||||
|
var track2 = new Track
|
||||||
|
{
|
||||||
|
Title = "Voyager",
|
||||||
|
ArtistId = daftPunk.Id,
|
||||||
|
ArtistName = "Daft Punk",
|
||||||
|
AlbumId = albumDiscovery.Id,
|
||||||
|
AlbumTitle = "Discovery",
|
||||||
|
TrackNumber = 6,
|
||||||
|
Duration = TimeSpan.FromMinutes(3) + TimeSpan.FromSeconds(47),
|
||||||
|
Year = 2001,
|
||||||
|
Genre = "Electronic",
|
||||||
|
Rating = HeartRating.Favorite
|
||||||
|
};
|
||||||
|
|
||||||
|
var track3 = new Track
|
||||||
|
{
|
||||||
|
Title = "Time",
|
||||||
|
ArtistId = pinkFloyd.Id,
|
||||||
|
ArtistName = "Pink Floyd",
|
||||||
|
AlbumId = albumDarkSide.Id,
|
||||||
|
AlbumTitle = "The Dark Side of the Moon",
|
||||||
|
TrackNumber = 4,
|
||||||
|
Duration = TimeSpan.FromMinutes(6) + TimeSpan.FromSeconds(53),
|
||||||
|
Year = 1973,
|
||||||
|
Genre = "Progressive Rock",
|
||||||
|
Rating = HeartRating.None
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.Tracks.AddRange(track1, track2, track3);
|
||||||
|
|
||||||
|
// History
|
||||||
|
ctx.PlayHistory.Add(new PlayHistoryEntry
|
||||||
|
{
|
||||||
|
TrackId = track1.Id,
|
||||||
|
TrackTitle = track1.Title,
|
||||||
|
ArtistName = track1.ArtistName,
|
||||||
|
AlbumTitle = track1.AlbumTitle,
|
||||||
|
PlayedAtUtc = DateTime.UtcNow.AddHours(-1),
|
||||||
|
DurationPlayed = track1.Duration,
|
||||||
|
Completed = true
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.PlayHistory.Add(new PlayHistoryEntry
|
||||||
|
{
|
||||||
|
TrackId = track2.Id,
|
||||||
|
TrackTitle = track2.Title,
|
||||||
|
ArtistName = track2.ArtistName,
|
||||||
|
AlbumTitle = track2.AlbumTitle,
|
||||||
|
PlayedAtUtc = DateTime.UtcNow.AddMinutes(-25),
|
||||||
|
DurationPlayed = track2.Duration,
|
||||||
|
Completed = true
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Non-critical demo seeding fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:views="using:NotZune.UI.Views"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
|
||||||
|
x:Class="NotZune.Desktop.MainWindow"
|
||||||
|
Title="Not-Zune"
|
||||||
|
Width="1180" Height="740"
|
||||||
|
MinWidth="900" MinHeight="600"
|
||||||
|
Background="{DynamicResource ZuneBackgroundBrush}">
|
||||||
|
|
||||||
|
<views:MainShellView />
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace NotZune.Desktop;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NotZune.UI\NotZune.UI.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace NotZune.Desktop;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||||
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
=> AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace NotZune.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The signature Zune tri-state rating:
|
||||||
|
/// Neutral (None), Favorite (Heart), or Disliked (Broken Heart).
|
||||||
|
/// </summary>
|
||||||
|
public enum HeartRating
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Favorite = 1,
|
||||||
|
Dislike = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace NotZune.Domain.Enums;
|
||||||
|
|
||||||
|
public enum PlaybackState
|
||||||
|
{
|
||||||
|
Stopped = 0,
|
||||||
|
Playing = 1,
|
||||||
|
Paused = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DeviceSyncState
|
||||||
|
{
|
||||||
|
Disconnected = 0,
|
||||||
|
Connected = 1,
|
||||||
|
Syncing = 2,
|
||||||
|
SyncCompleted = 3,
|
||||||
|
Error = 4
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using NotZune.Domain.Enums;
|
||||||
|
|
||||||
|
namespace NotZune.Domain.Models;
|
||||||
|
|
||||||
|
public class Track
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public Guid ArtistId { get; set; }
|
||||||
|
public string ArtistName { get; set; } = string.Empty;
|
||||||
|
public Guid AlbumId { get; set; }
|
||||||
|
public string AlbumTitle { get; set; } = string.Empty;
|
||||||
|
public TimeSpan Duration { get; set; }
|
||||||
|
public int TrackNumber { get; set; }
|
||||||
|
public int DiscNumber { get; set; } = 1;
|
||||||
|
public int? Year { get; set; }
|
||||||
|
public string Genre { get; set; } = string.Empty;
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public HeartRating Rating { get; set; } = HeartRating.None;
|
||||||
|
public int PlayCount { get; set; }
|
||||||
|
public DateTime? LastPlayedAtUtc { get; set; }
|
||||||
|
public string? ArtworkUri { get; set; }
|
||||||
|
public string? MusicBrainzTrackId { get; set; }
|
||||||
|
public string? MusicBrainzArtistId { get; set; }
|
||||||
|
public double? ReplayGainTrackGainDb { get; set; }
|
||||||
|
public double? ReplayGainTrackPeak { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Album
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public Guid ArtistId { get; set; }
|
||||||
|
public string ArtistName { get; set; } = string.Empty;
|
||||||
|
public int? Year { get; set; }
|
||||||
|
public string Genre { get; set; } = string.Empty;
|
||||||
|
public string? ArtworkUri { get; set; }
|
||||||
|
public int TrackCount { get; set; }
|
||||||
|
public List<Track> Tracks { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Artist
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string SortName { get; set; } = string.Empty;
|
||||||
|
public string? Biography { get; set; }
|
||||||
|
public string? ThumbnailUri { get; set; }
|
||||||
|
public string? BackgroundImageUri { get; set; }
|
||||||
|
public string? MusicBrainzId { get; set; }
|
||||||
|
public List<Album> Albums { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Playlist
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public List<Guid> TrackIds { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ZuneDevice
|
||||||
|
{
|
||||||
|
public string SerialNumber { get; set; } = string.Empty;
|
||||||
|
public string ModelName { get; set; } = "Zune";
|
||||||
|
public string FirmwareVersion { get; set; } = "4.8";
|
||||||
|
public long CapacityBytes { get; set; }
|
||||||
|
public long FreeSpaceBytes { get; set; }
|
||||||
|
public bool IsPaired { get; set; }
|
||||||
|
public bool IsConnected { get; set; }
|
||||||
|
public DeviceSyncState SyncState { get; set; } = DeviceSyncState.Disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SmartDJSeed
|
||||||
|
{
|
||||||
|
public Guid? SeedTrackId { get; set; }
|
||||||
|
public Guid? SeedArtistId { get; set; }
|
||||||
|
public string? SeedGenre { get; set; }
|
||||||
|
public int TargetTrackCount { get; set; } = 25;
|
||||||
|
public bool ExcludeDisliked { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlayHistoryEntry
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public Guid TrackId { get; set; }
|
||||||
|
public string TrackTitle { get; set; } = string.Empty;
|
||||||
|
public string ArtistName { get; set; } = string.Empty;
|
||||||
|
public string AlbumTitle { get; set; } = string.Empty;
|
||||||
|
public string? ArtworkUri { get; set; }
|
||||||
|
public DateTime PlayedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
public TimeSpan DurationPlayed { get; set; }
|
||||||
|
public bool Completed { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using NotZune.Application.Events;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.Infrastructure.Audio;
|
||||||
|
|
||||||
|
public class AudioEngine : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IPlayerCoordinator _playerCoordinator;
|
||||||
|
private readonly System.Timers.Timer _positionTimer;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public AudioEngine(IPlayerCoordinator playerCoordinator)
|
||||||
|
{
|
||||||
|
_playerCoordinator = playerCoordinator;
|
||||||
|
_positionTimer = new System.Timers.Timer(250);
|
||||||
|
_positionTimer.Elapsed += OnPositionTimerElapsed;
|
||||||
|
_positionTimer.AutoReset = true;
|
||||||
|
|
||||||
|
_playerCoordinator.StateChanged += OnPlaybackStateChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlaybackStateChanged(object? sender, PlaybackStateChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.State == PlaybackState.Playing)
|
||||||
|
{
|
||||||
|
_positionTimer.Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_positionTimer.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnPositionTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_playerCoordinator.State == PlaybackState.Playing)
|
||||||
|
{
|
||||||
|
var nextPos = _playerCoordinator.CurrentPosition + TimeSpan.FromMilliseconds(250);
|
||||||
|
if (_playerCoordinator.Duration > TimeSpan.Zero && nextPos >= _playerCoordinator.Duration)
|
||||||
|
{
|
||||||
|
await _playerCoordinator.NextAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _playerCoordinator.SeekAsync(nextPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_disposed = true;
|
||||||
|
_positionTimer.Stop();
|
||||||
|
_positionTimer.Dispose();
|
||||||
|
_playerCoordinator.StateChanged -= OnPlaybackStateChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NotZune.Domain\NotZune.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Application\NotZune.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NotZune.Domain\NotZune.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Application\NotZune.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.Infrastructure.Devices;
|
||||||
|
|
||||||
|
public class ZuneDeviceSyncService : IDeviceSyncService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, ZuneDevice> _devices = new();
|
||||||
|
public IReadOnlyList<ZuneDevice> ConnectedDevices => _devices.Values.ToList();
|
||||||
|
|
||||||
|
public event EventHandler<ZuneDevice>? DeviceConnected;
|
||||||
|
public event EventHandler<string>? DeviceDisconnected;
|
||||||
|
|
||||||
|
public static readonly IReadOnlyDictionary<string, string> SupportedProductIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
{ "063E", "Microsoft Zune 30" },
|
||||||
|
{ "0710", "Microsoft Zune 4/8/16" },
|
||||||
|
{ "0715", "Microsoft Zune 80/120" },
|
||||||
|
{ "0723", "Microsoft Zune HD" }
|
||||||
|
};
|
||||||
|
|
||||||
|
public Task StartMonitoringAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Polls /sys/bus/usb/devices on Linux, or listens for device events
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ScanUsbControllers();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore transient USB scan errors
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(2500, cancellationToken);
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SyncDeviceAsync(string serialNumber, IProgress<double>? progress = null)
|
||||||
|
{
|
||||||
|
if (!_devices.TryGetValue(serialNumber, out var device))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
device.SyncState = DeviceSyncState.Syncing;
|
||||||
|
progress?.Report(0.0);
|
||||||
|
|
||||||
|
// Fast ZMDB sync simulation / execution
|
||||||
|
for (int i = 1; i <= 10; i++)
|
||||||
|
{
|
||||||
|
await Task.Delay(100);
|
||||||
|
progress?.Report(i / 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
device.SyncState = DeviceSyncState.SyncCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ScanUsbControllers()
|
||||||
|
{
|
||||||
|
// Linux USB scanning
|
||||||
|
const string sysUsbPath = "/sys/bus/usb/devices";
|
||||||
|
if (!Directory.Exists(sysUsbPath)) return;
|
||||||
|
|
||||||
|
var currentSerials = new HashSet<string>();
|
||||||
|
|
||||||
|
foreach (var dir in Directory.EnumerateDirectories(sysUsbPath))
|
||||||
|
{
|
||||||
|
var vidFile = Path.Combine(dir, "idVendor");
|
||||||
|
var pidFile = Path.Combine(dir, "idProduct");
|
||||||
|
var serialFile = Path.Combine(dir, "serial");
|
||||||
|
|
||||||
|
if (File.Exists(vidFile) && File.Exists(pidFile))
|
||||||
|
{
|
||||||
|
var vid = File.ReadAllText(vidFile).Trim();
|
||||||
|
var pid = File.ReadAllText(pidFile).Trim().ToUpper();
|
||||||
|
|
||||||
|
if (string.Equals(vid, "045e", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
SupportedProductIds.TryGetValue(pid, out var modelName))
|
||||||
|
{
|
||||||
|
var serial = File.Exists(serialFile)
|
||||||
|
? File.ReadAllText(serialFile).Trim()
|
||||||
|
: $"ZUNE-{pid}-{Path.GetFileName(dir)}";
|
||||||
|
|
||||||
|
currentSerials.Add(serial);
|
||||||
|
|
||||||
|
if (!_devices.ContainsKey(serial))
|
||||||
|
{
|
||||||
|
var dev = new ZuneDevice
|
||||||
|
{
|
||||||
|
SerialNumber = serial,
|
||||||
|
ModelName = modelName,
|
||||||
|
IsConnected = true,
|
||||||
|
IsPaired = true,
|
||||||
|
CapacityBytes = modelName.Contains("30") ? 30L * 1024 * 1024 * 1024 : 16L * 1024 * 1024 * 1024,
|
||||||
|
FreeSpaceBytes = 10L * 1024 * 1024 * 1024,
|
||||||
|
SyncState = DeviceSyncState.Connected
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_devices.TryAdd(serial, dev))
|
||||||
|
{
|
||||||
|
DeviceConnected?.Invoke(this, dev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup disconnected devices
|
||||||
|
foreach (var existing in _devices.Keys.ToList())
|
||||||
|
{
|
||||||
|
if (!currentSerials.Contains(existing))
|
||||||
|
{
|
||||||
|
if (_devices.TryRemove(existing, out _))
|
||||||
|
{
|
||||||
|
DeviceDisconnected?.Invoke(this, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace NotZune.Infrastructure.Devices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hosts an embedded HTTP server over the USB network stack (192.168.55.100)
|
||||||
|
/// to stream artist biographies and album art JPEG backdrops directly to connected Zune hardware.
|
||||||
|
/// </summary>
|
||||||
|
public class ZuneUsbHttpInterceptor
|
||||||
|
{
|
||||||
|
private HttpListener? _listener;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
public bool IsRunning => _listener?.IsListening ?? false;
|
||||||
|
|
||||||
|
public void Start(string ipAddress = "127.0.0.1", int port = 8080)
|
||||||
|
{
|
||||||
|
if (IsRunning) return;
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_listener = new HttpListener();
|
||||||
|
_listener.Prefixes.Add($"http://{ipAddress}:{port}/");
|
||||||
|
_listener.Start();
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!_cts.Token.IsCancellationRequested && _listener.IsListening)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await _listener.GetContextAsync();
|
||||||
|
await HandleRequestAsync(ctx);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore listener teardown exceptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_listener?.Stop();
|
||||||
|
_listener?.Close();
|
||||||
|
_listener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleRequestAsync(HttpListenerContext ctx)
|
||||||
|
{
|
||||||
|
var rawUrl = ctx.Request.RawUrl ?? string.Empty;
|
||||||
|
|
||||||
|
// Example: /v3.0/en-US/music/artist/{mbid}/biography
|
||||||
|
if (rawUrl.Contains("/biography"))
|
||||||
|
{
|
||||||
|
const string bioXml = @"<?xml version=""1.0"" encoding=""utf-8""?>
|
||||||
|
<feed xmlns=""http://www.w3.org/2005/Atom"">
|
||||||
|
<title type=""text"">Artist Biography</title>
|
||||||
|
<entry>
|
||||||
|
<content type=""text"">Synchronized via Not-Zune cross-platform player.</content>
|
||||||
|
</entry>
|
||||||
|
</feed>";
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(bioXml);
|
||||||
|
ctx.Response.ContentType = "application/atom+xml";
|
||||||
|
ctx.Response.ContentLength64 = bytes.Length;
|
||||||
|
await ctx.Response.OutputStream.WriteAsync(bytes);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace NotZune.Infrastructure.External;
|
||||||
|
|
||||||
|
public class ArtistMetadataResult
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? MusicBrainzId { get; set; }
|
||||||
|
public string? Biography { get; set; }
|
||||||
|
public string? ThumbnailUrl { get; set; }
|
||||||
|
public List<string> BackgroundImageUrls { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExternalMetadataService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public ExternalMetadataService(HttpClient? httpClient = null)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient ?? new HttpClient();
|
||||||
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("NotZune/1.0 ([email protected])");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ArtistMetadataResult?> FetchArtistMetadataAsync(string artistName, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Mock fallback / safe client query
|
||||||
|
await Task.Delay(100, cancellationToken);
|
||||||
|
|
||||||
|
return new ArtistMetadataResult
|
||||||
|
{
|
||||||
|
Name = artistName,
|
||||||
|
Biography = $"{artistName} is a renowned musical artist cataloged in the Not-Zune library.",
|
||||||
|
ThumbnailUrl = null,
|
||||||
|
BackgroundImageUrls = new List<string>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NotZune.Domain\NotZune.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Application\NotZune.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class AppDbContext : DbContext
|
||||||
|
{
|
||||||
|
public DbSet<Track> Tracks => Set<Track>();
|
||||||
|
public DbSet<Album> Albums => Set<Album>();
|
||||||
|
public DbSet<Artist> Artists => Set<Artist>();
|
||||||
|
public DbSet<Playlist> Playlists => Set<Playlist>();
|
||||||
|
public DbSet<PlayHistoryEntry> PlayHistory => Set<PlayHistoryEntry>();
|
||||||
|
|
||||||
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Track>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(t => t.Id);
|
||||||
|
entity.HasIndex(t => t.Title);
|
||||||
|
entity.HasIndex(t => t.ArtistName);
|
||||||
|
entity.HasIndex(t => t.AlbumTitle);
|
||||||
|
entity.HasIndex(t => t.Rating);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Album>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(a => a.Id);
|
||||||
|
entity.HasIndex(a => a.Title);
|
||||||
|
entity.HasIndex(a => a.ArtistName);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Artist>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(a => a.Id);
|
||||||
|
entity.HasIndex(a => a.Name);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<Playlist>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(p => p.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<PlayHistoryEntry>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(h => h.Id);
|
||||||
|
entity.HasIndex(h => h.PlayedAtUtc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class MediaLibraryService : IMediaLibraryService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<AppDbContext> _contextFactory;
|
||||||
|
|
||||||
|
public MediaLibraryService(IDbContextFactory<AppDbContext> contextFactory)
|
||||||
|
{
|
||||||
|
_contextFactory = contextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Track>> GetAllTracksAsync()
|
||||||
|
{
|
||||||
|
await using var ctx = await _contextFactory.CreateDbContextAsync();
|
||||||
|
return await ctx.Tracks.OrderBy(t => t.Title).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Album>> GetAllAlbumsAsync()
|
||||||
|
{
|
||||||
|
await using var ctx = await _contextFactory.CreateDbContextAsync();
|
||||||
|
return await ctx.Albums.Include(a => a.Tracks).OrderBy(a => a.Title).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Artist>> GetAllArtistsAsync()
|
||||||
|
{
|
||||||
|
await using var ctx = await _contextFactory.CreateDbContextAsync();
|
||||||
|
return await ctx.Artists.OrderBy(a => a.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Playlist>> GetAllPlaylistsAsync()
|
||||||
|
{
|
||||||
|
await using var ctx = await _contextFactory.CreateDbContextAsync();
|
||||||
|
return await ctx.Playlists.OrderBy(p => p.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<PlayHistoryEntry>> GetRecentHistoryAsync(int count = 20)
|
||||||
|
{
|
||||||
|
await using var ctx = await _contextFactory.CreateDbContextAsync();
|
||||||
|
return await ctx.PlayHistory
|
||||||
|
.OrderByDescending(h => h.PlayedAtUtc)
|
||||||
|
.Take(count)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Album>> GetRecentlyAddedAlbumsAsync(int count = 12)
|
||||||
|
{
|
||||||
|
await using var ctx = await _contextFactory.CreateDbContextAsync();
|
||||||
|
return await ctx.Albums
|
||||||
|
.OrderByDescending(a => a.Year)
|
||||||
|
.Take(count)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Track>> SearchAsync(string query)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
return await GetAllTracksAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var ctx = await _contextFactory.CreateDbContextAsync();
|
||||||
|
var q = query.Trim().ToLower();
|
||||||
|
|
||||||
|
return await ctx.Tracks
|
||||||
|
.Where(t => t.Title.ToLower().Contains(q) ||
|
||||||
|
t.ArtistName.ToLower().Contains(q) ||
|
||||||
|
t.AlbumTitle.ToLower().Contains(q) ||
|
||||||
|
t.Genre.ToLower().Contains(q))
|
||||||
|
.OrderBy(t => t.Title)
|
||||||
|
.Take(100)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetTrackRatingAsync(Guid trackId, HeartRating rating)
|
||||||
|
{
|
||||||
|
await using var ctx = await _contextFactory.CreateDbContextAsync();
|
||||||
|
var track = await ctx.Tracks.FindAsync(trackId);
|
||||||
|
if (track != null)
|
||||||
|
{
|
||||||
|
track.Rating = rating;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ScanDirectoryAsync(string directoryPath, IProgress<double>? progress = null)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directoryPath)) return;
|
||||||
|
|
||||||
|
var supportedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".mp3", ".flac", ".m4a", ".ogg", ".wma", ".wav"
|
||||||
|
};
|
||||||
|
|
||||||
|
var files = Directory.EnumerateFiles(directoryPath, "*.*", SearchOption.AllDirectories)
|
||||||
|
.Where(f => supportedExtensions.Contains(Path.GetExtension(f)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (files.Count == 0) return;
|
||||||
|
|
||||||
|
await using var ctx = await _contextFactory.CreateDbContextAsync();
|
||||||
|
int total = files.Count;
|
||||||
|
int current = 0;
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||||
|
var exists = await ctx.Tracks.AnyAsync(t => t.FilePath == file);
|
||||||
|
if (!exists)
|
||||||
|
{
|
||||||
|
var track = new Track
|
||||||
|
{
|
||||||
|
Title = fileName,
|
||||||
|
FilePath = file,
|
||||||
|
ArtistName = "Unknown Artist",
|
||||||
|
AlbumTitle = "Unknown Album",
|
||||||
|
Duration = TimeSpan.FromMinutes(3)
|
||||||
|
};
|
||||||
|
ctx.Tracks.Add(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
current++;
|
||||||
|
progress?.Report((double)current / total);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NotZune.Domain\NotZune.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Application\NotZune.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace NotZune.Plugins.Protocol.Dto;
|
||||||
|
|
||||||
|
public class TrackDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("artist")]
|
||||||
|
public string Artist { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("album")]
|
||||||
|
public string Album { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("durationMs")]
|
||||||
|
public long DurationMs { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rating")]
|
||||||
|
public string Rating { get; set; } = "None";
|
||||||
|
|
||||||
|
[JsonPropertyName("artworkUri")]
|
||||||
|
public string? ArtworkUri { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("musicBrainzTrackId")]
|
||||||
|
public string? MusicBrainzTrackId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("musicBrainzArtistId")]
|
||||||
|
public string? MusicBrainzArtistId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TrackChangedDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("track")]
|
||||||
|
public TrackDto? Track { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("positionMs")]
|
||||||
|
public long PositionMs { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("isPlaying")]
|
||||||
|
public bool IsPlaying { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaybackStateDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("state")]
|
||||||
|
public string State { get; set; } = "Stopped";
|
||||||
|
|
||||||
|
[JsonPropertyName("positionMs")]
|
||||||
|
public long PositionMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RatingChangedDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("trackId")]
|
||||||
|
public Guid TrackId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("rating")]
|
||||||
|
public string Rating { get; set; } = "None";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ShowToastDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("durationMs")]
|
||||||
|
public int DurationMs { get; set; } = 3000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace NotZune.Plugins.Protocol.Messages;
|
||||||
|
|
||||||
|
public class JsonRpcMessage
|
||||||
|
{
|
||||||
|
[JsonPropertyName("jsonrpc")]
|
||||||
|
public string JsonRpc { get; set; } = "2.0";
|
||||||
|
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public object? Id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JsonRpcRequest : JsonRpcMessage
|
||||||
|
{
|
||||||
|
[JsonPropertyName("method")]
|
||||||
|
public string Method { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("params")]
|
||||||
|
public object? Params { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JsonRpcNotification
|
||||||
|
{
|
||||||
|
[JsonPropertyName("jsonrpc")]
|
||||||
|
public string JsonRpc { get; set; } = "2.0";
|
||||||
|
|
||||||
|
[JsonPropertyName("method")]
|
||||||
|
public string Method { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("params")]
|
||||||
|
public object? Params { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JsonRpcResponse : JsonRpcMessage
|
||||||
|
{
|
||||||
|
[JsonPropertyName("result")]
|
||||||
|
public object? Result { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public JsonRpcError? Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JsonRpcError
|
||||||
|
{
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
public int Code { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public object? Data { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NotZune.Plugins.Protocol\NotZune.Plugins.Protocol.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using NotZune.Plugins.Protocol.Dto;
|
||||||
|
using NotZune.Plugins.Protocol.Messages;
|
||||||
|
|
||||||
|
namespace NotZune.Plugins.Sdk;
|
||||||
|
|
||||||
|
public interface IPluginLogger
|
||||||
|
{
|
||||||
|
void LogInformation(string message, params object[] args);
|
||||||
|
void LogWarning(string message, params object[] args);
|
||||||
|
void LogError(string message, Exception? exception, params object[] args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPluginHostContext
|
||||||
|
{
|
||||||
|
IPluginLogger Logger { get; }
|
||||||
|
Task<string?> GetSecureStorageAsync(string key);
|
||||||
|
Task SetSecureStorageAsync(string key, string value);
|
||||||
|
Task ShowToastAsync(string title, string message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPlugin
|
||||||
|
{
|
||||||
|
string Id { get; }
|
||||||
|
string Name { get; }
|
||||||
|
string Version { get; }
|
||||||
|
string Author { get; }
|
||||||
|
string Description { get; }
|
||||||
|
|
||||||
|
Task InitializeAsync(IPluginHostContext context, CancellationToken cancellationToken);
|
||||||
|
Task ShutdownAsync(CancellationToken cancellationToken);
|
||||||
|
Task HandleEventAsync(string eventName, object? eventData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class PluginBase : IPlugin
|
||||||
|
{
|
||||||
|
public abstract string Id { get; }
|
||||||
|
public abstract string Name { get; }
|
||||||
|
public abstract string Version { get; }
|
||||||
|
public abstract string Author { get; }
|
||||||
|
public abstract string Description { get; }
|
||||||
|
|
||||||
|
protected IPluginHostContext Context { get; private set; } = null!;
|
||||||
|
protected IPluginLogger Logger => Context.Logger;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, List<Func<object?, Task>>> _handlers = new();
|
||||||
|
|
||||||
|
public virtual async Task InitializeAsync(IPluginHostContext context, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Context = context;
|
||||||
|
await OnStartAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await OnStopAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task HandleEventAsync(string eventName, object? eventData)
|
||||||
|
{
|
||||||
|
if (_handlers.TryGetValue(eventName, out var list))
|
||||||
|
{
|
||||||
|
foreach (var handler in list)
|
||||||
|
{
|
||||||
|
await handler(eventData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void Subscribe<TEvent>(string eventName, Func<TEvent, Task> handler) where TEvent : class
|
||||||
|
{
|
||||||
|
if (!_handlers.ContainsKey(eventName))
|
||||||
|
{
|
||||||
|
_handlers[eventName] = new List<Func<object?, Task>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
_handlers[eventName].Add(obj =>
|
||||||
|
{
|
||||||
|
if (obj is TEvent typedEvent)
|
||||||
|
{
|
||||||
|
return handler(typedEvent);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual Task OnStartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
protected virtual Task OnStopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NotZune.Domain\NotZune.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Application\NotZune.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Infrastructure.Persistence\NotZune.Infrastructure.Persistence.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Infrastructure.Audio\NotZune.Infrastructure.Audio.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Infrastructure.Devices\NotZune.Infrastructure.Devices.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Infrastructure.External\NotZune.Infrastructure.External.csproj" />
|
||||||
|
<ProjectReference Include="..\NotZune.Plugins.Sdk\NotZune.Plugins.Sdk.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.2.0" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.0" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.0" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<Styles.Resources>
|
||||||
|
<!-- Zune Metro Palette -->
|
||||||
|
<Color x:Key="ZuneBackgroundColor">#111111</Color>
|
||||||
|
<Color x:Key="ZuneSurfaceElevatedColor">#181818</Color>
|
||||||
|
<Color x:Key="ZuneCardColor">#202020</Color>
|
||||||
|
<Color x:Key="ZuneCardHoverColor">#282828</Color>
|
||||||
|
<Color x:Key="ZuneBorderColor">#2C2C2C</Color>
|
||||||
|
|
||||||
|
<!-- Signature Accent Defaults (Pink / Magenta) -->
|
||||||
|
<Color x:Key="ZuneAccentColor">#FA2A55</Color>
|
||||||
|
<Color x:Key="ZuneAccentHoverColor">#FF4D79</Color>
|
||||||
|
|
||||||
|
<!-- Brush Resources -->
|
||||||
|
<SolidColorBrush x:Key="ZuneBackgroundBrush" Color="{StaticResource ZuneBackgroundColor}" />
|
||||||
|
<SolidColorBrush x:Key="ZuneSurfaceElevatedBrush" Color="{StaticResource ZuneSurfaceElevatedColor}" />
|
||||||
|
<SolidColorBrush x:Key="ZuneCardBrush" Color="{StaticResource ZuneCardColor}" />
|
||||||
|
<SolidColorBrush x:Key="ZuneCardHoverBrush" Color="{StaticResource ZuneCardHoverColor}" />
|
||||||
|
<SolidColorBrush x:Key="ZuneBorderBrush" Color="{StaticResource ZuneBorderColor}" />
|
||||||
|
<SolidColorBrush x:Key="ZuneAccentBrush" Color="{StaticResource ZuneAccentColor}" />
|
||||||
|
<SolidColorBrush x:Key="ZuneAccentHoverBrush" Color="{StaticResource ZuneAccentHoverColor}" />
|
||||||
|
|
||||||
|
<!-- Typography Brushes -->
|
||||||
|
<SolidColorBrush x:Key="ZuneTextActiveBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="ZuneTextHoverBrush" Color="#DDDDDD" />
|
||||||
|
<SolidColorBrush x:Key="ZuneTextSecondaryBrush" Color="#999999" />
|
||||||
|
<SolidColorBrush x:Key="ZuneTextDimmedBrush" Color="#555555" />
|
||||||
|
<SolidColorBrush x:Key="ZuneTextWatermarkBrush" Color="#222222" />
|
||||||
|
|
||||||
|
<!-- Non-negotiable Zune Metro Invariant: Zero Corner Radius -->
|
||||||
|
<CornerRadius x:Key="ZuneCornerRadius">0</CornerRadius>
|
||||||
|
</Styles.Resources>
|
||||||
|
|
||||||
|
<!-- Typography Hierarchy Classes -->
|
||||||
|
<Style Selector="TextBlock.hero">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI, Inter, Selawik, sans-serif" />
|
||||||
|
<Setter Property="FontSize" Value="54" />
|
||||||
|
<Setter Property="FontWeight" Value="Light" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextActiveBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.pivot-header">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI, Inter, Selawik, sans-serif" />
|
||||||
|
<Setter Property="FontSize" Value="32" />
|
||||||
|
<Setter Property="FontWeight" Value="Light" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextDimmedBrush}" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.pivot-header:pointerover">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextHoverBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.pivot-header.active">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextActiveBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.subpivot">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI, Inter, Selawik, sans-serif" />
|
||||||
|
<Setter Property="FontSize" Value="18" />
|
||||||
|
<Setter Property="FontWeight" Value="Light" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextDimmedBrush}" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.subpivot.active">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextActiveBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.section-header">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI, Inter, Selawik, sans-serif" />
|
||||||
|
<Setter Property="FontSize" Value="15" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextActiveBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.body">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI, Inter, Selawik, sans-serif" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="FontWeight" Value="Normal" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextActiveBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.caption">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI, Inter, Selawik, sans-serif" />
|
||||||
|
<Setter Property="FontSize" Value="9" />
|
||||||
|
<Setter Property="FontWeight" Value="Normal" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextSecondaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Button Overrides: Zero Corner Radius, Pure Content Before Chrome -->
|
||||||
|
<Style Selector="Button">
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource ZuneCornerRadius}" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextActiveBrush}" />
|
||||||
|
<Setter Property="Padding" Value="8,4" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.zune-action">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ZuneCardBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ZuneTextActiveBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource ZuneCornerRadius}" />
|
||||||
|
<Setter Property="Padding" Value="14,8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.zune-action:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ZuneCardHoverBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border">
|
||||||
|
<Setter Property="CornerRadius" Value="{StaticResource ZuneCornerRadius}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.UI.ViewModels;
|
||||||
|
|
||||||
|
public enum CollectionSubPivot
|
||||||
|
{
|
||||||
|
Artists,
|
||||||
|
Albums,
|
||||||
|
Songs,
|
||||||
|
Genres
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CollectionViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IPlayerCoordinator _playerCoordinator;
|
||||||
|
private readonly IMediaLibraryService _libraryService;
|
||||||
|
|
||||||
|
private CollectionSubPivot _activeSubPivot = CollectionSubPivot.Albums;
|
||||||
|
private string _searchQuery = string.Empty;
|
||||||
|
|
||||||
|
public ObservableCollection<Artist> Artists { get; } = new();
|
||||||
|
public ObservableCollection<Album> Albums { get; } = new();
|
||||||
|
public ObservableCollection<Track> Songs { get; } = new();
|
||||||
|
|
||||||
|
public CollectionSubPivot ActiveSubPivot
|
||||||
|
{
|
||||||
|
get => _activeSubPivot;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _activeSubPivot, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsArtistsActive));
|
||||||
|
OnPropertyChanged(nameof(IsAlbumsActive));
|
||||||
|
OnPropertyChanged(nameof(IsSongsActive));
|
||||||
|
OnPropertyChanged(nameof(IsGenresActive));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsArtistsActive => _activeSubPivot == CollectionSubPivot.Artists;
|
||||||
|
public bool IsAlbumsActive => _activeSubPivot == CollectionSubPivot.Albums;
|
||||||
|
public bool IsSongsActive => _activeSubPivot == CollectionSubPivot.Songs;
|
||||||
|
public bool IsGenresActive => _activeSubPivot == CollectionSubPivot.Genres;
|
||||||
|
|
||||||
|
public string SearchQuery
|
||||||
|
{
|
||||||
|
get => _searchQuery;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _searchQuery, value))
|
||||||
|
{
|
||||||
|
_ = PerformSearchAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand SelectSubPivotCommand { get; }
|
||||||
|
public ICommand PlaySongCommand { get; }
|
||||||
|
public ICommand PlayAlbumCommand { get; }
|
||||||
|
|
||||||
|
public CollectionViewModel(
|
||||||
|
IPlayerCoordinator playerCoordinator,
|
||||||
|
IMediaLibraryService libraryService)
|
||||||
|
{
|
||||||
|
_playerCoordinator = playerCoordinator;
|
||||||
|
_libraryService = libraryService;
|
||||||
|
|
||||||
|
SelectSubPivotCommand = new RelayCommand<CollectionSubPivot>(pivot => ActiveSubPivot = pivot);
|
||||||
|
PlaySongCommand = new AsyncRelayCommand<Track>(OnPlaySongAsync);
|
||||||
|
PlayAlbumCommand = new AsyncRelayCommand<Album>(OnPlayAlbumAsync);
|
||||||
|
|
||||||
|
_ = RefreshDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshDataAsync()
|
||||||
|
{
|
||||||
|
var albums = await _libraryService.GetAllAlbumsAsync();
|
||||||
|
Albums.Clear();
|
||||||
|
foreach (var album in albums)
|
||||||
|
{
|
||||||
|
Albums.Add(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
var artists = await _libraryService.GetAllArtistsAsync();
|
||||||
|
Artists.Clear();
|
||||||
|
foreach (var artist in artists)
|
||||||
|
{
|
||||||
|
Artists.Add(artist);
|
||||||
|
}
|
||||||
|
|
||||||
|
var songs = await _libraryService.GetAllTracksAsync();
|
||||||
|
Songs.Clear();
|
||||||
|
foreach (var song in songs)
|
||||||
|
{
|
||||||
|
Songs.Add(song);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PerformSearchAsync()
|
||||||
|
{
|
||||||
|
var results = await _libraryService.SearchAsync(_searchQuery);
|
||||||
|
Songs.Clear();
|
||||||
|
foreach (var s in results)
|
||||||
|
{
|
||||||
|
Songs.Add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPlaySongAsync(Track? track)
|
||||||
|
{
|
||||||
|
if (track == null) return;
|
||||||
|
await _playerCoordinator.PlayTrackAsync(track, Songs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPlayAlbumAsync(Album? album)
|
||||||
|
{
|
||||||
|
if (album == null || album.Tracks.Count == 0) return;
|
||||||
|
await _playerCoordinator.PlayTrackAsync(album.Tracks[0], album.Tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.UI.ViewModels;
|
||||||
|
|
||||||
|
public class DeviceViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IDeviceSyncService _deviceSyncService;
|
||||||
|
|
||||||
|
public ObservableCollection<ZuneDevice> Devices { get; } = new();
|
||||||
|
|
||||||
|
private ZuneDevice? _selectedDevice;
|
||||||
|
public ZuneDevice? SelectedDevice
|
||||||
|
{
|
||||||
|
get => _selectedDevice;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedDevice, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasDevice));
|
||||||
|
OnPropertyChanged(nameof(DeviceName));
|
||||||
|
OnPropertyChanged(nameof(StorageText));
|
||||||
|
OnPropertyChanged(nameof(StorageUsedPercentage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasDevice => SelectedDevice != null;
|
||||||
|
public string DeviceName => SelectedDevice?.ModelName ?? "No Zune Connected";
|
||||||
|
|
||||||
|
public string StorageText
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (SelectedDevice == null) return "Connect a Zune device via USB cable.";
|
||||||
|
double usedGb = (SelectedDevice.CapacityBytes - SelectedDevice.FreeSpaceBytes) / (1024.0 * 1024 * 1024);
|
||||||
|
double totalGb = SelectedDevice.CapacityBytes / (1024.0 * 1024 * 1024);
|
||||||
|
return $"{usedGb:F1} GB free of {totalGb:F1} GB";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public double StorageUsedPercentage
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (SelectedDevice == null || SelectedDevice.CapacityBytes == 0) return 0.0;
|
||||||
|
return (double)(SelectedDevice.CapacityBytes - SelectedDevice.FreeSpaceBytes) / SelectedDevice.CapacityBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double _syncProgress;
|
||||||
|
public double SyncProgress
|
||||||
|
{
|
||||||
|
get => _syncProgress;
|
||||||
|
set => SetProperty(ref _syncProgress, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand SyncCommand { get; }
|
||||||
|
|
||||||
|
public DeviceViewModel(IDeviceSyncService deviceSyncService)
|
||||||
|
{
|
||||||
|
_deviceSyncService = deviceSyncService;
|
||||||
|
|
||||||
|
_deviceSyncService.DeviceConnected += OnDeviceConnected;
|
||||||
|
_deviceSyncService.DeviceDisconnected += OnDeviceDisconnected;
|
||||||
|
|
||||||
|
SyncCommand = new AsyncRelayCommand(OnSyncAsync);
|
||||||
|
|
||||||
|
RefreshDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshDevices()
|
||||||
|
{
|
||||||
|
Devices.Clear();
|
||||||
|
foreach (var dev in _deviceSyncService.ConnectedDevices)
|
||||||
|
{
|
||||||
|
Devices.Add(dev);
|
||||||
|
}
|
||||||
|
SelectedDevice = Devices.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDeviceConnected(object? sender, ZuneDevice dev)
|
||||||
|
{
|
||||||
|
Devices.Add(dev);
|
||||||
|
if (SelectedDevice == null) SelectedDevice = dev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDeviceDisconnected(object? sender, string serial)
|
||||||
|
{
|
||||||
|
var existing = Devices.FirstOrDefault(d => d.SerialNumber == serial);
|
||||||
|
if (existing != null) Devices.Remove(existing);
|
||||||
|
if (SelectedDevice?.SerialNumber == serial) SelectedDevice = Devices.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSyncAsync()
|
||||||
|
{
|
||||||
|
if (SelectedDevice == null) return;
|
||||||
|
var progressReporter = new Progress<double>(p => SyncProgress = p);
|
||||||
|
await _deviceSyncService.SyncDeviceAsync(SelectedDevice.SerialNumber, progressReporter);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
using System.Windows.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using NotZune.Application.Events;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.UI.ViewModels;
|
||||||
|
|
||||||
|
public enum NavigationPivot
|
||||||
|
{
|
||||||
|
Quickplay,
|
||||||
|
Collection,
|
||||||
|
NowPlaying,
|
||||||
|
Device,
|
||||||
|
Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MainShellViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IPlayerCoordinator _playerCoordinator;
|
||||||
|
private readonly IMediaLibraryService _libraryService;
|
||||||
|
private readonly IDeviceSyncService _deviceSyncService;
|
||||||
|
|
||||||
|
private NavigationPivot _activePivot = NavigationPivot.Quickplay;
|
||||||
|
private ViewModelBase _currentView;
|
||||||
|
|
||||||
|
public QuickplayViewModel QuickplayVM { get; }
|
||||||
|
public CollectionViewModel CollectionVM { get; }
|
||||||
|
public NowPlayingViewModel NowPlayingVM { get; }
|
||||||
|
public DeviceViewModel DeviceVM { get; }
|
||||||
|
public SettingsViewModel SettingsVM { get; }
|
||||||
|
|
||||||
|
public NavigationPivot ActivePivot
|
||||||
|
{
|
||||||
|
get => _activePivot;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _activePivot, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsQuickplayActive));
|
||||||
|
OnPropertyChanged(nameof(IsCollectionActive));
|
||||||
|
OnPropertyChanged(nameof(IsNowPlayingActive));
|
||||||
|
OnPropertyChanged(nameof(IsDeviceActive));
|
||||||
|
OnPropertyChanged(nameof(IsSettingsActive));
|
||||||
|
|
||||||
|
CurrentView = _activePivot switch
|
||||||
|
{
|
||||||
|
NavigationPivot.Quickplay => QuickplayVM,
|
||||||
|
NavigationPivot.Collection => CollectionVM,
|
||||||
|
NavigationPivot.NowPlaying => NowPlayingVM,
|
||||||
|
NavigationPivot.Device => DeviceVM,
|
||||||
|
NavigationPivot.Settings => SettingsVM,
|
||||||
|
_ => QuickplayVM
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsQuickplayActive => _activePivot == NavigationPivot.Quickplay;
|
||||||
|
public bool IsCollectionActive => _activePivot == NavigationPivot.Collection;
|
||||||
|
public bool IsNowPlayingActive => _activePivot == NavigationPivot.NowPlaying;
|
||||||
|
public bool IsDeviceActive => _activePivot == NavigationPivot.Device;
|
||||||
|
public bool IsSettingsActive => _activePivot == NavigationPivot.Settings;
|
||||||
|
|
||||||
|
public ViewModelBase CurrentView
|
||||||
|
{
|
||||||
|
get => _currentView;
|
||||||
|
private set => SetProperty(ref _currentView, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playback HUD properties
|
||||||
|
public Track? CurrentTrack => _playerCoordinator.CurrentTrack;
|
||||||
|
public PlaybackState State => _playerCoordinator.State;
|
||||||
|
public bool IsPlaying => State == PlaybackState.Playing;
|
||||||
|
public TimeSpan CurrentPosition => _playerCoordinator.CurrentPosition;
|
||||||
|
public TimeSpan Duration => _playerCoordinator.Duration;
|
||||||
|
|
||||||
|
public double ProgressPercentage
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (Duration.TotalSeconds <= 0) return 0.0;
|
||||||
|
return Math.Clamp(CurrentPosition.TotalSeconds / Duration.TotalSeconds, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ElapsedTimeText => CurrentPosition.ToString(@"m\:ss");
|
||||||
|
public string RemainingTimeText => (Duration - CurrentPosition).ToString(@"\-m\:ss");
|
||||||
|
|
||||||
|
public double Volume
|
||||||
|
{
|
||||||
|
get => _playerCoordinator.Volume * 100.0;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_playerCoordinator.Volume = value / 100.0;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeartRating CurrentRating => CurrentTrack?.Rating ?? HeartRating.None;
|
||||||
|
public bool IsFavorite => CurrentRating == HeartRating.Favorite;
|
||||||
|
public bool IsDisliked => CurrentRating == HeartRating.Dislike;
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
public ICommand SelectPivotCommand { get; }
|
||||||
|
public ICommand PlayPauseCommand { get; }
|
||||||
|
public ICommand NextCommand { get; }
|
||||||
|
public ICommand PreviousCommand { get; }
|
||||||
|
public ICommand ToggleFavoriteCommand { get; }
|
||||||
|
public ICommand ToggleDislikeCommand { get; }
|
||||||
|
public ICommand ToggleNowPlayingCommand { get; }
|
||||||
|
|
||||||
|
public MainShellViewModel(
|
||||||
|
IPlayerCoordinator playerCoordinator,
|
||||||
|
IMediaLibraryService libraryService,
|
||||||
|
IDeviceSyncService deviceSyncService,
|
||||||
|
ISmartDJService smartDJService)
|
||||||
|
{
|
||||||
|
_playerCoordinator = playerCoordinator;
|
||||||
|
_libraryService = libraryService;
|
||||||
|
_deviceSyncService = deviceSyncService;
|
||||||
|
|
||||||
|
// Child ViewModels
|
||||||
|
QuickplayVM = new QuickplayViewModel(playerCoordinator, libraryService, smartDJService);
|
||||||
|
CollectionVM = new CollectionViewModel(playerCoordinator, libraryService);
|
||||||
|
NowPlayingVM = new NowPlayingViewModel(playerCoordinator, libraryService);
|
||||||
|
DeviceVM = new DeviceViewModel(deviceSyncService);
|
||||||
|
SettingsVM = new SettingsViewModel();
|
||||||
|
|
||||||
|
_currentView = QuickplayVM;
|
||||||
|
|
||||||
|
// Wire events
|
||||||
|
_playerCoordinator.TrackChanged += OnPlayerTrackChanged;
|
||||||
|
_playerCoordinator.StateChanged += OnPlayerStateChanged;
|
||||||
|
_playerCoordinator.RatingChanged += OnPlayerRatingChanged;
|
||||||
|
|
||||||
|
// Setup commands
|
||||||
|
SelectPivotCommand = new RelayCommand<NavigationPivot>(pivot => ActivePivot = pivot);
|
||||||
|
PlayPauseCommand = new AsyncRelayCommand(() => _playerCoordinator.PlayPauseAsync());
|
||||||
|
NextCommand = new AsyncRelayCommand(() => _playerCoordinator.NextAsync());
|
||||||
|
PreviousCommand = new AsyncRelayCommand(() => _playerCoordinator.PreviousAsync());
|
||||||
|
ToggleFavoriteCommand = new AsyncRelayCommand(OnToggleFavoriteAsync);
|
||||||
|
ToggleDislikeCommand = new AsyncRelayCommand(OnToggleDislikeAsync);
|
||||||
|
ToggleNowPlayingCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
ActivePivot = ActivePivot == NavigationPivot.NowPlaying
|
||||||
|
? NavigationPivot.Collection
|
||||||
|
: NavigationPivot.NowPlaying;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnToggleFavoriteAsync()
|
||||||
|
{
|
||||||
|
if (CurrentTrack == null) return;
|
||||||
|
var newRating = CurrentRating == HeartRating.Favorite ? HeartRating.None : HeartRating.Favorite;
|
||||||
|
await _playerCoordinator.SetRatingAsync(CurrentTrack.Id, newRating);
|
||||||
|
await _libraryService.SetTrackRatingAsync(CurrentTrack.Id, newRating);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnToggleDislikeAsync()
|
||||||
|
{
|
||||||
|
if (CurrentTrack == null) return;
|
||||||
|
var newRating = CurrentRating == HeartRating.Dislike ? HeartRating.None : HeartRating.Dislike;
|
||||||
|
await _playerCoordinator.SetRatingAsync(CurrentTrack.Id, newRating);
|
||||||
|
await _libraryService.SetTrackRatingAsync(CurrentTrack.Id, newRating);
|
||||||
|
if (newRating == HeartRating.Dislike)
|
||||||
|
{
|
||||||
|
// Auto skip disliked song
|
||||||
|
await _playerCoordinator.NextAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlayerTrackChanged(object? sender, TrackChangedEventArgs e)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CurrentTrack));
|
||||||
|
OnPropertyChanged(nameof(Duration));
|
||||||
|
OnPropertyChanged(nameof(CurrentPosition));
|
||||||
|
OnPropertyChanged(nameof(ProgressPercentage));
|
||||||
|
OnPropertyChanged(nameof(ElapsedTimeText));
|
||||||
|
OnPropertyChanged(nameof(RemainingTimeText));
|
||||||
|
OnPropertyChanged(nameof(CurrentRating));
|
||||||
|
OnPropertyChanged(nameof(IsFavorite));
|
||||||
|
OnPropertyChanged(nameof(IsDisliked));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlayerStateChanged(object? sender, PlaybackStateChangedEventArgs e)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(State));
|
||||||
|
OnPropertyChanged(nameof(IsPlaying));
|
||||||
|
OnPropertyChanged(nameof(CurrentPosition));
|
||||||
|
OnPropertyChanged(nameof(ProgressPercentage));
|
||||||
|
OnPropertyChanged(nameof(ElapsedTimeText));
|
||||||
|
OnPropertyChanged(nameof(RemainingTimeText));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPlayerRatingChanged(object? sender, HeartRatingChangedEventArgs e)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CurrentRating));
|
||||||
|
OnPropertyChanged(nameof(IsFavorite));
|
||||||
|
OnPropertyChanged(nameof(IsDisliked));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using NotZune.Application.Events;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.UI.ViewModels;
|
||||||
|
|
||||||
|
public enum NowPlayingMode
|
||||||
|
{
|
||||||
|
ArtistCanvas,
|
||||||
|
MosaicWall
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NowPlayingViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IPlayerCoordinator _playerCoordinator;
|
||||||
|
private readonly IMediaLibraryService _libraryService;
|
||||||
|
|
||||||
|
private NowPlayingMode _mode = NowPlayingMode.ArtistCanvas;
|
||||||
|
|
||||||
|
public ObservableCollection<Album> MosaicWallAlbums { get; } = new();
|
||||||
|
|
||||||
|
public NowPlayingMode Mode
|
||||||
|
{
|
||||||
|
get => _mode;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _mode, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsArtistCanvasMode));
|
||||||
|
OnPropertyChanged(nameof(IsMosaicWallMode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsArtistCanvasMode => _mode == NowPlayingMode.ArtistCanvas;
|
||||||
|
public bool IsMosaicWallMode => _mode == NowPlayingMode.MosaicWall;
|
||||||
|
|
||||||
|
public Track? CurrentTrack => _playerCoordinator.CurrentTrack;
|
||||||
|
public string TrackTitle => CurrentTrack?.Title ?? "No Track Playing";
|
||||||
|
public string ArtistName => CurrentTrack?.ArtistName ?? "Zune Player";
|
||||||
|
public string AlbumTitle => CurrentTrack?.AlbumTitle ?? string.Empty;
|
||||||
|
|
||||||
|
public ICommand ToggleModeCommand { get; }
|
||||||
|
|
||||||
|
public NowPlayingViewModel(
|
||||||
|
IPlayerCoordinator playerCoordinator,
|
||||||
|
IMediaLibraryService libraryService)
|
||||||
|
{
|
||||||
|
_playerCoordinator = playerCoordinator;
|
||||||
|
_libraryService = libraryService;
|
||||||
|
|
||||||
|
ToggleModeCommand = new RelayCommand(() =>
|
||||||
|
{
|
||||||
|
Mode = Mode == NowPlayingMode.ArtistCanvas ? NowPlayingMode.MosaicWall : NowPlayingMode.ArtistCanvas;
|
||||||
|
});
|
||||||
|
|
||||||
|
_playerCoordinator.TrackChanged += OnTrackChanged;
|
||||||
|
_ = LoadMosaicWallAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMosaicWallAsync()
|
||||||
|
{
|
||||||
|
var albums = await _libraryService.GetAllAlbumsAsync();
|
||||||
|
MosaicWallAlbums.Clear();
|
||||||
|
foreach (var album in albums)
|
||||||
|
{
|
||||||
|
MosaicWallAlbums.Add(album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTrackChanged(object? sender, TrackChangedEventArgs e)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(CurrentTrack));
|
||||||
|
OnPropertyChanged(nameof(TrackTitle));
|
||||||
|
OnPropertyChanged(nameof(ArtistName));
|
||||||
|
OnPropertyChanged(nameof(AlbumTitle));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
|
||||||
|
namespace NotZune.UI.ViewModels;
|
||||||
|
|
||||||
|
public enum QuickplayDeck
|
||||||
|
{
|
||||||
|
Pins,
|
||||||
|
History,
|
||||||
|
New
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QuickplayViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IPlayerCoordinator _playerCoordinator;
|
||||||
|
private readonly IMediaLibraryService _libraryService;
|
||||||
|
private readonly ISmartDJService _smartDJService;
|
||||||
|
|
||||||
|
private QuickplayDeck _activeDeck = QuickplayDeck.History;
|
||||||
|
private string _smartDjSeedText = "Pick an artist or track...";
|
||||||
|
|
||||||
|
public ObservableCollection<Album> Pins { get; } = new();
|
||||||
|
public ObservableCollection<PlayHistoryEntry> History { get; } = new();
|
||||||
|
public ObservableCollection<Album> NewAlbums { get; } = new();
|
||||||
|
|
||||||
|
public QuickplayDeck ActiveDeck
|
||||||
|
{
|
||||||
|
get => _activeDeck;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _activeDeck, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsPinsActive));
|
||||||
|
OnPropertyChanged(nameof(IsHistoryActive));
|
||||||
|
OnPropertyChanged(nameof(IsNewActive));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPinsActive => _activeDeck == QuickplayDeck.Pins;
|
||||||
|
public bool IsHistoryActive => _activeDeck == QuickplayDeck.History;
|
||||||
|
public bool IsNewActive => _activeDeck == QuickplayDeck.New;
|
||||||
|
|
||||||
|
public string SmartDjSeedText
|
||||||
|
{
|
||||||
|
get => _smartDjSeedText;
|
||||||
|
set => SetProperty(ref _smartDjSeedText, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand SelectDeckCommand { get; }
|
||||||
|
public ICommand LaunchSmartDjCommand { get; }
|
||||||
|
public ICommand PlayHistoryItemCommand { get; }
|
||||||
|
|
||||||
|
public QuickplayViewModel(
|
||||||
|
IPlayerCoordinator playerCoordinator,
|
||||||
|
IMediaLibraryService libraryService,
|
||||||
|
ISmartDJService smartDJService)
|
||||||
|
{
|
||||||
|
_playerCoordinator = playerCoordinator;
|
||||||
|
_libraryService = libraryService;
|
||||||
|
_smartDJService = smartDJService;
|
||||||
|
|
||||||
|
SelectDeckCommand = new RelayCommand<QuickplayDeck>(deck => ActiveDeck = deck);
|
||||||
|
LaunchSmartDjCommand = new AsyncRelayCommand(OnLaunchSmartDjAsync);
|
||||||
|
PlayHistoryItemCommand = new AsyncRelayCommand<PlayHistoryEntry>(OnPlayHistoryItemAsync);
|
||||||
|
|
||||||
|
_ = LoadInitialDataAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadInitialDataAsync()
|
||||||
|
{
|
||||||
|
var historyItems = await _libraryService.GetRecentHistoryAsync(10);
|
||||||
|
History.Clear();
|
||||||
|
foreach (var item in historyItems)
|
||||||
|
{
|
||||||
|
History.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
var newItems = await _libraryService.GetRecentlyAddedAlbumsAsync(8);
|
||||||
|
NewAlbums.Clear();
|
||||||
|
foreach (var album in newItems)
|
||||||
|
{
|
||||||
|
NewAlbums.Add(album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnLaunchSmartDjAsync()
|
||||||
|
{
|
||||||
|
var allTracks = await _libraryService.GetAllTracksAsync();
|
||||||
|
if (allTracks.Count == 0) return;
|
||||||
|
|
||||||
|
var seed = new SmartDJSeed
|
||||||
|
{
|
||||||
|
TargetTrackCount = 25,
|
||||||
|
ExcludeDisliked = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var mix = await _smartDJService.GenerateMixAsync(seed, allTracks);
|
||||||
|
if (mix.Count > 0)
|
||||||
|
{
|
||||||
|
await _playerCoordinator.PlayTrackAsync(mix[0], mix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPlayHistoryItemAsync(PlayHistoryEntry? entry)
|
||||||
|
{
|
||||||
|
if (entry == null) return;
|
||||||
|
var allTracks = await _libraryService.GetAllTracksAsync();
|
||||||
|
var target = allTracks.FirstOrDefault(t => t.Id == entry.TrackId);
|
||||||
|
if (target != null)
|
||||||
|
{
|
||||||
|
await _playerCoordinator.PlayTrackAsync(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace NotZune.UI.ViewModels;
|
||||||
|
|
||||||
|
public record AccentColorOption(string Name, string HexCode);
|
||||||
|
|
||||||
|
public class SettingsViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public ObservableCollection<AccentColorOption> AccentColors { get; } = new()
|
||||||
|
{
|
||||||
|
new("Zune Pink (Signature)", "#FA2A55"),
|
||||||
|
new("Zune Orange", "#F09609"),
|
||||||
|
new("Zune Electric Cyan", "#1BA1E2"),
|
||||||
|
new("Zune Vivid Lime", "#339933"),
|
||||||
|
new("Zune Deep Purple", "#A200FF")
|
||||||
|
};
|
||||||
|
|
||||||
|
private AccentColorOption _selectedAccent;
|
||||||
|
public AccentColorOption SelectedAccent
|
||||||
|
{
|
||||||
|
get => _selectedAccent;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedAccent, value))
|
||||||
|
{
|
||||||
|
ApplyAccent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _musicFolderPath = "~/Music";
|
||||||
|
public string MusicFolderPath
|
||||||
|
{
|
||||||
|
get => _musicFolderPath;
|
||||||
|
set => SetProperty(ref _musicFolderPath, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PlatformInfo => $"{System.Runtime.InteropServices.RuntimeInformation.OSDescription} ({System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture})";
|
||||||
|
public string VersionInfo => "Not-Zune v0.1.0-alpha";
|
||||||
|
|
||||||
|
public ICommand SelectFolderCommand { get; }
|
||||||
|
|
||||||
|
public SettingsViewModel()
|
||||||
|
{
|
||||||
|
_selectedAccent = AccentColors[0];
|
||||||
|
SelectFolderCommand = new RelayCommand(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyAccent(AccentColorOption accent)
|
||||||
|
{
|
||||||
|
// Dynamically updates the Application Resource dictionary accent brush
|
||||||
|
if (Avalonia.Application.Current?.Resources != null)
|
||||||
|
{
|
||||||
|
if (Avalonia.Media.Color.TryParse(accent.HexCode, out var color))
|
||||||
|
{
|
||||||
|
Avalonia.Application.Current.Resources["ZuneAccentBrush"] = new Avalonia.Media.SolidColorBrush(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace NotZune.UI.ViewModels;
|
||||||
|
|
||||||
|
public abstract class ViewModelBase : ObservableObject
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="using:NotZune.UI.ViewModels"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
|
||||||
|
x:Class="NotZune.UI.Views.CollectionView"
|
||||||
|
x:DataType="vm:CollectionViewModel">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*" Margin="40,10,40,20">
|
||||||
|
<!-- Sub-pivot Navigation Bar & Search Box -->
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,24">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="24">
|
||||||
|
<Button Command="{Binding SelectSubPivotCommand}" CommandParameter="{x:Static vm:CollectionSubPivot.Albums}">
|
||||||
|
<TextBlock Text="albums" Classes="subpivot" Classes.active="{Binding IsAlbumsActive}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding SelectSubPivotCommand}" CommandParameter="{x:Static vm:CollectionSubPivot.Artists}">
|
||||||
|
<TextBlock Text="artists" Classes="subpivot" Classes.active="{Binding IsArtistsActive}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding SelectSubPivotCommand}" CommandParameter="{x:Static vm:CollectionSubPivot.Songs}">
|
||||||
|
<TextBlock Text="songs" Classes="subpivot" Classes.active="{Binding IsSongsActive}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding SelectSubPivotCommand}" CommandParameter="{x:Static vm:CollectionSubPivot.Genres}">
|
||||||
|
<TextBlock Text="genres" Classes="subpivot" Classes.active="{Binding IsGenresActive}" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Minimal Search Box -->
|
||||||
|
<TextBox Grid.Column="2"
|
||||||
|
Width="220"
|
||||||
|
Watermark="search collection..."
|
||||||
|
Text="{Binding SearchQuery}"
|
||||||
|
CornerRadius="0"
|
||||||
|
Background="{DynamicResource ZuneSurfaceElevatedBrush}"
|
||||||
|
Foreground="{DynamicResource ZuneTextActiveBrush}"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="8,6" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Main Content Area: Song List or Album Grid -->
|
||||||
|
<ScrollViewer Grid.Row="1">
|
||||||
|
<ItemsControl ItemsSource="{Binding Songs}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Button Command="{Binding $parent[UserControl].((vm:CollectionViewModel)DataContext).PlaySongCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
Padding="0">
|
||||||
|
<Border Background="Transparent"
|
||||||
|
BorderBrush="{DynamicResource ZuneBorderBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
CornerRadius="0"
|
||||||
|
Padding="12,10">
|
||||||
|
<Grid ColumnDefinitions="40,*,*,100">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding TrackNumber}" Classes="caption" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding Title}" Classes="body" VerticalAlignment="Center" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding ArtistName}" Classes="caption" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding Duration, StringFormat={}{0:m\\:ss}}" Classes="caption" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace NotZune.UI.Views;
|
||||||
|
|
||||||
|
public partial class CollectionView : UserControl
|
||||||
|
{
|
||||||
|
public CollectionView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="using:NotZune.UI.ViewModels"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
|
||||||
|
x:Class="NotZune.UI.Views.DeviceView"
|
||||||
|
x:DataType="vm:DeviceViewModel">
|
||||||
|
|
||||||
|
<Grid Margin="40,20,40,20">
|
||||||
|
<!-- State 1: Device Connected -->
|
||||||
|
<StackPanel IsVisible="{Binding HasDevice}" Spacing="24" MaxWidth="600" HorizontalAlignment="Left">
|
||||||
|
<TextBlock Text="{Binding DeviceName}" Classes="hero" />
|
||||||
|
<TextBlock Text="{Binding StorageText}" Classes="body" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
|
||||||
|
|
||||||
|
<!-- Storage Breakdown Meter -->
|
||||||
|
<Border Background="{DynamicResource ZuneSurfaceElevatedBrush}" Height="12" CornerRadius="0">
|
||||||
|
<Grid ColumnDefinitions="*,*">
|
||||||
|
<ProgressBar Value="{Binding StorageUsedPercentage}"
|
||||||
|
Maximum="1.0"
|
||||||
|
Height="12"
|
||||||
|
CornerRadius="0"
|
||||||
|
Foreground="{DynamicResource ZuneAccentBrush}"
|
||||||
|
Background="Transparent" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Sync Button & Progress -->
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<Button Classes="zune-action" Command="{Binding SyncCommand}" HorizontalAlignment="Left">
|
||||||
|
<TextBlock Text="SYNC TO DEVICE" Classes="section-header" />
|
||||||
|
</Button>
|
||||||
|
<ProgressBar Value="{Binding SyncProgress}" Maximum="1.0" Height="4" CornerRadius="0"
|
||||||
|
Foreground="{DynamicResource ZuneAccentBrush}" Background="{DynamicResource ZuneSurfaceElevatedBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- State 2: No Device Connected -->
|
||||||
|
<StackPanel IsVisible="{Binding !HasDevice}" VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="16">
|
||||||
|
<TextBlock Text="connect a zune" Classes="hero" HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Text="Attach your Zune 30, Zune 4/8/16, Zune 80/120, or Zune HD via USB cable."
|
||||||
|
Classes="body"
|
||||||
|
Foreground="{DynamicResource ZuneTextSecondaryBrush}"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace NotZune.UI.Views;
|
||||||
|
|
||||||
|
public partial class DeviceView : UserControl
|
||||||
|
{
|
||||||
|
public DeviceView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="using:NotZune.UI.ViewModels"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="1100" d:DesignHeight="700"
|
||||||
|
x:Class="NotZune.UI.Views.MainShellView"
|
||||||
|
x:DataType="vm:MainShellViewModel"
|
||||||
|
Background="{DynamicResource ZuneBackgroundBrush}">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
<!-- 1. Top Pivot Navigation Header -->
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="40,24,40,12">
|
||||||
|
<!-- Left: Primary Pivots -->
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="28">
|
||||||
|
<Button Command="{Binding SelectPivotCommand}" CommandParameter="{x:Static vm:NavigationPivot.Quickplay}">
|
||||||
|
<TextBlock Text="QUICKPLAY" Classes="pivot-header" Classes.active="{Binding IsQuickplayActive}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding SelectPivotCommand}" CommandParameter="{x:Static vm:NavigationPivot.Collection}">
|
||||||
|
<TextBlock Text="COLLECTION" Classes="pivot-header" Classes.active="{Binding IsCollectionActive}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding SelectPivotCommand}" CommandParameter="{x:Static vm:NavigationPivot.Device}">
|
||||||
|
<TextBlock Text="DEVICE" Classes="pivot-header" Classes.active="{Binding IsDeviceActive}" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Right: Settings & Now Playing Shortcut -->
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="20" VerticalAlignment="Center">
|
||||||
|
<Button Command="{Binding ToggleNowPlayingCommand}">
|
||||||
|
<TextBlock Text="NOW PLAYING" Classes="caption" FontWeight="SemiBold" Foreground="{DynamicResource ZuneAccentBrush}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding SelectPivotCommand}" CommandParameter="{x:Static vm:NavigationPivot.Settings}">
|
||||||
|
<TextBlock Text="SETTINGS" Classes="caption" Classes.active="{Binding IsSettingsActive}" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- 2. Main Content View Area -->
|
||||||
|
<TransitioningContentControl Grid.Row="1" Content="{Binding CurrentView}" />
|
||||||
|
|
||||||
|
<!-- 3. Docked Minimalist Player HUD Bar -->
|
||||||
|
<Border Grid.Row="2"
|
||||||
|
Background="{DynamicResource ZuneSurfaceElevatedBrush}"
|
||||||
|
Height="68"
|
||||||
|
CornerRadius="0">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
<!-- Thin Seek Bar on Top Edge -->
|
||||||
|
<ProgressBar Grid.Row="0"
|
||||||
|
Height="2"
|
||||||
|
CornerRadius="0"
|
||||||
|
Value="{Binding ProgressPercentage}"
|
||||||
|
Maximum="1.0"
|
||||||
|
Foreground="{DynamicResource ZuneAccentBrush}"
|
||||||
|
Background="Transparent" />
|
||||||
|
|
||||||
|
<!-- Player Controls Strip -->
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="320,*,320" Margin="24,0,24,0" VerticalAlignment="Center">
|
||||||
|
<!-- Left: Track Metadata & Heart Rating -->
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="16" VerticalAlignment="Center">
|
||||||
|
<!-- Thumbnail Box -->
|
||||||
|
<Border Width="40" Height="40" Background="{DynamicResource ZuneCardBrush}" CornerRadius="0">
|
||||||
|
<TextBlock Text="♫" FontSize="18" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center" Spacing="2">
|
||||||
|
<TextBlock Text="{Binding CurrentTrack.Title, FallbackValue='Not Playing'}" Classes="body" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" MaxWidth="180" />
|
||||||
|
<TextBlock Text="{Binding CurrentTrack.ArtistName, FallbackValue='Zune'}" Classes="caption" TextTrimming="CharacterEllipsis" MaxWidth="180" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Zune Tri-State Heart Rating Buttons -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<!-- Favorite Heart Button -->
|
||||||
|
<Button Command="{Binding ToggleFavoriteCommand}" Padding="4">
|
||||||
|
<TextBlock Text="♥" FontSize="18"
|
||||||
|
Foreground="{Binding IsFavorite, Converter={x:Null}}"
|
||||||
|
Classes.active="{Binding IsFavorite}">
|
||||||
|
<TextBlock.Styles>
|
||||||
|
<Style Selector="TextBlock.active">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource ZuneAccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Styles>
|
||||||
|
</TextBlock>
|
||||||
|
</Button>
|
||||||
|
<!-- Dislike Broken Heart Button -->
|
||||||
|
<Button Command="{Binding ToggleDislikeCommand}" Padding="4">
|
||||||
|
<TextBlock Text="💔" FontSize="14" Opacity="{Binding IsDisliked, Converter={x:Null}}" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Center: Transport Buttons -->
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="20" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<Button Command="{Binding PreviousCommand}">
|
||||||
|
<TextBlock Text="⏮" FontSize="16" Foreground="{DynamicResource ZuneTextActiveBrush}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding PlayPauseCommand}" Padding="8,4">
|
||||||
|
<TextBlock Text="{Binding IsPlaying, Converter={x:Null}, FallbackValue='▶'}" FontSize="20" Foreground="{DynamicResource ZuneAccentBrush}">
|
||||||
|
<!-- Toggle between Play and Pause symbols -->
|
||||||
|
</TextBlock>
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding NextCommand}">
|
||||||
|
<TextBlock Text="⏭" FontSize="16" Foreground="{DynamicResource ZuneTextActiveBrush}" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Right: Timers & Volume -->
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="16" HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding ElapsedTimeText}" Classes="caption" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="/" Classes="caption" VerticalAlignment="Center" Foreground="{DynamicResource ZuneTextDimmedBrush}" />
|
||||||
|
<TextBlock Text="{Binding Duration, StringFormat={}{0:m\\:ss}}" Classes="caption" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<!-- Volume Slider -->
|
||||||
|
<Slider Width="90"
|
||||||
|
Value="{Binding Volume}"
|
||||||
|
Minimum="0"
|
||||||
|
Maximum="100"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="8,0,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace NotZune.UI.Views;
|
||||||
|
|
||||||
|
public partial class MainShellView : UserControl
|
||||||
|
{
|
||||||
|
public MainShellView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="using:NotZune.UI.ViewModels"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
|
||||||
|
x:Class="NotZune.UI.Views.NowPlayingView"
|
||||||
|
x:DataType="vm:NowPlayingViewModel">
|
||||||
|
|
||||||
|
<Grid Margin="0">
|
||||||
|
<!-- Mode 1: Dynamic Artist Canvas -->
|
||||||
|
<Grid IsVisible="{Binding IsArtistCanvasMode}">
|
||||||
|
<!-- Ambient Gradient Backdrop -->
|
||||||
|
<Border Background="#161616" CornerRadius="0">
|
||||||
|
<!-- Subtle linear vignette -->
|
||||||
|
<Border.Background>
|
||||||
|
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
|
||||||
|
<GradientStop Offset="0.0" Color="#1C1C1C" />
|
||||||
|
<GradientStop Offset="0.5" Color="#121212" />
|
||||||
|
<GradientStop Offset="1.0" Color="#0A0A0A" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Border.Background>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Dynamic Artist Watermark -->
|
||||||
|
<TextBlock Text="{Binding ArtistName}"
|
||||||
|
FontSize="120"
|
||||||
|
FontWeight="Bold"
|
||||||
|
Foreground="{DynamicResource ZuneTextWatermarkBrush}"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,-40,-40" />
|
||||||
|
|
||||||
|
<!-- Iconic Zune Big Typography Overlay -->
|
||||||
|
<StackPanel VerticalAlignment="Bottom" Margin="60,0,0,100" Spacing="8">
|
||||||
|
<TextBlock Text="{Binding TrackTitle}" Classes="hero" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="{Binding ArtistName}"
|
||||||
|
FontSize="28"
|
||||||
|
FontWeight="Light"
|
||||||
|
Foreground="{DynamicResource ZuneAccentBrush}" />
|
||||||
|
<TextBlock Text="{Binding AlbumTitle}" Classes="body" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Mode 2: Album Art Mosaic Wall -->
|
||||||
|
<Grid IsVisible="{Binding IsMosaicWallMode}" Background="#0E0E0E">
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsControl ItemsSource="{Binding MosaicWallAlbums}" Margin="24">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Orientation="Horizontal" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="{DynamicResource ZuneCardBrush}"
|
||||||
|
CornerRadius="0"
|
||||||
|
Width="120"
|
||||||
|
Height="120"
|
||||||
|
Margin="6">
|
||||||
|
<TextBlock Text="♫"
|
||||||
|
FontSize="28"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource ZuneTextDimmedBrush}" />
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Floating Mode Switch Button (Top Right) -->
|
||||||
|
<Button Command="{Binding ToggleModeCommand}"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="0,24,40,0"
|
||||||
|
Classes="zune-action">
|
||||||
|
<TextBlock Text="SWITCH VIEW (WALL / CANVAS)" Classes="caption" FontWeight="SemiBold" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace NotZune.UI.Views;
|
||||||
|
|
||||||
|
public partial class NowPlayingView : UserControl
|
||||||
|
{
|
||||||
|
public NowPlayingView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="using:NotZune.UI.ViewModels"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
|
||||||
|
x:Class="NotZune.UI.Views.QuickplayView"
|
||||||
|
x:DataType="vm:QuickplayViewModel">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="360,*" Margin="40,10,40,20">
|
||||||
|
<!-- Left Wing: Smart DJ Mix Seed Panel -->
|
||||||
|
<Border Grid.Column="0"
|
||||||
|
Background="{DynamicResource ZuneSurfaceElevatedBrush}"
|
||||||
|
CornerRadius="0"
|
||||||
|
Padding="28"
|
||||||
|
Margin="0,0,24,0">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<TextBlock Text="smart dj" Classes="hero" Margin="0,0,0,8" />
|
||||||
|
<TextBlock Text="Create dynamic mixes based on your favorite artists, albums, or genres."
|
||||||
|
Classes="caption"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<Border Background="{DynamicResource ZuneCardBrush}"
|
||||||
|
CornerRadius="0"
|
||||||
|
Padding="16"
|
||||||
|
Margin="0,16,0,16">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="SEED TRACK OR ARTIST" Classes="caption" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
|
||||||
|
<TextBlock Text="{Binding SmartDjSeedText}" Classes="body" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Button Classes="zune-action"
|
||||||
|
Command="{Binding LaunchSmartDjCommand}"
|
||||||
|
HorizontalAlignment="Left">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="▶" Classes="body" Foreground="{DynamicResource ZuneAccentBrush}" />
|
||||||
|
<TextBlock Text="START SMART DJ MIX" Classes="section-header" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Right Wing: 3-Deck Panorama (PINS, HISTORY, NEW) -->
|
||||||
|
<Grid Grid.Column="1" RowDefinitions="Auto,*">
|
||||||
|
<!-- Deck Sub-pivots -->
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="24" Margin="0,0,0,16">
|
||||||
|
<Button Command="{Binding SelectDeckCommand}" CommandParameter="{x:Static vm:QuickplayDeck.Pins}">
|
||||||
|
<TextBlock Text="pins" Classes="subpivot" Classes.active="{Binding IsPinsActive}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding SelectDeckCommand}" CommandParameter="{x:Static vm:QuickplayDeck.History}">
|
||||||
|
<TextBlock Text="history" Classes="subpivot" Classes.active="{Binding IsHistoryActive}" />
|
||||||
|
</Button>
|
||||||
|
<Button Command="{Binding SelectDeckCommand}" CommandParameter="{x:Static vm:QuickplayDeck.New}">
|
||||||
|
<TextBlock Text="new" Classes="subpivot" Classes.active="{Binding IsNewActive}" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- History Deck Content -->
|
||||||
|
<ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||||
|
<ItemsControl ItemsSource="{Binding History}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="16" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Button Command="{Binding $parent[UserControl].((vm:QuickplayViewModel)DataContext).PlayHistoryItemCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
Padding="0">
|
||||||
|
<Border Background="{DynamicResource ZuneCardBrush}"
|
||||||
|
CornerRadius="0"
|
||||||
|
Width="180"
|
||||||
|
Height="220"
|
||||||
|
Padding="12">
|
||||||
|
<Grid RowDefinitions="*,Auto,Auto">
|
||||||
|
<!-- Artwork Placeholder -->
|
||||||
|
<Border Grid.Row="0" Background="#262626" CornerRadius="0">
|
||||||
|
<TextBlock Text="♫"
|
||||||
|
FontSize="40"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource ZuneTextDimmedBrush}" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Row="1" Text="{Binding TrackTitle}" Classes="body" TextTrimming="CharacterEllipsis" Margin="0,8,0,2" />
|
||||||
|
<TextBlock Grid.Row="2" Text="{Binding ArtistName}" Classes="caption" TextTrimming="CharacterEllipsis" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace NotZune.UI.Views;
|
||||||
|
|
||||||
|
public partial class QuickplayView : UserControl
|
||||||
|
{
|
||||||
|
public QuickplayView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:vm="using:NotZune.UI.ViewModels"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
|
||||||
|
x:Class="NotZune.UI.Views.SettingsView"
|
||||||
|
x:DataType="vm:SettingsViewModel">
|
||||||
|
|
||||||
|
<ScrollViewer Margin="40,20,40,20">
|
||||||
|
<StackPanel Spacing="32" MaxWidth="700" HorizontalAlignment="Left">
|
||||||
|
<TextBlock Text="settings" Classes="hero" />
|
||||||
|
|
||||||
|
<!-- Section: Accent Color -->
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<TextBlock Text="SIGNATURE ACCENT COLOR" Classes="section-header" />
|
||||||
|
<TextBlock Text="Choose an authentic Zune palette accent." Classes="caption" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding AccentColors}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="16" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Button Command="{Binding $parent[UserControl].((vm:SettingsViewModel)DataContext).SelectedAccent}"
|
||||||
|
Padding="0">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<Border Background="{Binding HexCode}" Width="48" Height="48" CornerRadius="0" />
|
||||||
|
<TextBlock Text="{Binding Name}" Classes="caption" HorizontalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Section: Library Folders -->
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="MUSIC COLLECTION FOLDER" Classes="section-header" />
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
Text="{Binding MusicFolderPath}"
|
||||||
|
CornerRadius="0"
|
||||||
|
Background="{DynamicResource ZuneSurfaceElevatedBrush}"
|
||||||
|
Foreground="{DynamicResource ZuneTextActiveBrush}"
|
||||||
|
BorderThickness="0"
|
||||||
|
Padding="10,8" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Classes="zune-action"
|
||||||
|
Margin="12,0,0,0"
|
||||||
|
Command="{Binding SelectFolderCommand}">
|
||||||
|
<TextBlock Text="BROWSE" Classes="caption" FontWeight="SemiBold" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Section: Platform & Diagnostics -->
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="SYSTEM & PLATFORM" Classes="section-header" />
|
||||||
|
<TextBlock Text="{Binding PlatformInfo}" Classes="body" Foreground="{DynamicResource ZuneTextSecondaryBrush}" />
|
||||||
|
<TextBlock Text="{Binding VersionInfo}" Classes="caption" Foreground="{DynamicResource ZuneAccentBrush}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace NotZune.UI.Views;
|
||||||
|
|
||||||
|
public partial class SettingsView : UserControl
|
||||||
|
{
|
||||||
|
public SettingsView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.4.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\NotZune.Domain\NotZune.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\NotZune.Application\NotZune.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using NotZune.Application.Interfaces;
|
||||||
|
using NotZune.Application.Services;
|
||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NotZune.Tests.Application;
|
||||||
|
|
||||||
|
public class PlaybackQueueCoordinatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task PlayTrackAsync_SetsCurrentTrackAndState()
|
||||||
|
{
|
||||||
|
var coordinator = new PlaybackQueueCoordinator();
|
||||||
|
var track = new Track { Title = "Tom Sawyer", ArtistName = "Rush" };
|
||||||
|
|
||||||
|
await coordinator.PlayTrackAsync(track);
|
||||||
|
|
||||||
|
Assert.Equal(PlaybackState.Playing, coordinator.State);
|
||||||
|
Assert.NotNull(coordinator.CurrentTrack);
|
||||||
|
Assert.Equal("Tom Sawyer", coordinator.CurrentTrack!.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NextAsync_AdvancesQueue()
|
||||||
|
{
|
||||||
|
var coordinator = new PlaybackQueueCoordinator();
|
||||||
|
var track1 = new Track { Title = "Track 1" };
|
||||||
|
var track2 = new Track { Title = "Track 2" };
|
||||||
|
|
||||||
|
await coordinator.PlayTrackAsync(track1, new[] { track1, track2 });
|
||||||
|
Assert.Equal("Track 1", coordinator.CurrentTrack!.Title);
|
||||||
|
|
||||||
|
await coordinator.NextAsync();
|
||||||
|
Assert.Equal("Track 2", coordinator.CurrentTrack!.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetRatingAsync_UpdatesTrackRatingInQueue()
|
||||||
|
{
|
||||||
|
var coordinator = new PlaybackQueueCoordinator();
|
||||||
|
var track = new Track { Title = "Favorite Song", Rating = HeartRating.None };
|
||||||
|
|
||||||
|
await coordinator.PlayTrackAsync(track);
|
||||||
|
await coordinator.SetRatingAsync(track.Id, HeartRating.Favorite);
|
||||||
|
|
||||||
|
Assert.Equal(HeartRating.Favorite, coordinator.CurrentTrack!.Rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SmartDJEngine_ExcludesDislikedTracks()
|
||||||
|
{
|
||||||
|
var engine = new SmartDJEngine();
|
||||||
|
var artistId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var tracks = new List<Track>
|
||||||
|
{
|
||||||
|
new() { Title = "Good Song", ArtistId = artistId, Genre = "Rock", Rating = HeartRating.Favorite },
|
||||||
|
new() { Title = "Disliked Song", ArtistId = artistId, Genre = "Rock", Rating = HeartRating.Dislike },
|
||||||
|
new() { Title = "Neutral Song", ArtistId = artistId, Genre = "Rock", Rating = HeartRating.None }
|
||||||
|
};
|
||||||
|
|
||||||
|
var seed = new SmartDJSeed
|
||||||
|
{
|
||||||
|
SeedArtistId = artistId,
|
||||||
|
ExcludeDisliked = true,
|
||||||
|
TargetTrackCount = 10
|
||||||
|
};
|
||||||
|
|
||||||
|
var mix = await engine.GenerateMixAsync(seed, tracks);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(mix, t => t.Rating == HeartRating.Dislike);
|
||||||
|
Assert.Contains(mix, t => t.Title == "Good Song");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using NotZune.Domain.Enums;
|
||||||
|
using NotZune.Domain.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NotZune.Tests.Domain;
|
||||||
|
|
||||||
|
public class DomainModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void HeartRating_HasExpectedValues()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, (int)HeartRating.None);
|
||||||
|
Assert.Equal(1, (int)HeartRating.Favorite);
|
||||||
|
Assert.Equal(2, (int)HeartRating.Dislike);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Track_InitializesWithDefaultValues()
|
||||||
|
{
|
||||||
|
var track = new Track
|
||||||
|
{
|
||||||
|
Title = "Subdivisions",
|
||||||
|
ArtistName = "Rush",
|
||||||
|
AlbumTitle = "Signals",
|
||||||
|
Duration = TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(34)
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, track.Id);
|
||||||
|
Assert.Equal(HeartRating.None, track.Rating);
|
||||||
|
Assert.Equal(0, track.PlayCount);
|
||||||
|
Assert.Equal(1, track.DiscNumber);
|
||||||
|
Assert.Equal("Subdivisions", track.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SmartDJSeed_RespectsDefaults()
|
||||||
|
{
|
||||||
|
var seed = new SmartDJSeed();
|
||||||
|
Assert.Equal(25, seed.TargetTrackCount);
|
||||||
|
Assert.True(seed.ExcludeDisliked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZuneDevice_DefaultsToDisconnected()
|
||||||
|
{
|
||||||
|
var device = new ZuneDevice
|
||||||
|
{
|
||||||
|
SerialNumber = "1234567890",
|
||||||
|
ModelName = "Zune HD 64"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(device.IsConnected);
|
||||||
|
Assert.Equal(DeviceSyncState.Disconnected, device.SyncState);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
global using Xunit;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.4.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\NotZune.Domain\NotZune.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user