NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player

Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
This commit is contained in:
Dallas Groot 2026-03-28 20:49:47 +00:00
commit d8041c0019
58 changed files with 18358 additions and 0 deletions

55
.gitignore vendored Normal file
View file

@ -0,0 +1,55 @@
# Xcode
*.xcodeproj/
*.xcworkspace/
xcuserdata/
*.xcuserstate
DerivedData/
build/
*.pbxuser
*.mode1v3
*.mode2v3
*.perspectivev3
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
# XcodeGen (regenerated from project.yml)
NavidromePlayer.xcodeproj/
# Swift Package Manager
.build/
.swiftpm/
Package.resolved
# CocoaPods (if ever used)
Pods/
Podfile.lock
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes
# IDE
*.swp
*.swo
*~
.idea/
.vscode/
# Archives
*.zip
*.tar.gz
# Provisioning profiles
*.mobileprovision
*.provisionprofile
# Secrets (never commit)
*.p12
*.p8
*.pem

278
README.md Normal file
View file

@ -0,0 +1,278 @@
# NavidromePlayer
A native iOS + watchOS music player for [Navidrome](https://www.navidrome.org/) servers, built with SwiftUI and AVFoundation. Features a Mitsuha-style audio visualizer, Apple Watch companion app, radio streaming with Shazam identification, and an optional Companion API for advanced server-side features.
## Features
### iOS App
- **Full Navidrome/Subsonic library** — browse by albums, artists, songs, genres, playlists
- **Offline playback** — download songs for offline listening with per-track and per-album downloads
- **Mitsuha visualizer** — real-time FFT waveform visualization with Catmull-Rom spline rendering, multiple styles (wave, bar, line), color modes (dynamic, album art, custom), and configurable presets
- **Now Playing** — full-screen player with album art color extraction, drag-to-dismiss, AirPlay, seek bar, queue management
- **Mini player** — persistent bottom bar with scrubbable progress, visualizer overlay, transport controls
- **Radio streaming** — live radio with HLS/PLS/M3U playlist resolution, timeshift buffering, recording, and Shazam identification
- **Smart DJ** — crossfade engine with silence skipping, loudness normalization (LUFS), and predictive transitions (requires Companion API)
- **Batch metadata editing** — edit album/artist tags across multiple albums simultaneously (requires Companion API)
- **Custom album covers** — long-press album art to replace with photos from your library
- **Multi-server support** — automatic failover between servers with the same credentials
- **Background audio** — Lock Screen / Control Center controls, Dynamic Island support
- **Keyboard dismiss** — tap anywhere outside text fields or scroll to dismiss
### watchOS App
- **Offline playback** — transfer songs from iPhone to Apple Watch for standalone listening
- **Bluetooth + Speaker mode** — Apple Watch Ultra speaker support via HKWorkoutSession
- **Crown control** — Digital Crown volume control with haptic feedback
- **Compact visualizer** — waveform visualization on watch face
### Companion API (Optional)
A Python FastAPI server that runs alongside Navidrome on your server (e.g., Raspberry Pi) providing:
- **Smart DJ analysis** — BPM detection, silence boundary mapping, LUFS loudness measurement
- **Mitsuha visualizer pre-computation** — generate FFT frames on the server instead of on-device
- **Remote ID3 tag editing** — edit metadata on server files via mutagen
- **File uploads** — upload and auto-tag new music via the app
- **WebSocket push** — real-time notifications to the iOS app when metadata changes or uploads complete
- **Navidrome scan trigger** — automatically rescan after tag edits
## Architecture
```
NavidromePlayer/
├── Shared/ # Code shared between iOS and watchOS
│ ├── API/SubsonicClient.swift # Full Subsonic/Navidrome REST client
│ ├── Audio/AudioPlayer.swift # AVPlayer + AVAudioEngine, FFT, visualizer levels
│ ├── Models/Models.swift # Codable models for all API responses
│ └── Storage/
│ ├── LibraryCache.swift # Disk cache for instant offline browsing
│ ├── OfflineManager.swift # Download manager with progress tracking
│ ├── ServerManager.swift # Multi-server with auto-failover
│ └── WatchConnectivityManager.swift # WCSession file transfers
├── iOS/
│ ├── App/NavidromePlayerApp.swift # Entry point, background upload delegate
│ └── Views/
│ ├── Common/ # MainTabView, MiniPlayer, AsyncCoverArt, DebugConsole
│ ├── Companion/ # Companion API integration
│ │ ├── CompanionAPIService.swift # API client + WebSocket push client
│ │ ├── CompanionSettingsView.swift # Config, Smart DJ toggles, analysis triggers
│ │ ├── SmartCrossfadeManager.swift # Dual AVPlayer A/B crossfade engine
│ │ ├── TrackEditorView.swift # Single-track metadata editor
│ │ ├── BatchAlbumEditorSheet.swift # Edit tags for one album
│ │ ├── MultiAlbumEditorSheet.swift # Batch edit across multiple albums
│ │ ├── BatchUploadView.swift # Zip import + batch upload
│ │ └── ZipImportManager.swift # Background URLSession uploads
│ ├── Library/ # MyMusic, Albums, Artists, Playlists, Search, Radio, Downloads
│ ├── Login/ # Server configuration
│ ├── NowPlaying/ # Full player, SiriSeekBar, RadioStreamBuffer, Shazam
│ └── Visualizer/ # MitsuhaVisualizerView, OfflineAudioAnalyzer, storage
├── watchOS/
│ ├── App/ # Watch app entry, offline store, session manager
│ ├── Audio/WatchAudioPlayer.swift # Dual mode: Bluetooth + Ultra speaker
│ └── Views/ # Library, NowPlaying, Setup, Visualizer
├── project.yml # XcodeGen project definition
└── generate.sh # Regenerate .xcodeproj
```
## Requirements
- **iOS 17.0+** / **watchOS 10.0+**
- **Xcode 15+**
- **XcodeGen**`brew install xcodegen`
- **Navidrome server** — any version with Subsonic API support
## Building
```bash
./generate.sh
# Opens Xcode automatically. Select device and build.
```
Set your Apple Developer Team ID in `project.yml` under `DEVELOPMENT_TEAM`.
---
## Companion API
The Companion API is optional. Without it, the app works as a standard Navidrome player. With it, you get Smart DJ, tag editing, uploads, and server-side visualizer pre-computation.
### Server Directory Structure
```
/home/pi/docker/navidrome/
├── docker-compose.yml # Both Navidrome + Companion
├── navidrome_data/ # Navidrome database (auto-created)
├── companion_data/ # Persistent data (auto-created)
│ ├── smart_dj.db # BPM, silence, loudness profiles
│ └── vis_cache/ # Pre-computed Mitsuha FFT frames
└── companion_api/ # Companion API source code
├── Dockerfile
├── main.py
└── pre_analyze.py
/home/pi/navidrome/music/ # Your music library
├── Artist/Album/song.flac
└── ...
```
### docker-compose.yml
```yaml
services:
navidrome:
image: deluan/navidrome:latest
container_name: navidrome
restart: unless-stopped
ports:
- "4533:4533"
environment:
- ND_SCANSCHEDULE=1h
- ND_BASEURL=/navidrome
volumes:
- /home/pi/navidrome:/music:ro
- ./navidrome_data:/data
music-companion:
build: ./companion_api
container_name: music-companion
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- /home/pi/navidrome/music:/music:rw
- ./companion_data:/app/data
environment:
- MUSIC_DIR=/music
- DB_PATH=/app/data/smart_dj.db
- VIS_CACHE_DIR=/app/data/vis_cache
- NAVIDROME_URL=http://navidrome:4533/navidrome
- SUBSONIC_USER=your_username
- SUBSONIC_TOKEN=your_token
- SUBSONIC_SALT=your_salt
depends_on:
- navidrome
```
> **Volume mount note:** The companion mounts `/home/pi/navidrome/music` directly (not the parent `/home/pi/navidrome`). This ensures `MUSIC_DIR=/music` maps to your actual music files without path prefix issues.
### Commands
```bash
cd /home/pi/docker/navidrome
# ── Startup ──────────────────────────────────────────────
docker compose up -d # Start everything
docker compose up -d --build music-companion # Rebuild after code changes
docker compose logs -f music-companion # View logs
# ── Health Check ─────────────────────────────────────────
curl http://localhost:8000/health
# Returns: profiles count, vis cache count, connected clients
# ── Pre-Analysis (run after adding new music) ────────────
docker compose exec music-companion python pre_analyze.py # Analyze missing (DJ + vis)
docker compose exec music-companion python pre_analyze.py --dj # DJ profiles only (faster)
docker compose exec music-companion python pre_analyze.py --vis # Visualizer frames only
docker compose exec music-companion python pre_analyze.py --force # Re-analyze everything
# ── Reset Analysis Data ─────────────────────────────────
rm companion_data/smart_dj.db # Delete DJ profiles
rm -rf companion_data/vis_cache/* # Delete vis frame cache
docker compose exec music-companion python pre_analyze.py # Regenerate from scratch
# ── Maintenance ──────────────────────────────────────────
docker compose restart music-companion # Restart API
docker compose down # Stop everything
docker compose down -v # Stop + remove volumes
```
### API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/health` | Server status, profile count, vis cache count, connected clients |
| `PATCH` | `/edit-metadata` | Edit ID3 tags (JSON body: `relative_path`, `title`, `artist`, `album`, etc.) |
| `POST` | `/upload-track` | Upload audio file with metadata (multipart: `file`, `title`, `artist`, `album`) |
| `GET` | `/smart-dj/profile?relative_path=...` | BPM, silence start/end, LUFS for a track |
| `GET` | `/smart-dj/bulk-profiles?paths=...` | Batch fetch profiles (comma-separated paths) |
| `GET` | `/visualizer/frames?relative_path=...` | Pre-computed Mitsuha FFT frames (JSON array) |
| `POST` | `/visualizer/precompute` | Trigger background vis frame generation for all tracks |
| `POST` | `/bulk-fix` | Trigger Navidrome library rescan |
| `WS` | `/ws/push` | Real-time push events to iOS app |
### Path Resolution
Navidrome's `song.path` field can differ from the actual filesystem path. The Companion API uses a three-strategy resolver:
1. **Direct join**`MUSIC_DIR + relative_path`
2. **Strip prefix** — removes leading components one at a time (handles Navidrome's library folder prefix)
3. **Filename search** — walks the music directory looking for an exact filename match
If a 404 still occurs, the error response includes the exact paths that were tried for debugging.
### iOS App Configuration
1. **Settings** → **Companion API**
2. Enter your server's IP address and port (default: 8000)
3. Toggle **Enable Companion API**
4. Tap **Test Connection** to verify
5. Toggle **Smart DJ** for crossfade and analysis features
---
## Feature Details
### Mitsuha Visualizer
The visualizer renders smooth liquid waveforms using Catmull-Rom splines with configurable tension. FFT data comes from one of three sources:
- **Real-time engine FFT** — AVAudioEngine tap on local files
- **Offline pre-analyzed frames** — cached FFT data synced to playback position
- **Server-computed frames** — downloaded from Companion API (saves device battery)
Settings include per-view configuration for Now Playing and Mini Player independently, with four built-in presets. The mini player visualizer automatically pauses when the full-screen Now Playing view is open (battery optimization).
### Radio
- **Playlist resolution** — resolves `.pls`, `.m3u`, `.asx` playlist URLs to direct stream URLs
- **HLS detection** — identifies HLS streams from URL extension or content-type, disables raw buffering
- **Timeshift** — buffer live radio and scrub back through recent audio
- **Recording** — capture radio segments to local files
- **Shazam** — identify currently playing content via SHSession + dedicated AVAudioEngine mic tap
- **Recorded playback** — recorded radio files play with ±5s skip buttons instead of prev/next
- **Live toggle** — start/stop buffering, snap to live edge
### Batch Tag Editing
Long-press any album to:
- **Edit Album** — edit tags for all tracks in that album
- **Select Albums** — enter multi-select mode (nav bar transforms: Cancel / count / Edit button), tap albums to select, "Select All" toggle, then apply changes across all selected albums
The "Set same artist on all tracks" toggle fixes compilation albums that split into separate per-artist entries in Navidrome.
### Downloads
Split into two sub-tabs:
- **Offline** — storage usage, downloaded songs with playback, swipe to delete
- **Watch** — Apple Watch connection status, pending transfers with progress, songs on watch, send all / delete all
### Debug Console
Toggle in Settings → Developer. Two display modes:
- **Docked** — panel above the tab bar, drag handle to resize (120500pt)
- **PiP** — floating draggable/resizable window with collapse, dock-back, and close buttons
### Caching
All views use cache-first loading to eliminate spinners on warm launches:
- **LibraryCache** — albums, artists, playlists, genres, album/artist details as JSON
- **ImageCache** — two-tier NSCache + disk JPEG with 200MB limit and LRU eviction
- **SmartDJCache** — Smart DJ profiles cached locally per song path
- **AlbumCoverStore** — user-set custom album covers in Documents
- **VisualizerStorageManager** — pre-analyzed FFT frames per song
Detail views load from cache instantly, refresh from server silently, and show a Retry button if both cache and server are unavailable.
---
## License
Personal project. Not affiliated with Navidrome.

View file

@ -0,0 +1,461 @@
import Foundation
import CryptoKit
// MARK: - Subsonic API Client
/// Full Subsonic/Navidrome API client supporting all endpoints
class SubsonicClient: ObservableObject {
private let session: URLSession
private let apiVersion = "1.16.1"
private let clientName = "NavidromePlayer"
@Published var currentServer: ServerConfig?
@Published var isAuthenticated = false
@Published var lastError: String?
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
// MARK: - Authentication Helpers
private func authParams(for server: ServerConfig? = nil) -> [URLQueryItem] {
guard let srv = server ?? currentServer else { return [] }
// Use token-based auth (salt + token)
let salt = randomSalt()
let token = md5Hash(srv.password + salt)
return [
URLQueryItem(name: "u", value: srv.username),
URLQueryItem(name: "t", value: token),
URLQueryItem(name: "s", value: salt),
URLQueryItem(name: "v", value: apiVersion),
URLQueryItem(name: "c", value: clientName),
URLQueryItem(name: "f", value: "json")
]
}
private func buildURL(server: ServerConfig? = nil, endpoint: String, params: [URLQueryItem] = []) -> URL? {
guard let srv = server ?? currentServer,
let baseURL = srv.baseURL else { return nil }
var components = URLComponents(url: baseURL.appendingPathComponent("rest/\(endpoint)"), resolvingAgainstBaseURL: false)
components?.queryItems = authParams(for: srv) + params
return components?.url
}
private func randomSalt(length: Int = 12) -> String {
let chars = "abcdefghijklmnopqrstuvwxyz0123456789"
return String((0..<length).map { _ in chars.randomElement()! })
}
private func md5Hash(_ string: String) -> String {
let data = Data(string.utf8)
let hash = Insecure.MD5.hash(data: data)
return hash.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Generic Request
private func request<T: Codable>(endpoint: String, params: [URLQueryItem] = [], server: ServerConfig? = nil) async throws -> T {
guard let url = buildURL(server: server, endpoint: endpoint, params: params) else {
throw APIError.invalidURL
}
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw APIError.httpError(httpResponse.statusCode)
}
let decoder = JSONDecoder()
let subsonicResp = try decoder.decode(SubsonicResponse.self, from: data)
if let error = subsonicResp.subsonicResponse.error {
throw APIError.subsonicError(error.code, error.message)
}
// Re-decode to extract the specific type
return try decoder.decode(T.self, from: data)
}
private func requestBody(endpoint: String, params: [URLQueryItem] = [], server: ServerConfig? = nil) async throws -> SubsonicResponseBody {
guard let url = buildURL(server: server, endpoint: endpoint, params: params) else {
throw APIError.invalidURL
}
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw APIError.httpError(httpResponse.statusCode)
}
let decoder = JSONDecoder()
let subsonicResp = try decoder.decode(SubsonicResponse.self, from: data)
if let error = subsonicResp.subsonicResponse.error {
throw APIError.subsonicError(error.code, error.message)
}
return subsonicResp.subsonicResponse
}
// MARK: - Raw Data Request (for streaming/downloading)
func requestData(endpoint: String, params: [URLQueryItem] = [], server: ServerConfig? = nil) async throws -> (Data, URLResponse) {
guard let url = buildURL(server: server, endpoint: endpoint, params: params) else {
throw APIError.invalidURL
}
return try await session.data(from: url)
}
func streamURL(songId: String, format: String? = nil, maxBitRate: Int? = nil, server: ServerConfig? = nil) -> URL? {
var params: [URLQueryItem] = [URLQueryItem(name: "id", value: songId)]
if let fmt = format { params.append(URLQueryItem(name: "format", value: fmt)) }
if let br = maxBitRate { params.append(URLQueryItem(name: "maxBitRate", value: "\(br)")) }
return buildURL(server: server, endpoint: "stream", params: params)
}
func coverArtURL(id: String, size: Int? = nil, server: ServerConfig? = nil) -> URL? {
var params: [URLQueryItem] = [URLQueryItem(name: "id", value: id)]
if let s = size { params.append(URLQueryItem(name: "size", value: "\(s)")) }
return buildURL(server: server, endpoint: "getCoverArt", params: params)
}
// MARK: - System
func ping(server: ServerConfig? = nil) async throws -> Bool {
let body = try await requestBody(endpoint: "ping", server: server)
return body.status == "ok"
}
func getLicense(server: ServerConfig? = nil) async throws -> SubsonicResponseBody {
return try await requestBody(endpoint: "getLicense", server: server)
}
func getScanStatus() async throws -> ScanStatus? {
let body = try await requestBody(endpoint: "getScanStatus")
return body.scanStatus
}
func startScan() async throws {
_ = try await requestBody(endpoint: "startScan")
}
// MARK: - Browsing
func getMusicFolders() async throws -> [MusicFolder] {
let body = try await requestBody(endpoint: "getMusicFolders")
return body.musicFolders?.musicFolder ?? []
}
func getIndexes(musicFolderId: String? = nil, ifModifiedSince: Int64? = nil) async throws -> [ArtistIndex] {
var params: [URLQueryItem] = []
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
if let since = ifModifiedSince { params.append(URLQueryItem(name: "ifModifiedSince", value: "\(since)")) }
let body = try await requestBody(endpoint: "getIndexes", params: params)
return body.indexes?.index ?? []
}
func getMusicDirectory(id: String) async throws -> DirectoryContainer? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getMusicDirectory", params: params)
return body.directory
}
func getGenres() async throws -> [Genre] {
let body = try await requestBody(endpoint: "getGenres")
return body.genres?.genre ?? []
}
func getArtists(musicFolderId: String? = nil) async throws -> [ArtistIndex] {
var params: [URLQueryItem] = []
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "getArtists", params: params)
return body.artists?.index ?? []
}
func getArtist(id: String) async throws -> ArtistWithAlbums? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getArtist", params: params)
return body.artist
}
func getAlbum(id: String) async throws -> AlbumWithSongs? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getAlbum", params: params)
return body.album
}
func getSong(id: String) async throws -> Song? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getSong", params: params)
return body.song
}
// MARK: - Album Lists
func getAlbumList2(type: String, size: Int = 20, offset: Int = 0, fromYear: Int? = nil, toYear: Int? = nil, genre: String? = nil, musicFolderId: String? = nil) async throws -> [Album] {
var params: [URLQueryItem] = [
URLQueryItem(name: "type", value: type),
URLQueryItem(name: "size", value: "\(size)"),
URLQueryItem(name: "offset", value: "\(offset)")
]
if let fy = fromYear { params.append(URLQueryItem(name: "fromYear", value: "\(fy)")) }
if let ty = toYear { params.append(URLQueryItem(name: "toYear", value: "\(ty)")) }
if let g = genre { params.append(URLQueryItem(name: "genre", value: g)) }
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "getAlbumList2", params: params)
return body.albumList2?.album ?? []
}
func getRandomSongs(size: Int = 20, genre: String? = nil, fromYear: Int? = nil, toYear: Int? = nil, musicFolderId: String? = nil) async throws -> [Song] {
var params: [URLQueryItem] = [URLQueryItem(name: "size", value: "\(size)")]
if let g = genre { params.append(URLQueryItem(name: "genre", value: g)) }
if let fy = fromYear { params.append(URLQueryItem(name: "fromYear", value: "\(fy)")) }
if let ty = toYear { params.append(URLQueryItem(name: "toYear", value: "\(ty)")) }
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "getRandomSongs", params: params)
return body.randomSongs?.song ?? []
}
func getNowPlaying() async throws -> [NowPlayingEntry] {
let body = try await requestBody(endpoint: "getNowPlaying")
return body.nowPlaying?.entry ?? []
}
func getStarred2(musicFolderId: String? = nil) async throws -> Starred2Container? {
var params: [URLQueryItem] = []
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "getStarred2", params: params)
return body.starred2
}
func getLyrics(artist: String? = nil, title: String? = nil) async throws -> LyricsResult? {
var params: [URLQueryItem] = []
if let a = artist { params.append(URLQueryItem(name: "artist", value: a)) }
if let t = title { params.append(URLQueryItem(name: "title", value: t)) }
let body = try await requestBody(endpoint: "getLyrics", params: params)
return body.lyrics
}
/// Instant Mix returns similar songs to seed song
func getSimilarSongs2(id: String, count: Int = 50) async throws -> [Song] {
let params = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "count", value: "\(count)")
]
let body = try await requestBody(endpoint: "getSimilarSongs2", params: params)
return body.similarSongs2?.song ?? []
}
/// Add a new internet radio station
func createInternetRadioStation(streamUrl: String, name: String, homepageUrl: String? = nil) async throws {
var params = [
URLQueryItem(name: "streamUrl", value: streamUrl),
URLQueryItem(name: "name", value: name)
]
if let hp = homepageUrl { params.append(URLQueryItem(name: "homepageUrl", value: hp)) }
_ = try await requestBody(endpoint: "createInternetRadioStation", params: params)
}
/// Delete an internet radio station
func deleteInternetRadioStation(id: String) async throws {
_ = try await requestBody(endpoint: "deleteInternetRadioStation", params: [URLQueryItem(name: "id", value: id)])
}
// MARK: - Search
func search3(query: String, artistCount: Int = 20, artistOffset: Int = 0, albumCount: Int = 20, albumOffset: Int = 0, songCount: Int = 20, songOffset: Int = 0, musicFolderId: String? = nil) async throws -> SearchResult3? {
var params: [URLQueryItem] = [
URLQueryItem(name: "query", value: query),
URLQueryItem(name: "artistCount", value: "\(artistCount)"),
URLQueryItem(name: "artistOffset", value: "\(artistOffset)"),
URLQueryItem(name: "albumCount", value: "\(albumCount)"),
URLQueryItem(name: "albumOffset", value: "\(albumOffset)"),
URLQueryItem(name: "songCount", value: "\(songCount)"),
URLQueryItem(name: "songOffset", value: "\(songOffset)")
]
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "search3", params: params)
return body.searchResult3
}
// MARK: - Playlists
func getPlaylists(username: String? = nil) async throws -> [Playlist] {
var params: [URLQueryItem] = []
if let u = username { params.append(URLQueryItem(name: "username", value: u)) }
let body = try await requestBody(endpoint: "getPlaylists", params: params)
return body.playlists?.playlist ?? []
}
func getPlaylist(id: String) async throws -> PlaylistWithSongs? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getPlaylist", params: params)
return body.playlist
}
func createPlaylist(name: String, songIds: [String] = []) async throws {
var params: [URLQueryItem] = [URLQueryItem(name: "name", value: name)]
for sid in songIds {
params.append(URLQueryItem(name: "songId", value: sid))
}
_ = try await requestBody(endpoint: "createPlaylist", params: params)
}
func updatePlaylist(id: String, name: String? = nil, comment: String? = nil, isPublic: Bool? = nil, songIdsToAdd: [String] = [], songIndexesToRemove: [Int] = []) async throws {
var params: [URLQueryItem] = [URLQueryItem(name: "playlistId", value: id)]
if let n = name { params.append(URLQueryItem(name: "name", value: n)) }
if let c = comment { params.append(URLQueryItem(name: "comment", value: c)) }
if let p = isPublic { params.append(URLQueryItem(name: "public", value: p ? "true" : "false")) }
for sid in songIdsToAdd { params.append(URLQueryItem(name: "songIdToAdd", value: sid)) }
for idx in songIndexesToRemove { params.append(URLQueryItem(name: "songIndexToRemove", value: "\(idx)")) }
_ = try await requestBody(endpoint: "updatePlaylist", params: params)
}
func deletePlaylist(id: String) async throws {
let params = [URLQueryItem(name: "id", value: id)]
_ = try await requestBody(endpoint: "deletePlaylist", params: params)
}
// MARK: - Media Annotation
func star(id: String? = nil, albumId: String? = nil, artistId: String? = nil) async throws {
var params: [URLQueryItem] = []
if let i = id { params.append(URLQueryItem(name: "id", value: i)) }
if let ai = albumId { params.append(URLQueryItem(name: "albumId", value: ai)) }
if let ari = artistId { params.append(URLQueryItem(name: "artistId", value: ari)) }
_ = try await requestBody(endpoint: "star", params: params)
}
func unstar(id: String? = nil, albumId: String? = nil, artistId: String? = nil) async throws {
var params: [URLQueryItem] = []
if let i = id { params.append(URLQueryItem(name: "id", value: i)) }
if let ai = albumId { params.append(URLQueryItem(name: "albumId", value: ai)) }
if let ari = artistId { params.append(URLQueryItem(name: "artistId", value: ari)) }
_ = try await requestBody(endpoint: "unstar", params: params)
}
func setRating(id: String, rating: Int) async throws {
let params = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "rating", value: "\(rating)")
]
_ = try await requestBody(endpoint: "setRating", params: params)
}
func scrobble(id: String, time: Int64? = nil, submission: Bool = true) async throws {
var params: [URLQueryItem] = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "submission", value: submission ? "true" : "false")
]
if let t = time { params.append(URLQueryItem(name: "time", value: "\(t)")) }
_ = try await requestBody(endpoint: "scrobble", params: params)
}
// MARK: - User Management
func getUser(username: String) async throws -> SubsonicResponseBody {
let params = [URLQueryItem(name: "username", value: username)]
return try await requestBody(endpoint: "getUser", params: params)
}
func getUsers() async throws -> SubsonicResponseBody {
return try await requestBody(endpoint: "getUsers")
}
// MARK: - Media Retrieval
func downloadSong(id: String) async throws -> (Data, URLResponse) {
let params = [URLQueryItem(name: "id", value: id)]
return try await requestData(endpoint: "download", params: params)
}
func getAvatar(username: String) async throws -> (Data, URLResponse) {
let params = [URLQueryItem(name: "username", value: username)]
return try await requestData(endpoint: "getAvatar", params: params)
}
// MARK: - Bookmarks
func getBookmarks() async throws -> SubsonicResponseBody {
return try await requestBody(endpoint: "getBookmarks")
}
func createBookmark(id: String, position: Int64, comment: String? = nil) async throws {
var params: [URLQueryItem] = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "position", value: "\(position)")
]
if let c = comment { params.append(URLQueryItem(name: "comment", value: c)) }
_ = try await requestBody(endpoint: "createBookmark", params: params)
}
func deleteBookmark(id: String) async throws {
let params = [URLQueryItem(name: "id", value: id)]
_ = try await requestBody(endpoint: "deleteBookmark", params: params)
}
// MARK: - Internet Radio
func getInternetRadioStations() async throws -> [RadioStation] {
let body = try await requestBody(endpoint: "getInternetRadioStations")
return body.internetRadioStations?.internetRadioStation ?? []
}
// MARK: - Shares
func getShares() async throws -> SubsonicResponseBody {
return try await requestBody(endpoint: "getShares")
}
func createShare(ids: [String], description: String? = nil, expires: Int64? = nil) async throws -> SubsonicResponseBody {
var params: [URLQueryItem] = []
for id in ids { params.append(URLQueryItem(name: "id", value: id)) }
if let d = description { params.append(URLQueryItem(name: "description", value: d)) }
if let e = expires { params.append(URLQueryItem(name: "expires", value: "\(e)")) }
return try await requestBody(endpoint: "createShare", params: params)
}
func deleteShare(id: String) async throws {
let params = [URLQueryItem(name: "id", value: id)]
_ = try await requestBody(endpoint: "deleteShare", params: params)
}
}
// MARK: - API Errors
enum APIError: LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int)
case subsonicError(Int, String)
case noServer
case downloadFailed
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid server URL"
case .invalidResponse: return "Invalid response from server"
case .httpError(let code): return "HTTP error: \(code)"
case .subsonicError(_, let msg): return msg
case .noServer: return "No server configured"
case .downloadFailed: return "Download failed"
}
}
}

File diff suppressed because it is too large Load diff

319
Shared/Models/Models.swift Normal file
View file

@ -0,0 +1,319 @@
import Foundation
// MARK: - Server Configuration
struct ServerConfig: Codable, Identifiable, Hashable {
var id: UUID = UUID()
var name: String
var url: String
var username: String
var password: String // stored in Keychain in production
var isActive: Bool = true
var baseURL: URL? { URL(string: url) }
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
// MARK: - Subsonic API Response Wrappers
struct SubsonicResponse: Codable {
let subsonicResponse: SubsonicResponseBody
enum CodingKeys: String, CodingKey {
case subsonicResponse = "subsonic-response"
}
}
struct SubsonicResponseBody: Codable {
let status: String
let version: String?
let type: String?
let serverVersion: String?
let openSubsonic: Bool?
let error: SubsonicError?
// Various response types
let musicFolders: MusicFoldersContainer?
let indexes: IndexesContainer?
let artists: ArtistsContainer?
let artist: ArtistWithAlbums?
let album: AlbumWithSongs?
let song: Song?
let directory: DirectoryContainer?
let searchResult3: SearchResult3?
let albumList2: AlbumList2Container?
let playlists: PlaylistsContainer?
let playlist: PlaylistWithSongs?
let genres: GenresContainer?
let starred2: Starred2Container?
let nowPlaying: NowPlayingContainer?
let randomSongs: RandomSongsContainer?
let scanStatus: ScanStatus?
let lyrics: LyricsResult?
let internetRadioStations: InternetRadioContainer?
let similarSongs2: SimilarSongsContainer?
}
struct SubsonicError: Codable {
let code: Int
let message: String
}
// MARK: - Music Folders
struct MusicFoldersContainer: Codable {
let musicFolder: [MusicFolder]?
}
struct MusicFolder: Codable, Identifiable {
let id: String
let name: String?
}
// MARK: - Indexes / Artists
struct IndexesContainer: Codable {
let index: [ArtistIndex]?
let lastModified: Int64?
let ignoredArticles: String?
}
struct ArtistsContainer: Codable {
let index: [ArtistIndex]?
let ignoredArticles: String?
}
struct ArtistIndex: Codable, Identifiable {
let name: String
let artist: [Artist]?
var id: String { name }
}
struct Artist: Codable, Identifiable {
let id: String
let name: String
let coverArt: String?
let artistImageUrl: String?
let albumCount: Int?
let starred: String?
let musicBrainzId: String?
let sortName: String?
}
// MARK: - Albums
struct ArtistWithAlbums: Codable, Identifiable {
let id: String
let name: String
let coverArt: String?
let albumCount: Int?
let album: [Album]?
}
struct Album: Codable, Identifiable {
let id: String
let name: String
let artist: String?
let artistId: String?
let coverArt: String?
let songCount: Int?
let duration: Int?
let playCount: Int?
let created: String?
let starred: String?
let year: Int?
let genre: String?
}
struct AlbumWithSongs: Codable, Identifiable {
let id: String
let name: String
let artist: String?
let artistId: String?
let coverArt: String?
let songCount: Int?
let duration: Int?
let playCount: Int?
let created: String?
let starred: String?
let year: Int?
let genre: String?
let song: [Song]?
}
struct AlbumList2Container: Codable {
let album: [Album]?
}
// MARK: - Songs
struct Song: Codable, Identifiable {
let id: String
let parent: String?
let isDir: Bool?
let title: String
let album: String?
let artist: String?
let track: Int?
let year: Int?
let genre: String?
let coverArt: String?
let size: Int64?
let contentType: String?
let suffix: String?
let transcodedContentType: String?
let transcodedSuffix: String?
let duration: Int?
let bitRate: Int?
let path: String?
let playCount: Int?
let discNumber: Int?
let created: String?
let albumId: String?
let artistId: String?
let type: String?
let starred: String?
let bpm: Int?
let musicBrainzId: String?
var durationFormatted: String {
guard let dur = duration else { return "--:--" }
let min = dur / 60
let sec = dur % 60
return String(format: "%d:%02d", min, sec)
}
}
// MARK: - Directory
struct DirectoryContainer: Codable {
let id: String
let name: String?
let parent: String?
let child: [Song]?
}
// MARK: - Search
struct SearchResult3: Codable {
let artist: [Artist]?
let album: [Album]?
let song: [Song]?
}
// MARK: - Playlists
struct PlaylistsContainer: Codable {
let playlist: [Playlist]?
}
struct Playlist: Codable, Identifiable {
let id: String
let name: String
let comment: String?
let songCount: Int?
let duration: Int?
let coverArt: String?
let owner: String?
let `public`: Bool?
let created: String?
let changed: String?
}
struct PlaylistWithSongs: Codable, Identifiable {
let id: String
let name: String
let comment: String?
let songCount: Int?
let duration: Int?
let coverArt: String?
let owner: String?
let `public`: Bool?
let created: String?
let changed: String?
let entry: [Song]?
}
// MARK: - Genres
struct GenresContainer: Codable {
let genre: [Genre]?
}
struct Genre: Codable, Identifiable {
let value: String
let songCount: Int?
let albumCount: Int?
var id: String { value }
enum CodingKeys: String, CodingKey {
case value, songCount, albumCount
}
}
// MARK: - Starred
struct Starred2Container: Codable {
let artist: [Artist]?
let album: [Album]?
let song: [Song]?
}
// MARK: - Now Playing
struct NowPlayingContainer: Codable {
let entry: [NowPlayingEntry]?
}
struct NowPlayingEntry: Codable, Identifiable {
let id: String
let title: String
let artist: String?
let album: String?
let username: String?
let minutesAgo: Int?
let playerId: Int?
let playerName: String?
}
// MARK: - Random Songs
struct RandomSongsContainer: Codable {
let song: [Song]?
}
// MARK: - Scan Status
struct ScanStatus: Codable {
let scanning: Bool
let count: Int?
}
// MARK: - Lyrics
struct LyricsResult: Codable {
let artist: String?
let title: String?
let value: String?
}
// MARK: - Offline Download
struct DownloadedSong: Codable, Identifiable {
let id: String
let serverId: UUID
let song: Song
let localPath: String
let downloadDate: Date
let fileSize: Int64
}
// MARK: - Internet Radio
struct InternetRadioContainer: Codable {
let internetRadioStation: [RadioStation]?
}
struct RadioStation: Codable, Identifiable {
let id: String
let name: String
let streamUrl: String
let homePageUrl: String?
enum CodingKeys: String, CodingKey {
case id, name, streamUrl, homePageUrl
}
}
// MARK: - Similar Songs
struct SimilarSongsContainer: Codable {
let song: [Song]?
}

View file

@ -0,0 +1,126 @@
import Foundation
import Network
/// Caches library data (albums, artists, playlists, songs) to disk for offline browsing.
/// Also monitors network connectivity.
class LibraryCache: ObservableObject {
static let shared = LibraryCache()
@Published var isOffline = false
private let fileManager = FileManager.default
private let cacheDir: URL
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "com.navidromeplayer.network")
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDir = caches.appendingPathComponent("LibraryCache", isDirectory: true)
try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true)
// Allow background access prevents -54 sandbox errors
try? (cacheDir as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
// Set file protection to allow background access prevents -54 sandbox errors
try? (cacheDir as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
// Monitor network status
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isOffline = (path.status != .satisfied)
}
}
monitor.start(queue: monitorQueue)
}
deinit {
monitor.cancel()
}
// MARK: - Generic Cache Read/Write
private func cacheURL(for key: String) -> URL {
cacheDir.appendingPathComponent("\(key).json")
}
func save<T: Encodable>(_ data: T, key: String) {
if let encoded = try? JSONEncoder().encode(data) {
try? encoded.write(to: cacheURL(for: key), options: .atomic)
}
}
func load<T: Decodable>(_ type: T.Type, key: String) -> T? {
let url = cacheURL(for: key)
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
// MARK: - Typed Helpers
func cacheAlbums(_ albums: [Album], key: String = "recent_albums") {
save(albums, key: key)
}
func loadAlbums(key: String = "recent_albums") -> [Album]? {
load([Album].self, key: key)
}
func cacheArtists(_ artists: [ArtistIndex]) {
save(artists, key: "artists")
}
func loadArtists() -> [ArtistIndex]? {
load([ArtistIndex].self, key: "artists")
}
func cachePlaylists(_ playlists: [Playlist]) {
save(playlists, key: "playlists")
}
func loadPlaylists() -> [Playlist]? {
load([Playlist].self, key: "playlists")
}
func cacheAlbumDetail(_ album: AlbumWithSongs) {
save(album, key: "album_\(album.id)")
}
func loadAlbumDetail(id: String) -> AlbumWithSongs? {
load(AlbumWithSongs.self, key: "album_\(id)")
}
func cacheArtistDetail(_ artist: ArtistWithAlbums) {
save(artist, key: "artist_\(artist.id)")
}
func loadArtistDetail(id: String) -> ArtistWithAlbums? {
load(ArtistWithAlbums.self, key: "artist_\(id)")
}
func cacheRadioStations(_ stations: [RadioStation]) {
save(stations, key: "radio_stations")
}
func loadRadioStations() -> [RadioStation]? {
load([RadioStation].self, key: "radio_stations")
}
// MARK: - Download Status Check
/// Returns set of song IDs that are downloaded for offline playback
func downloadedSongIds() -> Set<String> {
let key = "offline_catalog"
guard let data = UserDefaults.standard.data(forKey: key),
let songs = try? JSONDecoder().decode([DownloadedSong].self, from: data) else {
return []
}
return Set(songs.map { $0.id })
}
// MARK: - Clear
func clearAll() {
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
}

View file

@ -0,0 +1,256 @@
import Foundation
import Combine
/// Manages downloading songs for offline playback on both iOS and watchOS
class OfflineManager: ObservableObject {
static let shared = OfflineManager()
@Published var downloads: [String: DownloadState] = [:] // songId -> state
@Published var downloadedSongs: [DownloadedSong] = []
@Published var totalDownloadSize: Int64 = 0
@Published var isDownloading = false
private let fileManager = FileManager.default
private let catalogKey = "offline_catalog"
private var downloadQueue: [(Song, ServerConfig)] = []
private var isProcessingQueue = false
enum DownloadState: Equatable {
case queued
case downloading(progress: Double)
case completed(localPath: String)
case failed(error: String)
}
private init() {
loadCatalog()
}
// MARK: - Download Directory
private var downloadDirectory: URL {
let paths = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
let dir = paths[0].appendingPathComponent("OfflineMusic", isDirectory: true)
if !fileManager.fileExists(atPath: dir.path) {
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
// Allow background access for audio playback
try? (dir as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
}
return dir
}
// MARK: - Download Songs
func downloadSong(_ song: Song, server: ServerConfig) {
guard downloads[song.id] == nil || {
if case .failed = downloads[song.id] { return true }
return false
}() else { return }
downloads[song.id] = .queued
downloadQueue.append((song, server))
processQueue()
}
func downloadAlbum(_ album: AlbumWithSongs, server: ServerConfig) {
guard let songs = album.song else { return }
for song in songs {
downloadSong(song, server: server)
}
}
func downloadPlaylist(_ playlist: PlaylistWithSongs, server: ServerConfig) {
guard let songs = playlist.entry else { return }
for song in songs {
downloadSong(song, server: server)
}
}
private func processQueue() {
guard !isProcessingQueue, !downloadQueue.isEmpty else { return }
isProcessingQueue = true
isDownloading = true
Task {
while !downloadQueue.isEmpty {
let (song, server) = downloadQueue.removeFirst()
await performDownload(song: song, server: server)
}
await MainActor.run {
self.isProcessingQueue = false
self.isDownloading = false
}
}
}
private func performDownload(song: Song, server: ServerConfig) async {
await MainActor.run {
downloads[song.id] = .downloading(progress: 0)
}
let client = SubsonicClient()
client.currentServer = server
do {
let data: Data
let ext: String
#if os(iOS)
let quality = StreamingQuality.shared
if quality.cacheFormat != "raw" {
// Stream with transcoding using cache format settings
guard let streamURL = client.streamURL(
songId: song.id,
format: quality.cacheFormat,
maxBitRate: quality.downloadBitRate
) else {
throw NSError(domain: "Download", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not build stream URL"])
}
let (streamData, _) = try await URLSession.shared.data(from: streamURL)
data = streamData
ext = quality.cacheFormat == "ogg" ? "ogg" : quality.cacheFormat
} else {
let (rawData, _) = try await client.downloadSong(id: song.id)
data = rawData
ext = song.suffix ?? "mp3"
}
#else
// watchOS always downloads original
let (rawData, _) = try await client.downloadSong(id: song.id)
data = rawData
ext = song.suffix ?? "mp3"
#endif
let filename = "\(song.id).\(ext)"
let localURL = downloadDirectory.appendingPathComponent(filename)
try data.write(to: localURL)
// Also download cover art if available
if let coverArtId = song.coverArt {
if let artURL = client.coverArtURL(id: coverArtId, size: 600) {
if let (artData, _) = try? await URLSession.shared.data(from: artURL) {
let artPath = downloadDirectory.appendingPathComponent("\(coverArtId).jpg")
try? artData.write(to: artPath)
}
}
}
let downloaded = DownloadedSong(
id: song.id,
serverId: server.id,
song: song,
localPath: localURL.path,
downloadDate: Date(),
fileSize: Int64(data.count)
)
await MainActor.run {
self.downloads[song.id] = .completed(localPath: localURL.path)
self.downloadedSongs.append(downloaded)
self.totalDownloadSize += Int64(data.count)
self.saveCatalog()
}
// Auto-analyze for visualizer cache
#if os(iOS)
Task.detached(priority: .background) {
let storage = VisualizerStorageManager.shared
let hasCache = await storage.hasCache(for: song.id)
if !hasCache {
do {
let frames = try await OfflineAudioAnalyzer.shared.analyze(url: localURL)
try await storage.saveCache(frames: frames, for: song.id)
print("[Vis] Auto-analyzed: \(song.title)")
} catch {
print("[Vis] Auto-analyze failed for \(song.title): \(error.localizedDescription)")
}
}
}
#endif
} catch {
await MainActor.run {
self.downloads[song.id] = .failed(error: error.localizedDescription)
}
}
}
// MARK: - Remove Downloads
func removeSong(_ songId: String) {
if let idx = downloadedSongs.firstIndex(where: { $0.id == songId }) {
let song = downloadedSongs[idx]
let filename = (song.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
try? fileManager.removeItem(at: url)
totalDownloadSize -= song.fileSize
downloadedSongs.remove(at: idx)
downloads.removeValue(forKey: songId)
saveCatalog()
}
}
func removeAll() {
for song in downloadedSongs {
let filename = (song.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
try? fileManager.removeItem(at: url)
}
downloadedSongs.removeAll()
downloads.removeAll()
totalDownloadSize = 0
saveCatalog()
}
func isSongDownloaded(_ songId: String) -> Bool {
return downloadedSongs.contains { $0.id == songId }
}
func localURL(for songId: String) -> URL? {
guard let song = downloadedSongs.first(where: { $0.id == songId }) else { return nil }
// Reconstruct from filename stored absolute paths break when sandbox UUID changes
let filename = (song.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
return fileManager.fileExists(atPath: url.path) ? url : nil
}
func coverArtLocalURL(for coverArtId: String) -> URL? {
let path = downloadDirectory.appendingPathComponent("\(coverArtId).jpg")
return fileManager.fileExists(atPath: path.path) ? path : nil
}
// MARK: - Persistence
private func saveCatalog() {
if let data = try? JSONEncoder().encode(downloadedSongs) {
UserDefaults.standard.set(data, forKey: catalogKey)
}
}
private func loadCatalog() {
if let data = UserDefaults.standard.data(forKey: catalogKey),
let decoded = try? JSONDecoder().decode([DownloadedSong].self, from: data) {
downloadedSongs = decoded
totalDownloadSize = decoded.reduce(0) { $0 + $1.fileSize }
for song in decoded {
// Reconstruct path from filename absolute paths break across app launches
let filename = (song.localPath as NSString).lastPathComponent
let url = downloadDirectory.appendingPathComponent(filename)
if fileManager.fileExists(atPath: url.path) {
downloads[song.id] = .completed(localPath: url.path)
}
}
}
}
// MARK: - Storage Info
var formattedSize: String {
ByteCountFormatter.string(fromByteCount: totalDownloadSize, countStyle: .file)
}
}

View file

@ -0,0 +1,229 @@
import Foundation
import Combine
/// Manages multiple Navidrome server connections with automatic failover.
/// When one server is unreachable (e.g. public domain on local WiFi),
/// it automatically tries the next server with the same credentials.
class ServerManager: ObservableObject {
static let shared = ServerManager()
@Published var servers: [ServerConfig] = []
@Published var activeServer: ServerConfig?
@Published var connectionState: ConnectionState = .disconnected
@Published var lastFailoverMessage: String?
private let storageKey = "navidrome_servers"
private let activeServerKey = "navidrome_active_server"
private var failoverTask: Task<Void, Never>?
enum ConnectionState: Equatable {
case disconnected
case connecting
case connected
case error(String)
}
let client = SubsonicClient()
private init() {
loadServers()
}
// MARK: - Server CRUD
func addServer(_ server: ServerConfig) {
var srv = server
srv.url = srv.url.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
servers.append(srv)
saveServers()
}
func updateServer(_ server: ServerConfig) {
if let idx = servers.firstIndex(where: { $0.id == server.id }) {
servers[idx] = server
saveServers()
if activeServer?.id == server.id {
activeServer = server
client.currentServer = server
}
}
}
func removeServer(at offsets: IndexSet) {
let removedIds = offsets.map { servers[$0].id }
servers.remove(atOffsets: offsets)
if let active = activeServer, removedIds.contains(active.id) {
activeServer = servers.first
client.currentServer = activeServer
}
saveServers()
}
func removeServer(_ server: ServerConfig) {
servers.removeAll { $0.id == server.id }
if activeServer?.id == server.id {
activeServer = servers.first
client.currentServer = activeServer
}
saveServers()
}
// MARK: - Connection with Auto-Failover
/// Connects to a specific server. On failure, tries other servers
/// with the same username (likely the same Navidrome instance).
func connect(to server: ServerConfig) async -> Bool {
await MainActor.run {
connectionState = .connecting
lastFailoverMessage = nil
}
// Try the requested server first
if await tryConnect(server) {
return true
}
// Failed try other servers with same username (same Navidrome instance)
let alternatives = servers.filter { $0.id != server.id && $0.username == server.username }
for alt in alternatives {
await MainActor.run {
lastFailoverMessage = "Trying \(alt.name)..."
}
if await tryConnect(alt) {
await MainActor.run {
lastFailoverMessage = "Connected via \(alt.name)"
}
return true
}
}
// Also try servers with different usernames as last resort
let others = servers.filter { $0.id != server.id && $0.username != server.username }
for other in others {
await MainActor.run {
lastFailoverMessage = "Trying \(other.name)..."
}
if await tryConnect(other) {
await MainActor.run {
lastFailoverMessage = "Connected via \(other.name)"
}
return true
}
}
// All failed
await MainActor.run {
connectionState = .error("All servers unreachable")
lastFailoverMessage = nil
}
return false
}
/// Low-level connect attempt with short timeout
private func tryConnect(_ server: ServerConfig) async -> Bool {
client.currentServer = server
do {
let ok = try await withTimeout(seconds: 5) {
try await self.client.ping(server: server)
}
if ok {
await MainActor.run {
self.activeServer = server
self.connectionState = .connected
self.client.isAuthenticated = true
self.saveActiveServer()
}
return true
}
} catch {
// Connection failed will try next
}
return false
}
/// Timeout wrapper for network calls
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw URLError(.timedOut)
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
func connectToActive() async {
guard let server = activeServer else {
// No active server try all
if let first = servers.first {
_ = await connect(to: first)
}
return
}
_ = await connect(to: server)
}
/// Called when an API request fails triggers background failover
func handleConnectionFailure() {
guard failoverTask == nil else { return }
failoverTask = Task {
if let active = activeServer {
_ = await connect(to: active)
}
failoverTask = nil
}
}
func switchServer(_ server: ServerConfig) async -> Bool {
return await connect(to: server)
}
func disconnect() {
connectionState = .disconnected
client.isAuthenticated = false
}
// MARK: - Persistence
private func saveServers() {
if let data = try? JSONEncoder().encode(servers) {
UserDefaults.standard.set(data, forKey: storageKey)
}
syncToWatch()
}
private func saveActiveServer() {
if let data = try? JSONEncoder().encode(activeServer) {
UserDefaults.standard.set(data, forKey: activeServerKey)
}
}
private func loadServers() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([ServerConfig].self, from: data) {
servers = decoded
}
if let data = UserDefaults.standard.data(forKey: activeServerKey),
let decoded = try? JSONDecoder().decode(ServerConfig.self, from: data) {
activeServer = decoded
client.currentServer = decoded
} else {
activeServer = servers.first
client.currentServer = servers.first
}
}
private func syncToWatch() {
#if os(iOS)
WatchConnectivityManager.shared.sendServersToWatch(servers)
#endif
}
}

View file

@ -0,0 +1,676 @@
import Foundation
import WatchConnectivity
import Combine
/// Bridges data between iOS and watchOS apps
class WatchConnectivityManager: NSObject, ObservableObject {
static let shared = WatchConnectivityManager()
@Published var isReachable = false
@Published var isWatchPaired = false
@Published var receivedServers: [ServerConfig] = []
@Published var transferringIds: Set<String> = []
@Published var transferredIds: Set<String> = []
/// Per-song transfer progress (0.0 to 1.0)
@Published var transferProgressMap: [String: Double] = [:]
/// Song titles for display
@Published var transferTitleMap: [String: String] = [:]
private var session: WCSession?
private var activeTransfers: [String: WCSessionFileTransfer] = [:]
private var progressTimer: Timer?
private override init() {
super.init()
#if os(iOS)
loadWatchSongCache()
#endif
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
wcLog("WCSession supported, activating...")
} else {
wcLog("WCSession NOT supported on this device")
}
}
private func wcLog(_ msg: String) {
#if os(iOS)
DebugLogger.shared.log(msg, category: "Watch")
#else
print("[Watch] \(msg)")
#endif
}
// MARK: - Send message (both platforms)
func sendMessage(_ message: [String: Any], replyHandler: (([String: Any]) -> Void)? = nil) {
guard let session = session, session.isReachable else {
wcLog("sendMessage failed: session not reachable")
return
}
session.sendMessage(message, replyHandler: replyHandler, errorHandler: { [weak self] error in
self?.wcLog("Message send error: \(error)")
})
}
// =========================================================================
// MARK: - iOS-Only: Sending data TO the watch
// =========================================================================
#if os(iOS)
func sendServersToWatch(_ servers: [ServerConfig]) {
guard let session = session, session.isPaired, session.isWatchAppInstalled else {
wcLog("sendServers: watch not paired/installed")
return
}
do {
let data = try JSONEncoder().encode(servers)
let dict: [String: Any] = ["servers": data]
try session.updateApplicationContext(dict)
wcLog("Sent \(servers.count) servers to watch via applicationContext")
} catch {
wcLog("Failed to send servers: \(error)")
}
}
func transferSongToWatch(fileURL: URL, metadata: [String: Any]) {
guard let session = session, session.isPaired, session.isWatchAppInstalled else {
wcLog("transferFile: watch not available")
return
}
let transfer = session.transferFile(fileURL, metadata: metadata)
wcLog("Started file transfer: \(transfer.file.fileURL.lastPathComponent)")
}
/// High-level: send a downloaded song to the watch for offline playback.
/// Always transcodes to MP3 192kbps via the server's stream endpoint for fast transfer.
@discardableResult
func sendSongToWatch(_ song: Song) -> Bool {
wcLog("sendSongToWatch: \(song.title) (id: \(song.id))")
guard let session = session else {
wcLog("FAIL: WCSession is nil")
return false
}
guard session.isPaired, session.isWatchAppInstalled else {
wcLog("FAIL: Watch not paired or app not installed")
return false
}
// Check if local file is already a small MP3 use it directly
if let localURL = OfflineManager.shared.localURL(for: song.id),
FileManager.default.fileExists(atPath: localURL.path) {
let suffix = (song.suffix ?? "").lowercased()
let fileSize = (try? FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int64) ?? 0
let isSmallMP3 = suffix == "mp3" && fileSize < 15_000_000 // < 15MB
if isSmallMP3 {
wcLog("Local MP3 is small enough (\(ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file))), sending directly")
return transferFile(localURL, song: song, suffix: "mp3")
}
}
// Otherwise, download a transcoded MP3 192kbps from server
wcLog("Transcoding to MP3 192kbps via server stream endpoint")
DispatchQueue.main.async {
self.transferringIds.insert(song.id)
self.transferProgressMap[song.id] = 0.0
self.transferTitleMap[song.id] = song.title
}
guard let streamURL = ServerManager.shared.client.streamURL(
songId: song.id,
format: "mp3",
maxBitRate: 192
) else {
wcLog("FAIL: Could not build stream URL")
DispatchQueue.main.async {
self.transferringIds.remove(song.id)
self.transferProgressMap.removeValue(forKey: song.id)
self.transferTitleMap.removeValue(forKey: song.id)
}
return false
}
// Download transcoded file to temp, then transfer
Task {
do {
let (data, _) = try await URLSession.shared.data(from: streamURL)
let tempDir = FileManager.default.temporaryDirectory
let tempFile = tempDir.appendingPathComponent("watch_\(song.id).mp3")
try data.write(to: tempFile)
let size = ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
self.wcLog("Transcoded: \(size) → sending to watch")
await MainActor.run {
_ = self.transferFile(tempFile, song: song, suffix: "mp3")
}
} catch {
self.wcLog("FAIL: Transcode download failed: \(error.localizedDescription)")
await MainActor.run {
self.transferringIds.remove(song.id)
self.transferProgressMap.removeValue(forKey: song.id)
self.transferTitleMap.removeValue(forKey: song.id)
}
}
}
return true
}
/// Transfer a file to the watch with song metadata
private func transferFile(_ url: URL, song: Song, suffix: String) -> Bool {
guard let session = session, session.isPaired, session.isWatchAppInstalled else { return false }
let metadata: [String: Any] = [
"type": "offlineSong",
"songId": song.id,
"id": song.id,
"title": song.title,
"artist": song.artist ?? "Unknown",
"album": song.album ?? "Unknown",
"duration": song.duration ?? 0,
"coverArt": song.coverArt ?? "",
"coverArtId": song.coverArt ?? "",
"suffix": suffix
]
DispatchQueue.main.async {
self.transferringIds.insert(song.id)
self.transferProgressMap[song.id] = 0.0
self.transferTitleMap[song.id] = song.title
}
let transfer = session.transferFile(url, metadata: metadata)
activeTransfers[song.id] = transfer
startProgressPolling()
let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
wcLog("Transfer initiated: \(url.lastPathComponent) (\(ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file))) → Watch")
wcLog("Outstanding transfers: \(session.outstandingFileTransfers.count)")
return true
}
/// Poll active transfers for progress updates
private func startProgressPolling() {
guard progressTimer == nil else { return }
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
guard let self = self else { return }
var anyActive = false
for (songId, transfer) in self.activeTransfers {
let pct = transfer.progress.fractionCompleted
DispatchQueue.main.async {
self.transferProgressMap[songId] = pct
}
if !transfer.progress.isFinished {
anyActive = true
}
}
if !anyActive {
self.progressTimer?.invalidate()
self.progressTimer = nil
}
}
}
var isWatchAvailable: Bool {
isWatchPaired
}
/// Cancel a specific pending transfer
func cancelTransfer(songId: String) {
if let transfer = activeTransfers[songId] {
transfer.cancel()
activeTransfers.removeValue(forKey: songId)
wcLog("Cancelled transfer for: \(transferTitleMap[songId] ?? songId)")
}
DispatchQueue.main.async {
self.transferringIds.remove(songId)
self.transferProgressMap.removeValue(forKey: songId)
self.transferTitleMap.removeValue(forKey: songId)
}
}
/// Cancel all pending transfers
func cancelAllTransfers() {
for (_, transfer) in activeTransfers {
transfer.cancel()
}
activeTransfers.removeAll()
wcLog("Cancelled all transfers")
DispatchQueue.main.async {
self.transferringIds.removeAll()
self.transferProgressMap.removeAll()
self.transferTitleMap.removeAll()
}
}
/// Ordered list of pending transfer song IDs (for display)
var pendingTransferList: [(id: String, title: String, progress: Double)] {
transferringIds.map { id in
(id: id, title: transferTitleMap[id] ?? id, progress: transferProgressMap[id] ?? 0)
}.sorted { $0.title < $1.title }
}
/// Request the watch's song catalog
@Published var watchSongs: [WatchSongInfo] = []
@Published var isLoadingWatchSongs = false
/// Cached set of song IDs on the watch persists across disconnects
@Published var watchSongIds: Set<String> = []
struct WatchSongInfo: Identifiable, Codable {
let id: String
let title: String
let artist: String
let album: String
let fileSize: Int64
}
/// Check if a song is on the watch (uses cached data)
func isSongOnWatch(_ songId: String) -> Bool {
watchSongIds.contains(songId)
}
/// Persist watch song IDs to UserDefaults
private func saveWatchSongCache() {
let ids = Array(watchSongIds)
UserDefaults.standard.set(ids, forKey: "cached_watch_song_ids")
if let data = try? JSONEncoder().encode(watchSongs) {
UserDefaults.standard.set(data, forKey: "cached_watch_songs")
}
}
/// Load cached watch song data from UserDefaults
private func loadWatchSongCache() {
if let ids = UserDefaults.standard.stringArray(forKey: "cached_watch_song_ids") {
watchSongIds = Set(ids)
}
if let data = UserDefaults.standard.data(forKey: "cached_watch_songs"),
let songs = try? JSONDecoder().decode([WatchSongInfo].self, from: data) {
watchSongs = songs
}
}
func requestWatchSongList() {
guard let session = session, session.isReachable else {
wcLog("requestWatchSongList: watch not reachable")
return
}
isLoadingWatchSongs = true
session.sendMessage(["type": "requestSongList"], replyHandler: { [weak self] reply in
if let data = reply["songs"] as? Data,
let songs = try? JSONDecoder().decode([WatchSongInfo].self, from: data) {
DispatchQueue.main.async {
self?.watchSongs = songs
self?.watchSongIds = Set(songs.map { $0.id })
self?.isLoadingWatchSongs = false
self?.saveWatchSongCache()
self?.wcLog("Received \(songs.count) songs from watch")
}
} else {
DispatchQueue.main.async {
self?.isLoadingWatchSongs = false
}
}
}, errorHandler: { [weak self] error in
DispatchQueue.main.async {
self?.isLoadingWatchSongs = false
self?.wcLog("requestSongList failed: \(error.localizedDescription)")
}
})
}
/// Request the watch to delete a song
func requestWatchDeleteSong(_ songId: String) {
guard let session = session, session.isReachable else { return }
session.sendMessage(["type": "deleteSong", "songId": songId], replyHandler: { [weak self] _ in
DispatchQueue.main.async {
self?.watchSongs.removeAll { $0.id == songId }
self?.watchSongIds.remove(songId)
self?.saveWatchSongCache()
self?.wcLog("Watch deleted: \(songId)")
}
}, errorHandler: { error in
print("Delete song failed: \(error)")
})
}
/// Request the watch to delete all songs
func requestWatchDeleteAll() {
guard let session = session, session.isReachable else { return }
session.sendMessage(["type": "deleteAllSongs"], replyHandler: { [weak self] _ in
DispatchQueue.main.async {
self?.watchSongs.removeAll()
self?.watchSongIds.removeAll()
self?.saveWatchSongCache()
self?.wcLog("Watch deleted all songs")
}
}, errorHandler: nil)
}
/// Try to wake the watch app by sending a user info transfer
/// This queues up and is delivered when the watch app launches
func pingWatch() {
guard let session = session, session.isPaired, session.isWatchAppInstalled else {
wcLog("pingWatch: watch not available")
return
}
// transferUserInfo is queued and delivered even if watch app is not running
session.transferUserInfo(["type": "wake", "timestamp": Date().timeIntervalSince1970])
wcLog("pingWatch: sent wake signal. isReachable: \(session.isReachable)")
if session.isReachable {
wcLog("pingWatch: watch app is already running")
} else {
wcLog("pingWatch: watch app not running — transfer will queue")
}
}
/// Returns a summary of watch connectivity state for debugging
var watchStatusDescription: String {
guard let session = session else { return "WCSession not supported" }
var parts: [String] = []
parts.append("Paired: \(session.isPaired)")
parts.append("App installed: \(session.isWatchAppInstalled)")
parts.append("Reachable: \(session.isReachable)")
parts.append("Activation: \(session.activationState.rawValue)")
parts.append("Outstanding: \(session.outstandingFileTransfers.count)")
return parts.joined(separator: " · ")
}
func sendNowPlayingToWatch(song: Song?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) {
guard let session = session, session.isReachable else { return }
var info: [String: Any] = [
"type": "nowPlaying",
"isPlaying": isPlaying,
"currentTime": currentTime,
"duration": duration
]
if let song = song {
info["songId"] = song.id
info["title"] = song.title
info["artist"] = song.artist ?? ""
info["album"] = song.album ?? ""
info["coverArtId"] = song.coverArt ?? ""
}
session.sendMessage(info, replyHandler: nil, errorHandler: nil)
}
func syncOfflineSongsToWatch(songs: [DownloadedSong]) {
guard let session = session, session.isPaired, session.isWatchAppInstalled else { return }
do {
let catalogData = try JSONEncoder().encode(songs.map { $0.song })
try session.updateApplicationContext([
"offlineCatalog": catalogData,
"songCount": songs.count
])
} catch {
print("Failed to send catalog: \(error)")
}
for downloaded in songs {
let fileURL = URL(fileURLWithPath: downloaded.localPath)
guard FileManager.default.fileExists(atPath: fileURL.path) else { continue }
let metadata: [String: Any] = [
"songId": downloaded.song.id,
"title": downloaded.song.title,
"artist": downloaded.song.artist ?? "",
"album": downloaded.song.album ?? "",
"coverArtId": downloaded.song.coverArt ?? "",
"duration": downloaded.song.duration ?? 0,
"suffix": downloaded.song.suffix ?? "mp3"
]
transferSongToWatch(fileURL: fileURL, metadata: metadata)
}
}
#endif
}
// MARK: - WCSessionDelegate
extension WatchConnectivityManager: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
let stateNames = ["notActivated", "inactive", "activated"]
let stateName = activationState.rawValue < stateNames.count ? stateNames[Int(activationState.rawValue)] : "unknown"
wcLog("Activation complete: \(stateName), error: \(error?.localizedDescription ?? "none")")
#if os(iOS)
wcLog("isPaired: \(session.isPaired), isWatchAppInstalled: \(session.isWatchAppInstalled), isReachable: \(session.isReachable)")
#endif
DispatchQueue.main.async {
self.isReachable = session.isReachable
#if os(iOS)
self.isWatchPaired = session.isPaired && session.isWatchAppInstalled
#endif
}
#if os(iOS)
if activationState == .activated {
sendServersToWatch(ServerManager.shared.servers)
}
#endif
}
func sessionReachabilityDidChange(_ session: WCSession) {
wcLog("Reachability changed: \(session.isReachable)")
DispatchQueue.main.async {
self.isReachable = session.isReachable
}
}
// These two delegate methods are REQUIRED on iOS but do not exist on watchOS
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) { }
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
#endif
// MARK: - Receive messages
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
guard let type = message["type"] as? String else {
replyHandler(["error": "unknown message type"])
return
}
#if os(iOS)
// iOS handles commands from watch
switch type {
case "requestServers":
do {
let data = try JSONEncoder().encode(ServerManager.shared.servers)
replyHandler(["servers": data])
} catch {
replyHandler(["error": error.localizedDescription])
}
case "playCommand":
DispatchQueue.main.async {
AudioPlayer.shared.togglePlayPause()
replyHandler(["isPlaying": AudioPlayer.shared.isPlaying])
}
case "nextCommand":
DispatchQueue.main.async {
AudioPlayer.shared.next()
replyHandler(["success": true])
}
case "previousCommand":
DispatchQueue.main.async {
AudioPlayer.shared.previous()
replyHandler(["success": true])
}
case "requestDownload":
if let songId = message["songId"] as? String {
Task {
do {
let client = ServerManager.shared.client
if let song = try await client.getSong(id: songId),
let server = ServerManager.shared.activeServer {
OfflineManager.shared.downloadSong(song, server: server)
replyHandler(["status": "downloading"])
} else {
replyHandler(["error": "Song not found"])
}
} catch {
replyHandler(["error": error.localizedDescription])
}
}
}
default:
replyHandler(["error": "unhandled type: \(type)"])
}
#else
// watchOS handles messages from iPhone
replyHandler(["error": "unhandled on watchOS: \(type)"])
#endif
}
// Receive application context
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
if let data = applicationContext["servers"] as? Data,
let servers = try? JSONDecoder().decode([ServerConfig].self, from: data) {
DispatchQueue.main.async {
self.receivedServers = servers
}
}
}
// Receive file transfers watchOS saves songs for offline playback
func session(_ session: WCSession, didReceive file: WCSessionFile) {
#if os(watchOS)
guard let metadata = file.metadata,
let songId = metadata["id"] as? String,
let suffix = metadata["suffix"] as? String else {
print("Received file without proper metadata")
return
}
let fm = FileManager.default
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first!
let musicDir = docs.appendingPathComponent("OfflineMusic", isDirectory: true)
try? fm.createDirectory(at: musicDir, withIntermediateDirectories: true)
let destURL = musicDir.appendingPathComponent("\(songId).\(suffix)")
// Remove existing if re-transferring
try? fm.removeItem(at: destURL)
do {
try fm.moveItem(at: file.fileURL, to: destURL)
print("Saved offline song: \(songId) to \(destURL.lastPathComponent)")
// Save metadata catalog
var catalog = loadWatchCatalog()
catalog[songId] = metadata
saveWatchCatalog(catalog)
} catch {
print("Failed to save transferred file: \(error)")
}
#endif
}
#if os(watchOS)
private var watchCatalogURL: URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
return docs.appendingPathComponent("watchOfflineCatalog.json")
}
func loadWatchCatalog() -> [String: [String: Any]] {
guard let data = try? Data(contentsOf: watchCatalogURL),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] else {
return [:]
}
return dict
}
private func saveWatchCatalog(_ catalog: [String: [String: Any]]) {
if let data = try? JSONSerialization.data(withJSONObject: catalog) {
try? data.write(to: watchCatalogURL)
}
}
func watchOfflineSongs() -> [(id: String, title: String, artist: String, album: String, duration: Int, suffix: String)] {
let catalog = loadWatchCatalog()
let musicDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
.appendingPathComponent("OfflineMusic", isDirectory: true)
return catalog.compactMap { id, meta in
let suffix = meta["suffix"] as? String ?? "mp3"
let url = musicDir.appendingPathComponent("\(id).\(suffix)")
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
return (
id: id,
title: meta["title"] as? String ?? "Unknown",
artist: meta["artist"] as? String ?? "Unknown",
album: meta["album"] as? String ?? "Unknown",
duration: meta["duration"] as? Int ?? 0,
suffix: suffix
)
}.sorted { $0.title < $1.title }
}
func watchOfflineURL(for songId: String) -> URL? {
let catalog = loadWatchCatalog()
guard let meta = catalog[songId] else { return nil }
let suffix = meta["suffix"] as? String ?? "mp3"
let musicDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
.appendingPathComponent("OfflineMusic", isDirectory: true)
let url = musicDir.appendingPathComponent("\(songId).\(suffix)")
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
#endif
// File transfer completion iOS only
#if os(iOS)
func session(_ session: WCSession, didFinish fileTransfer: WCSessionFileTransfer, error: Error?) {
let songId = fileTransfer.file.metadata?["id"] as? String
let title = fileTransfer.file.metadata?["title"] as? String ?? "unknown"
if let id = songId {
activeTransfers.removeValue(forKey: id)
}
DispatchQueue.main.async {
if let id = songId {
self.transferringIds.remove(id)
self.transferProgressMap.removeValue(forKey: id)
self.transferTitleMap.removeValue(forKey: id)
if error == nil {
self.transferredIds.insert(id)
self.watchSongIds.insert(id)
self.saveWatchSongCache()
}
}
}
if let error = error {
wcLog("Transfer FAILED for '\(title)': \(error.localizedDescription)")
} else {
wcLog("Transfer COMPLETE: '\(title)' (\(fileTransfer.file.fileURL.lastPathComponent))")
wcLog("Remaining outstanding transfers: \(session.outstandingFileTransfers.count)")
}
}
#endif
}

40
generate.sh Executable file
View file

@ -0,0 +1,40 @@
#!/bin/bash
# NavidromePlayer — Xcode project generator
# Runs XcodeGen and opens the project in Xcode
#
# Usage:
# ./generate.sh Generate project and open Xcode
# ./generate.sh --no-open Generate project only
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
# Check for XcodeGen
if ! command -v xcodegen &> /dev/null; then
echo "❌ XcodeGen not found. Install it with:"
echo " brew install xcodegen"
exit 1
fi
# Check for project.yml
if [ ! -f "project.yml" ]; then
echo "❌ project.yml not found in $(pwd)"
exit 1
fi
echo "🔧 Generating Xcode project..."
xcodegen generate
if [ $? -eq 0 ]; then
echo "✅ NavidromePlayer.xcodeproj generated successfully"
if [ "$1" != "--no-open" ]; then
echo "📂 Opening in Xcode..."
open NavidromePlayer.xcodeproj
fi
else
echo "❌ XcodeGen failed"
exit 1
fi

View file

@ -0,0 +1,79 @@
import SwiftUI
// MARK: - App Delegate (Background Upload Session)
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
if identifier == "com.navidromeplayer.batchupload" {
ZipImportManager.shared.setBackgroundCompletionHandler(completionHandler)
DebugLogger.shared.log("Background session woke app: \(identifier)", category: "Upload")
}
}
}
@main
struct NavidromePlayerApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var serverManager = ServerManager.shared
@StateObject private var audioPlayer = AudioPlayer.shared
@StateObject private var offlineManager = OfflineManager.shared
// Initialize WatchConnectivity so sync works immediately
private let watchConnectivity = WatchConnectivityManager.shared
init() {
// Dismiss keyboard when scrolling any scroll view
UIScrollView.appearance().keyboardDismissMode = .interactive
}
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(serverManager)
.environmentObject(audioPlayer)
.environmentObject(offlineManager)
.tint(Color(red: 1.0, green: 0.176, blue: 0.333)) // iOS 8 Music pink
}
}
}
struct RootView: View {
@EnvironmentObject var serverManager: ServerManager
var body: some View {
Group {
if serverManager.servers.isEmpty {
// No servers configured show login
LoginView()
} else {
// Servers exist show main UI immediately (connects in background)
MainTabView()
}
}
.dismissKeyboardOnTap()
.task {
ImageCache.shared.trimDiskCache()
AudioPreFetcher.shared.cleanOldPrefetches(keeping: AudioPlayer.shared.queue)
if serverManager.activeServer != nil {
await serverManager.connectToActive()
// Background sync fills LibraryCache so UI never waits for network
SyncEngine.shared.syncIfNeeded()
// Flush any pending optimistic actions (star/unstar that failed offline)
OptimisticActionQueue.shared.flush()
}
// Connect Companion push client if enabled
if CompanionSettings.shared.isEnabled {
CompanionPushClient.shared.connect()
}
}
}
}

View file

@ -0,0 +1,131 @@
import Foundation
/// Pre-fetches the next few tracks in the queue so playback is instant.
/// Uses OfflineManager's download system but marks pre-fetched files as temporary
/// (not user-requested downloads).
class AudioPreFetcher {
static let shared = AudioPreFetcher()
private let maxPrefetch = 3
private var prefetchedIds: Set<String> = []
private var prefetchTasks: [String: Task<Void, Never>] = [:]
private init() {}
/// Call when queue changes or track advances.
/// Pre-caches the next `maxPrefetch` tracks that aren't already downloaded.
func prefetchUpcoming(queue: [Song], currentIndex: Int) {
guard !queue.isEmpty else { return }
let start = currentIndex + 1
let end = min(start + maxPrefetch, queue.count)
guard start < end else { return }
let upcoming = Array(queue[start..<end])
let offline = OfflineManager.shared
// Cancel prefetches for songs no longer upcoming
let upcomingIds = Set(upcoming.map { $0.id })
for (id, task) in prefetchTasks where !upcomingIds.contains(id) {
task.cancel()
prefetchTasks[id] = nil
}
for song in upcoming {
// Skip if already downloaded or already prefetching
if offline.isSongDownloaded(song.id) { continue }
if prefetchTasks[song.id] != nil { continue }
// Prefetch by downloading to the streaming cache
prefetchTasks[song.id] = Task {
do {
let url = streamURL(for: song)
guard let url else { return }
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else { return }
// Write to streaming cache
let cacheDir = Self.prefetchCacheDir()
let file = cacheDir.appendingPathComponent("\(song.id).\(song.suffix ?? "mp3")")
try data.write(to: file, options: .atomic)
prefetchedIds.insert(song.id)
DebugLogger.shared.log("Prefetched: \(song.title) (\(Self.formatBytes(data.count)))", category: "Prefetch")
} catch {
if !Task.isCancelled {
DebugLogger.shared.log("Prefetch failed: \(song.title)\(error.localizedDescription)", category: "Prefetch")
}
}
prefetchTasks[song.id] = nil
}
}
}
/// Returns the local prefetch URL if available
func prefetchedURL(for songId: String, suffix: String? = nil) -> URL? {
let ext = suffix ?? "mp3"
let file = Self.prefetchCacheDir().appendingPathComponent("\(songId).\(ext)")
return FileManager.default.fileExists(atPath: file.path) ? file : nil
}
/// Clean up old prefetch files (call on app launch)
func cleanOldPrefetches(keeping currentQueue: [Song]) {
let keepIds = Set(currentQueue.map { $0.id })
let dir = Self.prefetchCacheDir()
guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return }
for file in files {
let name = file.deletingPathExtension().lastPathComponent
if !keepIds.contains(name) {
try? FileManager.default.removeItem(at: file)
}
}
prefetchedIds = prefetchedIds.intersection(keepIds)
}
/// Total size of prefetch cache
var cacheSize: Int64 {
let dir = Self.prefetchCacheDir()
guard let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey]) else { return 0 }
return files.reduce(0) { sum, url in
sum + Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
}
}
func clearCache() {
let dir = Self.prefetchCacheDir()
try? FileManager.default.removeItem(at: dir)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
prefetchedIds.removeAll()
prefetchTasks.values.forEach { $0.cancel() }
prefetchTasks.removeAll()
}
// MARK: - Private
private static func prefetchCacheDir() -> URL {
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
let dir = caches.appendingPathComponent("AudioPrefetch", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func streamURL(for song: Song) -> URL? {
let quality = StreamingQuality.shared
return ServerManager.shared.client.streamURL(
songId: song.id,
format: quality.format,
maxBitRate: quality.maxBitRate
)
}
private static func formatBytes(_ bytes: Int) -> String {
if bytes < 1024 * 1024 {
return "\(bytes / 1024)KB"
}
return String(format: "%.1fMB", Double(bytes) / 1_048_576.0)
}
}

View file

@ -0,0 +1,182 @@
import Foundation
/// Handles optimistic UI updates mutations happen locally first,
/// then get pushed to the server with automatic retry.
///
/// Example: starring a song updates the local cache immediately,
/// then queues the API call. If the API fails (offline), it retries
/// on next sync or when connectivity returns.
class OptimisticActionQueue: ObservableObject {
static let shared = OptimisticActionQueue()
/// Pending actions that haven't been confirmed by the server
@Published var pendingCount: Int = 0
private var pendingActions: [PendingAction] = []
private let storageKey = "optimistic_pending_actions"
private var retryTask: Task<Void, Never>?
private init() {
loadPending()
}
// MARK: - Star / Unstar
/// Star a song optimistically updates cache immediately, queues API call
func star(songId: String) {
queueAction(.star(id: songId, type: .song))
DebugLogger.shared.log("Optimistic: starred song \(songId)", category: "Sync")
}
func unstar(songId: String) {
// Check if there's a pending star for this just remove it instead
if removePending(matching: .star(id: songId, type: .song)) {
DebugLogger.shared.log("Optimistic: cancelled pending star for \(songId)", category: "Sync")
return
}
queueAction(.unstar(id: songId, type: .song))
DebugLogger.shared.log("Optimistic: unstarred song \(songId)", category: "Sync")
}
func starAlbum(albumId: String) {
queueAction(.star(id: albumId, type: .album))
}
func unstarAlbum(albumId: String) {
if removePending(matching: .star(id: albumId, type: .album)) { return }
queueAction(.unstar(id: albumId, type: .album))
}
// MARK: - Scrobble
func scrobble(songId: String) {
queueAction(.scrobble(id: songId, timestamp: Int64(Date().timeIntervalSince1970 * 1000)))
}
// MARK: - Process Queue
/// Try to flush all pending actions to the server.
/// Call this on app launch, on connectivity change, or after sync.
func flush() {
guard !pendingActions.isEmpty else { return }
retryTask?.cancel()
retryTask = Task { [weak self] in
guard let self else { return }
let actions = self.pendingActions
var remaining: [PendingAction] = []
for action in actions {
do {
try await self.execute(action)
DebugLogger.shared.log("Flushed: \(action.description)", category: "Sync")
} catch {
if action.retryCount < 5 {
var retry = action
retry.retryCount += 1
remaining.append(retry)
} else {
DebugLogger.shared.log("Dropped after 5 retries: \(action.description)", category: "Sync")
}
}
}
let final = remaining
await MainActor.run {
self.pendingActions = final
self.pendingCount = final.count
self.savePending()
}
}
}
// MARK: - Execution
private func execute(_ action: PendingAction) async throws {
let client = ServerManager.shared.client
switch action.kind {
case .star(let id, let type):
switch type {
case .song: try await client.star(id: id)
case .album: try await client.star(albumId: id)
case .artist: try await client.star(artistId: id)
}
case .unstar(let id, let type):
switch type {
case .song: try await client.unstar(id: id)
case .album: try await client.unstar(albumId: id)
case .artist: try await client.unstar(artistId: id)
}
case .scrobble(let id, let timestamp):
try await client.scrobble(id: id, time: timestamp)
}
}
// MARK: - Queue Management
private func queueAction(_ kind: ActionKind) {
let action = PendingAction(kind: kind, createdAt: Date(), retryCount: 0)
pendingActions.append(action)
pendingCount = pendingActions.count
savePending()
// Try immediately
flush()
}
private func removePending(matching kind: ActionKind) -> Bool {
if let idx = pendingActions.firstIndex(where: { $0.kind == kind }) {
pendingActions.remove(at: idx)
pendingCount = pendingActions.count
savePending()
return true
}
return false
}
// MARK: - Persistence
private func savePending() {
if let data = try? JSONEncoder().encode(pendingActions) {
UserDefaults.standard.set(data, forKey: storageKey)
}
}
private func loadPending() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let actions = try? JSONDecoder().decode([PendingAction].self, from: data) {
pendingActions = actions
pendingCount = actions.count
}
}
}
// MARK: - Models
struct PendingAction: Codable {
let kind: ActionKind
let createdAt: Date
var retryCount: Int
var description: String {
switch kind {
case .star(let id, let type): return "star \(type.rawValue) \(id)"
case .unstar(let id, let type): return "unstar \(type.rawValue) \(id)"
case .scrobble(let id, _): return "scrobble \(id)"
}
}
}
enum ActionKind: Codable, Equatable {
case star(id: String, type: ItemType)
case unstar(id: String, type: ItemType)
case scrobble(id: String, timestamp: Int64)
enum ItemType: String, Codable {
case song, album, artist
}
}

266
iOS/Data/SyncEngine.swift Normal file
View file

@ -0,0 +1,266 @@
import Foundation
import Combine
/// Manages background syncing of the entire library to local cache.
/// The UI reads exclusively from LibraryCache this engine fills it.
///
/// Flow:
/// 1. App launch syncIfNeeded()
/// 2. If lastSyncTimestamp is nil full bootstrap (getArtists + all albums)
/// 3. If lastSyncTimestamp exists delta sync (ifModifiedSince)
/// 4. Subsequent pulls run every 15 minutes in background
///
/// All sync work happens off-main. LibraryCache writes trigger @Published changes
/// which SwiftUI observes reactively.
class SyncEngine: ObservableObject {
static let shared = SyncEngine()
@Published var isSyncing = false
@Published var lastSyncDate: Date?
@Published var syncProgress: String?
private let cache = LibraryCache.shared
private var syncTask: Task<Void, Never>?
private var periodicTimer: Timer?
private let syncInterval: TimeInterval = 15 * 60 // 15 minutes
private var lastSyncTimestamp: Int64 {
get { Int64(UserDefaults.standard.integer(forKey: "sync_last_timestamp")) }
set { UserDefaults.standard.set(Int(newValue), forKey: "sync_last_timestamp") }
}
private init() {
if let ts = UserDefaults.standard.object(forKey: "sync_last_date") as? Date {
lastSyncDate = ts
}
}
// MARK: - Public API
/// Called on app launch syncs if needed, then starts periodic refresh.
func syncIfNeeded() {
guard !isSyncing else { return }
syncTask = Task { [weak self] in
guard let self else { return }
await MainActor.run { self.isSyncing = true }
do {
if self.lastSyncTimestamp == 0 {
// Never synced full bootstrap
try await self.fullBootstrap()
} else {
// Delta sync
try await self.deltaSync()
}
let now = Date()
await MainActor.run {
self.lastSyncDate = now
self.isSyncing = false
self.syncProgress = nil
UserDefaults.standard.set(now, forKey: "sync_last_date")
}
} catch {
await MainActor.run {
self.isSyncing = false
self.syncProgress = nil
}
DebugLogger.shared.log("Sync failed: \(error.localizedDescription)", category: "Sync")
}
}
startPeriodicSync()
}
/// Force a full re-sync (user-triggered)
func forceSync() {
lastSyncTimestamp = 0
syncTask?.cancel()
syncTask = nil
isSyncing = false
syncIfNeeded()
}
/// Stop all sync activity
func stop() {
syncTask?.cancel()
syncTask = nil
periodicTimer?.invalidate()
periodicTimer = nil
}
// MARK: - Full Bootstrap
/// First-time sync: pulls entire library metadata and caches it.
private func fullBootstrap() async throws {
let client = ServerManager.shared.client
await setProgress("Syncing artists...")
DebugLogger.shared.log("Bootstrap: starting full sync", category: "Sync")
// 1. Artists use cacheArtists() which saves to "artists" key
let artistIndexes = try await client.getArtists()
cache.cacheArtists(artistIndexes)
let artistCount = artistIndexes.flatMap { $0.artist ?? [] }.count
await setProgress("Synced \(artistCount) artists")
// 2. Genres save to "genres" key (matches MyMusicView)
let genres = try await client.getGenres()
cache.save(genres, key: "genres")
// 3. Albums paginate to get everything
var allAlbums: [Album] = []
var offset = 0
let pageSize = 500
while true {
let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset)
allAlbums.append(contentsOf: page)
await setProgress("Albums: \(allAlbums.count)...")
if page.count < pageSize { break }
offset += pageSize
}
cache.save(allAlbums, key: "all_albums")
DebugLogger.shared.log("Bootstrap: \(allAlbums.count) albums cached", category: "Sync")
// 4. Recently Added use cacheAlbums() which saves to "recent_albums"
let recent = try await client.getAlbumList2(type: "newest", size: 30)
cache.cacheAlbums(recent)
// 5. Starred
if let starred = try await client.getStarred2() {
if let starredAlbums = starred.album { cache.save(starredAlbums, key: "starred_albums") }
if let starredSongs = starred.song { cache.save(starredSongs, key: "starred_songs") }
if let starredArtists = starred.artist { cache.save(starredArtists, key: "starred_artists") }
}
// 6. Playlists use cachePlaylists() which saves to "playlists" key
let playlists = try await client.getPlaylists()
cache.cachePlaylists(playlists)
lastSyncTimestamp = Int64(Date().timeIntervalSince1970)
DebugLogger.shared.log("Bootstrap complete: \(artistCount) artists, \(allAlbums.count) albums, \(genres.count) genres", category: "Sync")
// 7. Pre-cache album details in background (so tapping any album is instant)
Task.detached(priority: .utility) { [weak self] in
await self?.preCacheAlbumDetails(allAlbums)
}
}
// MARK: - Delta Sync
/// Incremental sync using ifModifiedSince where supported.
/// Falls back to a light refresh of recently changed data.
private func deltaSync() async throws {
let client = ServerManager.shared.client
let since = lastSyncTimestamp
await setProgress("Checking for changes...")
DebugLogger.shared.log("Delta sync: since \(since)", category: "Sync")
let indexes = try await client.getIndexes(ifModifiedSince: since)
if !indexes.isEmpty {
let artistIndexes = try await client.getArtists()
cache.cacheArtists(artistIndexes)
let artistCount = artistIndexes.flatMap { $0.artist ?? [] }.count
await setProgress("Updated \(artistCount) artists")
var allAlbums: [Album] = []
var offset = 0
let pageSize = 500
while true {
let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset)
allAlbums.append(contentsOf: page)
if page.count < pageSize { break }
offset += pageSize
}
cache.save(allAlbums, key: "all_albums")
// Pre-cache album details for new/changed albums
Task.detached(priority: .utility) { [weak self] in
await self?.preCacheAlbumDetails(allAlbums)
}
}
let recent = try await client.getAlbumList2(type: "newest", size: 30)
cache.cacheAlbums(recent)
if let starred = try await client.getStarred2() {
if let starredAlbums = starred.album { cache.save(starredAlbums, key: "starred_albums") }
if let starredSongs = starred.song { cache.save(starredSongs, key: "starred_songs") }
if let starredArtists = starred.artist { cache.save(starredArtists, key: "starred_artists") }
}
let playlists = try await client.getPlaylists()
cache.cachePlaylists(playlists)
let genres = try await client.getGenres()
cache.save(genres, key: "genres")
lastSyncTimestamp = Int64(Date().timeIntervalSince1970)
DebugLogger.shared.log("Delta sync complete", category: "Sync")
}
// MARK: - Periodic
private func startPeriodicSync() {
periodicTimer?.invalidate()
periodicTimer = Timer.scheduledTimer(withTimeInterval: syncInterval, repeats: true) { [weak self] _ in
self?.syncIfNeeded()
}
}
// MARK: - Album Detail Pre-Caching
/// Fetches full album details (with songs) for every album and caches them.
/// Runs in background at low priority skips already-cached albums.
private func preCacheAlbumDetails(_ albums: [Album]) async {
let client = ServerManager.shared.client
var cached = 0
var skipped = 0
for album in albums {
// Skip if already cached
if cache.loadAlbumDetail(id: album.id) != nil {
skipped += 1
continue
}
do {
if let detail = try await client.getAlbum(id: album.id) {
cache.cacheAlbumDetail(detail)
cached += 1
}
} catch {
// Non-fatal will be fetched on demand
}
// Yield occasionally to not starve other tasks
if (cached + skipped) % 20 == 0 {
try? await Task.sleep(for: .milliseconds(100))
}
}
DebugLogger.shared.log("Album detail pre-cache: \(cached) new, \(skipped) already cached", category: "Sync")
}
// MARK: - Server Change
/// Call when the active server changes invalidates sync state so next sync is a full bootstrap
func serverChanged() {
lastSyncTimestamp = 0
lastSyncDate = nil
cache.clearAll()
DebugLogger.shared.log("Server changed — cache cleared, will re-bootstrap", category: "Sync")
}
// MARK: - Helpers
private func setProgress(_ msg: String) async {
await MainActor.run { self.syncProgress = msg }
}
}

View file

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.333",
"green" : "0.176",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

63
iOS/Resources/Info.plist Normal file
View file

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Navidrome</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose cover images for your radio stations</string>
<key>NSMicrophoneUsageDescription</key>
<string>Identify songs playing on the radio using Shazam</string>
</dict>
</plist>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.navidromeplayer.shared</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,336 @@
import SwiftUI
import UIKit
// MARK: - Image Cache (Memory + Disk)
/// Two-tier image cache: NSCache for fast memory access, disk for persistence across launches.
class ImageCache {
static let shared = ImageCache()
private let memoryCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default
private let diskCacheURL: URL
private let ioQueue = DispatchQueue(label: "com.navidromeplayer.imagecache", qos: .utility)
/// Max memory cache: ~80 images
/// Max disk cache: 200 MB
private let maxDiskBytes: UInt64 = 200 * 1024 * 1024
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
diskCacheURL = caches.appendingPathComponent("ImageCache", isDirectory: true)
try? fileManager.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
try? (diskCacheURL as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
memoryCache.countLimit = 80
memoryCache.totalCostLimit = 50 * 1024 * 1024 // ~50 MB
}
// MARK: - Key Hashing
private func cacheKey(for url: URL) -> String {
// Use endpoint + query params only (not the server base URL)
// so the same cover art ID caches the same regardless of local vs public server
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let path = components?.path ?? ""
// Extract just the endpoint name and relevant params (id, size)
let endpoint = (path as NSString).lastPathComponent
let params = (components?.queryItems ?? [])
.filter { ["id", "size"].contains($0.name) }
.sorted { $0.name < $1.name }
.map { "\($0.name)=\($0.value ?? "")" }
.joined(separator: "&")
let keyStr = "\(endpoint)?\(params)"
let hash = keyStr.utf8.reduce(0) { ($0 &* 31) &+ UInt64($1) }
return String(hash, radix: 16)
}
// MARK: - Memory Cache
func memoryImage(for url: URL) -> UIImage? {
let key = cacheKey(for: url) as NSString
return memoryCache.object(forKey: key)
}
func storeInMemory(_ image: UIImage, for url: URL) {
let key = cacheKey(for: url) as NSString
let cost = Int(image.size.width * image.size.height * image.scale * 4)
memoryCache.setObject(image, forKey: key, cost: cost)
}
// MARK: - Disk Cache
private func diskURL(for url: URL) -> URL {
diskCacheURL.appendingPathComponent(cacheKey(for: url) + ".jpg")
}
func diskImage(for url: URL) -> UIImage? {
let path = diskURL(for: url)
guard let data = try? Data(contentsOf: path) else { return nil }
return UIImage(data: data)
}
func storeToDisk(_ image: UIImage, for url: URL) {
ioQueue.async { [weak self] in
guard let self = self else { return }
let path = self.diskURL(for: url)
// JPEG at 85% quality for good size/quality balance
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: path, options: .atomic)
}
}
}
// MARK: - Combined Lookup
/// Returns cached image from memory or disk, promoting disk hits to memory.
func cachedImage(for url: URL) -> UIImage? {
// 1. Memory
if let img = memoryImage(for: url) {
return img
}
// 2. Disk -> promote to memory
if let img = diskImage(for: url) {
storeInMemory(img, for: url)
return img
}
return nil
}
/// Stores image in both memory and disk.
func store(_ image: UIImage, for url: URL) {
storeInMemory(image, for: url)
storeToDisk(image, for: url)
}
// MARK: - Cleanup
/// Trims disk cache to maxDiskBytes. Call periodically or on app launch.
func trimDiskCache() {
ioQueue.async { [weak self] in
guard let self = self else { return }
guard let files = try? self.fileManager.contentsOfDirectory(
at: self.diskCacheURL,
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]
) else { return }
// Get file info
var fileInfos: [(url: URL, date: Date, size: UInt64)] = []
var totalSize: UInt64 = 0
for file in files {
guard let attrs = try? file.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]),
let date = attrs.contentModificationDate,
let size = attrs.fileSize else { continue }
let s = UInt64(size)
fileInfos.append((file, date, s))
totalSize += s
}
guard totalSize > self.maxDiskBytes else { return }
// Sort oldest first, delete until under limit
fileInfos.sort { $0.date < $1.date }
for info in fileInfos {
guard totalSize > self.maxDiskBytes else { break }
try? self.fileManager.removeItem(at: info.url)
totalSize -= info.size
}
}
}
/// Clears all cached images
func clearAll() {
memoryCache.removeAllObjects()
ioQueue.async { [weak self] in
guard let self = self else { return }
if let files = try? self.fileManager.contentsOfDirectory(at: self.diskCacheURL, includingPropertiesForKeys: nil) {
for file in files {
try? self.fileManager.removeItem(at: file)
}
}
}
}
}
// MARK: - Album Cover Store (custom covers, disk-persisted)
/// Stores user-set album cover art on disk, keyed by coverArtId.
/// Custom covers override server art everywhere (AlbumDetail, NowPlaying, MiniPlayer, grids).
class AlbumCoverStore: ObservableObject {
static let shared = AlbumCoverStore()
/// Triggers SwiftUI refresh when a cover changes
@Published var updateTrigger = UUID()
private let fileManager = FileManager.default
private let coverDir: URL
private init() {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
coverDir = docs.appendingPathComponent("AlbumCovers", isDirectory: true)
try? fileManager.createDirectory(at: coverDir, withIntermediateDirectories: true)
}
private func coverURL(for coverArtId: String) -> URL {
// Sanitize the ID for filesystem safety
let safe = coverArtId.replacingOccurrences(of: "/", with: "_")
return coverDir.appendingPathComponent("\(safe).jpg")
}
func hasCover(for coverArtId: String) -> Bool {
fileManager.fileExists(atPath: coverURL(for: coverArtId).path)
}
func loadCover(for coverArtId: String) -> UIImage? {
let url = coverURL(for: coverArtId)
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}
func saveCover(_ image: UIImage, for coverArtId: String) {
let url = coverURL(for: coverArtId)
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: url, options: .atomic)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for coverArtId: String) {
let url = coverURL(for: coverArtId)
try? fileManager.removeItem(at: url)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
// MARK: - Cached Image Loader (ObservableObject per-view)
class CachedImageLoader: ObservableObject {
@Published var image: UIImage?
@Published var isLoading = false
private var url: URL?
private var task: URLSessionDataTask?
func load(from url: URL) {
// Skip if already loaded this URL
guard self.url != url else { return }
self.url = url
// Check cache first
if let cached = ImageCache.shared.cachedImage(for: url) {
self.image = cached
return
}
// Download
isLoading = true
task?.cancel()
task = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self = self, error == nil, let data = data,
let img = UIImage(data: data) else {
DispatchQueue.main.async { self?.isLoading = false }
return
}
// Cache it
ImageCache.shared.store(img, for: url)
DispatchQueue.main.async {
self.image = img
self.isLoading = false
}
}
task?.resume()
}
func cancel() {
task?.cancel()
task = nil
}
}
// MARK: - CachedAsyncImage View
struct CachedAsyncImage: View {
let url: URL?
@StateObject private var loader = CachedImageLoader()
var body: some View {
Group {
if let img = loader.image {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Color.clear // placeholder handled by caller
}
}
.onAppear {
if let url = url {
loader.load(from: url)
}
}
.onChange(of: url) { _, newURL in
if let newURL = newURL {
loader.load(from: newURL)
}
}
.onDisappear {
loader.cancel()
}
}
}
// MARK: - AsyncCoverArt (drop-in replacement, now cached)
struct AsyncCoverArt: View {
let coverArtId: String?
let size: Int
@ObservedObject private var albumCoverStore = AlbumCoverStore.shared
var body: some View {
if let id = coverArtId {
// Check for user-set custom cover first
if let customImage = albumCoverStore.loadCover(for: id) {
let _ = albumCoverStore.updateTrigger // reactive dependency
Image(uiImage: customImage)
.resizable()
.aspectRatio(contentMode: .fill)
.clipped()
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: size) {
ZStack {
placeholderView
CachedAsyncImage(url: url)
}
.clipped()
} else {
placeholderView
}
} else {
placeholderView
}
}
private var placeholderView: some View {
ZStack {
Rectangle()
.fill(
LinearGradient(
colors: [Color(white: 0.2), Color(white: 0.15)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Image(systemName: "music.note")
.font(.system(size: max(12, CGFloat(size) * 0.15)))
.foregroundColor(.gray)
}
}
}

View file

@ -0,0 +1,233 @@
import SwiftUI
// MARK: - Debug Logger
/// Singleton that captures log messages for the in-app debug console
class DebugLogger: ObservableObject {
static let shared = DebugLogger()
struct LogEntry: Identifiable {
let id = UUID()
let timestamp: Date
let category: String
let message: String
var formatted: String {
let df = DateFormatter()
df.dateFormat = "HH:mm:ss.SSS"
return "[\(df.string(from: timestamp))] [\(category)] \(message)"
}
}
@Published var entries: [LogEntry] = []
@Published var isEnabled: Bool {
didSet { UserDefaults.standard.set(isEnabled, forKey: "debug_console_enabled") }
}
private let maxEntries = 500
private init() {
isEnabled = UserDefaults.standard.bool(forKey: "debug_console_enabled")
}
func log(_ message: String, category: String = "General") {
let entry = LogEntry(timestamp: Date(), category: category, message: message)
DispatchQueue.main.async {
self.entries.append(entry)
if self.entries.count > self.maxEntries {
self.entries.removeFirst(self.entries.count - self.maxEntries)
}
}
// Also print to Xcode console
print("[\(category)] \(message)")
}
func clear() {
entries.removeAll()
}
}
// MARK: - Debug Console View
struct DebugConsoleView: View {
@ObservedObject private var logger = DebugLogger.shared
@State private var filterText = ""
@State private var selectedCategory: String? = nil
@State private var autoScroll = true
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var categories: [String] {
Array(Set(logger.entries.map { $0.category })).sorted()
}
private var filteredEntries: [DebugLogger.LogEntry] {
logger.entries.filter { entry in
let matchesCategory = selectedCategory == nil || entry.category == selectedCategory
let matchesText = filterText.isEmpty ||
entry.message.localizedCaseInsensitiveContains(filterText) ||
entry.category.localizedCaseInsensitiveContains(filterText)
return matchesCategory && matchesText
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Filter bar
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
.font(.system(size: 13))
TextField("Filter logs...", text: $filterText)
.font(.system(size: 13))
.foregroundColor(.white)
if !filterText.isEmpty {
Button(action: { filterText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.system(size: 13))
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white.opacity(0.08))
.cornerRadius(8)
.padding(.horizontal, 12)
.padding(.top, 8)
// Category pills
if !categories.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
categoryPill("All", isSelected: selectedCategory == nil) {
selectedCategory = nil
}
ForEach(categories, id: \.self) { cat in
categoryPill(cat, isSelected: selectedCategory == cat) {
selectedCategory = cat
}
}
}
.padding(.horizontal, 12)
}
.padding(.vertical, 6)
}
Divider().background(Color.white.opacity(0.1))
// Log entries
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 1) {
ForEach(filteredEntries) { entry in
logRow(entry)
.id(entry.id)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
}
.onChange(of: logger.entries.count) { _, _ in
if autoScroll, let last = filteredEntries.last {
withAnimation(.easeOut(duration: 0.2)) {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
// Bottom bar
HStack {
Text("\(filteredEntries.count) entries")
.font(.system(size: 11))
.foregroundColor(.gray)
Spacer()
Toggle("Auto-scroll", isOn: $autoScroll)
.font(.system(size: 11))
.foregroundColor(.gray)
.toggleStyle(.switch)
.tint(accentPink)
.fixedSize()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.black.opacity(0.3))
}
.background(Color(white: 0.06))
.navigationTitle("Debug Console")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: { logger.clear() }) {
Text("Clear").font(.system(size: 14))
}
.foregroundColor(.red)
}
ToolbarItem(placement: .navigationBarTrailing) {
ShareLink(item: exportLogs()) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 14))
}
.foregroundColor(accentPink)
}
}
}
}
private func categoryPill(_ title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundColor(isSelected ? .white : .gray)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(isSelected ? accentPink.opacity(0.6) : Color.white.opacity(0.08))
.cornerRadius(12)
}
}
private func logRow(_ entry: DebugLogger.LogEntry) -> some View {
let color: Color = {
switch entry.category {
case "Watch": return .cyan
case "Audio": return .green
case "FFT": return .orange
case "Network": return .yellow
case "Error": return .red
case "Radio": return .purple
default: return .gray
}
}()
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(entry.category)
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(color)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(color.opacity(0.15))
.cornerRadius(3)
let df = DateFormatter()
let _ = df.dateFormat = "HH:mm:ss.SSS"
Text(df.string(from: entry.timestamp))
.font(.system(size: 9, design: .monospaced))
.foregroundColor(.gray)
}
Text(entry.message)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.textSelection(.enabled)
}
.padding(.vertical, 4)
}
private func exportLogs() -> String {
filteredEntries.map { $0.formatted }.joined(separator: "\n")
}
}

View file

@ -0,0 +1,641 @@
import SwiftUI
import UIKit
import Combine
struct MainTabView: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@ObservedObject private var debugLogger = DebugLogger.shared
@State private var selectedTab = 0
@State private var navigateToPlaylistId: String?
@State private var navigateToAlbumId: String?
@State private var navigateToArtistId: String?
@State var showNowPlaying = false
// Debug panel (docked)
@State private var debugPanelHeight: CGFloat = 250
@State private var debugDragOffset: CGFloat = 0
// Debug PiP (floating)
@State private var debugPipMode = false
@State private var pipPosition: CGPoint = CGPoint(x: 16, y: 100)
@State private var pipDragOffset: CGSize = .zero
@State private var pipSize: CGSize = CGSize(width: 320, height: 220)
@State private var pipCollapsed = false
@State private var pipResizeStart: CGSize = .zero
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
ZStack {
// Main tab content
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
MyMusicView(
navigateToPlaylistId: $navigateToPlaylistId,
navigateToAlbumId: $navigateToAlbumId,
navigateToArtistId: $navigateToArtistId
)
.tabItem {
Image(systemName: "music.note")
Text("My Music")
}
.tag(0)
SearchView()
.tabItem {
Image(systemName: "magnifyingglass")
Text("Search")
}
.tag(1)
RadioView()
.tabItem {
Image(systemName: "antenna.radiowaves.left.and.right")
Text("Radio")
}
.tag(2)
DownloadsView()
.tabItem {
Image(systemName: "arrow.down.circle")
Text("Downloads")
}
.tag(3)
SettingsView()
.tabItem {
Image(systemName: "gear")
Text("Settings")
}
.tag(4)
}
.tint(accentPink)
// Debug panel (docked) only when enabled and NOT in PiP mode
if debugLogger.isEnabled && !debugPipMode {
debugPanel
.transition(.move(edge: .bottom).combined(with: .opacity))
.zIndex(2)
}
if audioPlayer.currentSong != nil {
let effectiveDebugHeight = max(debugPanelHeight - debugDragOffset, 120)
let showDocked = debugLogger.isEnabled && !debugPipMode
MiniPlayerBar(showNowPlaying: $showNowPlaying)
.padding(.bottom, showDocked ? 49 + effectiveDebugHeight : 49)
.animation(.easeInOut(duration: 0.25), value: showDocked)
.zIndex(3)
}
}
// Now Playing overlay always rendered, positioned via offset
// This avoids re-layout animation when appearing
if audioPlayer.currentSong != nil {
NowPlayingView(isPresented: $showNowPlaying)
.zIndex(1)
.offset(y: showNowPlaying ? 0 : 1500)
.allowsHitTesting(showNowPlaying)
}
// Debug PiP (floating) above everything except Now Playing
if debugLogger.isEnabled && debugPipMode {
debugPipView
.zIndex(showNowPlaying ? 0 : 5)
.opacity(showNowPlaying ? 0 : 1)
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToPlaylist)) { notif in
if let playlistId = notif.userInfo?["playlistId"] as? String {
selectedTab = 0
withAnimation(.easeInOut(duration: 0.35)) {
showNowPlaying = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
navigateToPlaylistId = playlistId
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToAlbum)) { notif in
if let albumId = notif.userInfo?["albumId"] as? String {
selectedTab = 0
withAnimation(.easeInOut(duration: 0.35)) {
showNowPlaying = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
navigateToAlbumId = albumId
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToArtist)) { notif in
if let artistId = notif.userInfo?["artistId"] as? String {
selectedTab = 0
withAnimation(.easeInOut(duration: 0.35)) {
showNowPlaying = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
navigateToArtistId = artistId
}
}
}
}
// MARK: - Debug Panel
private var debugPanel: some View {
VStack(spacing: 0) {
// Drag handle + header
VStack(spacing: 4) {
Capsule()
.fill(Color.white.opacity(0.4))
.frame(width: 36, height: 4)
.padding(.top, 8)
HStack {
Image(systemName: "ladybug.fill")
.font(.system(size: 12))
.foregroundColor(.red)
Text("Debug Console")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
Text("\(DebugLogger.shared.entries.count)")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.gray)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.1))
.cornerRadius(8)
Spacer()
Button(action: { DebugLogger.shared.clear() }) {
Text("Clear")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.red.opacity(0.8))
}
Button(action: {
withAnimation(.easeInOut(duration: 0.3)) {
debugPipMode = true
}
}) {
Image(systemName: "pip")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.6))
}
Button(action: {
debugLogger.isEnabled = false
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundColor(.gray)
}
}
.padding(.horizontal, 14)
.padding(.bottom, 6)
}
.background(Color(white: 0.1))
.gesture(
DragGesture()
.onChanged { value in
debugDragOffset = value.translation.height
}
.onEnded { value in
let newHeight = debugPanelHeight - value.translation.height
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
debugDragOffset = 0
debugPanelHeight = min(max(newHeight, 120), 500)
}
}
)
Divider().background(Color.white.opacity(0.15))
// Log entries
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 1) {
ForEach(DebugLogger.shared.entries) { entry in
debugLogRow(entry)
.id(entry.id)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
}
.onChange(of: DebugLogger.shared.entries.count) { _, _ in
if let last = DebugLogger.shared.entries.last {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
}
.frame(height: max(debugPanelHeight - debugDragOffset, 120))
.background(Color(white: 0.06))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.shadow(color: .black.opacity(0.4), radius: 8, y: -2)
.padding(.horizontal, 4)
.padding(.bottom, 49) // above tab bar
}
private func debugLogRow(_ entry: DebugLogger.LogEntry) -> some View {
let color: Color = {
switch entry.category {
case "Watch": return .cyan
case "Audio": return .green
case "FFT": return .orange
case "Network": return .yellow
case "Error": return .red
case "Radio": return .purple
default: return .gray
}
}()
return HStack(alignment: .top, spacing: 6) {
Text(entry.category)
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(color)
.padding(.horizontal, 3)
.padding(.vertical, 1)
.background(color.opacity(0.15))
.cornerRadius(3)
Text(entry.message)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.85))
.lineLimit(3)
}
.padding(.vertical, 3)
}
// MARK: - Debug PiP (Floating)
private var debugPipView: some View {
let currentPos = CGPoint(
x: pipPosition.x + pipDragOffset.width,
y: pipPosition.y + pipDragOffset.height
)
return VStack(spacing: 0) {
// PiP header always visible
HStack(spacing: 8) {
Image(systemName: "ladybug.fill")
.font(.system(size: 10))
.foregroundColor(.red)
Text("Debug")
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.white)
Text("\(DebugLogger.shared.entries.count)")
.font(.system(size: 9, weight: .medium, design: .monospaced))
.foregroundColor(.gray)
Spacer()
Button(action: { DebugLogger.shared.clear() }) {
Image(systemName: "trash")
.font(.system(size: 11))
.foregroundColor(.red.opacity(0.7))
}
// Collapse/expand
Button(action: {
withAnimation(.easeInOut(duration: 0.2)) {
pipCollapsed.toggle()
}
}) {
Image(systemName: pipCollapsed ? "chevron.down" : "chevron.up")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white.opacity(0.6))
}
// Dock back
Button(action: {
withAnimation(.easeInOut(duration: 0.3)) {
debugPipMode = false
}
}) {
Image(systemName: "dock.rectangle")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.6))
}
Button(action: {
withAnimation(.easeInOut(duration: 0.2)) {
debugLogger.isEnabled = false
debugPipMode = false
}
}) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.gray)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(Color(white: 0.12))
.gesture(
DragGesture()
.onChanged { value in
pipDragOffset = value.translation
}
.onEnded { value in
pipPosition = CGPoint(
x: pipPosition.x + value.translation.width,
y: pipPosition.y + value.translation.height
)
pipDragOffset = .zero
}
)
// Log content hidden when collapsed
if !pipCollapsed {
Divider().background(Color.white.opacity(0.1))
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 1) {
ForEach(DebugLogger.shared.entries) { entry in
debugLogRow(entry)
.id(entry.id)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
.onChange(of: DebugLogger.shared.entries.count) { _, _ in
if let last = DebugLogger.shared.entries.last {
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
.frame(height: pipCollapsed ? 0 : pipSize.height - 32)
// Resize handle
HStack {
Spacer()
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 9))
.foregroundColor(.white.opacity(0.3))
.padding(6)
.gesture(
DragGesture()
.onChanged { value in
if pipResizeStart == .zero {
pipResizeStart = pipSize
}
let newW = max(200, pipResizeStart.width + value.translation.width)
let newH = max(100, pipResizeStart.height + value.translation.height)
pipSize = CGSize(width: min(newW, 500), height: min(newH, 500))
}
.onEnded { _ in
pipResizeStart = .zero
}
)
}
.frame(height: 18)
.background(Color(white: 0.08))
}
}
.frame(width: pipSize.width)
.background(Color(white: 0.06).opacity(0.95))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.08), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
.position(x: currentPos.x + pipSize.width / 2, y: currentPos.y + (pipCollapsed ? 16 : pipSize.height / 2))
}
}
extension Notification.Name {
static let navigateToPlaylist = Notification.Name("navigateToPlaylist")
static let navigateToAlbum = Notification.Name("navigateToAlbum")
static let navigateToArtist = Notification.Name("navigateToArtist")
}
// MARK: - Mini Player Bar with scrubbable progress
struct MiniPlayerBar: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@StateObject private var colorExtractor = AlbumColorExtractor.shared
@Binding var showNowPlaying: Bool
@State private var isScrubbing = false
@State private var scrubPosition: Double = 0
@State private var playbackTime: TimeInterval = 0
@State private var playbackDuration: TimeInterval = 0
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
// Poll currentTime at our own pace doesn't trigger parent view redraws
private let timePoller = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
private var displayProgress: Double {
if isScrubbing { return scrubPosition }
guard playbackDuration > 0 else { return 0 }
return min(playbackTime / playbackDuration, 1.0)
}
var body: some View {
VStack(spacing: 0) {
// Scrubbable progress bar at top uses album color, generous touch target
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.1))
.frame(height: isScrubbing ? 8 : 3)
Rectangle()
.fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink)
.frame(
width: geo.size.width * displayProgress,
height: isScrubbing ? 8 : 3
)
if isScrubbing {
Circle()
.fill(colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink)
.frame(width: 16, height: 16)
.offset(x: geo.size.width * displayProgress - 8, y: -1)
}
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isScrubbing = true
scrubPosition = min(max(value.location.x / geo.size.width, 0), 1)
}
.onEnded { value in
let pct = min(max(value.location.x / geo.size.width, 0), 1)
audioPlayer.seekToPercent(pct)
isScrubbing = false
}
)
}
.frame(height: isScrubbing ? 20 : 14) // Generous touch target even when not scrubbing
.animation(.easeInOut(duration: 0.15), value: isScrubbing)
// Player content
ZStack(alignment: .center) {
// Visualizer behind controls paused when full NowPlaying is open
if VisualizerSettings.shared.enabled && VisualizerSettings.shared.miniPlayerEnabled && !showNowPlaying {
CompactVisualizerView(
isPlaying: audioPlayer.isPlaying,
accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink,
height: VisualizerSettings.shared.miniPlayerHeight
)
.offset(y: 10)
.opacity(VisualizerSettings.shared.miniOpacity)
.allowsHitTesting(false)
}
HStack(spacing: 12) {
// Cover art
Group {
if let song = audioPlayer.currentSong,
(song.album == "Radio" || audioPlayer.isRadioStream),
let img = RadioCoverStore.shared.loadCover(for: song.id) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
AsyncCoverArt(
coverArtId: audioPlayer.currentSong?.coverArt,
size: 48
)
}
}
.frame(width: 44, height: 44)
.cornerRadius(6)
.shadow(radius: 3)
VStack(alignment: .leading, spacing: 1) {
Text(audioPlayer.currentSong?.title ?? "")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
Text(audioPlayer.currentSong?.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
.lineLimit(1)
}
Spacer()
Button(action: { audioPlayer.previous() }) {
Image(systemName: "backward.fill")
.font(.system(size: 16))
.foregroundColor(.white)
}
.frame(width: 36, height: 44)
Button(action: { audioPlayer.togglePlayPause() }) {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22))
.foregroundColor(.white)
}
.frame(width: 44, height: 44)
Button(action: { audioPlayer.next() }) {
Image(systemName: "forward.fill")
.font(.system(size: 16))
.foregroundColor(.white)
}
.frame(width: 36, height: 44)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.frame(height: 60)
.contentShape(Rectangle())
.onTapGesture {
if !isScrubbing {
dismissKeyboard()
withAnimation(.easeInOut(duration: 0.35)) {
showNowPlaying = true
}
}
}
}
.background(.ultraThinMaterial)
.background(
colorExtractor.isLoaded ? colorExtractor.primaryColor.opacity(0.15) : Color.clear
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.shadow(color: .black.opacity(0.3), radius: 8, y: 4)
.padding(.horizontal, 8)
.onReceive(timePoller) { _ in
playbackTime = audioPlayer.currentTime
playbackDuration = audioPlayer.duration
}
}
}
// MARK: - Keyboard Dismiss
/// Dismiss keyboard from anywhere
func dismissKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
/// UIKit tap recognizer that dismisses keyboard without blocking other touches.
/// Added once to the key window fires alongside all gestures.
private class KeyboardDismissTapRecognizer: UITapGestureRecognizer, UIGestureRecognizerDelegate {
override init(target: Any?, action: Selector?) {
super.init(target: target, action: action)
cancelsTouchesInView = false
delegate = self
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
true // Never block other gestures
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
// Don't fire when tapping on text inputs themselves
!(touch.view is UITextField || touch.view is UITextView)
}
}
/// Helper target for the tap gesture calls endEditing on the window
private class KeyboardDismissTarget: NSObject {
@objc func dismiss() {
dismissKeyboard()
}
}
/// View modifier that installs a one-time keyboard dismiss gesture on the window.
struct KeyboardDismissible: ViewModifier {
// Held as static so the target isn't deallocated
private static let target = KeyboardDismissTarget()
func body(content: Content) -> some View {
content.onAppear {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first else { return }
// Only add once
let alreadyAdded = window.gestureRecognizers?.contains(where: { $0 is KeyboardDismissTapRecognizer }) ?? false
if !alreadyAdded {
let tap = KeyboardDismissTapRecognizer(
target: KeyboardDismissible.target,
action: #selector(KeyboardDismissTarget.dismiss)
)
window.addGestureRecognizer(tap)
}
}
}
}
extension View {
func dismissKeyboardOnTap() -> some View {
modifier(KeyboardDismissible())
}
}

View file

@ -0,0 +1,255 @@
import SwiftUI
/// Batch-edit metadata for all songs in an album.
/// Fixes "Various Artists" albums that split into per-artist albums in Navidrome.
struct BatchAlbumEditorSheet: View {
let album: AlbumWithSongs
@Environment(\.dismiss) private var dismiss
@State private var albumName: String
@State private var albumArtist: String
@State private var artist: String
@State private var genre: String
@State private var year: String
@State private var applyArtistToAll = false
@State private var isSaving = false
@State private var progress: Double = 0
@State private var resultMessage: String?
@State private var failedTracks: [String] = []
@ObservedObject private var settings = CompanionSettings.shared
private let api = CompanionAPIService()
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
init(album: AlbumWithSongs) {
self.album = album
_albumName = State(initialValue: album.name)
_albumArtist = State(initialValue: album.artist ?? "")
_artist = State(initialValue: album.artist ?? "")
_genre = State(initialValue: album.genre ?? "")
_year = State(initialValue: album.year != nil ? "\(album.year!)" : "")
}
var body: some View {
NavigationStack {
List {
if !settings.isEnabled {
Section {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28))
.foregroundColor(.yellow)
Text("Companion API Required")
.font(.system(size: 15, weight: .medium))
Text("Enable the Companion API in Settings to batch-edit tags.")
.font(.system(size: 13))
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
} else {
// Scope
Section {
HStack {
Text("Tracks")
.foregroundColor(.gray)
Spacer()
Text("\(album.song?.count ?? 0) songs")
.foregroundColor(.white)
}
.font(.system(size: 14))
} header: {
Text("Batch Edit")
} footer: {
Text("Changes will be applied to ALL \(album.song?.count ?? 0) tracks in this album on the server. Navidrome will rescan automatically.")
}
// Fields
Section {
editField("Album", text: $albumName)
editField("Album Artist", text: $albumArtist)
editField("Genre", text: $genre)
editField("Year", text: $year, keyboard: .numberPad)
} header: {
Text("Album Tags")
}
Section {
Toggle("Set artist on all tracks", isOn: $applyArtistToAll)
.tint(accentPink)
if applyArtistToAll {
editField("Artist", text: $artist)
}
} header: {
Text("Artist Override")
} footer: {
if applyArtistToAll {
Text("This will set the same artist on every track — useful for fixing compilation albums that split into separate artist albums.")
} else {
Text("Each track keeps its original artist. Only album-level tags are changed.")
}
}
// Track preview
Section {
ForEach(album.song ?? [], id: \.id) { song in
HStack(spacing: 8) {
Text("\(song.track ?? 0)")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.gray)
.frame(width: 24)
VStack(alignment: .leading, spacing: 1) {
Text(song.title)
.font(.system(size: 13))
.foregroundColor(.white)
.lineLimit(1)
Text(applyArtistToAll ? artist : (song.artist ?? ""))
.font(.system(size: 11))
.foregroundColor(applyArtistToAll ? accentPink : .gray)
.lineLimit(1)
}
Spacer()
if song.path != nil {
Image(systemName: "checkmark.circle")
.font(.system(size: 11))
.foregroundColor(.green.opacity(0.5))
} else {
Image(systemName: "xmark.circle")
.font(.system(size: 11))
.foregroundColor(.red.opacity(0.5))
}
}
}
} header: {
Text("Tracks to Update")
}
// Progress / Results
if isSaving {
Section {
VStack(spacing: 8) {
ProgressView(value: progress)
.tint(accentPink)
Text("Updating \(Int(progress * Double(album.song?.count ?? 0)))/\(album.song?.count ?? 0)...")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
if let msg = resultMessage {
Section {
VStack(spacing: 4) {
Text(msg)
.font(.system(size: 13))
.foregroundColor(failedTracks.isEmpty ? .green : .yellow)
ForEach(failedTracks, id: \.self) { t in
Text("\(t)")
.font(.system(size: 11))
.foregroundColor(.red)
}
}
}
}
}
}
.navigationTitle("Edit Album Tags")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
.foregroundColor(.gray)
}
ToolbarItem(placement: .navigationBarTrailing) {
if settings.isEnabled {
Button("Apply All", action: applyBatch)
.fontWeight(.semibold)
.foregroundColor(accentPink)
.disabled(isSaving || albumName.isEmpty)
}
}
}
}
}
// MARK: - Apply Batch
private func applyBatch() {
guard let songs = album.song, !songs.isEmpty else { return }
// Collect paths skip songs without file paths
let paths = songs.compactMap { $0.path }
guard !paths.isEmpty else {
resultMessage = "No tracks have file paths"
return
}
isSaving = true
progress = 0
failedTracks = []
resultMessage = nil
Task {
do {
// Build a single batch request with ALL tag fields
let request = BatchMetadataEditRequest(
relativePaths: paths,
title: nil, // Keep original titles
artist: applyArtistToAll ? artist : nil,
album: albumName.isEmpty ? nil : albumName,
albumArtist: albumArtist.isEmpty ? nil : albumArtist,
genre: genre.isEmpty ? nil : genre,
year: Int(year)
)
DebugLogger.shared.log(
"Batch edit: \(paths.count) tracks, album='\(albumName)', albumArtist='\(albumArtist)', artist=\(applyArtistToAll ? "'\(artist)'" : "nil")",
category: "Companion"
)
await MainActor.run { progress = 0.3 }
let result = try await api.batchEditMetadata(request)
await MainActor.run {
progress = 1.0
isSaving = false
let succeeded = result.succeeded?.count ?? 0
let failed = result.failed ?? []
if failed.isEmpty {
resultMessage = "✓ All \(succeeded) tracks updated"
} else {
resultMessage = "\(succeeded)/\(paths.count) succeeded, \(failed.count) failed"
failedTracks = failed.map { "\($0.path): \($0.error)" }
}
}
} catch {
await MainActor.run {
isSaving = false
resultMessage = "Failed: \(error.localizedDescription)"
}
}
}
}
// MARK: - Helpers
private func editField(_ label: String, text: Binding<String>, keyboard: UIKeyboardType = .default) -> some View {
HStack {
Text(label)
.foregroundColor(.gray)
.frame(width: 100, alignment: .leading)
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
}
.font(.system(size: 14))
}
}

View file

@ -0,0 +1,259 @@
import SwiftUI
import UniformTypeIdentifiers
struct BatchUploadView: View {
@ObservedObject private var manager = ZipImportManager.shared
@ObservedObject private var settings = CompanionSettings.shared
@State private var showFilePicker = false
@State private var album = ""
@State private var artist = ""
@State private var albumArtist = ""
@State private var genre = ""
@State private var year = ""
@State private var keepOffline = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
List {
if !settings.isEnabled {
Section {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28))
.foregroundColor(.yellow)
Text("Companion API Not Configured")
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white)
Text("Set your Companion API host and port in Settings to upload tracks.")
.font(.system(size: 13))
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
} else {
// Step 1: Import
importSection
// Step 2: Extracted files
if !manager.extractedFiles.isEmpty {
metadataSection
fileListSection
uploadSection
}
}
}
.navigationTitle("Upload Music")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if manager.isUploading {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") {
manager.cancelAll()
}
.foregroundColor(.red)
}
}
}
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: [UTType.zip, UTType.archive],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
if let url = urls.first {
manager.extractZip(at: url)
}
case .failure(let error):
DebugLogger.shared.log("File picker error: \(error.localizedDescription)", category: "Upload")
}
}
}
}
// MARK: - Import Section
private var importSection: some View {
Section {
Button(action: {
manager.reset()
showFilePicker = true
}) {
HStack {
Image(systemName: "doc.zipper")
.font(.system(size: 20))
.foregroundColor(accentPink)
VStack(alignment: .leading, spacing: 2) {
Text("Import Zip Archive")
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white)
Text("Select a .zip containing audio files")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
if manager.isExtracting {
HStack(spacing: 12) {
ProgressView()
.tint(accentPink)
Text(manager.extractionProgress)
.font(.system(size: 13))
.foregroundColor(.gray)
}
} else if !manager.extractionProgress.isEmpty && manager.extractedFiles.isEmpty {
Text(manager.extractionProgress)
.font(.system(size: 13))
.foregroundColor(.gray)
}
} header: {
Text("Import")
}
}
// MARK: - Metadata Section
private var metadataSection: some View {
Section {
TextField("Album", text: $album)
.textInputAutocapitalization(.words)
TextField("Artist", text: $artist)
.textInputAutocapitalization(.words)
TextField("Album Artist", text: $albumArtist)
.textInputAutocapitalization(.words)
TextField("Genre", text: $genre)
.textInputAutocapitalization(.words)
TextField("Year", text: $year)
.keyboardType(.numberPad)
Toggle("Keep Offline After Upload", isOn: $keepOffline)
.tint(accentPink)
} header: {
Text("Batch Tags")
} footer: {
Text(keepOffline
? "Uploaded files will be kept in local storage for offline playback."
: "Uploaded files will be deleted locally. Re-download from server for offline use.")
}
}
// MARK: - File List
private var fileListSection: some View {
Section {
ForEach(manager.extractedFiles) { file in
HStack(spacing: 12) {
// Status indicator
statusIcon(for: file.filename)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(file.filename)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .file))
.font(.system(size: 11))
.foregroundColor(.gray)
}
Spacer()
// Progress for uploading
if case .uploading(let pct) = manager.uploadStates[file.filename] {
Text("\(Int(pct * 100))%")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(accentPink)
}
}
}
} header: {
Text("\(manager.extractedFiles.count) Tracks")
}
}
// MARK: - Upload Section
private var uploadSection: some View {
Section {
if manager.isUploading {
VStack(spacing: 8) {
ProgressView(value: manager.uploadProgress)
.tint(accentPink)
let completed = manager.uploadStates.values.filter { $0 == .completed }.count
let failed = manager.uploadStates.values.filter {
if case .failed = $0 { return true }
return false
}.count
Text("Uploading: \(completed)/\(manager.extractedFiles.count) complete" + (failed > 0 ? ", \(failed) failed" : ""))
.font(.system(size: 12))
.foregroundColor(.gray)
}
} else {
Button(action: {
let meta = UploadMetadata(
title: "", // Per-file from filename
artist: artist,
album: album,
albumArtist: albumArtist.isEmpty ? artist : albumArtist,
genre: genre,
year: year,
trackNumber: "" // Auto-assigned per file
)
manager.startBatchUpload(metadata: meta, keepOffline: keepOffline)
}) {
HStack {
Image(systemName: "icloud.and.arrow.up")
.foregroundColor(.white)
Text("Upload All")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
}
.listRowBackground(accentPink)
.disabled(artist.isEmpty || album.isEmpty)
}
} footer: {
if !manager.isUploading && (artist.isEmpty || album.isEmpty) {
Text("Artist and Album are required.")
}
}
}
// MARK: - Status Icon
@ViewBuilder
private func statusIcon(for filename: String) -> some View {
switch manager.uploadStates[filename] {
case .pending, .none:
Image(systemName: "circle")
.font(.system(size: 14))
.foregroundColor(.gray)
case .uploading:
ProgressView()
.scaleEffect(0.7)
case .completed:
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14))
.foregroundColor(.green)
case .failed:
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundColor(.red)
}
}
}

View file

@ -0,0 +1,577 @@
import Foundation
// MARK: - Companion API Settings
class CompanionSettings: ObservableObject {
static let shared = CompanionSettings()
@Published var host: String {
didSet { UserDefaults.standard.set(host, forKey: "companion_host") }
}
@Published var port: Int {
didSet { UserDefaults.standard.set(port, forKey: "companion_port") }
}
@Published var isEnabled: Bool {
didSet { UserDefaults.standard.set(isEnabled, forKey: "companion_enabled") }
}
@Published var smartDJEnabled: Bool {
didSet { UserDefaults.standard.set(smartDJEnabled, forKey: "companion_smart_dj") }
}
var baseURL: URL? {
URL(string: "http://\(host):\(port)")
}
private init() {
host = UserDefaults.standard.string(forKey: "companion_host") ?? "192.168.1.100"
port = UserDefaults.standard.integer(forKey: "companion_port").nonZero ?? 8000
isEnabled = UserDefaults.standard.bool(forKey: "companion_enabled")
smartDJEnabled = UserDefaults.standard.bool(forKey: "companion_smart_dj")
}
}
private extension Int {
var nonZero: Int? { self == 0 ? nil : self }
}
// MARK: - Smart DJ Profile
struct SmartDJProfile: Codable, Equatable {
let bpm: Double?
let silenceStart: Double?
let silenceEnd: Double?
let loudnessLUFS: Double?
enum CodingKeys: String, CodingKey {
case bpm
case silenceStart = "silence_start"
case silenceEnd = "silence_end"
case loudnessLUFS = "loudness_lufs"
}
}
// MARK: - Local Smart DJ Cache
class SmartDJCache {
static let shared = SmartDJCache()
private let fileManager = FileManager.default
private let cacheDir: URL
private var memoryCache: [String: SmartDJProfile] = [:]
private init() {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
cacheDir = caches.appendingPathComponent("SmartDJProfiles", isDirectory: true)
try? fileManager.createDirectory(at: cacheDir, withIntermediateDirectories: true)
}
private func cacheURL(for relativePath: String) -> URL {
let safe = relativePath
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: " ", with: "_")
return cacheDir.appendingPathComponent("\(safe).json")
}
func get(_ relativePath: String) -> SmartDJProfile? {
if let cached = memoryCache[relativePath] { return cached }
let url = cacheURL(for: relativePath)
guard let data = try? Data(contentsOf: url),
let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: data) else { return nil }
memoryCache[relativePath] = profile
return profile
}
func store(_ profile: SmartDJProfile, for relativePath: String) {
memoryCache[relativePath] = profile
let url = cacheURL(for: relativePath)
if let data = try? JSONEncoder().encode(profile) {
try? data.write(to: url, options: .atomic)
}
}
func clearAll() {
memoryCache.removeAll()
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
var cachedCount: Int {
(try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil))?.count ?? 0
}
}
// MARK: - Metadata Edit Request (matches Python MetadataUpdate model)
struct MetadataEditRequest: Codable {
let relativePath: String
var title: String?
var artist: String?
var album: String?
var albumArtist: String?
var genre: String?
var year: Int?
var trackNumber: Int?
enum CodingKeys: String, CodingKey {
case relativePath = "relative_path"
case title, artist, album
case albumArtist = "album_artist"
case genre, year
case trackNumber = "track_number"
}
}
/// Request body for PATCH /batch-edit-metadata
struct BatchMetadataEditRequest: Codable {
let relativePaths: [String]
var title: String?
var artist: String?
var album: String?
var albumArtist: String?
var genre: String?
var year: Int?
enum CodingKeys: String, CodingKey {
case relativePaths = "relative_paths"
case title, artist, album
case albumArtist = "album_artist"
case genre, year
}
}
// MARK: - Upload Metadata (matches Python upload-track Form fields)
struct UploadMetadata {
var title: String
var artist: String
var album: String
var albumArtist: String
var genre: String
var year: String
var trackNumber: String
}
// MARK: - Companion API Service
actor CompanionAPIService {
private let session: URLSession
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 600
self.session = URLSession(configuration: config)
}
private func baseURL() throws -> URL {
guard let url = CompanionSettings.shared.baseURL else {
throw CompanionError.notConfigured
}
return url
}
// MARK: - Health Check (GET /health)
func healthCheck() async throws -> Bool {
let base = try baseURL()
let url = base.appendingPathComponent("health")
var req = URLRequest(url: url)
req.timeoutInterval = 5
let (_, response) = try await session.data(for: req)
if let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) {
return true
}
return false
}
// MARK: - Smart DJ Profile (GET /smart-dj/profile?relative_path=...)
func fetchProfile(relativePath: String) async throws -> SmartDJProfile {
if let cached = SmartDJCache.shared.get(relativePath) {
return cached
}
let base = try baseURL()
// Use addingPercentEncoding directly instead of URLQueryItem
// which double-encodes paths with brackets, unicode, etc.
guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "\(base)/smart-dj/profile?relative_path=\(encoded)") else {
throw CompanionError.invalidURL
}
let (data, response) = try await session.data(from: url)
try validateResponse(response)
let profile = try JSONDecoder().decode(SmartDJProfile.self, from: data)
SmartDJCache.shared.store(profile, for: relativePath)
return profile
}
/// Prefetch profiles for a batch of songs (e.g. queue, album)
func prefetchProfiles(for songs: [Song]) async {
await withTaskGroup(of: Void.self) { group in
for song in songs {
guard let path = song.path else { continue }
if SmartDJCache.shared.get(path) != nil { continue }
group.addTask { _ = try? await self.fetchProfile(relativePath: path) }
}
}
}
// MARK: - Edit Metadata (PATCH /edit-metadata)
func editMetadata(_ request: MetadataEditRequest) async throws {
let base = try baseURL()
let url = base.appendingPathComponent("edit-metadata")
var req = URLRequest(url: url)
req.httpMethod = "PATCH"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(request)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse }
if !(200...299).contains(http.statusCode) {
// Parse server error detail for better debugging
let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"]
?? String(data: data, encoding: .utf8)
?? "Unknown error"
throw CompanionError.serverErrorDetail(http.statusCode, detail)
}
}
// MARK: - Batch Edit Metadata (PATCH /batch-edit-metadata)
struct BatchEditResult: Codable {
let succeeded: [String]?
let failed: [BatchEditFailure]?
}
struct BatchEditFailure: Codable {
let path: String
let error: String
}
/// Edit the same tags on multiple files in a single request + single Navidrome scan.
func batchEditMetadata(_ request: BatchMetadataEditRequest) async throws -> BatchEditResult {
let base = try baseURL()
let url = base.appendingPathComponent("batch-edit-metadata")
var req = URLRequest(url: url)
req.httpMethod = "PATCH"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(request)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse }
if !(200...299).contains(http.statusCode) {
let detail = (try? JSONDecoder().decode([String:String].self, from: data))?["detail"]
?? String(data: data, encoding: .utf8)
?? "Unknown error"
throw CompanionError.serverErrorDetail(http.statusCode, detail)
}
return try JSONDecoder().decode(BatchEditResult.self, from: data)
}
// MARK: - Upload Track (POST /upload-track)
func uploadTrack(fileURL: URL, metadata: UploadMetadata) async throws {
let base = try baseURL()
let url = base.appendingPathComponent("upload-track")
let boundary = "Boundary-\(UUID().uuidString)"
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.httpBody = try buildMultipartBody(fileURL: fileURL, metadata: metadata, boundary: boundary)
let (_, response) = try await session.data(for: req)
try validateResponse(response)
}
// MARK: - Bulk Fix (POST /bulk-fix)
func triggerBulkFix() async throws {
let base = try baseURL()
let url = base.appendingPathComponent("bulk-fix")
var req = URLRequest(url: url)
req.httpMethod = "POST"
let (_, response) = try await session.data(for: req)
try validateResponse(response)
}
// MARK: - Bulk Profiles (GET /smart-dj/bulk-profiles?paths=...)
func fetchBulkProfiles(songs: [Song]) async throws -> [String: SmartDJProfile] {
let paths = songs.compactMap { $0.path }.filter { SmartDJCache.shared.get($0) == nil }
guard !paths.isEmpty else { return [:] }
let base = try baseURL()
var components = URLComponents(url: base.appendingPathComponent("smart-dj/bulk-profiles"), resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "paths", value: paths.joined(separator: ","))]
guard let url = components.url else { throw CompanionError.invalidURL }
let (data, response) = try await session.data(from: url)
try validateResponse(response)
let results = try JSONDecoder().decode([String: SmartDJProfile?].self, from: data)
var profiles: [String: SmartDJProfile] = [:]
for (path, profile) in results {
if let p = profile {
SmartDJCache.shared.store(p, for: path)
profiles[path] = p
}
}
return profiles
}
// MARK: - Visualizer Frames (GET /visualizer/frames?relative_path=...)
/// Fetch pre-computed Mitsuha visualizer frames from the server.
/// Returns nil if not available. The frames match iOS OfflineAudioAnalyzer format.
func fetchVisualizerFrames(relativePath: String) async throws -> [[Float]]? {
let base = try baseURL()
guard let encoded = relativePath.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "\(base)/visualizer/frames?relative_path=\(encoded)") else {
throw CompanionError.invalidURL
}
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse else { return nil }
guard http.statusCode == 200 else { return nil }
struct VisResponse: Codable {
let frame_count: Int
let fps: Double
let points: Int
let frames: [[Float]]
}
let vis = try JSONDecoder().decode(VisResponse.self, from: data)
return vis.frames
}
// MARK: - Background Upload Helpers
nonisolated func buildMultipartPayloadFile(
fileURL: URL, metadata: UploadMetadata, boundary: String
) throws -> URL {
let tempDir = FileManager.default.temporaryDirectory
let payloadURL = tempDir.appendingPathComponent("upload_\(UUID().uuidString).multipart")
let body = try buildMultipartBody(fileURL: fileURL, metadata: metadata, boundary: boundary)
try body.write(to: payloadURL)
return payloadURL
}
nonisolated func uploadURL() throws -> URL {
guard let url = CompanionSettings.shared.baseURL else { throw CompanionError.notConfigured }
return url.appendingPathComponent("upload-track")
}
// MARK: - Multipart Builder
private nonisolated func buildMultipartBody(
fileURL: URL, metadata: UploadMetadata, boundary: String
) throws -> Data {
var body = Data()
let crlf = "\r\n"
func field(_ name: String, _ value: String) {
guard !value.isEmpty else { return }
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(name)\"\(crlf)\(crlf)".data(using: .utf8)!)
body.append("\(value)\(crlf)".data(using: .utf8)!)
}
field("title", metadata.title)
field("artist", metadata.artist)
field("album", metadata.album)
if !metadata.albumArtist.isEmpty { field("album_artist", metadata.albumArtist) }
if !metadata.genre.isEmpty { field("genre", metadata.genre) }
if !metadata.year.isEmpty { field("year", metadata.year) }
if !metadata.trackNumber.isEmpty { field("track_number", metadata.trackNumber) }
let fileData = try Data(contentsOf: fileURL)
let filename = fileURL.lastPathComponent
let mime: String = {
switch fileURL.pathExtension.lowercased() {
case "mp3": return "audio/mpeg"
case "flac": return "audio/flac"
case "m4a", "aac": return "audio/mp4"
case "ogg", "opus": return "audio/ogg"
case "wav": return "audio/wav"
default: return "application/octet-stream"
}
}()
body.append("--\(boundary)\(crlf)".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\(crlf)".data(using: .utf8)!)
body.append("Content-Type: \(mime)\(crlf)\(crlf)".data(using: .utf8)!)
body.append(fileData)
body.append(crlf.data(using: .utf8)!)
body.append("--\(boundary)--\(crlf)".data(using: .utf8)!)
return body
}
private func validateResponse(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else { throw CompanionError.invalidResponse }
guard (200...299).contains(http.statusCode) else { throw CompanionError.serverError(http.statusCode) }
}
}
// MARK: - WebSocket Push Client
/// Connects to the Companion API WebSocket for real-time push events.
/// Events: metadata_updated, track_uploaded, analysis_complete, vis_ready
class CompanionPushClient: ObservableObject {
static let shared = CompanionPushClient()
@Published var isConnected = false
@Published var lastEvent: String?
private var webSocketTask: URLSessionWebSocketTask?
private var pingTimer: Timer?
private var reconnectTask: Task<Void, Never>?
private init() {}
func connect() {
guard CompanionSettings.shared.isEnabled,
CompanionSettings.shared.baseURL != nil else { return }
disconnect()
let wsURL = URL(string: "ws://\(CompanionSettings.shared.host):\(CompanionSettings.shared.port)/ws/push")!
webSocketTask = URLSession.shared.webSocketTask(with: wsURL)
webSocketTask?.resume()
isConnected = true
DebugLogger.shared.log("Push: connecting to \(wsURL)", category: "Companion")
listen()
startPing()
}
func disconnect() {
pingTimer?.invalidate()
pingTimer = nil
reconnectTask?.cancel()
reconnectTask = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
isConnected = false
}
private func listen() {
webSocketTask?.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let message):
switch message {
case .string(let text):
self.handleMessage(text)
default:
break
}
self.listen() // Keep listening
case .failure(let error):
DebugLogger.shared.log("Push: disconnected — \(error.localizedDescription)", category: "Companion")
DispatchQueue.main.async {
self.isConnected = false
self.scheduleReconnect()
}
}
}
}
private func handleMessage(_ text: String) {
guard let data = text.data(using: .utf8),
let msg = try? JSONDecoder().decode(PushMessage.self, from: data) else { return }
DispatchQueue.main.async {
self.lastEvent = msg.event
DebugLogger.shared.log("Push event: \(msg.event)", category: "Companion")
switch msg.event {
case "metadata_updated":
// Notify the app to refresh library
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
case "track_uploaded":
NotificationCenter.default.post(name: .companionTrackUploaded, object: nil, userInfo: msg.data)
case "profile":
// Cache the profile locally
if let path = msg.data?["path"] as? String,
let jsonData = try? JSONSerialization.data(withJSONObject: msg.data ?? [:]),
let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: jsonData) {
SmartDJCache.shared.store(profile, for: path)
}
default:
break
}
}
}
func sendAction(_ action: String, data: [String: String] = [:]) {
var payload = data
payload["action"] = action
if let jsonData = try? JSONSerialization.data(withJSONObject: payload),
let text = String(data: jsonData, encoding: .utf8) {
webSocketTask?.send(.string(text)) { error in
if let error = error {
DebugLogger.shared.log("Push send failed: \(error.localizedDescription)", category: "Companion")
}
}
}
}
private func startPing() {
pingTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.sendAction("ping")
}
}
private func scheduleReconnect() {
reconnectTask = Task {
try? await Task.sleep(for: .seconds(5))
await MainActor.run { self.connect() }
}
}
}
struct PushMessage: Codable {
let event: String
let data: [String: String]?
}
extension Notification.Name {
static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated")
static let companionTrackUploaded = Notification.Name("companionTrackUploaded")
}
// MARK: - Errors
enum CompanionError: LocalizedError {
case notConfigured, invalidURL, invalidResponse
case serverError(Int)
case serverErrorDetail(Int, String)
case extractionFailed(String)
case noAudioFiles
var errorDescription: String? {
switch self {
case .notConfigured: return "Companion API not configured"
case .invalidURL: return "Invalid Companion API URL"
case .invalidResponse: return "Invalid response from Companion API"
case .serverError(let code): return "Companion API error (HTTP \(code))"
case .serverErrorDetail(let code, let detail): return "HTTP \(code): \(detail)"
case .extractionFailed(let msg): return "Zip extraction failed: \(msg)"
case .noAudioFiles: return "No audio files found in archive"
}
}
}

View file

@ -0,0 +1,378 @@
import SwiftUI
struct CompanionSettingsView: View {
@ObservedObject private var settings = CompanionSettings.shared
@ObservedObject private var crossfade = SmartCrossfadeManager.shared
@State private var testStatus: TestStatus = .idle
@State private var hostInput: String = ""
@State private var portInput: String = ""
@State private var analyzeStatus: AnalyzeStatus = .idle
@State private var analyzeMessage: String?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
enum TestStatus: Equatable {
case idle, testing, success, failed(String)
}
enum AnalyzeStatus {
case idle, analyzing, done, failed
}
init() {
_hostInput = State(initialValue: CompanionSettings.shared.host)
_portInput = State(initialValue: "\(CompanionSettings.shared.port)")
}
var body: some View {
List {
// Connection
Section {
Toggle("Enable Companion API", isOn: $settings.isEnabled)
.tint(accentPink)
if settings.isEnabled {
HStack {
Text("Host")
.foregroundColor(.gray)
.frame(width: 50, alignment: .leading)
TextField("192.168.1.100", text: $hostInput)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.numbersAndPunctuation)
.multilineTextAlignment(.trailing)
.onChange(of: hostInput) { _, val in
settings.host = val
}
}
HStack {
Text("Port")
.foregroundColor(.gray)
.frame(width: 50, alignment: .leading)
TextField("8000", text: $portInput)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.onChange(of: portInput) { _, val in
settings.port = Int(val) ?? 8000
}
}
Button(action: testConnection) {
HStack {
switch testStatus {
case .idle:
Image(systemName: "bolt.horizontal")
.foregroundColor(accentPink)
Text("Test Connection")
.foregroundColor(accentPink)
case .testing:
ProgressView()
.scaleEffect(0.8)
Text("Testing...")
.foregroundColor(.gray)
case .success:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Connected")
.foregroundColor(.green)
case .failed(let msg):
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
Text(msg)
.foregroundColor(.red)
.lineLimit(2)
}
}
.font(.system(size: 14))
}
.disabled(testStatus == .testing)
}
} header: {
Text("Companion API")
} footer: {
Text("The Companion API provides advanced features: file uploads, ID3 tag editing, and Smart DJ audio analysis.")
}
// Smart DJ
if settings.isEnabled {
Section {
Toggle("Smart DJ", isOn: $settings.smartDJEnabled)
.tint(accentPink)
if settings.smartDJEnabled {
Toggle("Smart Crossfade", isOn: $crossfade.isEnabled)
.tint(accentPink)
if crossfade.isEnabled {
HStack {
Text("Crossfade")
.foregroundColor(.gray)
Spacer()
Text("\(String(format: "%.0f", crossfade.crossfadeDuration))s")
.foregroundColor(.white)
.frame(width: 30)
Slider(value: $crossfade.crossfadeDuration, in: 1...12, step: 1)
.tint(accentPink)
.frame(width: 140)
}
HStack {
Text("Target LUFS")
.foregroundColor(.gray)
Spacer()
Text("\(String(format: "%.0f", crossfade.targetLUFS))")
.foregroundColor(.white)
.frame(width: 30)
Slider(value: $crossfade.targetLUFS, in: -24 ... -8, step: 1)
.tint(accentPink)
.frame(width: 140)
}
Toggle("Skip Silence", isOn: $crossfade.skipSilence)
.tint(accentPink)
}
}
} header: {
Text("Smart DJ")
} footer: {
Text("Smart DJ analyzes tracks for BPM, silence boundaries, and loudness. Crossfade uses this data for seamless transitions and volume normalization.")
}
// Upload
Section {
NavigationLink(destination: BatchUploadView()) {
HStack {
Image(systemName: "icloud.and.arrow.up")
.foregroundColor(accentPink)
Text("Upload Music")
}
}
} header: {
Text("Upload")
} footer: {
Text("Import a .zip archive of audio files, batch-tag them, and upload to your server via the Companion API.")
}
// Server Actions
Section {
Button(action: triggerScan) {
HStack {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundColor(accentPink)
Text("Trigger Navidrome Scan")
.foregroundColor(.white)
}
}
HStack {
Text("Smart DJ Profiles Cached")
.foregroundColor(.gray)
Spacer()
Text("\(SmartDJCache.shared.cachedCount)")
.foregroundColor(.white)
}
.font(.system(size: 14))
Button(role: .destructive, action: {
SmartDJCache.shared.clearAll()
}) {
Text("Clear Local DJ Cache")
}
} header: {
Text("Server Actions")
}
// Analysis (runs on the Pi)
Section {
Button(action: { triggerPreAnalyze(force: false) }) {
HStack {
Image(systemName: analyzeStatus == .analyzing ? "waveform" : "waveform.badge.magnifyingglass")
.foregroundColor(accentPink)
.symbolEffect(.pulse, isActive: analyzeStatus == .analyzing)
VStack(alignment: .leading, spacing: 2) {
Text("Pre-Analyze Missing Songs")
.foregroundColor(.white)
Text("Analyze only songs without a Smart DJ profile")
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
}
.disabled(analyzeStatus == .analyzing)
Button(action: { triggerPreAnalyze(force: true) }) {
HStack {
Image(systemName: "arrow.clockwise")
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Re-Analyze All Songs")
.foregroundColor(.white)
Text("Force re-analyze every track (slow on large libraries)")
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
}
.disabled(analyzeStatus == .analyzing)
Button(action: triggerVisPrecompute) {
HStack {
Image(systemName: "waveform.path")
.foregroundColor(accentPink)
VStack(alignment: .leading, spacing: 2) {
Text("Pre-Compute Visualizer Frames")
.foregroundColor(.white)
Text("Generate Mitsuha FFT frames on the server")
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
}
.disabled(analyzeStatus == .analyzing)
if let msg = analyzeMessage {
Text(msg)
.font(.system(size: 12))
.foregroundColor(analyzeStatus == .done ? .green : analyzeStatus == .failed ? .red : .gray)
}
} header: {
Text("Server Analysis (runs on Pi)")
} footer: {
Text("These commands run on your Companion API server. Large libraries may take a while.")
}
}
}
.navigationTitle("Companion API")
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Trigger Server Scan
private func triggerScan() {
Task {
do {
try await CompanionAPIService().triggerBulkFix()
DebugLogger.shared.log("Triggered Navidrome scan", category: "Companion")
} catch {
DebugLogger.shared.log("Scan failed: \(error.localizedDescription)", category: "Companion")
}
}
}
// MARK: - Pre-Analyze
private func triggerPreAnalyze(force: Bool) {
analyzeStatus = .analyzing
analyzeMessage = force ? "Re-analyzing all songs on server..." : "Analyzing missing songs on server..."
Task {
do {
// Trigger server-side precompute via the visualizer/precompute endpoint
if force {
// For force re-analyze, we hit the precompute endpoint
// which processes all un-cached files
guard let base = CompanionSettings.shared.baseURL else { return }
var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute"))
req.httpMethod = "POST"
req.timeoutInterval = 10
let (data, _) = try await URLSession.shared.data(for: req)
let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started"
await MainActor.run {
analyzeStatus = .done
analyzeMessage = "\(msg)"
}
} else {
// Pre-analyze missing: same endpoint, server skips already-analyzed
guard let base = CompanionSettings.shared.baseURL else { return }
var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute"))
req.httpMethod = "POST"
req.timeoutInterval = 10
let (data, _) = try await URLSession.shared.data(for: req)
let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started"
await MainActor.run {
analyzeStatus = .done
analyzeMessage = "\(msg)"
}
}
} catch {
await MainActor.run {
analyzeStatus = .failed
analyzeMessage = "\(error.localizedDescription)"
}
}
// Reset after 5s
try? await Task.sleep(for: .seconds(5))
await MainActor.run {
if analyzeStatus != .analyzing {
analyzeStatus = .idle
analyzeMessage = nil
}
}
}
}
private func triggerVisPrecompute() {
analyzeStatus = .analyzing
analyzeMessage = "Generating visualizer frames on server..."
Task {
do {
guard let base = CompanionSettings.shared.baseURL else { return }
var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute"))
req.httpMethod = "POST"
req.timeoutInterval = 10
let (data, _) = try await URLSession.shared.data(for: req)
let msg = (try? JSONDecoder().decode([String:String].self, from: data))?["message"] ?? "Started"
await MainActor.run {
analyzeStatus = .done
analyzeMessage = "\(msg)"
}
} catch {
await MainActor.run {
analyzeStatus = .failed
analyzeMessage = "\(error.localizedDescription)"
}
}
try? await Task.sleep(for: .seconds(5))
await MainActor.run {
if analyzeStatus != .analyzing {
analyzeStatus = .idle
analyzeMessage = nil
}
}
}
}
// MARK: - Test Connection
private func testConnection() {
testStatus = .testing
Task {
do {
let api = CompanionAPIService()
let ok = try await api.healthCheck()
await MainActor.run { testStatus = ok ? .success : .failed("Unhealthy") }
} catch let error as URLError {
await MainActor.run {
testStatus = .failed(error.code == .timedOut ? "Timeout" : error.localizedDescription)
}
} catch {
await MainActor.run { testStatus = .failed(error.localizedDescription) }
}
try? await Task.sleep(for: .seconds(3))
await MainActor.run {
if case .success = testStatus { testStatus = .idle }
if case .failed = testStatus { testStatus = .idle }
}
}
}
}

View file

@ -0,0 +1,336 @@
import SwiftUI
/// Batch-edit tags across one or more albums.
/// Fetches full album data for each ID, then applies changes to all tracks.
struct MultiAlbumEditorSheet: View {
let albumIds: [String]
var onDismiss: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var serverManager: ServerManager
@State private var albums: [AlbumWithSongs] = []
@State private var isLoadingAlbums = true
@State private var albumName: String = ""
@State private var albumArtist: String = ""
@State private var artist: String = ""
@State private var genre: String = ""
@State private var year: String = ""
@State private var applyArtistToAll = false
@State private var isSaving = false
@State private var progress: Double = 0
@State private var resultMessage: String?
@State private var failedTracks: [String] = []
@ObservedObject private var settings = CompanionSettings.shared
private let api = CompanionAPIService()
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var allSongs: [Song] {
albums.flatMap { $0.song ?? [] }
}
var body: some View {
NavigationStack {
Group {
if !settings.isEnabled {
noApiView
} else if isLoadingAlbums {
loadingView
} else {
editorForm
}
}
.navigationTitle(albumIds.count == 1 ? "Edit Album" : "Edit \(albumIds.count) Albums")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
onDismiss?()
dismiss()
}
.foregroundColor(.gray)
}
ToolbarItem(placement: .navigationBarTrailing) {
if settings.isEnabled && !isLoadingAlbums {
Button("Apply", action: applyBatch)
.fontWeight(.semibold)
.foregroundColor(accentPink)
.disabled(isSaving || albumName.isEmpty)
}
}
}
.task { await loadAlbums() }
}
}
// MARK: - Loading
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
.tint(accentPink)
Text("Loading \(albumIds.count) album\(albumIds.count == 1 ? "" : "s")...")
.font(.system(size: 14))
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var noApiView: some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 32))
.foregroundColor(.yellow)
Text("Companion API Required")
.font(.system(size: 16, weight: .medium))
Text("Enable the Companion API in Settings to edit tags.")
.font(.system(size: 13))
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
.padding(40)
}
// MARK: - Editor Form
private var editorForm: some View {
List {
// Scope summary
Section {
HStack {
Text("Albums")
.foregroundColor(.gray)
Spacer()
Text("\(albums.count)")
.foregroundColor(.white)
}
HStack {
Text("Total Tracks")
.foregroundColor(.gray)
Spacer()
Text("\(allSongs.count)")
.foregroundColor(.white)
}
// Show album names
ForEach(albums) { album in
HStack(spacing: 8) {
AsyncCoverArt(coverArtId: album.coverArt, size: 40)
.frame(width: 32, height: 32)
.cornerRadius(3)
VStack(alignment: .leading, spacing: 1) {
Text(album.name)
.font(.system(size: 13))
.foregroundColor(.white)
.lineLimit(1)
Text("\(album.artist ?? "Unknown") · \(album.song?.count ?? 0) tracks")
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
}
} header: {
Text("Selected Albums")
} footer: {
Text("Changes apply to ALL tracks across all selected albums.")
}
// Tag fields
Section {
editField("Album", text: $albumName)
editField("Album Artist", text: $albumArtist)
editField("Genre", text: $genre)
editField("Year", text: $year, keyboard: .numberPad)
} header: {
Text("Album Tags")
}
// Artist override
Section {
Toggle("Set same artist on all tracks", isOn: $applyArtistToAll)
.tint(accentPink)
if applyArtistToAll {
editField("Artist", text: $artist)
}
} header: {
Text("Artist Override")
} footer: {
if applyArtistToAll {
Text("Every track gets the same artist — useful for fixing compilations that split into per-artist albums.")
} else {
Text("Each track keeps its current artist.")
}
}
// Track preview
Section {
ForEach(allSongs, id: \.id) { song in
HStack(spacing: 8) {
Text("\(song.track ?? 0)")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.gray)
.frame(width: 20)
VStack(alignment: .leading, spacing: 1) {
Text(song.title)
.font(.system(size: 13))
.foregroundColor(.white)
.lineLimit(1)
Text(applyArtistToAll ? artist : (song.artist ?? ""))
.font(.system(size: 11))
.foregroundColor(applyArtistToAll ? accentPink : .gray)
.lineLimit(1)
}
Spacer()
if song.path != nil {
Image(systemName: "checkmark.circle")
.font(.system(size: 10))
.foregroundColor(.green.opacity(0.4))
}
}
}
} header: {
Text("\(allSongs.count) Tracks")
}
// Progress
if isSaving {
Section {
VStack(spacing: 8) {
ProgressView(value: progress)
.tint(accentPink)
Text("Updating \(Int(progress * Double(allSongs.count)))/\(allSongs.count)...")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
if let msg = resultMessage {
Section {
VStack(alignment: .leading, spacing: 4) {
Text(msg)
.font(.system(size: 13))
.foregroundColor(failedTracks.isEmpty ? .green : .yellow)
ForEach(failedTracks, id: \.self) { t in
Text("\(t)")
.font(.system(size: 11))
.foregroundColor(.red)
}
}
}
}
}
}
// MARK: - Load Albums
private func loadAlbums() async {
isLoadingAlbums = true
var loaded: [AlbumWithSongs] = []
for id in albumIds {
do {
if let album = try await serverManager.client.getAlbum(id: id) {
loaded.append(album)
}
} catch {
DebugLogger.shared.log("Failed to load album \(id): \(error.localizedDescription)", category: "Companion")
}
}
await MainActor.run {
albums = loaded
isLoadingAlbums = false
// Pre-fill from first album
if let first = loaded.first {
if albumName.isEmpty { albumName = first.name }
if albumArtist.isEmpty { albumArtist = first.artist ?? "" }
if artist.isEmpty { artist = first.artist ?? "" }
if genre.isEmpty { genre = first.genre ?? "" }
if year.isEmpty, let y = first.year { year = "\(y)" }
}
}
}
// MARK: - Apply Batch
private func applyBatch() {
let songs = allSongs
guard !songs.isEmpty else { return }
let paths = songs.compactMap { $0.path }
guard !paths.isEmpty else {
resultMessage = "No tracks have file paths"
return
}
isSaving = true
progress = 0
failedTracks = []
resultMessage = nil
Task {
do {
let request = BatchMetadataEditRequest(
relativePaths: paths,
title: nil,
artist: applyArtistToAll ? artist : nil,
album: albumName.isEmpty ? nil : albumName,
albumArtist: albumArtist.isEmpty ? nil : albumArtist,
genre: genre.isEmpty ? nil : genre,
year: Int(year)
)
DebugLogger.shared.log(
"Multi-album edit: \(paths.count) tracks across \(albums.count) albums, " +
"album='\(albumName)', albumArtist='\(albumArtist)', artist=\(applyArtistToAll ? "'\(artist)'" : "nil")",
category: "Companion"
)
await MainActor.run { progress = 0.3 }
let result = try await api.batchEditMetadata(request)
await MainActor.run {
progress = 1.0
isSaving = false
let succeeded = result.succeeded?.count ?? 0
let failed = result.failed ?? []
if failed.isEmpty {
resultMessage = "✓ All \(succeeded) tracks updated"
} else {
resultMessage = "\(succeeded)/\(paths.count) succeeded, \(failed.count) failed"
failedTracks = failed.map { "\($0.path): \($0.error)" }
}
}
} catch {
await MainActor.run {
isSaving = false
resultMessage = "Failed: \(error.localizedDescription)"
}
}
}
}
// MARK: - Helpers
private func editField(_ label: String, text: Binding<String>, keyboard: UIKeyboardType = .default) -> some View {
HStack {
Text(label)
.foregroundColor(.gray)
.frame(width: 100, alignment: .leading)
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
}
.font(.system(size: 14))
}
}

View file

@ -0,0 +1,487 @@
import Foundation
import AVFoundation
import Combine
import QuartzCore
// MARK: - Smart Crossfade Manager
// Dual-AVPlayer (A/B) crossfade engine.
//
// Profile fields (from Companion API):
// silence_start = where TRAILING silence begins (crossfade trigger point)
// silence_end = where LEADING silence ends (seek-to point for skip)
// loudness_lufs = integrated loudness for volume normalization
//
// Flow:
// 1. playSong() loads item, starts playback IMMEDIATELY, then fetches profile async
// 2. Profile arrives applies LUFS volume, installs boundary observer at silence_start - fade
// 3. Boundary fires beginCrossfade() fades A B via CADisplayLink
// 4. Fade completes swap slots, install new boundary, request next track
class SmartCrossfadeManager: ObservableObject {
static let shared = SmartCrossfadeManager()
// MARK: - Settings (persisted)
@Published var isEnabled = false {
didSet { UserDefaults.standard.set(isEnabled, forKey: "smart_crossfade_enabled") }
}
@Published var crossfadeDuration: TimeInterval = 5.0 {
didSet { UserDefaults.standard.set(crossfadeDuration, forKey: "smart_crossfade_duration") }
}
@Published var targetLUFS: Double = -14.0 {
didSet { UserDefaults.standard.set(targetLUFS, forKey: "smart_target_lufs") }
}
@Published var skipSilence: Bool = true {
didSet { UserDefaults.standard.set(skipSilence, forKey: "smart_skip_silence") }
}
// MARK: - State
@Published var currentProfile: SmartDJProfile?
@Published var nextProfile: SmartDJProfile?
@Published var isCrossfading = false
// MARK: - Dual Players
private(set) var playerA: AVPlayer = AVPlayer()
private(set) var playerB: AVPlayer = AVPlayer()
private var activeSlot: Slot = .a
var activePlayer: AVPlayer { activeSlot == .a ? playerA : playerB }
var standbyPlayer: AVPlayer { activeSlot == .a ? playerB : playerA }
enum Slot { case a, b }
// MARK: - Volume Targets (LUFS-normalized)
private var activeMaxVolume: Float = 1.0
private var standbyMaxVolume: Float = 1.0
// MARK: - Observers
private var boundaryObserver: Any?
private var timeObserver: Any?
private var statusObservation: NSKeyValueObservation?
// MARK: - Crossfade Animation
private var displayLink: CADisplayLink?
private var fadeStartTime: CFTimeInterval = 0
private var fadeOutStartVolume: Float = 1.0
private var fadeInTargetVolume: Float = 1.0
private var didHandoffVisualizer = false
// MARK: - Track Info (for self-crossfade)
private var currentSongURL: URL?
private var currentSong: Song?
// MARK: - Profile Cache
private var profiles: [String: SmartDJProfile] = [:]
private let api = CompanionAPIService()
// MARK: - Callbacks
var needsNextTrack: (() -> Void)?
var timeUpdate: ((TimeInterval, TimeInterval) -> Void)?
var visualizerHandoff: ((AVPlayer) -> Void)?
private init() {
isEnabled = UserDefaults.standard.bool(forKey: "smart_crossfade_enabled")
let dur = UserDefaults.standard.double(forKey: "smart_crossfade_duration")
crossfadeDuration = dur > 0 ? dur : 5.0
let lufs = UserDefaults.standard.double(forKey: "smart_target_lufs")
targetLUFS = lufs < 0 ? lufs : -14.0
skipSilence = UserDefaults.standard.object(forKey: "smart_skip_silence") as? Bool ?? true
}
//
// MARK: - PUBLIC API
//
/// Load and play a song on the active player.
/// Starts playback IMMEDIATELY profile adjustments arrive async.
func playSong(_ song: Song, url: URL) {
log("▶ Playing: \(song.title) on \(slotName(activeSlot))")
cancelCrossfade()
removeAllObservers()
currentSong = song
currentSongURL = url
// Load and play immediately don't wait for profile
let asset = AVURLAsset(url: url)
let item = AVPlayerItem(asset: asset)
activePlayer.replaceCurrentItem(with: item)
activePlayer.volume = 1.0
activeMaxVolume = 1.0
activePlayer.play()
// Install time observer right away for seek bar
installTimeObserver()
// Fetch profile async apply adjustments when it arrives
Task { @MainActor [weak self] in
guard let self else { return }
let profile = await self.fetchProfile(for: song)
self.currentProfile = profile
// Apply LUFS normalization
self.activeMaxVolume = self.lufsToVolume(profile?.loudnessLUFS)
// Only adjust volume if not already crossfading
if !self.isCrossfading {
self.activePlayer.volume = self.activeMaxVolume
}
// Skip leading silence (silenceEnd = where leading silence ends)
if self.skipSilence, let silEnd = profile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 {
let current = self.activePlayer.currentTime().seconds
// Only seek if we're still near the start
if current < silEnd + 1.0 {
await self.activePlayer.seek(to: CMTime(seconds: silEnd, preferredTimescale: 1000))
self.log("Skipped \(String(format: "%.1f", silEnd))s leading silence")
}
}
// Install boundary observer for crossfade trigger
self.installCrossfadeTrigger(profile: profile)
}
}
/// Pre-load the next song into standby (paused, vol 0, seeked past leading silence).
func prepareNext(_ song: Song, url: URL) {
log("⏳ Preparing: \(song.title) in \(slotName(activeSlot == .a ? .b : .a))")
let asset = AVURLAsset(url: url)
let item = AVPlayerItem(asset: asset)
standbyPlayer.replaceCurrentItem(with: item)
standbyPlayer.volume = 0.0
standbyPlayer.pause()
Task { @MainActor [weak self] in
guard let self else { return }
let profile = await self.fetchProfile(for: song)
self.nextProfile = profile
self.standbyMaxVolume = self.lufsToVolume(profile?.loudnessLUFS)
// Wait for item to be ready before seeking
if let silEnd = profile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 {
let seekTarget = CMTime(seconds: silEnd, preferredTimescale: 1000)
let standby = self.standbyPlayer
self.statusObservation = item.observe(\.status) { [weak self] observedItem, _ in
guard observedItem.status == .readyToPlay else { return }
DispatchQueue.main.async { [weak self] in
self?.statusObservation?.invalidate()
self?.statusObservation = nil
standby.seek(to: seekTarget)
}
}
}
}
}
func pause() {
activePlayer.pause()
if isCrossfading { standbyPlayer.pause() }
}
func resume() {
activePlayer.play()
if isCrossfading { standbyPlayer.play() }
}
func seek(to time: TimeInterval) {
let cmTime = CMTime(seconds: time, preferredTimescale: 1000)
activePlayer.seek(to: cmTime)
}
func stop() {
cancelCrossfade()
removeAllObservers()
playerA.pause()
playerA.replaceCurrentItem(with: nil)
playerB.pause()
playerB.replaceCurrentItem(with: nil)
activeSlot = .a
currentProfile = nil
nextProfile = nil
currentSong = nil
currentSongURL = nil
activeMaxVolume = 1.0
standbyMaxVolume = 1.0
}
//
// MARK: - LUFS Volume
//
private func lufsToVolume(_ lufs: Double?) -> Float {
guard let lufs = lufs, lufs < 0 else { return 1.0 }
let gainDB = targetLUFS - lufs
let linear = pow(10.0, gainDB / 20.0)
let clamped = Float(min(max(linear, 0.05), 1.0))
log("LUFS \(String(format: "%.1f", lufs)) → vol \(String(format: "%.3f", clamped))")
return clamped
}
//
// MARK: - CROSSFADE TRIGGER
//
/// Install a boundary time observer to trigger crossfade.
/// Uses silenceStart (trailing silence begin) as the end-of-content marker.
/// Falls back to duration - crossfadeDuration if no profile.
private func installCrossfadeTrigger(profile: SmartDJProfile?) {
removeBoundaryObserver()
guard let item = activePlayer.currentItem else { return }
let duration = item.duration.seconds
// If duration isn't ready yet, poll until it is
guard duration.isFinite, duration > 0 else {
let pollObs = activePlayer.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] _ in
guard let self else { return }
let dur = self.activePlayer.currentItem?.duration.seconds ?? 0
if dur.isFinite, dur > 0 {
// Duration ready remove poll, install real trigger
if let obs = self.boundaryObserver {
self.activePlayer.removeTimeObserver(obs)
self.boundaryObserver = nil
}
self.installCrossfadeTriggerWithDuration(dur, profile: profile)
}
}
boundaryObserver = pollObs
return
}
installCrossfadeTriggerWithDuration(duration, profile: profile)
}
private func installCrossfadeTriggerWithDuration(_ duration: TimeInterval, profile: SmartDJProfile?) {
// silenceStart = where trailing silence begins
let silenceStart = profile?.silenceStart ?? 0
let contentEnd: TimeInterval
// Valid if it's in the second half of the track
if silenceStart > duration * 0.5 && silenceStart <= duration {
contentEnd = silenceStart
} else {
// No valid trailing silence use full duration
contentEnd = duration
}
// Trigger = content end minus crossfade duration (min 1s from start)
let triggerTime = max(1.0, contentEnd - crossfadeDuration)
log("🎯 Trigger at \(String(format: "%.1f", triggerTime))s " +
"(content \(String(format: "%.1f", contentEnd))s, " +
"dur \(String(format: "%.1f", duration))s)")
let boundary = CMTime(seconds: triggerTime, preferredTimescale: 1000)
boundaryObserver = activePlayer.addBoundaryTimeObserver(
forTimes: [NSValue(time: boundary)],
queue: .main
) { [weak self] in
self?.beginCrossfade()
}
}
private func removeBoundaryObserver() {
if let obs = boundaryObserver {
activePlayer.removeTimeObserver(obs)
boundaryObserver = nil
}
}
//
// MARK: - TIME OBSERVER
//
private func installTimeObserver() {
removeTimeObserver()
let interval = CMTime(seconds: 0.1, preferredTimescale: 600)
timeObserver = activePlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
guard let self else { return }
// During crossfade past midpoint, report from the INCOMING player
// so the seek bar shows the new track's position, not -0:00
let reportPlayer: AVPlayer
if self.isCrossfading && self.didHandoffVisualizer {
reportPlayer = self.standbyPlayer
} else {
reportPlayer = self.activePlayer
}
let current = reportPlayer.currentTime().seconds
guard current.isFinite else { return }
let dur = reportPlayer.currentItem?.duration.seconds ?? 0
if dur.isFinite, dur > 0 {
self.timeUpdate?(current, dur)
}
}
}
private func removeTimeObserver() {
if let obs = timeObserver {
activePlayer.removeTimeObserver(obs)
timeObserver = nil
}
}
private func removeAllObservers() {
removeBoundaryObserver()
removeTimeObserver()
statusObservation?.invalidate()
statusObservation = nil
}
//
// MARK: - CROSSFADE EXECUTION
//
private func beginCrossfade() {
guard !isCrossfading else { return }
// If standby has nothing, load self-crossfade
if standbyPlayer.currentItem == nil {
guard let url = currentSongURL else {
log("⚠️ No standby and no current URL")
return
}
log("🔁 No next track — self-crossfade")
let asset = AVURLAsset(url: url)
let item = AVPlayerItem(asset: asset)
standbyPlayer.replaceCurrentItem(with: item)
standbyMaxVolume = activeMaxVolume
nextProfile = currentProfile
// Seek past leading silence
if skipSilence, let silEnd = currentProfile?.silenceEnd, silEnd > 0.3, silEnd < 10.0 {
standbyPlayer.seek(to: CMTime(seconds: silEnd, preferredTimescale: 1000))
}
}
log("🔀 BEGIN (\(String(format: "%.1f", crossfadeDuration))s)")
isCrossfading = true
didHandoffVisualizer = false
fadeOutStartVolume = activePlayer.volume
fadeInTargetVolume = standbyMaxVolume
standbyPlayer.volume = 0.0
standbyPlayer.play()
fadeStartTime = CACurrentMediaTime()
displayLink = CADisplayLink(target: self, selector: #selector(crossfadeTick))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func crossfadeTick() {
let elapsed = CACurrentMediaTime() - fadeStartTime
let progress = Float(min(elapsed / crossfadeDuration, 1.0))
// Equal-power crossfade
let fadeOut = cosf(progress * .pi / 2.0)
let fadeIn = sinf(progress * .pi / 2.0)
activePlayer.volume = fadeOutStartVolume * fadeOut
standbyPlayer.volume = fadeInTargetVolume * fadeIn
if progress >= 0.5 && !didHandoffVisualizer {
didHandoffVisualizer = true
visualizerHandoff?(standbyPlayer)
}
if progress >= 1.0 {
finalizeCrossfade()
}
}
private func finalizeCrossfade() {
displayLink?.invalidate()
displayLink = nil
let outgoing = activePlayer
outgoing.pause()
outgoing.replaceCurrentItem(with: nil)
removeAllObservers()
activeSlot = (activeSlot == .a) ? .b : .a
currentProfile = nextProfile
nextProfile = nil
activeMaxVolume = standbyMaxVolume
standbyMaxVolume = 1.0
activePlayer.volume = activeMaxVolume
isCrossfading = false
log("🔀 DONE → \(slotName(activeSlot))")
installTimeObserver()
installCrossfadeTrigger(profile: currentProfile)
needsNextTrack?()
}
private func cancelCrossfade() {
displayLink?.invalidate()
displayLink = nil
isCrossfading = false
didHandoffVisualizer = false
}
//
// MARK: - PROFILE FETCHING
//
private func fetchProfile(for song: Song) async -> SmartDJProfile? {
guard CompanionSettings.shared.smartDJEnabled else { return nil }
guard let path = song.path else { return nil }
if let cached = profiles[song.id] { return cached }
do {
let profile = try await api.fetchProfile(relativePath: path)
profiles[song.id] = profile
log("Profile: trailing=\(profile.silenceStart ?? 0)s, " +
"leading=\(profile.silenceEnd ?? 0)s, " +
"LUFS=\(profile.loudnessLUFS ?? 0)")
return profile
} catch {
log("Profile failed: \(error.localizedDescription)")
return nil
}
}
//
// MARK: - HELPERS
//
func resolveURL(for song: Song) -> URL? {
if let localURL = OfflineManager.shared.localURL(for: song.id) {
return localURL
}
let quality = StreamingQuality.shared
return ServerManager.shared.client.streamURL(
songId: song.id,
format: quality.format,
maxBitRate: quality.maxBitRate
)
}
private func slotName(_ slot: Slot) -> String {
slot == .a ? "A" : "B"
}
private func log(_ msg: String) {
DebugLogger.shared.log(msg, category: "SmartDJ")
}
}
// MARK: - Notification
extension Notification.Name {
static let smartDJNeedsNextTrack = Notification.Name("smartDJNeedsNextTrack")
}

View file

@ -0,0 +1,215 @@
import SwiftUI
struct TrackEditorView: View {
let song: Song
@Environment(\.dismiss) private var dismiss
@State private var title: String
@State private var artist: String
@State private var album: String
@State private var albumArtist: String
@State private var genre: String
@State private var year: String
@State private var trackNumber: String
@State private var discNumber: String
@State private var comment: String
@State private var isSaving = false
@State private var errorMessage: String?
@State private var showSuccess = false
@ObservedObject private var settings = CompanionSettings.shared
private let api = CompanionAPIService()
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
init(song: Song) {
self.song = song
_title = State(initialValue: song.title)
_artist = State(initialValue: song.artist ?? "")
_album = State(initialValue: song.album ?? "")
_albumArtist = State(initialValue: "")
_genre = State(initialValue: song.genre ?? "")
_year = State(initialValue: song.year != nil ? "\(song.year!)" : "")
_trackNumber = State(initialValue: song.track != nil ? "\(song.track!)" : "")
_discNumber = State(initialValue: song.discNumber != nil ? "\(song.discNumber!)" : "")
_comment = State(initialValue: "")
}
var body: some View {
NavigationStack {
List {
if !settings.isEnabled {
Section {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28))
.foregroundColor(.yellow)
Text("Companion API Required")
.font(.system(size: 15, weight: .medium))
Text("Configure the Companion API in Settings to edit track metadata remotely.")
.font(.system(size: 13))
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
} else {
// File info (read-only)
Section {
infoRow("File", song.path ?? song.id)
if let suffix = song.suffix {
infoRow("Format", suffix.uppercased())
}
if let bitRate = song.bitRate {
infoRow("Bit Rate", "\(bitRate) kbps")
}
} header: {
Text("File Info")
}
// Editable fields
Section {
editField("Title", text: $title)
editField("Artist", text: $artist)
editField("Album", text: $album)
editField("Album Artist", text: $albumArtist)
} header: {
Text("Basic Info")
}
Section {
editField("Genre", text: $genre)
editField("Year", text: $year, keyboard: .numberPad)
editField("Track #", text: $trackNumber, keyboard: .numberPad)
editField("Disc #", text: $discNumber, keyboard: .numberPad)
} header: {
Text("Details")
}
Section {
TextEditor(text: $comment)
.frame(minHeight: 60)
.font(.system(size: 14))
} header: {
Text("Comment")
}
// Error / Success
if let error = errorMessage {
Section {
Text(error)
.font(.system(size: 13))
.foregroundColor(.red)
}
}
}
}
.navigationTitle("Edit Tags")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
.foregroundColor(.gray)
}
ToolbarItem(placement: .navigationBarTrailing) {
if settings.isEnabled {
Button(action: save) {
if isSaving {
ProgressView()
.tint(accentPink)
} else {
Text("Save")
.fontWeight(.semibold)
.foregroundColor(accentPink)
}
}
.disabled(isSaving || song.path == nil)
}
}
}
.overlay {
if showSuccess {
VStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 44))
.foregroundColor(.green)
Text("Tags Updated")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
}
.padding(30)
.background(.ultraThinMaterial)
.cornerRadius(16)
.transition(.scale.combined(with: .opacity))
}
}
}
}
// MARK: - Save
private func save() {
guard let path = song.path else { return }
isSaving = true
errorMessage = nil
Task {
do {
let request = MetadataEditRequest(
relativePath: path,
title: title.isEmpty ? nil : title,
artist: artist.isEmpty ? nil : artist,
album: album.isEmpty ? nil : album
)
try await api.editMetadata(request)
await MainActor.run {
isSaving = false
withAnimation { showSuccess = true }
DebugLogger.shared.log("Tags updated: \(title)\(artist)", category: "Companion")
}
try? await Task.sleep(for: .seconds(1.2))
await MainActor.run {
dismiss()
}
} catch {
await MainActor.run {
isSaving = false
errorMessage = error.localizedDescription
DebugLogger.shared.log("Tag edit failed: \(error.localizedDescription)", category: "Companion")
}
}
}
}
// MARK: - Helpers
private func editField(_ label: String, text: Binding<String>, keyboard: UIKeyboardType = .default) -> some View {
HStack {
Text(label)
.foregroundColor(.gray)
.frame(width: 90, alignment: .leading)
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
}
.font(.system(size: 14))
}
private func infoRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.foregroundColor(.gray)
Spacer()
Text(value)
.foregroundColor(.white.opacity(0.7))
.lineLimit(1)
}
.font(.system(size: 13))
}
}

View file

@ -0,0 +1,362 @@
import Foundation
import UIKit
import Combine
import UniformTypeIdentifiers
// MARK: - Zip Import Manager
class ZipImportManager: NSObject, ObservableObject, URLSessionDataDelegate, URLSessionTaskDelegate {
static let shared = ZipImportManager()
// MARK: - Published State
@Published var isExtracting = false
@Published var extractionProgress: String = ""
@Published var extractedFiles: [ExtractedAudioFile] = []
@Published var uploadStates: [String: UploadFileState] = [:] // filename -> state
@Published var isUploading = false
@Published var uploadProgress: Double = 0 // 0..1
struct ExtractedAudioFile: Identifiable {
let id = UUID()
let url: URL
let filename: String
let fileSize: Int64
}
enum UploadFileState: Equatable {
case pending
case uploading(progress: Double)
case completed
case failed(String)
}
// MARK: - Internal
private let api = CompanionAPIService()
private let fileManager = FileManager.default
private var extractDir: URL?
private var backgroundSession: URLSession!
private var backgroundCompletionHandler: (() -> Void)?
private var activeUploads: [Int: String] = [:] // taskId -> filename
private var keepOfflineFiles: Set<String> = []
private var totalUploads = 0
private var completedUploads = 0
private static let backgroundSessionId = "com.navidromeplayer.batchupload"
static let audioExtensions: Set<String> = [
"mp3", "flac", "m4a", "aac", "ogg", "opus", "wav", "wma", "alac", "aiff", "ape", "wv"
]
private override init() {
super.init()
let config = URLSessionConfiguration.background(withIdentifier: Self.backgroundSessionId)
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
backgroundSession = URLSession(configuration: config, delegate: self, delegateQueue: .main)
}
/// Called by AppDelegate when background session completes
func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) {
backgroundCompletionHandler = handler
}
// MARK: - Extract Zip
func extractZip(at sourceURL: URL) {
isExtracting = true
extractionProgress = "Preparing..."
extractedFiles = []
uploadStates = [:]
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
do {
// Gain access to the security-scoped resource
let accessing = sourceURL.startAccessingSecurityScopedResource()
defer { if accessing { sourceURL.stopAccessingSecurityScopedResource() } }
// Create extraction directory
let tempDir = FileManager.default.temporaryDirectory
let extractDir = tempDir.appendingPathComponent("zip_extract_\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true)
await MainActor.run {
self.extractDir = extractDir
self.extractionProgress = "Extracting..."
}
// Copy zip to temp (fileImporter URLs may be transient)
let tempZip = tempDir.appendingPathComponent("import_\(UUID().uuidString).zip")
try FileManager.default.copyItem(at: sourceURL, to: tempZip)
// Use built-in Archive/unzip via Process or NSFileCoordinator
// iOS doesn't have /usr/bin/unzip use Apple's compression framework
try self.unzipFile(at: tempZip, to: extractDir)
// Clean up zip
try? FileManager.default.removeItem(at: tempZip)
// Find audio files recursively
let enumerator = FileManager.default.enumerator(at: extractDir, includingPropertiesForKeys: [.fileSizeKey])
var audioFiles: [ExtractedAudioFile] = []
while let fileURL = enumerator?.nextObject() as? URL {
let ext = fileURL.pathExtension.lowercased()
guard Self.audioExtensions.contains(ext) else { continue }
// Skip macOS resource fork junk
guard !fileURL.lastPathComponent.hasPrefix("._") else { continue }
guard !fileURL.path.contains("__MACOSX") else { continue }
let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey])
let size = Int64(attrs?.fileSize ?? 0)
audioFiles.append(ExtractedAudioFile(
url: fileURL,
filename: fileURL.lastPathComponent,
fileSize: size
))
}
audioFiles.sort { $0.filename.localizedCaseInsensitiveCompare($1.filename) == .orderedAscending }
let finalFiles = audioFiles
await MainActor.run {
self.extractedFiles = finalFiles
self.isExtracting = false
self.extractionProgress = finalFiles.isEmpty ? "No audio files found" : "\(finalFiles.count) tracks found"
// Initialize upload states
for file in finalFiles {
self.uploadStates[file.filename] = .pending
}
}
if finalFiles.isEmpty {
try? FileManager.default.removeItem(at: extractDir)
}
} catch {
await MainActor.run {
self.isExtracting = false
self.extractionProgress = "Extraction failed: \(error.localizedDescription)"
}
DebugLogger.shared.log("Zip extract failed: \(error.localizedDescription)", category: "Upload")
}
}
}
// MARK: - Zip Extraction (Foundation-based)
/// Extracts a zip file using FileManager's built-in support (iOS 16+)
/// Falls back to manual extraction via compression framework
private func unzipFile(at zipURL: URL, to destDir: URL) throws {
// Use Process/unzip if available, otherwise use Foundation
// On iOS, we'll use a simple approach: copy zip and use FileManager
// Actually, iOS doesn't have native unzip API we need to use
// the compress/decompress approach or bundle a library.
// For simplicity, use the fact that .zip can be opened as a read archive.
// Use Apple's built-in support via spawning an extraction coordinator
let coordinator = NSFileCoordinator()
var coordError: NSError?
var extractError: Error?
coordinator.coordinate(readingItemAt: zipURL, options: [.forUploading], error: &coordError) { url in
// The system gives us a temp URL we can work with
do {
// Try to move/copy the contents
let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil)
for item in contents {
let dest = destDir.appendingPathComponent(item.lastPathComponent)
try FileManager.default.copyItem(at: item, to: dest)
}
} catch {
extractError = error
}
}
if let error = coordError ?? extractError {
// Fallback: try treating the zip as a directory (works on some iOS versions)
// If that fails too, try a manual byte-level approach
let zipContents = try? FileManager.default.contentsOfDirectory(at: zipURL, includingPropertiesForKeys: nil)
if let contents = zipContents, !contents.isEmpty {
for item in contents {
let dest = destDir.appendingPathComponent(item.lastPathComponent)
try FileManager.default.copyItem(at: item, to: dest)
}
} else {
throw CompanionError.extractionFailed(error.localizedDescription)
}
}
}
// MARK: - Batch Upload
func startBatchUpload(
metadata: UploadMetadata,
keepOffline: Bool
) {
guard !extractedFiles.isEmpty else { return }
isUploading = true
uploadProgress = 0
totalUploads = extractedFiles.count
completedUploads = 0
keepOfflineFiles = keepOffline ? Set(extractedFiles.map { $0.filename }) : []
DebugLogger.shared.log("Starting batch upload: \(totalUploads) files, keepOffline=\(keepOffline)", category: "Upload")
for (index, file) in extractedFiles.enumerated() {
do {
// Per-file metadata with track number
var fileMeta = metadata
fileMeta.trackNumber = "\(index + 1)"
// Use filename (without extension) as title if title is empty
if fileMeta.title.isEmpty {
fileMeta.title = file.url.deletingPathExtension().lastPathComponent
}
let boundary = "Boundary-\(UUID().uuidString)"
let payloadURL = try api.buildMultipartPayloadFile(
fileURL: file.url,
metadata: fileMeta,
boundary: boundary
)
let uploadURL = try api.uploadURL()
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
let task = backgroundSession.uploadTask(with: request, fromFile: payloadURL)
activeUploads[task.taskIdentifier] = file.filename
uploadStates[file.filename] = .uploading(progress: 0)
task.resume()
DebugLogger.shared.log("Queued upload: \(file.filename) (task \(task.taskIdentifier))", category: "Upload")
} catch {
uploadStates[file.filename] = .failed(error.localizedDescription)
completedUploads += 1
DebugLogger.shared.log("Upload prep failed: \(file.filename)\(error.localizedDescription)", category: "Upload")
}
}
}
// MARK: - URLSessionTaskDelegate
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
guard let filename = activeUploads[task.taskIdentifier] else { return }
let progress = totalBytesExpectedToSend > 0 ? Double(totalBytesSent) / Double(totalBytesExpectedToSend) : 0
uploadStates[filename] = .uploading(progress: progress)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let filename = activeUploads.removeValue(forKey: task.taskIdentifier) else { return }
if let error = error {
uploadStates[filename] = .failed(error.localizedDescription)
DebugLogger.shared.log("Upload failed: \(filename)\(error.localizedDescription)", category: "Upload")
} else if let httpResponse = task.response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) {
uploadStates[filename] = .completed
DebugLogger.shared.log("Upload completed: \(filename)", category: "Upload")
// Post-upload: keep offline or delete
handlePostUpload(filename: filename)
} else {
let code = (task.response as? HTTPURLResponse)?.statusCode ?? 0
uploadStates[filename] = .failed("HTTP \(code)")
DebugLogger.shared.log("Upload failed: \(filename) — HTTP \(code)", category: "Upload")
}
completedUploads += 1
uploadProgress = totalUploads > 0 ? Double(completedUploads) / Double(totalUploads) : 1
if completedUploads >= totalUploads {
isUploading = false
cleanupTempFiles()
DebugLogger.shared.log("Batch upload complete", category: "Upload")
}
}
func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
DebugLogger.shared.log("Background session invalidated: \(error?.localizedDescription ?? "nil")", category: "Upload")
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DebugLogger.shared.log("Background session finished all events", category: "Upload")
backgroundCompletionHandler?()
backgroundCompletionHandler = nil
}
// MARK: - Post-Upload Logic
private func handlePostUpload(filename: String) {
guard let file = extractedFiles.first(where: { $0.filename == filename }) else { return }
if keepOfflineFiles.contains(filename) {
// Move to Documents for offline playback
let offlineDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
.appendingPathComponent("OfflineMusic", isDirectory: true)
try? fileManager.createDirectory(at: offlineDir, withIntermediateDirectories: true)
let dest = offlineDir.appendingPathComponent(filename)
try? fileManager.moveItem(at: file.url, to: dest)
DebugLogger.shared.log("Kept offline: \(filename)", category: "Upload")
} else {
// Delete extracted file server is source of truth
try? fileManager.removeItem(at: file.url)
}
// Always clean up the multipart payload temp file
cleanupPayloadFile(for: filename)
}
private func cleanupPayloadFile(for filename: String) {
let tempDir = fileManager.temporaryDirectory
if let files = try? fileManager.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil) {
for file in files where file.lastPathComponent.hasPrefix("upload_") && file.pathExtension == "multipart" {
try? fileManager.removeItem(at: file)
}
}
}
// MARK: - Cleanup
private func cleanupTempFiles() {
// Clean extraction directory
if let dir = extractDir {
try? fileManager.removeItem(at: dir)
extractDir = nil
}
// Clean any leftover multipart payloads
let tempDir = fileManager.temporaryDirectory
if let files = try? fileManager.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil) {
for file in files {
if file.lastPathComponent.hasPrefix("upload_") || file.lastPathComponent.hasPrefix("zip_extract_") {
try? fileManager.removeItem(at: file)
}
}
}
}
func cancelAll() {
backgroundSession.getTasksWithCompletionHandler { _, uploads, _ in
for task in uploads { task.cancel() }
}
activeUploads.removeAll()
isUploading = false
cleanupTempFiles()
}
func reset() {
cancelAll()
extractedFiles = []
uploadStates = [:]
uploadProgress = 0
extractionProgress = ""
}
}

View file

@ -0,0 +1,638 @@
import SwiftUI
import PhotosUI
struct AlbumDetailView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var offlineManager: OfflineManager
@ObservedObject private var watchManager = WatchConnectivityManager.shared
@ObservedObject private var albumCoverStore = AlbumCoverStore.shared
@ObservedObject private var libraryCache = LibraryCache.shared
let albumId: String
@State private var album: AlbumWithSongs?
@State private var isLoading = true
@State private var isDownloading = false
@State private var showPlaylistPicker = false
@State private var playlistPickerSongId: String?
@State private var availablePlaylists: [Playlist] = []
@State private var showGetInfo = false
@State private var getInfoSong: Song?
@State private var showCoverPicker = false
@State private var selectedCoverPhoto: PhotosPickerItem?
@State private var showTrackEditor = false
@State private var trackEditorSong: Song?
@State private var showBatchEditor = false
@State private var loadFailed = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
ScrollView {
if let album = album {
VStack(spacing: 0) {
// Album header - iOS 8 style
albumHeader(album)
// Song list
songList(album)
// Bottom spacing
Color.clear.frame(height: 120)
}
} else if loadFailed {
VStack(spacing: 16) {
Image(systemName: "wifi.slash")
.font(.system(size: 32))
.foregroundColor(.gray)
Text("Couldn't load album")
.font(.system(size: 15))
.foregroundColor(.gray)
Button(action: { Task { await loadAlbum() } }) {
Text("Retry")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
}
}
.padding(.top, 100)
} else if isLoading {
ProgressView()
.tint(accentPink)
.padding(.top, 100)
}
}
.background(Color(white: 0.06))
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showPlaylistPicker) {
AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists)
}
.sheet(isPresented: $showGetInfo) {
if let song = getInfoSong {
SongInfoSheet(song: song)
}
}
.sheet(isPresented: $showTrackEditor) {
if let song = trackEditorSong {
TrackEditorView(song: song)
}
}
.sheet(isPresented: $showBatchEditor) {
if let album = album {
BatchAlbumEditorSheet(album: album)
}
}
.photosPicker(
isPresented: $showCoverPicker,
selection: $selectedCoverPhoto,
matching: .images,
photoLibrary: .shared()
)
.onChange(of: selectedCoverPhoto) { _, newItem in
guard let item = newItem, let coverArtId = album?.coverArt else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
let resized = resizeAlbumCover(image, maxSize: 600)
albumCoverStore.saveCover(resized, for: coverArtId)
}
selectedCoverPhoto = nil
}
}
.task {
await loadAlbum()
}
}
// MARK: - Album Header
private func albumHeader(_ album: AlbumWithSongs) -> some View {
VStack(spacing: 12) {
// Album art long press for cover options
AsyncCoverArt(
coverArtId: album.coverArt,
size: 260
)
.frame(width: 220, height: 220)
.cornerRadius(6)
.shadow(color: .black.opacity(0.4), radius: 12, y: 6)
.padding(.top, 20)
.contextMenu {
Button(action: { showCoverPicker = true }) {
Label("Choose Cover Art...", systemImage: "photo.on.rectangle")
}
if CompanionSettings.shared.isEnabled {
Button(action: { showBatchEditor = true }) {
Label("Edit Album Tags...", systemImage: "tag")
}
}
if let coverArtId = album.coverArt, albumCoverStore.hasCover(for: coverArtId) {
Button(role: .destructive, action: {
albumCoverStore.removeCover(for: coverArtId)
}) {
Label("Restore Original Cover", systemImage: "arrow.uturn.backward")
}
}
}
// Album info
Text(album.name)
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
.multilineTextAlignment(.center)
Text(album.artist ?? "Unknown Artist")
.font(.system(size: 15))
.foregroundColor(accentPink)
HStack(spacing: 4) {
Text(album.genre ?? "")
if album.year != nil {
Text("")
Text(String(album.year!))
}
}
.font(.system(size: 13))
.foregroundColor(.gray)
// Action buttons row
HStack(spacing: 10) {
// Play button
Button(action: { playAlbum(album, shuffle: false) }) {
HStack(spacing: 6) {
Image(systemName: "play.fill")
Text("Play")
}
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(accentPink)
.foregroundColor(.white)
.cornerRadius(8)
}
// Shuffle button
Button(action: { playAlbum(album, shuffle: true) }) {
HStack(spacing: 6) {
Image(systemName: "shuffle")
Text("Shuffle")
}
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.white.opacity(0.12))
.foregroundColor(.white)
.cornerRadius(8)
}
// Download button (red = not downloaded, green = downloaded)
Button(action: { downloadAlbum(album) }) {
Image(systemName: isAlbumDownloaded(album) ? "checkmark.circle.fill" : "arrow.down.circle")
.font(.system(size: 20))
.foregroundColor(isAlbumDownloaded(album) ? .green : .red)
}
.frame(width: 38, height: 38)
.background(Color.white.opacity(0.12))
.cornerRadius(8)
// Watch button (red = not synced, green = synced)
if WatchConnectivityManager.shared.isWatchAvailable {
Button(action: { sendAlbumToWatch(album) }) {
Image(systemName: "applewatch")
.font(.system(size: 17))
.foregroundColor(isAlbumOnWatch(album) ? .green : .red)
}
.frame(width: 38, height: 38)
.background(Color.white.opacity(0.12))
.cornerRadius(8)
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
Divider()
.background(Color.white.opacity(0.1))
.padding(.top, 12)
}
}
// MARK: - Song List
private func songList(_ album: AlbumWithSongs) -> some View {
LazyVStack(spacing: 0) {
ForEach(Array((album.song ?? []).enumerated()), id: \.element.id) { index, song in
let available = isSongAvailable(song)
let dlState = offlineManager.downloads[song.id]
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
HStack(spacing: 14) {
// Track number with download overlay
ZStack {
Text("\(song.track ?? (index + 1))")
.font(.system(size: 15))
.foregroundColor(available ? .gray : .gray.opacity(0.3))
if case .downloading(let progress) = dlState {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 2)
.frame(width: 22, height: 22)
Circle()
.trim(from: 0, to: progress)
.stroke(accentPink, style: StrokeStyle(lineWidth: 2, lineCap: .round))
.frame(width: 22, height: 22)
.rotationEffect(.degrees(-90))
} else if case .queued = dlState {
Circle()
.stroke(accentPink.opacity(0.3), lineWidth: 2)
.frame(width: 22, height: 22)
}
}
.frame(width: 24, alignment: .center)
// Song title + progress bar (tappable area)
VStack(alignment: .leading, spacing: 3) {
Text(song.title)
.font(.system(size: 16))
.foregroundColor(
!available ? .gray.opacity(0.35) :
audioPlayer.currentSong?.id == song.id ? accentPink : .white
)
.lineLimit(1)
if let artist = song.artist, artist != album.artist {
Text(artist)
.font(.system(size: 12))
.foregroundColor(.gray)
}
if case .downloading(let progress) = dlState {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08)).frame(height: 2)
Capsule().fill(accentPink)
.frame(width: geo.size.width * progress, height: 2)
}
}
.frame(height: 2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
if available { playSong(song, from: album) }
}
// Status indicators
HStack(spacing: 6) {
if isOnWatch {
Image(systemName: "applewatch")
.font(.system(size: 10))
.foregroundColor(.blue.opacity(0.7))
}
if isDownloaded {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 12))
.foregroundColor(.green.opacity(0.6))
}
}
// Duration
Text(song.durationFormatted)
.font(.system(size: 14))
.foregroundColor(.gray)
// More button standalone Menu, NOT inside a Button
Menu {
Button(action: { audioPlayer.playNow(song) }) {
Label("Play Now", systemImage: "play.fill")
}
Button(action: { audioPlayer.playNext(song) }) {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
Button(action: { audioPlayer.playLater(song) }) {
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
}
Divider()
Button(action: { audioPlayer.playInstantMix(basedOn: song) }) {
Label("Instant Mix", systemImage: "wand.and.stars")
}
Button(action: {
playlistPickerSongId = song.id
Task {
availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? []
showPlaylistPicker = true
}
}) {
Label("Add to Playlist...", systemImage: "text.badge.plus")
}
Divider()
if offlineManager.isSongDownloaded(song.id) {
Button(role: .destructive, action: {
offlineManager.removeSong(song.id)
}) {
Label("Remove Download", systemImage: "trash")
}
if WatchConnectivityManager.shared.isWatchAvailable {
Button(action: {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}) {
Label("Send to Watch", systemImage: "applewatch.and.arrow.forward")
}
}
} else {
Button(action: {
if let server = serverManager.activeServer {
offlineManager.downloadSong(song, server: server)
}
}) {
Label("Download", systemImage: "arrow.down.circle")
}
}
Button(action: {
Task {
if song.starred != nil {
try? await serverManager.client.unstar(id: song.id)
} else {
try? await serverManager.client.star(id: song.id)
}
}
}) {
Label(song.starred != nil ? "Unstar" : "Star", systemImage: song.starred != nil ? "heart.slash" : "heart")
}
Divider()
Button(action: {
getInfoSong = song
showGetInfo = true
}) {
Label("Get Info", systemImage: "info.circle")
}
if CompanionSettings.shared.isEnabled {
Button(action: {
trackEditorSong = song
showTrackEditor = true
}) {
Label("Edit Tags", systemImage: "tag")
}
}
} label: {
Image(systemName: "ellipsis")
.foregroundColor(.gray)
.frame(width: 30, height: 30)
.contentShape(Rectangle())
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
if index < (album.song?.count ?? 0) - 1 {
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 54)
}
}
}
}
// MARK: - Actions
private func playAlbum(_ album: AlbumWithSongs, shuffle: Bool) {
guard let songs = album.song, !songs.isEmpty else { return }
if shuffle { audioPlayer.shuffleEnabled = true }
audioPlayer.play(song: songs[0], fromQueue: songs)
}
private func playSong(_ song: Song, from album: AlbumWithSongs) {
guard let songs = album.song else { return }
let idx = songs.firstIndex(where: { $0.id == song.id }) ?? 0
audioPlayer.play(song: song, fromQueue: songs, at: idx)
}
private func downloadAlbum(_ album: AlbumWithSongs) {
guard let server = serverManager.activeServer else { return }
offlineManager.downloadAlbum(album, server: server)
}
private func isAlbumDownloaded(_ album: AlbumWithSongs) -> Bool {
guard let songs = album.song else { return false }
return songs.allSatisfy { offlineManager.isSongDownloaded($0.id) }
}
private func isAlbumOnWatch(_ album: AlbumWithSongs) -> Bool {
guard let songs = album.song, !songs.isEmpty else { return false }
return songs.allSatisfy { WatchConnectivityManager.shared.isSongOnWatch($0.id) }
}
private func sendAlbumToWatch(_ album: AlbumWithSongs) {
guard let songs = album.song else { return }
// Download first if needed, then send each song
for song in songs {
if offlineManager.isSongDownloaded(song.id) {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
} else if let server = serverManager.activeServer {
offlineManager.downloadSong(song, server: server)
// Queue watch send after download completes
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
if self.offlineManager.isSongDownloaded(song.id) {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}
}
}
}
}
private func loadAlbum() async {
let cache = LibraryCache.shared
loadFailed = false
// 1. Show cached version instantly no spinner if we have data
if album == nil, let cached = cache.loadAlbumDetail(id: albumId) {
album = cached
isLoading = false
}
// 2. Fetch from server in background
do {
if let result = try await serverManager.client.getAlbum(id: albumId) {
cache.cacheAlbumDetail(result)
await MainActor.run {
self.album = result
self.isLoading = false
}
} else if album == nil {
await retryAlbumLoad()
} else {
await MainActor.run { self.isLoading = false }
}
} catch {
if album == nil {
await retryAlbumLoad()
} else {
await MainActor.run { self.isLoading = false }
}
}
}
private func retryAlbumLoad() async {
// Wait for server connection to establish
for _ in 0..<10 {
if serverManager.connectionState == .connected { break }
try? await Task.sleep(for: .milliseconds(500))
}
do {
if let result = try await serverManager.client.getAlbum(id: albumId) {
LibraryCache.shared.cacheAlbumDetail(result)
await MainActor.run {
self.album = result
self.isLoading = false
}
return
}
} catch { }
await MainActor.run {
self.isLoading = false
self.loadFailed = true
}
}
/// Whether a song is available (downloaded or online)
private func isSongAvailable(_ song: Song) -> Bool {
if offlineManager.isSongDownloaded(song.id) { return true }
return !libraryCache.isOffline
}
/// Resize image to max dimension while preserving aspect ratio
private func resizeAlbumCover(_ image: UIImage, maxSize: CGFloat) -> UIImage {
let size = image.size
let scale = min(maxSize / size.width, maxSize / size.height)
guard scale < 1 else { return image }
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}
// MARK: - Album Grid View (for "more" navigation)
struct AlbumGridView: View {
@EnvironmentObject var serverManager: ServerManager
let title: String
let type: String
var genre: String? = nil
@State private var albums: [Album] = []
@State private var isLoading = true
@State private var searchText = ""
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private let columns = [
GridItem(.adaptive(minimum: 160), spacing: 14)
]
private var filtered: [Album] {
let deduped = deduplicateAlbums(albums)
return searchText.isEmpty ? deduped : deduped.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
}
}
private func deduplicateAlbums(_ albums: [Album]) -> [Album] {
var seen = Set<String>()
var result: [Album] = []
for album in albums {
let key = "\(album.name)|\(album.coverArt ?? "")"
if seen.contains(key) { continue }
seen.insert(key)
let sameAlbum = albums.filter { "\($0.name)|\($0.coverArt ?? "")" == key }
if sameAlbum.count > 1 {
result.append(Album(
id: album.id, name: album.name,
artist: "Various Artists", artistId: nil,
coverArt: album.coverArt, songCount: album.songCount,
duration: album.duration, playCount: album.playCount,
created: album.created, starred: album.starred,
year: album.year, genre: album.genre
))
} else {
result.append(album)
}
}
return result
}
var body: some View {
VStack(spacing: 0) {
// Search
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundColor(.gray).font(.system(size: 14))
TextField("Search albums...", text: $searchText)
.font(.system(size: 15)).foregroundColor(.white).autocorrectionDisabled()
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill").foregroundColor(.gray).font(.system(size: 14))
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.vertical, 8)
ScrollView {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(filtered) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverArt(coverArtId: album.coverArt, size: 180)
.frame(height: 160)
.cornerRadius(4)
Text(album.name)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
}
.padding(16)
}
}
.background(Color(white: 0.06))
.navigationTitle(title)
.task {
do {
let result = try await serverManager.client.getAlbumList2(type: type, size: 200, genre: genre)
await MainActor.run {
albums = result
isLoading = false
}
} catch {
isLoading = false
}
}
}
}

View file

@ -0,0 +1,292 @@
import SwiftUI
// MARK: - Artist Detail View
struct ArtistDetailView: View {
@EnvironmentObject var serverManager: ServerManager
let artistId: String
@State private var artist: ArtistWithAlbums?
@State private var isLoading = true
@State private var loadFailed = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private let cache = LibraryCache.shared
var body: some View {
ScrollView {
if let artist = artist {
VStack(spacing: 0) {
// Artist header
VStack(spacing: 12) {
AsyncCoverArt(
coverArtId: artist.coverArt,
size: 200
)
.frame(width: 160, height: 160)
.clipShape(Circle())
.shadow(color: .black.opacity(0.4), radius: 12)
.padding(.top, 20)
Text(artist.name)
.font(.system(size: 24, weight: .bold))
.foregroundColor(.white)
Text("\(artist.albumCount ?? artist.album?.count ?? 0) Albums")
.font(.system(size: 14))
.foregroundColor(.gray)
}
.padding(.bottom, 24)
// Albums
LazyVStack(spacing: 0) {
ForEach(artist.album ?? []) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
HStack(spacing: 14) {
AsyncCoverArt(
coverArtId: album.coverArt,
size: 60
)
.frame(width: 56, height: 56)
.cornerRadius(4)
VStack(alignment: .leading, spacing: 3) {
Text(album.name)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
HStack(spacing: 4) {
if let year = album.year {
Text(String(year))
}
if let count = album.songCount {
Text("\(count) songs")
}
}
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 86)
}
}
Color.clear.frame(height: 120)
}
} else if loadFailed {
// Connection failed show retry
VStack(spacing: 16) {
Image(systemName: "wifi.slash")
.font(.system(size: 32))
.foregroundColor(.gray)
Text("Couldn't load artist")
.font(.system(size: 15))
.foregroundColor(.gray)
Button(action: { Task { await loadArtist() } }) {
Text("Retry")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
}
}
.padding(.top, 100)
} else if isLoading {
ProgressView()
.tint(accentPink)
.padding(.top, 100)
}
}
.background(Color(white: 0.06))
.navigationBarTitleDisplayMode(.inline)
.task { await loadArtist() }
}
private func loadArtist() async {
loadFailed = false
// 1. Load from cache instantly no spinner if we have data
if artist == nil, let cached = cache.loadArtistDetail(id: artistId) {
artist = cached
isLoading = false
}
// 2. Fetch from server in background
do {
if let fetched = try await serverManager.client.getArtist(id: artistId) {
cache.cacheArtistDetail(fetched)
await MainActor.run {
self.artist = fetched
self.isLoading = false
}
} else if artist == nil {
// Server returned nil and no cache wait for connection and retry
await retryAfterConnection()
} else {
isLoading = false
}
} catch {
if artist == nil {
// No cache, fetch failed try waiting for connection
await retryAfterConnection()
} else {
// We have cache, just stop loading
isLoading = false
}
}
}
/// Wait for server connection to establish, then retry once
private func retryAfterConnection() async {
// If server is still connecting, wait up to 5 seconds
for _ in 0..<10 {
if serverManager.connectionState == .connected { break }
try? await Task.sleep(for: .milliseconds(500))
}
// One more attempt
do {
if let fetched = try await serverManager.client.getArtist(id: artistId) {
cache.cacheArtistDetail(fetched)
await MainActor.run {
self.artist = fetched
self.isLoading = false
}
return
}
} catch { }
// Truly failed
await MainActor.run {
self.isLoading = false
self.loadFailed = true
}
}
}
// MARK: - Genre Detail View
struct GenreDetailView: View {
@EnvironmentObject var serverManager: ServerManager
let genre: Genre
@State private var albums: [Album] = []
@State private var isLoading = true
@State private var loadFailed = false
private let columns = [
GridItem(.adaptive(minimum: 160), spacing: 14)
]
var body: some View {
ScrollView {
if !albums.isEmpty {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(albums) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverArt(coverArtId: album.coverArt, size: 180)
.frame(height: 160)
.cornerRadius(4)
Text(album.name)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
}
.padding(16)
Color.clear.frame(height: 120)
} else if loadFailed {
VStack(spacing: 16) {
Image(systemName: "wifi.slash")
.font(.system(size: 32))
.foregroundColor(.gray)
Text("Couldn't load genre")
.font(.system(size: 15))
.foregroundColor(.gray)
Button(action: { Task { await loadGenre() } }) {
Text("Retry")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333))
}
}
.padding(.top, 100)
} else if isLoading {
ProgressView()
.tint(Color(red: 1.0, green: 0.176, blue: 0.333))
.padding(.top, 100)
}
}
.background(Color(white: 0.06))
.navigationTitle(genre.value)
.task { await loadGenre() }
}
private func loadGenre() async {
let cache = LibraryCache.shared
loadFailed = false
// Cache first
if albums.isEmpty, let cached = cache.load([Album].self, key: "genre_\(genre.value)") {
albums = cached
isLoading = false
}
// Server fetch
do {
let result = try await serverManager.client.getAlbumList2(
type: "byGenre", size: 100, genre: genre.value
)
cache.save(result, key: "genre_\(genre.value)")
await MainActor.run {
self.albums = result
self.isLoading = false
}
} catch {
if albums.isEmpty {
// Wait for connection
for _ in 0..<10 {
if serverManager.connectionState == .connected { break }
try? await Task.sleep(for: .milliseconds(500))
}
do {
let result = try await serverManager.client.getAlbumList2(
type: "byGenre", size: 100, genre: genre.value
)
cache.save(result, key: "genre_\(genre.value)")
await MainActor.run {
self.albums = result
self.isLoading = false
}
} catch {
await MainActor.run {
self.isLoading = false
self.loadFailed = true
}
}
} else {
await MainActor.run { self.isLoading = false }
}
}
}
}

View file

@ -0,0 +1,931 @@
import SwiftUI
import Network
// MARK: - Downloads View
struct DownloadsView: View {
@EnvironmentObject var offlineManager: OfflineManager
@EnvironmentObject var audioPlayer: AudioPlayer
@ObservedObject private var watchManager = WatchConnectivityManager.shared
@State private var selectedTab: DownloadTab = .offline
enum DownloadTab: String, CaseIterable {
case offline = "Offline"
case watch = "Watch"
}
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Tab picker
Picker("", selection: $selectedTab) {
ForEach(DownloadTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 16)
.padding(.vertical, 10)
switch selectedTab {
case .offline:
offlineTab
case .watch:
watchTab
}
}
.background(Color(white: 0.06))
.navigationTitle("Downloads")
}
}
// MARK: - Offline Tab
private var offlineTab: some View {
List {
// Storage info
Section {
HStack {
Image(systemName: "internaldrive")
.foregroundColor(accentPink)
Text("Storage Used")
Spacer()
Text(offlineManager.formattedSize)
.foregroundColor(.gray)
}
HStack {
Image(systemName: "music.note")
.foregroundColor(accentPink)
Text("Downloaded Songs")
Spacer()
Text("\(offlineManager.downloadedSongs.count)")
.foregroundColor(.gray)
}
}
// Downloaded songs
Section("Offline Songs") {
if offlineManager.downloadedSongs.isEmpty {
VStack(spacing: 12) {
Image(systemName: "arrow.down.circle")
.font(.system(size: 36))
.foregroundColor(.gray)
Text("No downloads yet")
.font(.system(size: 14))
.foregroundColor(.gray)
Text("Download songs from albums or playlists to listen offline")
.font(.system(size: 12))
.foregroundColor(.gray.opacity(0.7))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else {
ForEach(offlineManager.downloadedSongs) { downloaded in
Button(action: {
audioPlayer.play(
song: downloaded.song,
fromQueue: offlineManager.downloadedSongs.map { $0.song }
)
}) {
HStack(spacing: 12) {
AsyncCoverArt(
coverArtId: downloaded.song.coverArt,
size: 44
)
.frame(width: 44, height: 44)
.cornerRadius(3)
VStack(alignment: .leading, spacing: 2) {
Text(downloaded.song.title)
.font(.system(size: 15))
.foregroundColor(
audioPlayer.currentSong?.id == downloaded.id ? accentPink : .white
)
.lineLimit(1)
Text(downloaded.song.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(downloaded.song.durationFormatted)
.font(.system(size: 12))
.foregroundColor(.gray)
Text(ByteCountFormatter.string(fromByteCount: downloaded.fileSize, countStyle: .file))
.font(.system(size: 10))
.foregroundColor(.gray.opacity(0.7))
}
}
}
}
.onDelete { offsets in
for idx in offsets {
offlineManager.removeSong(offlineManager.downloadedSongs[idx].id)
}
}
}
}
// Remove all
if !offlineManager.downloadedSongs.isEmpty {
Section {
Button("Remove All Downloads", role: .destructive) {
offlineManager.removeAll()
}
}
}
}
}
// MARK: - Watch Tab
private var watchTab: some View {
List {
if !watchManager.isWatchPaired {
Section {
VStack(spacing: 12) {
Image(systemName: "applewatch.slash")
.font(.system(size: 36))
.foregroundColor(.gray)
Text("No Apple Watch Paired")
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white)
Text("Pair an Apple Watch and install the app to send music")
.font(.system(size: 12))
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
}
} else {
// Watch status
Section {
HStack(spacing: 10) {
Image(systemName: watchManager.isReachable ? "applewatch.radiowaves.left.and.right" : "applewatch")
.foregroundColor(watchManager.isReachable ? .green : .gray)
.font(.system(size: 18))
VStack(alignment: .leading, spacing: 2) {
Text(watchManager.isReachable ? "Watch App Active" : "Watch App Not Running")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
Text(watchManager.isReachable
? "Files will transfer immediately"
: "Open the app on your watch to receive files")
.font(.system(size: 11))
.foregroundColor(.gray)
}
Spacer()
if watchManager.isReachable {
Button(action: { watchManager.requestWatchSongList() }) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 13))
.foregroundColor(accentPink)
}
}
}
} header: {
Text("Status")
}
// Pending transfers
if !watchManager.transferringIds.isEmpty {
Section {
ForEach(watchManager.pendingTransferList, id: \.id) { item in
HStack(spacing: 12) {
ZStack {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 3)
Circle()
.trim(from: 0, to: item.progress)
.stroke(accentPink, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
Text("\(Int(item.progress * 100))")
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundColor(.gray)
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.system(size: 14))
.foregroundColor(.white)
.lineLimit(1)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.1)).frame(height: 3)
Capsule().fill(accentPink)
.frame(width: geo.size.width * item.progress, height: 3)
}
}
.frame(height: 3)
}
Spacer()
}
}
.onDelete { indexSet in
let list = watchManager.pendingTransferList
for idx in indexSet {
if idx < list.count {
watchManager.cancelTransfer(songId: list[idx].id)
}
}
}
if watchManager.transferringIds.count > 1 {
Button(role: .destructive, action: { watchManager.cancelAllTransfers() }) {
HStack {
Image(systemName: "xmark.circle")
Text("Cancel All (\(watchManager.transferringIds.count))")
}
.font(.system(size: 13))
}
}
} header: {
Text("Pending Transfers (\(watchManager.transferringIds.count))")
}
}
// Completed
if !watchManager.transferredIds.isEmpty {
Section {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("\(watchManager.transferredIds.count) songs sent")
.font(.system(size: 14))
.foregroundColor(.white)
Spacer()
Button("Clear") { watchManager.transferredIds.removeAll() }
.font(.system(size: 13))
.foregroundColor(accentPink)
}
} header: {
Text("Recently Sent")
}
}
// Songs on watch
Section {
if watchManager.isLoadingWatchSongs {
HStack {
ProgressView().tint(accentPink)
Text("Loading watch library...")
.font(.system(size: 13))
.foregroundColor(.gray)
.padding(.leading, 8)
}
} else if watchManager.watchSongs.isEmpty {
VStack(spacing: 8) {
Image(systemName: "applewatch")
.font(.system(size: 24))
.foregroundColor(.gray)
Text(watchManager.isReachable ? "No songs on Watch" : "Connect watch to view songs")
.font(.system(size: 13))
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
} else {
HStack {
Text("\(watchManager.watchSongs.count) songs")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
Spacer()
let totalSize = watchManager.watchSongs.reduce(0) { $0 + $1.fileSize }
Text(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file))
.font(.system(size: 13))
.foregroundColor(.gray)
}
ForEach(watchManager.watchSongs) { song in
HStack(spacing: 12) {
Image(systemName: "music.note")
.font(.system(size: 12))
.foregroundColor(accentPink)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 14))
.foregroundColor(.white)
.lineLimit(1)
Text("\(song.artist) · \(song.album)")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Text(ByteCountFormatter.string(fromByteCount: song.fileSize, countStyle: .file))
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
.onDelete { indexSet in
for idx in indexSet {
if idx < watchManager.watchSongs.count {
watchManager.requestWatchDeleteSong(watchManager.watchSongs[idx].id)
}
}
}
Button(role: .destructive, action: { watchManager.requestWatchDeleteAll() }) {
HStack {
Image(systemName: "trash")
Text("Remove All from Watch")
}
.font(.system(size: 13))
}
}
} header: {
Text("On Watch")
}
// Send all offline songs to watch
if !offlineManager.downloadedSongs.isEmpty {
Section {
Button(action: {
watchManager.syncOfflineSongsToWatch(songs: offlineManager.downloadedSongs)
}) {
Label("Send All Offline Songs to Watch", systemImage: "applewatch.and.arrow.forward")
}
}
}
}
}
.onAppear {
if watchManager.isReachable && watchManager.watchSongs.isEmpty {
watchManager.requestWatchSongList()
}
}
.onChange(of: watchManager.isReachable) { _, reachable in
if reachable { watchManager.requestWatchSongList() }
}
}
}
// MARK: - Streaming Quality Manager (shared)
class StreamingQuality: ObservableObject {
static let shared = StreamingQuality()
// WiFi
@Published var wifiFormat: String { didSet { UserDefaults.standard.set(wifiFormat, forKey: "wifi_format") } }
@Published var wifiBitRate: String { didSet { UserDefaults.standard.set(wifiBitRate, forKey: "wifi_bitrate") } }
// Cellular
@Published var cellularFormat: String { didSet { UserDefaults.standard.set(cellularFormat, forKey: "cell_format") } }
@Published var cellularBitRate: String { didSet { UserDefaults.standard.set(cellularBitRate, forKey: "cell_bitrate") } }
// Cache / Downloads
@Published var cacheFormat: String { didSet { UserDefaults.standard.set(cacheFormat, forKey: "cache_format") } }
@Published var cacheBitRate: String { didSet { UserDefaults.standard.set(cacheBitRate, forKey: "cache_bitrate") } }
// Scrobbling
@Published var scrobbleEnabled: Bool { didSet { UserDefaults.standard.set(scrobbleEnabled, forKey: "scrobble_enabled") } }
/// Formats supported by Navidrome's stream endpoint
static let formatOptions: [(value: String, label: String)] = [
("raw", "Raw/Original"),
("mp3", "MP3"),
("opus", "Opus"),
("aac", "AAC"),
("ogg", "OGG Vorbis"),
]
/// Bitrate options for transcoded formats
static let bitrateOptions: [(value: String, label: String)] = [
("0", "No Limit (default)"),
("320", "320 kbps"),
("256", "256 kbps"),
("192", "192 kbps"),
("128", "128 kbps"),
("96", "96 kbps"),
("64", "64 kbps"),
]
/// Active format based on current connection
var format: String? {
let fmt = isOnCellular ? cellularFormat : wifiFormat
return fmt == "raw" ? nil : fmt // nil = no format param = original
}
/// Active bitrate based on current connection
var maxBitRate: Int? {
let br = isOnCellular ? cellularBitRate : wifiBitRate
if br == "0" { return nil }
return Int(br)
}
/// Format for downloads/cache
var downloadFormat: String? {
return cacheFormat == "raw" ? nil : cacheFormat
}
var downloadBitRate: Int? {
if cacheBitRate == "0" { return nil }
return Int(cacheBitRate)
}
/// Summary string for current WiFi config
var wifiSummary: String {
if wifiFormat == "raw" { return "Raw/Original" }
let br = wifiBitRate == "0" ? "No limit" : "\(wifiBitRate) kbps"
return "\(wifiFormat.uppercased()) · \(br)"
}
/// Summary string for current Cellular config
var cellularSummary: String {
if cellularFormat == "raw" { return "Raw/Original" }
let br = cellularBitRate == "0" ? "No limit" : "\(cellularBitRate) kbps"
return "\(cellularFormat.uppercased()) · \(br)"
}
/// Summary string for cache config
var cacheSummary: String {
if cacheFormat == "raw" { return "Raw/Original" }
let br = cacheBitRate == "0" ? "No limit" : "\(cacheBitRate) kbps"
return "\(cacheFormat.uppercased()) · \(br)"
}
// Legacy compat
var streamBitRate: String {
get { isOnCellular ? cellularBitRate : wifiBitRate }
set { if isOnCellular { cellularBitRate = newValue } else { wifiBitRate = newValue } }
}
var streamFormat: String {
get { isOnCellular ? cellularFormat : wifiFormat }
set { if isOnCellular { cellularFormat = newValue } else { wifiFormat = newValue } }
}
var downloadOriginal: Bool {
get { cacheFormat == "raw" }
set { cacheFormat = newValue ? "raw" : "mp3" }
}
private var isOnCellular: Bool {
return !isOnWiFi
}
private var isOnWiFi: Bool {
var result = true
let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
let semaphore = DispatchSemaphore(value: 0)
monitor.pathUpdateHandler = { path in
result = (path.status == .satisfied)
semaphore.signal()
}
let queue = DispatchQueue(label: "wifi.check")
monitor.start(queue: queue)
_ = semaphore.wait(timeout: .now() + 0.1)
monitor.cancel()
return result
}
private init() {
let d = UserDefaults.standard
wifiFormat = d.string(forKey: "wifi_format") ?? "raw"
wifiBitRate = d.string(forKey: "wifi_bitrate") ?? "0"
cellularFormat = d.string(forKey: "cell_format") ?? "mp3"
cellularBitRate = d.string(forKey: "cell_bitrate") ?? "320"
cacheFormat = d.string(forKey: "cache_format") ?? "raw"
cacheBitRate = d.string(forKey: "cache_bitrate") ?? "0"
scrobbleEnabled = d.object(forKey: "scrobble_enabled") as? Bool ?? true
}
}
// MARK: - Settings View
struct SettingsView: View {
@EnvironmentObject var serverManager: ServerManager
@ObservedObject var quality = StreamingQuality.shared
@ObservedObject var debugLogger = DebugLogger.shared
@State private var showVisualizerSettings = false
@State private var cacheSizeText = "..."
@State private var visCacheText = "..."
@State private var isAnalyzing = false
@State private var analysisProgress = ""
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
let streamOptions = StreamingQuality.formatOptions
let bitrateOptions = StreamingQuality.bitrateOptions
var body: some View {
NavigationStack {
List {
// Active Server
Section("Server") {
if let server = serverManager.activeServer {
VStack(alignment: .leading, spacing: 4) {
Text(server.name)
.font(.system(size: 16, weight: .medium))
Text(server.url)
.font(.system(size: 13))
.foregroundColor(.gray)
Text("Logged in as \(server.username)")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
NavigationLink("Manage Servers") {
ManageServersView()
}
NavigationLink {
CompanionSettingsView()
} label: {
HStack {
Text("Companion API")
Spacer()
if CompanionSettings.shared.isEnabled {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 12))
.foregroundColor(.green)
}
}
}
}
// WiFi Streaming
Section {
Picker("Format (Transcoding)", selection: $quality.wifiFormat) {
ForEach(streamOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
if quality.wifiFormat != "raw" {
Picker("Bitrate Limit", selection: $quality.wifiBitRate) {
ForEach(bitrateOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
}
} header: {
Text("WiFi Streaming")
} footer: {
if quality.wifiFormat == "raw" {
Text("Streams your files as-is (FLAC, ALAC, etc). Uses the Subsonic 'download' action — no transcoding.")
} else {
Text("Select a transcoding format for WiFi streaming. Uses the Subsonic 'stream' action with server-side transcoding.")
}
}
// Cellular Streaming
Section {
Picker("Format (Transcoding)", selection: $quality.cellularFormat) {
ForEach(streamOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
if quality.cellularFormat != "raw" {
Picker("Bitrate Limit", selection: $quality.cellularBitRate) {
ForEach(bitrateOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
}
} header: {
Text("Cellular Streaming")
} footer: {
Text("Transcoding is recommended on cellular to reduce data usage. Default: MP3 320 kbps.")
}
// Cache / Downloads
Section {
Picker("Format (Transcoding)", selection: $quality.cacheFormat) {
ForEach(streamOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
if quality.cacheFormat != "raw" {
Picker("Bitrate Limit", selection: $quality.cacheBitRate) {
ForEach(bitrateOptions, id: \.value) { opt in
Text(opt.label).tag(opt.value)
}
}
}
} header: {
Text("Cache Format (Downloads)")
} footer: {
Text("For 'Raw/Original', uses the Subsonic 'download' action which skips transcoding. Other formats use 'stream' with server transcoding. Changes won't apply to already downloaded songs; clear cache and redownload if needed.")
}
// Watch
Section {
HStack {
Text("Watch Transfer Quality")
Spacer()
Text("MP3 192 kbps")
.foregroundColor(.gray)
}
} header: {
Text("Apple Watch")
} footer: {
Text("Songs sent to Apple Watch are always transcoded to MP3 192 kbps for fast transfer and storage efficiency.")
}
// Scrobbling
Section("Activity") {
Toggle("Scrobble Plays", isOn: $quality.scrobbleEnabled)
.tint(accentPink)
}
// Visualizer
Section("Visualizer") {
Button(action: { showVisualizerSettings = true }) {
HStack {
Text("Visualizer Settings")
.foregroundColor(.white)
Spacer()
Text(VisualizerSettings.shared.style.rawValue)
.foregroundColor(.gray)
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
// Storage
Section {
Button(action: {
ImageCache.shared.clearAll()
cacheSizeText = "0 MB"
}) {
HStack {
Text("Clear Image Cache")
.foregroundColor(.white)
Spacer()
Text(cacheSizeText)
.foregroundColor(.gray)
}
}
Button(action: {
LibraryCache.shared.clearAll()
}) {
HStack {
Text("Clear Library Cache")
.foregroundColor(.white)
Spacer()
}
}
Button(action: {
Task {
await VisualizerStorageManager.shared.clearCache()
await MainActor.run { visCacheText = "Cleared" }
}
}) {
HStack {
Text("Clear Visualizer Cache")
.foregroundColor(.white)
Spacer()
Text(visCacheText)
.foregroundColor(.gray)
}
}
// Pre-analyze missing songs
Button(action: { analyzeAllMissing(force: false) }) {
HStack {
Text("Pre-Analyze Missing Songs")
.foregroundColor(isAnalyzing ? .gray : .white)
Spacer()
if isAnalyzing {
HStack(spacing: 6) {
ProgressView().tint(accentPink)
Text(analysisProgress)
.font(.system(size: 12))
.foregroundColor(.gray)
}
} else {
Image(systemName: "waveform.badge.magnifyingglass")
.foregroundColor(accentPink)
}
}
}
.disabled(isAnalyzing)
// Force re-analyze all songs
Button(action: { analyzeAllMissing(force: true) }) {
HStack {
Text("Re-Analyze All Songs")
.foregroundColor(isAnalyzing ? .gray : .orange)
Spacer()
Image(systemName: "arrow.clockwise")
.foregroundColor(isAnalyzing ? .gray : .orange)
}
}
.disabled(isAnalyzing)
} header: {
Text("Storage")
} footer: {
Text("Pre-Analyze scans downloaded songs without visualizer data. Re-Analyze clears all caches and rebuilds from scratch.")
}
// About
Section {
Toggle("Debug Console", isOn: $debugLogger.isEnabled)
.tint(accentPink)
} header: {
Text("Developer")
} footer: {
Text("Shows a debug panel above the tab bar with real-time logs for Watch transfers, audio engine, network, and more. Drag to resize.")
}
Section("About") {
HStack {
Text("Version")
Spacer()
Text("1.0.0").foregroundColor(.gray)
}
HStack {
Text("API Version")
Spacer()
Text("1.16.1").foregroundColor(.gray)
}
}
// Logout
Section {
Button("Disconnect", role: .destructive) {
serverManager.disconnect()
}
}
}
.navigationTitle("Settings")
.sheet(isPresented: $showVisualizerSettings) {
VisualizerSettingsView()
}
.onAppear {
cacheSizeText = computeCacheSize()
Task {
let size = await VisualizerStorageManager.shared.cacheSize()
let count = await VisualizerStorageManager.shared.cachedTrackCount()
await MainActor.run {
visCacheText = "\(count) tracks · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))"
}
}
}
}
}
private func computeCacheSize() -> String {
let fm = FileManager.default
let caches = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!
let cacheDir = caches.appendingPathComponent("ImageCache", isDirectory: true)
guard let files = try? fm.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: [.fileSizeKey]) else {
return "0 MB"
}
var total: Int64 = 0
for file in files {
if let size = try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize {
total += Int64(size)
}
}
return ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
}
private func analyzeAllMissing(force: Bool) {
guard !isAnalyzing else { return }
isAnalyzing = true
analysisProgress = "Scanning..."
let downloaded = OfflineManager.shared.downloadedSongs
let points = VisualizerSettings.shared.numberOfPoints
let fps = VisualizerSettings.shared.effectiveFPS
let cutoff = VisualizerSettings.shared.frequencyCutoff
Task {
let storage = VisualizerStorageManager.shared
if force {
await storage.clearCache()
}
// Find songs that need analysis
var toAnalyze: [(id: String, url: URL)] = []
for song in downloaded {
if let url = OfflineManager.shared.localURL(for: song.id) {
let hasCache = await storage.hasCache(for: song.id)
if !hasCache || force {
toAnalyze.append((id: song.id, url: url))
}
}
}
if toAnalyze.isEmpty {
await MainActor.run {
analysisProgress = "All songs cached"
isAnalyzing = false
refreshVisCacheText()
}
return
}
await MainActor.run {
analysisProgress = "0/\(toAnalyze.count)"
}
DebugLogger.shared.log("Starting batch analysis: \(toAnalyze.count) songs (force: \(force))", category: "FFT")
for (idx, item) in toAnalyze.enumerated() {
await MainActor.run {
analysisProgress = "\(idx + 1)/\(toAnalyze.count)"
}
do {
let frames = try await OfflineAudioAnalyzer.shared.analyze(
url: item.url,
pointsCount: points,
fps: fps,
cutoff: cutoff
)
try? await storage.saveCache(frames: frames, for: item.id)
DebugLogger.shared.log("Analyzed: \(item.id)\(frames.count) frames", category: "FFT")
} catch {
DebugLogger.shared.log("Failed: \(item.id)\(error.localizedDescription)", category: "Error")
}
}
await MainActor.run {
analysisProgress = "Done (\(toAnalyze.count) songs)"
isAnalyzing = false
refreshVisCacheText()
}
DebugLogger.shared.log("Batch analysis complete: \(toAnalyze.count) songs", category: "FFT")
}
}
private func refreshVisCacheText() {
Task {
let size = await VisualizerStorageManager.shared.cacheSize()
let count = await VisualizerStorageManager.shared.cachedTrackCount()
await MainActor.run {
visCacheText = "\(count) tracks · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))"
}
}
}
}
// MARK: - Manage Servers View
struct ManageServersView: View {
@EnvironmentObject var serverManager: ServerManager
@State private var showAddServer = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
List {
ForEach(serverManager.servers) { server in
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(server.name)
.font(.system(size: 16, weight: .medium))
Text(server.url)
.font(.system(size: 12))
.foregroundColor(.gray)
Text(server.username)
.font(.system(size: 11))
.foregroundColor(.gray.opacity(0.7))
}
Spacer()
if serverManager.activeServer?.id == server.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(accentPink)
}
}
.contentShape(Rectangle())
.onTapGesture {
Task { _ = await serverManager.switchServer(server) }
}
}
.onDelete { offsets in
serverManager.removeServer(at: offsets)
}
Button(action: { showAddServer = true }) {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundColor(accentPink)
Text("Add Server")
.foregroundColor(accentPink)
}
}
}
.navigationTitle("Servers")
.sheet(isPresented: $showAddServer) {
AddServerSheet(isPresented: $showAddServer)
}
}
}

View file

@ -0,0 +1,900 @@
import SwiftUI
struct MyMusicView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var offlineManager: OfflineManager
@Binding var navigateToPlaylistId: String?
@Binding var navigateToAlbumId: String?
@Binding var navigateToArtistId: String?
@State private var recentAlbums: [Album] = []
@State private var allAlbums: [Album] = []
@State private var allSongs: [Song] = []
@State private var playlists: [Playlist] = []
@State private var artists: [ArtistIndex] = []
@State private var genres: [Genre] = []
@State private var isLoading = true
@State private var sortMode: SortMode = .recentlyAdded
@State private var showServerPicker = false
@State private var showCreatePlaylist = false
@State private var newPlaylistName = ""
@State private var searchText = ""
// Pagination for songs
@State private var songOffset = 0
@State private var hasMoreSongs = true
@State private var albumOffset = 0
@State private var hasMoreAlbums = true
// Album selection mode for batch editing
@State private var isSelectingAlbums = false
@State private var selectedAlbumIds: Set<String> = []
@State private var showBatchAlbumEditor = false
@State private var singleEditAlbumId: String?
@State private var showSingleAlbumEditor = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
enum SortMode: String, CaseIterable {
case recentlyAdded = "Recently Added"
case artists = "Artists"
case albums = "Albums"
case songs = "Songs"
case genres = "Genres"
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Tab pills
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(SortMode.allCases, id: \.self) { mode in
Button(action: {
sortMode = mode
searchText = ""
}) {
Text(mode.rawValue)
.font(.system(size: 13, weight: .medium))
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(sortMode == mode ? accentPink : Color.white.opacity(0.1))
.foregroundColor(sortMode == mode ? .white : .gray)
.cornerRadius(16)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
// Search bar (for all tabs except Recently Added)
if sortMode != .recentlyAdded {
searchBar
}
// Content
ScrollView {
VStack(alignment: .leading, spacing: 0) {
switch sortMode {
case .recentlyAdded: recentlyAddedTab
case .artists: artistsTab
case .albums: albumsTab
case .songs: songsTab
case .genres: genresTab
}
Color.clear.frame(height: 100)
}
}
}
.background(Color(white: 0.06))
.navigationTitle("My Music")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
if isSelectingAlbums {
Button(action: {
isSelectingAlbums = false
selectedAlbumIds.removeAll()
}) {
Text("Cancel")
.foregroundColor(.gray)
}
}
}
ToolbarItem(placement: .principal) {
if isSelectingAlbums {
Text("\(selectedAlbumIds.count) selected")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
if isSelectingAlbums {
Button(action: { showBatchAlbumEditor = true }) {
HStack(spacing: 4) {
Image(systemName: "tag")
Text("Edit")
}
.font(.system(size: 14, weight: .semibold))
.foregroundColor(selectedAlbumIds.isEmpty ? .gray : accentPink)
}
.disabled(selectedAlbumIds.isEmpty)
} else {
Button(action: { showServerPicker = true }) {
Image(systemName: "server.rack")
.foregroundColor(accentPink)
}
}
}
}
.sheet(isPresented: $showServerPicker) {
ServerPickerSheet(isPresented: $showServerPicker)
}
.alert("New Playlist", isPresented: $showCreatePlaylist) {
TextField("Playlist name", text: $newPlaylistName)
Button("Create") {
let name = newPlaylistName
newPlaylistName = ""
Task {
try? await serverManager.client.createPlaylist(name: name)
await loadData()
}
}
Button("Cancel", role: .cancel) { newPlaylistName = "" }
}
.task { await loadData() }
.refreshable { await loadData() }
.sheet(isPresented: $showBatchAlbumEditor) {
MultiAlbumEditorSheet(albumIds: Array(selectedAlbumIds)) {
isSelectingAlbums = false
selectedAlbumIds.removeAll()
}
}
.sheet(isPresented: $showSingleAlbumEditor) {
if let albumId = singleEditAlbumId {
MultiAlbumEditorSheet(albumIds: [albumId])
}
}
.navigationDestination(isPresented: Binding(
get: { navigateToPlaylistId != nil },
set: { if !$0 { navigateToPlaylistId = nil } }
)) {
if let pid = navigateToPlaylistId {
PlaylistDetailView(playlistId: pid)
}
}
.navigationDestination(isPresented: Binding(
get: { navigateToAlbumId != nil },
set: { if !$0 { navigateToAlbumId = nil } }
)) {
if let aid = navigateToAlbumId {
AlbumDetailView(albumId: aid)
}
}
.navigationDestination(isPresented: Binding(
get: { navigateToArtistId != nil },
set: { if !$0 { navigateToArtistId = nil } }
)) {
if let aid = navigateToArtistId {
ArtistDetailView(artistId: aid)
}
}
}
}
// MARK: - Search Bar
private var searchBar: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
.font(.system(size: 14))
TextField("Search \(sortMode.rawValue.lowercased())...", text: $searchText)
.font(.system(size: 15))
.foregroundColor(.white)
.autocorrectionDisabled()
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.system(size: 14))
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
// MARK: - Recently Added Tab
private var recentlyAddedTab: some View {
VStack(alignment: .leading, spacing: 0) {
recentlyAddedSection
playlistsSection
}
}
private var recentlyAddedSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Recently Added")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
Spacer()
NavigationLink(destination: AlbumGridView(title: "Recently Added", type: "newest")) {
Text("more")
.font(.system(size: 15))
.foregroundColor(accentPink)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 14) {
ForEach(deduplicateAlbums(Array(recentAlbums.prefix(20)))) { album in
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomTrailing) {
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
AsyncCoverArt(coverArtId: album.coverArt, size: 160)
.frame(width: 160, height: 160)
.cornerRadius(4)
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
}
Button(action: { playAlbum(album) }) {
Image(systemName: "play.circle.fill")
.font(.system(size: 32))
.foregroundColor(.white)
.shadow(radius: 4)
}
.padding(6)
}
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
.frame(width: 160)
.contextMenu {
Button(action: { playAlbum(album) }) {
Label("Play", systemImage: "play.fill")
}
if CompanionSettings.shared.isEnabled {
Divider()
Button(action: {
singleEditAlbumId = album.id
showSingleAlbumEditor = true
}) {
Label("Edit Album", systemImage: "tag")
}
Button(action: {
selectedAlbumIds = [album.id]
isSelectingAlbums = true
sortMode = .albums
}) {
Label("Select Albums", systemImage: "checkmark.circle")
}
}
}
}
}
.padding(.horizontal, 16)
}
}
}
// MARK: - Playlists Section
private var playlistsSection: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Playlists")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
Spacer()
Button(action: { showCreatePlaylist = true }) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 22))
.foregroundColor(accentPink)
}
}
.padding(.horizontal, 16)
.padding(.top, 24)
.padding(.bottom, 12)
LazyVStack(spacing: 0) {
ForEach(playlists) { playlist in
NavigationLink(destination: PlaylistDetailView(playlistId: playlist.id)) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: playlist.coverArt, size: 56)
.frame(width: 52, height: 52)
.cornerRadius(4)
VStack(alignment: .leading, spacing: 3) {
Text(playlist.name)
.font(.system(size: 15))
.foregroundColor(.white)
.lineLimit(1)
Text("\(playlist.songCount ?? 0) songs")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.contextMenu {
Button(role: .destructive, action: {
Task {
try? await serverManager.client.deletePlaylist(id: playlist.id)
await loadData()
}
}) {
Label("Delete Playlist", systemImage: "trash")
}
}
Divider()
.background(Color.white.opacity(0.1))
.padding(.leading, 86)
}
}
}
}
// MARK: - Artists Tab
private var artistsTab: some View {
let flatArtists = artists.flatMap { $0.artist ?? [] }
let filtered = searchText.isEmpty ? flatArtists : flatArtists.filter {
$0.name.localizedCaseInsensitiveContains(searchText)
}
return LazyVStack(spacing: 0) {
ForEach(filtered) { artist in
NavigationLink(destination: ArtistDetailView(artistId: artist.id)) {
HStack(spacing: 14) {
AsyncCoverArt(coverArtId: artist.coverArt, size: 48)
.frame(width: 48, height: 48)
.clipShape(Circle())
Text(artist.name)
.font(.system(size: 16))
.foregroundColor(.white)
Spacer()
if let count = artist.albumCount {
Text("\(count) albums")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
Divider()
.background(Color.white.opacity(0.1))
.padding(.leading, 78)
}
if filtered.isEmpty && !isLoading {
emptyState("No artists found")
}
}
.padding(.top, 4)
}
// MARK: - Albums Tab (grid with selection)
private var albumsTab: some View {
let deduped = deduplicateAlbums(allAlbums)
let filtered = searchText.isEmpty ? deduped : deduped.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
}
let columns = [GridItem(.adaptive(minimum: 160), spacing: 14)]
return VStack(spacing: 0) {
// Quick actions bar in selection mode
if isSelectingAlbums {
HStack(spacing: 16) {
Button(action: {
if selectedAlbumIds.count == filtered.count {
selectedAlbumIds.removeAll()
} else {
selectedAlbumIds = Set(filtered.map { $0.id })
}
}) {
HStack(spacing: 4) {
Image(systemName: selectedAlbumIds.count == filtered.count ? "checkmark.circle.fill" : "circle")
.font(.system(size: 14))
Text(selectedAlbumIds.count == filtered.count ? "Deselect All" : "Select All")
.font(.system(size: 13, weight: .medium))
}
.foregroundColor(accentPink)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.white.opacity(0.03))
}
LazyVGrid(columns: columns, spacing: 18) {
ForEach(filtered) { album in
if isSelectingAlbums {
// Selection mode: tap to toggle selection
albumTile(album)
.overlay(alignment: .topLeading) {
Image(systemName: selectedAlbumIds.contains(album.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22))
.foregroundColor(selectedAlbumIds.contains(album.id) ? accentPink : .white.opacity(0.5))
.shadow(radius: 4)
.padding(6)
}
.opacity(selectedAlbumIds.isEmpty || selectedAlbumIds.contains(album.id) ? 1 : 0.5)
.onTapGesture {
if selectedAlbumIds.contains(album.id) {
selectedAlbumIds.remove(album.id)
} else {
selectedAlbumIds.insert(album.id)
}
}
} else {
// Normal mode: navigate + context menu
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
albumTile(album)
}
.contextMenu {
Button(action: { playAlbum(album) }) {
Label("Play", systemImage: "play.fill")
}
if CompanionSettings.shared.isEnabled {
Divider()
Button(action: {
singleEditAlbumId = album.id
showSingleAlbumEditor = true
}) {
Label("Edit Album", systemImage: "tag")
}
Button(action: {
selectedAlbumIds = [album.id]
isSelectingAlbums = true
}) {
Label("Select Albums", systemImage: "checkmark.circle")
}
}
}
}
}
}
.padding(16)
if hasMoreAlbums && searchText.isEmpty {
Button(action: { Task { await loadMoreAlbums() } }) {
Text("Load More")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
}
if filtered.isEmpty && !isLoading {
emptyState("No albums found")
}
}
}
/// Reusable album tile (cover + title + artist)
private func albumTile(_ album: Album) -> some View {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverArt(coverArtId: album.coverArt, size: 180)
.frame(height: 160)
.cornerRadius(4)
Text(album.name)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
}
// MARK: - Songs Tab (list)
private var songsTab: some View {
let filtered = searchText.isEmpty ? allSongs : allSongs.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText) ||
($0.album ?? "").localizedCaseInsensitiveContains(searchText)
}
return LazyVStack(spacing: 0) {
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, song in
songRow(song: song, index: index, allSongs: filtered)
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 72)
}
if hasMoreSongs && searchText.isEmpty {
Button(action: { Task { await loadMoreSongs() } }) {
Text("Load More")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
}
if filtered.isEmpty && !isLoading {
emptyState("No songs found")
}
}
.padding(.top, 4)
}
@ViewBuilder
private func songRow(song: Song, index: Int, allSongs: [Song]) -> some View {
Button(action: {
audioPlayer.play(song: song, fromQueue: Array(allSongs), at: index)
}) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
.frame(width: 44, height: 44)
.cornerRadius(3)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(audioPlayer.currentSong?.id == song.id ? accentPink : .white)
.lineLimit(1)
Text("\(song.artist ?? "") · \(song.album ?? "")")
.font(.system(size: 12))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Text(song.durationFormatted)
.font(.system(size: 13))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.contextMenu {
Button(action: { audioPlayer.playNow(song) }) {
Label("Play Now", systemImage: "play.fill")
}
Button(action: { audioPlayer.playNext(song) }) {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
Button(action: { audioPlayer.playLater(song) }) {
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
}
Divider()
Button(action: { audioPlayer.playInstantMix(basedOn: song) }) {
Label("Instant Mix", systemImage: "wand.and.stars")
}
Divider()
if offlineManager.isSongDownloaded(song.id) {
Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) {
Label("Remove Download", systemImage: "trash")
}
} else {
Button(action: {
if let server = serverManager.activeServer {
offlineManager.downloadSong(song, server: server)
}
}) {
Label("Download", systemImage: "arrow.down.circle")
}
}
}
}
// MARK: - Genres Tab
private var genresTab: some View {
let filtered = searchText.isEmpty ? genres : genres.filter {
$0.value.localizedCaseInsensitiveContains(searchText)
}
return LazyVStack(spacing: 0) {
ForEach(filtered) { genre in
NavigationLink(destination: AlbumGridView(title: genre.value, type: "byGenre", genre: genre.value)) {
HStack(spacing: 14) {
Image(systemName: "music.note.list")
.font(.system(size: 18))
.foregroundColor(accentPink)
.frame(width: 36, height: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(8)
VStack(alignment: .leading, spacing: 2) {
Text(genre.value)
.font(.system(size: 16))
.foregroundColor(.white)
HStack(spacing: 8) {
if let sc = genre.songCount {
Text("\(sc) songs")
.font(.system(size: 12))
.foregroundColor(.gray)
}
if let ac = genre.albumCount {
Text("\(ac) albums")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
Divider()
.background(Color.white.opacity(0.1))
.padding(.leading, 66)
}
if filtered.isEmpty && !isLoading {
emptyState("No genres found")
}
}
.padding(.top, 4)
}
// MARK: - Empty State
private func emptyState(_ text: String) -> some View {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 28))
.foregroundColor(.gray)
Text(text)
.font(.system(size: 14))
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
// MARK: - Play Album
private func playAlbum(_ album: Album) {
Task {
do {
let albumDetail = try await serverManager.client.getAlbum(id: album.id)
if let songs = albumDetail?.song, !songs.isEmpty {
await MainActor.run {
audioPlayer.play(song: songs[0], fromQueue: songs, at: 0)
}
}
} catch {
// Fallback: navigate to album detail
}
}
}
// MARK: - Album Deduplication
// Navidrome returns one album entry per artist for compilations.
// Group by name + coverArt to show each album once.
private func deduplicateAlbums(_ albums: [Album]) -> [Album] {
var seen = Set<String>()
var result: [Album] = []
for album in albums {
let key = "\(album.name)|\(album.coverArt ?? "")"
if seen.contains(key) { continue }
seen.insert(key)
// Check if multiple artists share this album
let sameAlbum = albums.filter { "\($0.name)|\($0.coverArt ?? "")" == key }
if sameAlbum.count > 1 {
// Replace artist with "Various Artists"
let grouped = Album(
id: album.id, name: album.name,
artist: "Various Artists", artistId: nil,
coverArt: album.coverArt, songCount: album.songCount,
duration: album.duration, playCount: album.playCount,
created: album.created, starred: album.starred,
year: album.year, genre: album.genre
)
result.append(grouped)
} else {
result.append(album)
}
}
return result
}
// MARK: - Data Loading
private func loadData() async {
let client = serverManager.client
let cache = LibraryCache.shared
// Load cached data FIRST show instantly, never spinner on warm launch
let hadCache: Bool
if recentAlbums.isEmpty, let cached = cache.loadAlbums() { recentAlbums = cached }
if playlists.isEmpty, let cached = cache.loadPlaylists() { playlists = cached }
if artists.isEmpty, let cached = cache.loadArtists() { artists = cached }
if let cached = cache.load([Genre].self, key: "genres") { genres = cached }
if let cached = cache.load([Album].self, key: "all_albums") { allAlbums = cached }
hadCache = !recentAlbums.isEmpty || !playlists.isEmpty || !artists.isEmpty
// If we have cache, stop showing spinner immediately
if hadCache { isLoading = false }
// Then refresh from server in background
do {
async let albumsReq = client.getAlbumList2(type: "newest", size: 30)
async let playlistsReq = client.getPlaylists()
async let artistsReq = client.getArtists()
async let allAlbumsReq = client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: 0)
async let genresReq = client.getGenres()
async let songsReq = client.search3(query: "", songCount: 100, songOffset: 0)
let (albums, playlistList, artistList, albumsFull, genreList, searchResult) = try await (
albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq
)
cache.cacheAlbums(albums)
cache.cachePlaylists(playlistList)
cache.cacheArtists(artistList)
cache.save(genreList, key: "genres")
cache.save(albumsFull, key: "all_albums")
await MainActor.run {
self.recentAlbums = albums
self.playlists = playlistList
self.artists = artistList
self.allAlbums = albumsFull
self.albumOffset = 100
self.hasMoreAlbums = albumsFull.count >= 100
self.genres = genreList
self.allSongs = searchResult?.song ?? []
self.songOffset = 100
self.hasMoreSongs = (searchResult?.song?.count ?? 0) >= 100
self.isLoading = false
}
} catch {
serverManager.handleConnectionFailure()
await MainActor.run { self.isLoading = false }
}
}
private func loadMoreAlbums() async {
do {
let more = try await serverManager.client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: albumOffset)
await MainActor.run {
allAlbums.append(contentsOf: more)
albumOffset += 100
hasMoreAlbums = more.count >= 100
}
} catch { }
}
private func loadMoreSongs() async {
do {
let result = try await serverManager.client.search3(query: "", songCount: 100, songOffset: songOffset)
let moreSongs = result?.song ?? []
await MainActor.run {
allSongs.append(contentsOf: moreSongs)
songOffset += 100
hasMoreSongs = moreSongs.count >= 100
}
} catch { }
}
}
// MARK: - Server Picker Sheet
struct ServerPickerSheet: View {
@EnvironmentObject var serverManager: ServerManager
@Binding var isPresented: Bool
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
List {
Section("Active Server") {
ForEach(serverManager.servers) { server in
Button(action: {
Task {
_ = await serverManager.switchServer(server)
isPresented = false
}
}) {
HStack {
VStack(alignment: .leading) {
Text(server.name)
.foregroundColor(.white)
Text(server.url)
.font(.caption)
.foregroundColor(.gray)
}
Spacer()
if serverManager.activeServer?.id == server.id {
Image(systemName: "checkmark")
.foregroundColor(accentPink)
}
}
}
}
}
if let msg = serverManager.lastFailoverMessage {
Section {
Text(msg)
.font(.caption)
.foregroundColor(.gray)
}
}
}
.navigationTitle("Servers")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { isPresented = false }
.foregroundColor(accentPink)
}
}
}
}
}

View file

@ -0,0 +1,431 @@
import SwiftUI
struct PlaylistsView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
@State private var playlists: [Playlist] = []
@State private var isLoading = true
@State private var showCreatePlaylist = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
List {
if isLoading {
HStack {
Spacer()
ProgressView().tint(accentPink)
Spacer()
}
.listRowBackground(Color.clear)
} else if playlists.isEmpty {
VStack(spacing: 12) {
Image(systemName: "music.note.list")
.font(.system(size: 40))
.foregroundColor(.gray)
Text("No Playlists")
.font(.system(size: 16))
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity)
.padding(.top, 60)
.listRowBackground(Color.clear)
} else {
ForEach(playlists) { playlist in
NavigationLink(destination: PlaylistDetailView(playlistId: playlist.id)) {
HStack(spacing: 14) {
AsyncCoverArt(
coverArtId: playlist.coverArt,
size: 60
)
.frame(width: 56, height: 56)
.cornerRadius(4)
VStack(alignment: .leading, spacing: 3) {
Text(playlist.name)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
Text("\(playlist.songCount ?? 0) songs")
.font(.system(size: 12))
.foregroundColor(.gray)
}
}
.padding(.vertical, 4)
}
}
.onDelete(perform: deletePlaylists)
}
}
.navigationTitle("Playlists")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showCreatePlaylist = true }) {
Image(systemName: "plus")
.foregroundColor(accentPink)
}
}
}
.alert("New Playlist", isPresented: $showCreatePlaylist) {
CreatePlaylistAlert()
}
.task { await loadPlaylists() }
.refreshable { await loadPlaylists() }
}
}
private func loadPlaylists() async {
do {
let result = try await serverManager.client.getPlaylists()
await MainActor.run {
playlists = result
isLoading = false
}
} catch {
await MainActor.run { isLoading = false }
}
}
private func deletePlaylists(at offsets: IndexSet) {
for idx in offsets {
let playlist = playlists[idx]
Task { try? await serverManager.client.deletePlaylist(id: playlist.id) }
}
playlists.remove(atOffsets: offsets)
}
}
// MARK: - Create Playlist Alert
struct CreatePlaylistAlert: View {
@EnvironmentObject var serverManager: ServerManager
@State private var name = ""
var body: some View {
TextField("Playlist name", text: $name)
Button("Create") {
Task { try? await serverManager.client.createPlaylist(name: name) }
}
Button("Cancel", role: .cancel) { }
}
}
// MARK: - Playlist Detail View
struct PlaylistDetailView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var offlineManager: OfflineManager
@ObservedObject private var watchManager = WatchConnectivityManager.shared
@ObservedObject private var libraryCache = LibraryCache.shared
let playlistId: String
@State private var playlist: PlaylistWithSongs?
@State private var isLoading = true
@State private var showPlaylistPicker = false
@State private var playlistPickerSongId: String?
@State private var availablePlaylists: [Playlist] = []
@State private var showGetInfo = false
@State private var getInfoSong: Song?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
ScrollView {
if let playlist = playlist {
VStack(spacing: 0) {
playlistHeader(playlist)
Divider().background(Color.white.opacity(0.1))
playlistSongList(playlist)
Color.clear.frame(height: 120)
}
} else if isLoading {
ProgressView().tint(accentPink).padding(.top, 100)
}
}
.background(Color(white: 0.06))
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showPlaylistPicker) {
AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists)
}
.sheet(isPresented: $showGetInfo) {
if let song = getInfoSong { SongInfoSheet(song: song) }
}
.task {
do {
playlist = try await serverManager.client.getPlaylist(id: playlistId)
isLoading = false
} catch { isLoading = false }
}
}
@ViewBuilder
private func playlistHeader(_ playlist: PlaylistWithSongs) -> some View {
VStack(spacing: 12) {
AsyncCoverArt(coverArtId: playlist.coverArt, size: 220)
.frame(width: 180, height: 180)
.cornerRadius(6)
.shadow(color: .black.opacity(0.4), radius: 12)
.padding(.top, 20)
Text(playlist.name)
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
Text("\(playlist.songCount ?? 0) songs")
.font(.system(size: 14))
.foregroundColor(.gray)
HStack(spacing: 16) {
Button(action: { playPlaylist(shuffle: false) }) {
HStack(spacing: 6) {
Image(systemName: "play.fill")
Text("Play")
}
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(accentPink)
.foregroundColor(.white)
.cornerRadius(8)
}
Button(action: { playPlaylist(shuffle: true) }) {
HStack(spacing: 6) {
Image(systemName: "shuffle")
Text("Shuffle")
}
.font(.system(size: 14, weight: .semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.white.opacity(0.12))
.foregroundColor(.white)
.cornerRadius(8)
}
Button(action: { downloadPlaylist() }) {
Image(systemName: isPlaylistDownloaded() ? "checkmark.circle.fill" : "arrow.down.circle")
.font(.system(size: 22))
.foregroundColor(isPlaylistDownloaded() ? .green : .white)
.frame(width: 44, height: 38)
.background(Color.white.opacity(0.12))
.cornerRadius(8)
}
if WatchConnectivityManager.shared.isWatchAvailable {
Button(action: { sendPlaylistToWatch() }) {
Image(systemName: "applewatch")
.font(.system(size: 18))
.foregroundColor(isPlaylistOnWatch() ? .green : accentPink.opacity(0.6))
.frame(width: 44, height: 38)
.background(Color.white.opacity(0.12))
.cornerRadius(8)
}
}
}
.padding(.horizontal, 20)
}
.padding(.bottom, 16)
}
@ViewBuilder
private func playlistSongList(_ playlist: PlaylistWithSongs) -> some View {
let songs = playlist.entry ?? []
LazyVStack(spacing: 0) {
ForEach(Array(songs.enumerated()), id: \.element.id) { index, song in
playlistSongRow(song: song, index: index, songs: songs)
}
}
}
private func playPlaylist(shuffle: Bool) {
guard let songs = playlist?.entry, !songs.isEmpty else { return }
if shuffle { audioPlayer.shuffleEnabled = true }
audioPlayer.play(song: songs[0], fromQueue: songs, playlistId: playlistId, playlistName: playlist?.name)
}
private func downloadPlaylist() {
guard let playlist = playlist, let server = serverManager.activeServer else { return }
offlineManager.downloadPlaylist(playlist, server: server)
}
private func isPlaylistDownloaded() -> Bool {
guard let songs = playlist?.entry, !songs.isEmpty else { return false }
return songs.allSatisfy { offlineManager.isSongDownloaded($0.id) }
}
private func isPlaylistOnWatch() -> Bool {
guard let songs = playlist?.entry, !songs.isEmpty else { return false }
return songs.allSatisfy { WatchConnectivityManager.shared.isSongOnWatch($0.id) }
}
private func sendPlaylistToWatch() {
guard let songs = playlist?.entry else { return }
for song in songs {
if offlineManager.isSongDownloaded(song.id) {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
} else if let server = serverManager.activeServer {
offlineManager.downloadSong(song, server: server)
}
}
}
@ViewBuilder
private func playlistSongRow(song: Song, index: Int, songs: [Song]) -> some View {
let dlState = offlineManager.downloads[song.id]
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
let available = isDownloaded || !libraryCache.isOffline
Button(action: {
if available {
audioPlayer.play(song: song, fromQueue: songs, at: index, playlistId: playlistId, playlistName: playlist?.name)
}
}) {
HStack(spacing: 12) {
// Cover art with download progress overlay
ZStack {
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
.frame(width: 44, height: 44)
.cornerRadius(3)
.opacity(available ? 1.0 : 0.4)
if case .downloading(let progress) = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.5))
.frame(width: 44, height: 44)
Circle()
.trim(from: 0, to: progress)
.stroke(accentPink, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
.frame(width: 24, height: 24)
.rotationEffect(.degrees(-90))
} else if case .queued = dlState {
RoundedRectangle(cornerRadius: 3)
.fill(Color.black.opacity(0.4))
.frame(width: 44, height: 44)
ProgressView().tint(accentPink).scaleEffect(0.6)
}
}
VStack(alignment: .leading, spacing: 3) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(
!available ? .gray.opacity(0.35) :
audioPlayer.currentSong?.id == song.id ? accentPink : .white
)
.lineLimit(1)
HStack(spacing: 4) {
Text(song.artist ?? "")
.font(.system(size: 12))
.foregroundColor(available ? .gray : .gray.opacity(0.3))
.lineLimit(1)
}
// Download progress bar
if case .downloading(let progress) = dlState {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.08)).frame(height: 2)
Capsule().fill(accentPink)
.frame(width: geo.size.width * progress, height: 2)
}
}
.frame(height: 2)
}
}
Spacer()
// Status indicators
HStack(spacing: 6) {
if isOnWatch {
Image(systemName: "applewatch")
.font(.system(size: 10))
.foregroundColor(.blue.opacity(0.7))
}
if isDownloaded {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 12))
.foregroundColor(.green.opacity(0.6))
}
}
Text(song.durationFormatted)
.font(.system(size: 13))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.contextMenu { songContextMenu(song: song, index: index) }
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 72)
}
@ViewBuilder
private func songContextMenu(song: Song, index: Int) -> some View {
Button(action: { audioPlayer.playNow(song) }) {
Label("Play Now", systemImage: "play.fill")
}
Button(action: { audioPlayer.playNext(song) }) {
Label("Play Next", systemImage: "text.line.first.and.arrowtriangle.forward")
}
Button(action: { audioPlayer.playLater(song) }) {
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
}
Divider()
Button(action: { audioPlayer.playInstantMix(basedOn: song) }) {
Label("Instant Mix", systemImage: "wand.and.stars")
}
Button(action: {
playlistPickerSongId = song.id
Task {
availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? []
showPlaylistPicker = true
}
}) {
Label("Add to Playlist...", systemImage: "text.badge.plus")
}
Divider()
Button(role: .destructive, action: {
Task {
try? await serverManager.client.updatePlaylist(id: playlistId, songIndexesToRemove: [index])
playlist = try? await serverManager.client.getPlaylist(id: playlistId)
}
}) {
Label("Remove from Playlist", systemImage: "minus.circle")
}
Divider()
if offlineManager.isSongDownloaded(song.id) {
Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) {
Label("Remove Download", systemImage: "trash")
}
if WatchConnectivityManager.shared.isWatchAvailable {
Button(action: {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}) {
Label("Send to Watch", systemImage: "applewatch.and.arrow.forward")
}
}
} else {
Button(action: {
if let server = serverManager.activeServer {
offlineManager.downloadSong(song, server: server)
}
}) {
Label("Download", systemImage: "arrow.down.circle")
}
}
Button(action: { getInfoSong = song; showGetInfo = true }) {
Label("Get Info", systemImage: "info.circle")
}
}
}

View file

@ -0,0 +1,336 @@
import SwiftUI
import PhotosUI
import UIKit
// MARK: - Radio Cover Store (disk-persisted per station)
class RadioCoverStore: ObservableObject {
static let shared = RadioCoverStore()
/// Triggers SwiftUI refresh when a cover changes
@Published var updateTrigger = UUID()
private let fileManager = FileManager.default
private let coverDir: URL
private init() {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
coverDir = docs.appendingPathComponent("RadioCovers", isDirectory: true)
try? fileManager.createDirectory(at: coverDir, withIntermediateDirectories: true)
}
private func coverURL(for stationId: String) -> URL {
coverDir.appendingPathComponent("\(stationId).jpg")
}
func hasCover(for stationId: String) -> Bool {
fileManager.fileExists(atPath: coverURL(for: stationId).path)
}
func loadCover(for stationId: String) -> UIImage? {
let url = coverURL(for: stationId)
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}
func saveCover(_ image: UIImage, for stationId: String) {
let url = coverURL(for: stationId)
if let data = image.jpegData(compressionQuality: 0.85) {
try? data.write(to: url, options: .atomic)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
func removeCover(for stationId: String) {
let url = coverURL(for: stationId)
try? fileManager.removeItem(at: url)
DispatchQueue.main.async { self.updateTrigger = UUID() }
}
}
// MARK: - Radio Station Cover View
struct RadioStationCover: View {
let stationId: String
let isPlaying: Bool
@ObservedObject var store = RadioCoverStore.shared
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
// Depend on updateTrigger so we refresh when covers change
let _ = store.updateTrigger
if let img = store.loadCover(for: stationId) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 56, height: 56)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isPlaying ? accentPink : Color.clear, lineWidth: 2)
)
} else {
// Default icon
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(isPlaying ? accentPink.opacity(0.2) : Color.white.opacity(0.05))
.frame(width: 56, height: 56)
Image(systemName: isPlaying ? "antenna.radiowaves.left.and.right" : "radio")
.font(.system(size: 22))
.foregroundColor(isPlaying ? accentPink : .gray)
}
}
}
}
// MARK: - Radio View
struct RadioView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
@State private var stations: [RadioStation] = []
@State private var isLoading = true
@State private var playingStationId: String?
@State private var pickerStationId: String?
@State private var selectedPhoto: PhotosPickerItem?
@State private var showPhotoPicker = false
@State private var showAddStation = false
@State private var newStationName = ""
@State private var newStationURL = ""
@State private var pendingStationPhoto: PhotosPickerItem?
@ObservedObject private var coverStore = RadioCoverStore.shared
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
Group {
if isLoading && stations.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if stations.isEmpty {
VStack(spacing: 12) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 40))
.foregroundColor(.gray)
Text("No radio stations")
.font(.system(size: 16))
.foregroundColor(.gray)
Text("Add stations in your Navidrome settings")
.font(.system(size: 13))
.foregroundColor(.gray.opacity(0.7))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
ForEach(stations) { station in
stationRow(station)
}
}
}
}
.background(Color(white: 0.06))
.navigationTitle("Radio")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showAddStation = true }) {
Image(systemName: "plus")
.foregroundColor(accentPink)
}
}
}
.alert("Add Radio Station", isPresented: $showAddStation) {
TextField("Station Name", text: $newStationName)
TextField("Stream URL", text: $newStationURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Add") {
let name = newStationName
let url = newStationURL
newStationName = ""
newStationURL = ""
Task {
try? await serverManager.client.createInternetRadioStation(streamUrl: url, name: name)
await loadStations()
// If user had selected a photo, apply it after station is created
if let photo = pendingStationPhoto {
if let data = try? await photo.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
// Find the newly created station by name
let refreshed = try? await serverManager.client.getInternetRadioStations()
if let newStation = refreshed?.first(where: { $0.name == name }) {
RadioCoverStore.shared.saveCover(image, for: newStation.id)
}
}
pendingStationPhoto = nil
}
}
}
Button("Cancel", role: .cancel) {
newStationName = ""
newStationURL = ""
pendingStationPhoto = nil
}
}
.task { await loadStations() }
.refreshable { await loadStations() }
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhoto,
matching: .images,
photoLibrary: .shared()
)
.onChange(of: selectedPhoto) { _, newItem in
guard let item = newItem, let stationId = pickerStationId else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
// Resize to reasonable size
let resized = resizeImage(image, maxSize: 300)
coverStore.saveCover(resized, for: stationId)
}
selectedPhoto = nil
pickerStationId = nil
}
}
}
}
// MARK: - Station Row
private func stationRow(_ station: RadioStation) -> some View {
Button(action: { playStation(station) }) {
HStack(spacing: 14) {
RadioStationCover(
stationId: station.id,
isPlaying: playingStationId == station.id
)
VStack(alignment: .leading, spacing: 3) {
Text(station.name)
.font(.system(size: 16, weight: .medium))
.foregroundColor(
playingStationId == station.id ? accentPink : .white
)
if let home = station.homePageUrl, !home.isEmpty {
Text(home)
.font(.system(size: 12))
.foregroundColor(.gray)
.lineLimit(1)
}
}
Spacer()
if playingStationId == station.id {
Image(systemName: "waveform")
.font(.system(size: 14))
.foregroundColor(accentPink)
}
}
.padding(.vertical, 4)
}
.contextMenu {
Button(action: {
pickerStationId = station.id
showPhotoPicker = true
}) {
Label("Set Cover Image", systemImage: "photo.on.rectangle")
}
if coverStore.hasCover(for: station.id) {
Button(role: .destructive, action: {
coverStore.removeCover(for: station.id)
}) {
Label("Remove Cover", systemImage: "xmark.circle")
}
}
Divider()
Button(role: .destructive, action: {
Task {
try? await serverManager.client.deleteInternetRadioStation(id: station.id)
await loadStations()
}
}) {
Label("Delete Station", systemImage: "trash")
}
}
}
// MARK: - Data Loading
private func loadStations() async {
isLoading = true
let cache = LibraryCache.shared
if stations.isEmpty, let cached = cache.loadRadioStations() {
stations = cached
}
do {
let result = try await serverManager.client.getInternetRadioStations()
cache.cacheRadioStations(result)
await MainActor.run {
stations = result
isLoading = false
}
} catch {
await MainActor.run { isLoading = false }
}
}
// MARK: - Playback
private func playStation(_ station: RadioStation) {
guard let url = URL(string: station.streamUrl) else { return }
if playingStationId == station.id {
audioPlayer.stop()
playingStationId = nil
return
}
let radioSong = Song(
id: station.id, parent: nil, isDir: nil,
title: station.name, album: "Radio",
artist: station.homePageUrl ?? "Internet Radio",
track: nil, year: nil, genre: nil, coverArt: nil,
size: nil, contentType: nil, suffix: nil,
transcodedContentType: nil, transcodedSuffix: nil,
duration: 0, bitRate: nil, path: nil, playCount: nil,
discNumber: nil, created: nil, albumId: nil, artistId: nil,
type: nil, starred: nil, bpm: nil, musicBrainzId: nil
)
audioPlayer.playRadio(song: radioSong, streamURL: url)
playingStationId = station.id
// Set lock screen / Dynamic Island artwork if custom cover exists
if let coverImage = coverStore.loadCover(for: station.id) {
audioPlayer.setNowPlayingArtwork(image: coverImage)
}
}
// MARK: - Image Resize
private func resizeImage(_ image: UIImage, maxSize: CGFloat) -> UIImage {
let size = image.size
let ratio = min(maxSize / size.width, maxSize / size.height)
if ratio >= 1 { return image }
let newSize = CGSize(width: size.width * ratio, height: size.height * ratio)
let renderer = UIGraphicsImageRenderer(size: newSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: newSize))
}
}
}

View file

@ -0,0 +1,210 @@
import SwiftUI
struct SearchView: View {
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var offlineManager: OfflineManager
@ObservedObject private var libraryCache = LibraryCache.shared
@State private var searchText = ""
@State private var artists: [Artist] = []
@State private var albums: [Album] = []
@State private var songs: [Song] = []
@State private var isSearching = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
// Search bar
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
TextField("Artists, Songs, Albums", text: $searchText)
.foregroundColor(.white)
.autocapitalization(.none)
.disableAutocorrection(true)
.onSubmit { performSearch() }
.onChange(of: searchText) { oldValue, newValue in
if newValue.count >= 2 {
performSearch()
}
}
if !searchText.isEmpty {
Button(action: {
searchText = ""
artists = []; albums = []; songs = []
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
}
}
}
.padding(10)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
.padding(.horizontal, 16)
.padding(.top, 10)
if isSearching {
ProgressView().tint(accentPink).padding(.top, 40)
} else if !searchText.isEmpty {
searchResults
} else {
emptyState
}
Color.clear.frame(height: 120)
}
}
.background(Color(white: 0.06))
.navigationTitle("Search")
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 40))
.foregroundColor(.gray)
Text("Search your library")
.font(.system(size: 16))
.foregroundColor(.gray)
}
.padding(.top, 80)
}
private var searchResults: some View {
LazyVStack(alignment: .leading, spacing: 0) {
// Artists
if !artists.isEmpty {
sectionHeader("Artists")
ForEach(artists) { artist in
NavigationLink(destination: ArtistDetailView(artistId: artist.id)) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: artist.coverArt, size: 44)
.frame(width: 44, height: 44)
.clipShape(Circle())
Text(artist.name)
.font(.system(size: 15))
.foregroundColor(.white)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
// Albums
if !albums.isEmpty {
sectionHeader("Albums")
ForEach(albums) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: album.coverArt, size: 48)
.frame(width: 48, height: 48)
.cornerRadius(4)
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.system(size: 15))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
// Songs
if !songs.isEmpty {
sectionHeader("Songs")
ForEach(songs) { song in
let available = offlineManager.isSongDownloaded(song.id) || !libraryCache.isOffline
Button(action: {
if available {
audioPlayer.play(song: song, fromQueue: songs)
}
}) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: song.coverArt, size: 44)
.frame(width: 44, height: 44)
.cornerRadius(3)
.opacity(available ? 1.0 : 0.4)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(
!available ? .gray.opacity(0.35) :
audioPlayer.currentSong?.id == song.id ? accentPink : .white
)
.lineLimit(1)
Text("\(song.artist ?? "")\(song.album ?? "")")
.font(.system(size: 12))
.foregroundColor(available ? .gray : .gray.opacity(0.3))
.lineLimit(1)
}
Spacer()
Text(song.durationFormatted)
.font(.system(size: 13))
.foregroundColor(available ? .gray : .gray.opacity(0.3))
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
}
}
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 8)
}
private func performSearch() {
guard searchText.count >= 2 else { return }
isSearching = true
Task {
do {
let result = try await serverManager.client.search3(query: searchText)
await MainActor.run {
artists = result?.artist ?? []
albums = result?.album ?? []
songs = result?.song ?? []
isSearching = false
}
} catch {
await MainActor.run { isSearching = false }
}
}
}
}

View file

@ -0,0 +1,392 @@
import SwiftUI
struct LoginView: View {
@EnvironmentObject var serverManager: ServerManager
@State private var showingAddServer = false
@State private var editingServer: ServerConfig?
@State private var isConnecting = false
@State private var errorMessage: String?
// iOS 8 Music app accent
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
ZStack {
// Gradient background
LinearGradient(
gradient: Gradient(colors: [
Color(white: 0.12),
Color(white: 0.06)
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 0) {
// Header
VStack(spacing: 12) {
Image(systemName: "music.note.house.fill")
.font(.system(size: 56))
.foregroundColor(accentPink)
Text("Navidrome")
.font(.system(size: 34, weight: .bold))
.foregroundColor(.white)
Text("Connect to your music server")
.font(.system(size: 15))
.foregroundColor(.gray)
}
.padding(.top, 60)
.padding(.bottom, 40)
// Server list
if !serverManager.servers.isEmpty {
VStack(spacing: 1) {
ForEach(serverManager.servers) { server in
ServerRow(
server: server,
isActive: serverManager.activeServer?.id == server.id,
isConnecting: isConnecting && serverManager.activeServer?.id == server.id,
onConnect: { connectTo(server) },
onEdit: { editingServer = server },
onDelete: { delete(server) }
)
}
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 20)
}
// Error message
if let error = errorMessage {
Text(error)
.font(.system(size: 13))
.foregroundColor(.red)
.padding(.top, 12)
.padding(.horizontal, 20)
}
Spacer()
// Add Server Button
Button(action: { showingAddServer = true }) {
HStack {
Image(systemName: "plus.circle.fill")
Text("Add Server")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
.background(accentPink)
.foregroundColor(.white)
.cornerRadius(12)
}
.padding(.horizontal, 20)
.padding(.bottom, 40)
}
}
.navigationBarHidden(true)
.sheet(isPresented: $showingAddServer) {
AddServerSheet(isPresented: $showingAddServer)
}
.sheet(item: $editingServer) { server in
AddServerSheet(isPresented: Binding(
get: { editingServer != nil },
set: { if !$0 { editingServer = nil } }
), existingServer: server)
}
}
}
private func connectTo(_ server: ServerConfig) {
isConnecting = true
errorMessage = nil
Task {
let success = await serverManager.connect(to: server)
await MainActor.run {
isConnecting = false
if !success {
if case .error(let msg) = serverManager.connectionState {
errorMessage = msg
} else {
errorMessage = "Connection failed"
}
}
}
}
}
private func delete(_ server: ServerConfig) {
serverManager.removeServer(server)
}
}
// MARK: - Server Row
struct ServerRow: View {
let server: ServerConfig
let isActive: Bool
let isConnecting: Bool
let onConnect: () -> Void
let onEdit: () -> Void
let onDelete: () -> Void
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
Button(action: onConnect) {
HStack(spacing: 14) {
// Server icon
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(
isActive
? accentPink.opacity(0.2)
: Color.white.opacity(0.08)
)
.frame(width: 44, height: 44)
Image(systemName: "server.rack")
.font(.system(size: 18))
.foregroundColor(isActive ? accentPink : .gray)
}
VStack(alignment: .leading, spacing: 3) {
Text(server.name)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
Text(server.url)
.font(.system(size: 12))
.foregroundColor(.gray)
.lineLimit(1)
Text(server.username)
.font(.system(size: 11))
.foregroundColor(.gray.opacity(0.7))
}
Spacer()
if isConnecting {
ProgressView()
.tint(accentPink)
} else if isActive {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(accentPink)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color(white: 0.15))
}
.contextMenu {
Button("Edit", action: onEdit)
Button("Delete", role: .destructive, action: onDelete)
}
}
}
// MARK: - Add/Edit Server Sheet
struct AddServerSheet: View {
@EnvironmentObject var serverManager: ServerManager
@Binding var isPresented: Bool
var existingServer: ServerConfig?
@State private var name: String = ""
@State private var url: String = ""
@State private var username: String = ""
@State private var password: String = ""
@State private var isTesting = false
@State private var testResult: String?
@State private var testSuccess = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
ZStack {
Color(white: 0.1).ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Server Name
VStack(alignment: .leading, spacing: 8) {
Text("SERVER NAME")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.gray)
TextField("My Server", text: $name)
.textFieldStyle(DarkFieldStyle())
}
// URL
VStack(alignment: .leading, spacing: 8) {
Text("SERVER URL")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.gray)
TextField("https://music.example.com", text: $url)
.textFieldStyle(DarkFieldStyle())
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
}
// Username
VStack(alignment: .leading, spacing: 8) {
Text("USERNAME")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.gray)
TextField("username", text: $username)
.textFieldStyle(DarkFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
}
// Password
VStack(alignment: .leading, spacing: 8) {
Text("PASSWORD")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.gray)
SecureField("password", text: $password)
.textFieldStyle(DarkFieldStyle())
}
// Test Connection
Button(action: testConnection) {
HStack {
if isTesting {
ProgressView()
.tint(.white)
.scaleEffect(0.8)
} else {
Image(systemName: "antenna.radiowaves.left.and.right")
}
Text("Test Connection")
.fontWeight(.medium)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.white.opacity(0.15))
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(url.isEmpty || username.isEmpty)
if let result = testResult {
HStack(spacing: 6) {
Image(systemName: testSuccess ? "checkmark.circle.fill" : "xmark.circle.fill")
Text(result)
}
.font(.system(size: 13))
.foregroundColor(testSuccess ? .green : .red)
}
// Tip about multiple servers
HStack(spacing: 8) {
Image(systemName: "info.circle")
.foregroundColor(.gray)
Text("You can add multiple servers. For example, a public and private URL to the same Navidrome instance.")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(12)
.background(Color.white.opacity(0.05))
.cornerRadius(8)
}
.padding(20)
}
}
.navigationTitle(existingServer == nil ? "Add Server" : "Edit Server")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { isPresented = false }
.foregroundColor(accentPink)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") { save() }
.fontWeight(.semibold)
.foregroundColor(accentPink)
.disabled(name.isEmpty || url.isEmpty || username.isEmpty)
}
}
.onAppear {
if let s = existingServer {
name = s.name
url = s.url
username = s.username
password = s.password
}
}
}
}
private func testConnection() {
isTesting = true
testResult = nil
let testServer = ServerConfig(
name: name, url: url, username: username, password: password
)
Task {
do {
let success = try await serverManager.client.ping(server: testServer)
await MainActor.run {
isTesting = false
testSuccess = success
testResult = success ? "Connection successful!" : "Server returned error"
}
} catch {
await MainActor.run {
isTesting = false
testSuccess = false
testResult = error.localizedDescription
}
}
}
}
private func save() {
if var existing = existingServer {
existing.name = name
existing.url = url
existing.username = username
existing.password = password
serverManager.updateServer(existing)
} else {
let server = ServerConfig(
name: name, url: url, username: username, password: password
)
serverManager.addServer(server)
}
isPresented = false
}
}
// MARK: - Dark Text Field Style
struct DarkFieldStyle: TextFieldStyle {
func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding(12)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
.foregroundColor(.white)
}
}
// MARK: - Preview
#if DEBUG
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
LoginView()
.environmentObject(ServerManager.shared)
.preferredColorScheme(.dark)
}
}
#endif

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,399 @@
import Foundation
import AVFoundation
import Combine
/// Captures a radio stream into a local file buffer for timeshift scrubbing and recording.
/// No microphone is used the raw stream bytes are saved directly from URLSession.
class RadioStreamBuffer: NSObject, ObservableObject, URLSessionDataDelegate {
static let shared = RadioStreamBuffer()
// MARK: - Published State
@Published var isBuffering = false
@Published var isRecording = false
@Published var recordingTimeFormatted: String = "0:00"
@Published var bufferedDuration: TimeInterval = 0
@Published var isLive = true
@Published var isHLSStream = false // HLS streams can't be raw-buffered
// MARK: - Config
let maxBufferDuration: TimeInterval = 20 * 60 // 20 minutes
// MARK: - Internal
private var dataTask: URLSessionDataTask?
private var urlSession: URLSession?
private var bufferFileHandle: FileHandle?
private var bufferFileURL: URL?
private var totalBytesWritten: Int64 = 0
private var streamStartTime: Date?
private var estimatedBitRate: Double = 128000 // bits per second, updated from headers
private var recordingStartByte: Int64 = 0
private var recordingTimer: Timer?
private var recordingStartTime: Date?
private var stationName: String = ""
private var bufferGeneration: Int = 0 // Prevents old URLSession callbacks from overwriting state
private override init() {
super.init()
}
// MARK: - Start Buffering
private func rlog(_ msg: String) {
DebugLogger.shared.log(msg, category: "Radio")
}
/// Start capturing a radio stream. Call this when a radio station starts playing.
/// The URL should already be resolved (not a .pls/.m3u playlist) use resolveStreamURL() first.
func startBuffering(url: URL, stationName: String) {
// Increment generation BEFORE stopping so old callbacks are ignored
bufferGeneration += 1
let gen = bufferGeneration
stopBuffering()
self.stationName = stationName
isHLSStream = false
rlog("Start buffering (gen \(gen)): \(stationName)\(url.absoluteString)")
// Detect HLS from URL extension
let ext = url.pathExtension.lowercased()
if ext == "m3u8" {
rlog("HLS stream detected from URL extension — buffering/recording unavailable")
isHLSStream = true
isBuffering = false
return
}
// Create temp file
let tempDir = FileManager.default.temporaryDirectory
let filename = "radio_buffer_\(UUID().uuidString).raw"
let fileURL = tempDir.appendingPathComponent(filename)
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
do {
bufferFileHandle = try FileHandle(forWritingTo: fileURL)
} catch {
rlog("Failed to create buffer file: \(error)")
return
}
bufferFileURL = fileURL
totalBytesWritten = 0
streamStartTime = Date()
isBuffering = true
isLive = true
bufferedDuration = 0
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.waitsForConnectivity = true
urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
dataTask = urlSession?.dataTask(with: url)
dataTask?.resume()
}
/// Stop capturing cleans up temp file
func stopBuffering() {
if isBuffering { rlog("Stop buffering: \(stationName)") }
_ = stopRecording()
dataTask?.cancel()
dataTask = nil
urlSession?.invalidateAndCancel()
urlSession = nil
bufferFileHandle?.closeFile()
bufferFileHandle = nil
// Clean up temp file
if let url = bufferFileURL {
try? FileManager.default.removeItem(at: url)
}
bufferFileURL = nil
totalBytesWritten = 0
streamStartTime = nil
isBuffering = false
isHLSStream = false
bufferedDuration = 0
isLive = true
}
// MARK: - URLSessionDataDelegate
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
if let httpResponse = response as? HTTPURLResponse {
rlog("Stream response: HTTP \(httpResponse.statusCode)")
let ct = httpResponse.mimeType?.lowercased() ?? ""
// Detect HLS from content-type
let hlsTypes = ["application/vnd.apple.mpegurl", "application/x-mpegurl"]
if hlsTypes.contains(ct) || (ct.contains("mpegurl") && ct.contains("apple")) {
rlog("HLS stream detected from content-type (\(ct)) — cancelling raw buffer")
DispatchQueue.main.async {
self.isHLSStream = true
self.isBuffering = false
}
completionHandler(.cancel)
return
}
// Detect playlist content-types (PLS, M3U, ASX) shouldn't happen
// if resolution worked, but guard against it
if Self.playlistContentTypes.contains(ct) {
rlog("Playlist content-type detected (\(ct)) — URL was not resolved. Cancelling buffer.")
DispatchQueue.main.async {
self.isBuffering = false
}
completionHandler(.cancel)
return
}
if let icyBR = httpResponse.allHeaderFields["icy-br"] as? String,
let br = Double(icyBR) {
estimatedBitRate = br * 1000
rlog("Stream bitrate (icy-br): \(icyBR) kbps")
}
if let mimeType = httpResponse.mimeType {
rlog("Stream content-type: \(mimeType)")
}
}
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
bufferFileHandle?.write(data)
totalBytesWritten += Int64(data.count)
let bytesPerSecond = estimatedBitRate / 8.0
let estDuration = Double(totalBytesWritten) / bytesPerSecond
DispatchQueue.main.async {
self.bufferedDuration = estDuration
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
let gen = bufferGeneration // Capture current generation
if let error = error as? NSError, error.code != NSURLErrorCancelled {
rlog("Stream ended with error (gen \(gen)): \(error.localizedDescription)")
} else if error == nil {
rlog("Stream ended normally (gen \(gen))")
} else {
rlog("Stream cancelled (gen \(gen))")
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// Only mark as not buffering if this callback is from the CURRENT session
// Old cancelled sessions must not reset the flag
if gen == self.bufferGeneration {
self.isBuffering = false
self.rlog("isBuffering → false (gen \(gen) matches)")
} else {
self.rlog("Ignoring stale completion (gen \(gen), current \(self.bufferGeneration))")
}
}
}
// MARK: - Timeshift Playback
/// Get a playable URL for the buffer. AVPlayer can seek within this file.
var bufferPlaybackURL: URL? {
return bufferFileURL
}
/// Estimated duration of buffered content
var estimatedBufferSeconds: TimeInterval {
let bytesPerSecond = estimatedBitRate / 8.0
guard bytesPerSecond > 0 else { return 0 }
return Double(totalBytesWritten) / bytesPerSecond
}
/// Convert a "seconds ago" offset to a byte position for seeking
func seekPosition(secondsFromLive: TimeInterval) -> TimeInterval {
let total = estimatedBufferSeconds
return max(0, total - secondsFromLive)
}
// MARK: - Recording (no mic direct stream capture)
/// Start recording marks the current buffer position
func startRecording() {
rlog("startRecording: isBuffering=\(isBuffering), isHLS=\(isHLSStream), totalBytes=\(totalBytesWritten), gen=\(bufferGeneration)")
if isHLSStream {
rlog("startRecording SKIPPED: HLS streams cannot be raw-captured. Recording unavailable for this station.")
return
}
guard isBuffering else {
rlog("startRecording FAILED: not buffering. URLSession state: \(urlSession != nil ? "exists" : "nil"), dataTask: \(dataTask?.state.rawValue ?? -1)")
return
}
rlog("Recording started at byte offset \(totalBytesWritten)")
recordingStartByte = totalBytesWritten
recordingStartTime = Date()
isRecording = true
recordingTimeFormatted = "0:00"
recordingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let start = self?.recordingStartTime else { return }
let elapsed = Int(Date().timeIntervalSince(start))
let min = elapsed / 60
let sec = elapsed % 60
DispatchQueue.main.async {
self?.recordingTimeFormatted = String(format: "%d:%02d", min, sec)
}
}
}
/// Stop recording saves the captured segment to Documents/RadioRecordings/
func stopRecording() -> URL? {
guard isRecording else { return nil }
isRecording = false
recordingTimer?.invalidate()
recordingTimer = nil
guard let bufferURL = bufferFileURL else { return nil }
// Read the recorded segment from the buffer file
let endByte = totalBytesWritten
let recordedBytes = endByte - recordingStartByte
guard recordedBytes > 0 else { return nil }
do {
let fm = FileManager.default
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first!
let dir = docs.appendingPathComponent("RadioRecordings", isDirectory: true)
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let dateStr = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
let safeName = stationName.replacingOccurrences(of: "/", with: "-")
// Determine extension from stream content
let ext = guessExtension()
let filename = "\(safeName)_\(dateStr).\(ext)"
let destURL = dir.appendingPathComponent(filename)
// Read segment from buffer file
let readHandle = try FileHandle(forReadingFrom: bufferURL)
readHandle.seek(toFileOffset: UInt64(recordingStartByte))
let data = readHandle.readData(ofLength: Int(recordedBytes))
readHandle.closeFile()
try data.write(to: destURL)
print("Saved recording: \(filename) (\(data.count) bytes)")
recordingTimeFormatted = "0:00"
return destURL
} catch {
print("Failed to save recording: \(error)")
return nil
}
}
private func guessExtension() -> String {
// Default to mp3 for radio streams most common
return "mp3"
}
// MARK: - Playlist URL Resolver
/// Playlist file extensions that wrap the actual audio stream URL
private static let playlistExtensions: Set<String> = ["pls", "m3u", "asx", "xspf"]
/// Content-types that indicate a playlist, not audio
private static let playlistContentTypes: Set<String> = [
"audio/x-scpls", "audio/scpls", // PLS
"audio/x-mpegurl", "audio/mpegurl", // M3U
"application/vnd.apple.mpegurl", "application/x-mpegurl", // HLS M3U8
"video/x-ms-asf", "application/x-mms-framed", // ASX
"application/xspf+xml" // XSPF
]
/// Check if a URL points to a playlist file rather than a direct audio stream
static func isPlaylistURL(_ url: URL) -> Bool {
playlistExtensions.contains(url.pathExtension.lowercased())
}
/// Resolve a playlist URL to the actual audio stream URL.
/// If the URL is already a direct stream, returns it unchanged.
static func resolveStreamURL(_ url: URL) async -> URL {
let ext = url.pathExtension.lowercased()
guard playlistExtensions.contains(ext) else { return url }
DebugLogger.shared.log("Resolving playlist URL (\(ext)): \(url.lastPathComponent)", category: "Radio")
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let text = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) else {
DebugLogger.shared.log("Playlist: couldn't decode text", category: "Radio")
return url
}
// Check content-type to determine format
let ct = (response as? HTTPURLResponse)?.mimeType?.lowercased() ?? ""
// HLS return original URL, AVPlayer handles it natively
if ct.contains("mpegurl") && ext == "m3u8" {
DebugLogger.shared.log("Playlist is HLS — returning original URL for AVPlayer", category: "Radio")
return url
}
// Try parsing based on extension / content
if let resolved = parsePLS(text) ?? parseM3U(text) ?? parseASX(text) {
DebugLogger.shared.log("Resolved stream: \(resolved.absoluteString)", category: "Radio")
return resolved
}
DebugLogger.shared.log("Playlist: no stream URL found in \(data.count) bytes", category: "Radio")
return url
} catch {
DebugLogger.shared.log("Playlist resolve failed: \(error.localizedDescription)", category: "Radio")
return url
}
}
/// Parse PLS format: `File1=http://...`
private static func parsePLS(_ text: String) -> URL? {
for line in text.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Match File1=, File2=, etc.
if trimmed.lowercased().hasPrefix("file"),
let eqIndex = trimmed.firstIndex(of: "=") {
let urlString = String(trimmed[trimmed.index(after: eqIndex)...]).trimmingCharacters(in: .whitespaces)
if urlString.lowercased().hasPrefix("http"), let url = URL(string: urlString) {
return url
}
}
}
return nil
}
/// Parse M3U format: first non-comment line starting with http
private static func parseM3U(_ text: String) -> URL? {
for line in text.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
if trimmed.lowercased().hasPrefix("http"), let url = URL(string: trimmed) {
return url
}
}
return nil
}
/// Parse ASX format: `<ref href="http://..."/>`
private static func parseASX(_ text: String) -> URL? {
// Simple regex-free parse for <ref href="..."/>
let lower = text.lowercased()
guard let refRange = lower.range(of: "href") else { return nil }
let after = text[refRange.upperBound...]
// Find the quoted URL
guard let quoteStart = after.firstIndex(of: "\"") else { return nil }
let urlStart = after.index(after: quoteStart)
guard let quoteEnd = after[urlStart...].firstIndex(of: "\"") else { return nil }
let urlString = String(after[urlStart..<quoteEnd])
if urlString.lowercased().hasPrefix("http") {
return URL(string: urlString)
}
return nil
}
}

View file

@ -0,0 +1,60 @@
import SwiftUI
/// A custom seek bar with independent drag state to prevent timer/thumb fighting during scrubs.
struct SiriSeekBar: View {
@Binding var value: Double // 0.0 to 1.0, bound to playback progress
var onSeek: (Double) -> Void // Called when drag ends with final position
var onDragChanged: ((Double) -> Void)? = nil // Called continuously during drag
@State private var isDragging: Bool = false
@State private var dragValue: Double = 0.0
var body: some View {
GeometryReader { geo in
let width = geo.size.width
let currentVal = isDragging ? dragValue : value
let fillWidth = max(0, min(width, CGFloat(currentVal) * width))
ZStack(alignment: .leading) {
// 1. Background track
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: isDragging ? 8 : 4)
// 2. Fill track
Capsule()
.fill(Color.white)
.frame(width: fillWidth, height: isDragging ? 8 : 4)
// 3. Scrubber thumb (grows when dragging)
Circle()
.fill(Color.white)
.frame(width: isDragging ? 18 : 8, height: isDragging ? 18 : 8)
.offset(x: fillWidth - (isDragging ? 9 : 4))
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 2)
}
.frame(height: 30)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
let pct = max(0, min(1, Double(gesture.location.x / width)))
if !isDragging {
isDragging = true
dragValue = value
}
dragValue = pct
onDragChanged?(pct)
}
.onEnded { gesture in
let pct = max(0, min(1, Double(gesture.location.x / width)))
dragValue = pct
isDragging = false
onSeek(pct)
}
)
.animation(.easeInOut(duration: 0.15), value: isDragging)
}
.frame(height: 30)
}
}

View file

@ -0,0 +1,846 @@
import SwiftUI
// MARK: - Visualizer Settings (Mitsuha Infinity parity + advanced physics)
class VisualizerSettings: ObservableObject {
static let shared = VisualizerSettings()
// General
@Published var enabled: Bool { didSet { save("vis_enabled", enabled) } }
@Published var nowPlayingEnabled: Bool { didSet { save("vis_nowplaying", nowPlayingEnabled) } }
@Published var miniPlayerEnabled: Bool { didSet { save("vis_miniplayer", miniPlayerEnabled) } }
@Published var style: Style { didSet { save("vis_style", style.rawValue) } }
@Published var numberOfPoints: Int { didSet { save("vis_points", numberOfPoints) } }
@Published var sensitivity: Double { didSet { save("vis_sensitivity", sensitivity) } }
@Published var fps: Double { didSet { save("vis_fps", fps) } }
@Published var realAudioAnalysis: Bool { didSet { save("vis_real_fft", realAudioAnalysis) } }
// Wave
@Published var waveOffsetTop: Double { didSet { save("vis_wave_offset", waveOffsetTop) } }
// Bar
@Published var barSpacing: Double { didSet { save("vis_bar_spacing", barSpacing) } }
@Published var barCornerRadius: Double { didSet { save("vis_bar_radius", barCornerRadius) } }
// Line
@Published var lineThickness: Double { didSet { save("vis_line_thick", lineThickness) } }
// Color
@Published var colorMode: ColorMode { didSet { save("vis_color", colorMode.rawValue) } }
@Published var alpha: Double { didSet { save("vis_alpha", alpha) } }
@Published var customColor: Color = .pink
// Advanced Physics
@Published var viscosity: Double { didSet { save("vis_viscosity", viscosity) } }
@Published var frequencyCutoff: Int { didSet { save("vis_freq_cutoff", frequencyCutoff) } }
@Published var baseMultiplier: Double { didSet { save("vis_base_mult", baseMultiplier) } }
@Published var depthOffset: Double { didSet { save("vis_depth_offset", depthOffset) } }
@Published var depthOpacity: Double { didSet { save("vis_depth_opacity", depthOpacity) } }
@Published var idleAmplitude: Double { didSet { save("vis_idle_amp", idleAmplitude) } }
@Published var waveStrokeThickness: Double { didSet { save("vis_wave_stroke", waveStrokeThickness) } }
// Layout
@Published var nowPlayingHeightPct: Double { didSet { save("vis_np_height", nowPlayingHeightPct) } }
@Published var miniPlayerHeight: Double { didSet { save("vis_mini_height", miniPlayerHeight) } }
// Mini Player overrides
@Published var miniOpacity: Double { didSet { save("vis_mini_opacity", miniOpacity) } }
@Published var miniAmplitude: Double { didSet { save("vis_mini_amplitude", miniAmplitude) } }
@Published var miniIdleAmplitude: Double { didSet { save("vis_mini_idle", miniIdleAmplitude) } }
@Published var miniDepthOffset: Double { didSet { save("vis_mini_depth", miniDepthOffset) } }
@Published var miniDepthOpacity: Double { didSet { save("vis_mini_depth_opacity", miniDepthOpacity) } }
// Now Playing overrides
@Published var npAmplitude: Double { didSet { save("vis_np_amplitude", npAmplitude) } }
@Published var npBaseLift: Double { didSet { save("vis_np_baselift", npBaseLift) } }
enum Style: String, CaseIterable {
case wave = "Wave"
case bar = "Bar"
case line = "Line"
}
enum ColorMode: String, CaseIterable {
case dynamic = "Dynamic"
case albumArt = "Album Art"
case siri = "Siri"
case custom = "Custom"
}
private init() {
let d = UserDefaults.standard
enabled = d.object(forKey: "vis_enabled") as? Bool ?? true
nowPlayingEnabled = d.object(forKey: "vis_nowplaying") as? Bool ?? true
miniPlayerEnabled = d.object(forKey: "vis_miniplayer") as? Bool ?? true
style = Style(rawValue: d.string(forKey: "vis_style") ?? "") ?? .wave
numberOfPoints = { let v = d.integer(forKey: "vis_points"); return v > 0 ? v : 10 }()
sensitivity = { let v = d.double(forKey: "vis_sensitivity"); return v > 0 ? v : 1.5 }()
fps = { let v = d.double(forKey: "vis_fps"); return v > 0 ? v : 60.0 }()
realAudioAnalysis = d.object(forKey: "vis_real_fft") as? Bool ?? true
waveOffsetTop = d.double(forKey: "vis_wave_offset")
barSpacing = { let v = d.double(forKey: "vis_bar_spacing"); return v > 0 ? v : 5.0 }()
barCornerRadius = d.double(forKey: "vis_bar_radius")
lineThickness = { let v = d.double(forKey: "vis_line_thick"); return v > 0 ? v : 5.0 }()
colorMode = ColorMode(rawValue: d.string(forKey: "vis_color") ?? "") ?? .dynamic
alpha = { let v = d.double(forKey: "vis_alpha"); return v > 0 ? v : 0.6 }()
// Advanced
viscosity = { let v = d.double(forKey: "vis_viscosity"); return v > 0 ? v : 0.25 }()
frequencyCutoff = { let v = d.integer(forKey: "vis_freq_cutoff"); return v > 0 ? v : 80 }()
baseMultiplier = { let v = d.double(forKey: "vis_base_mult"); return v > 0 ? v : 40.0 }()
depthOffset = { let v = d.double(forKey: "vis_depth_offset"); return v > 0 ? v : 15.0 }()
depthOpacity = { let v = d.double(forKey: "vis_depth_opacity"); return v > 0 ? v : 0.2 }()
idleAmplitude = { let v = d.double(forKey: "vis_idle_amp"); return v > 0 ? v : 0.03 }()
waveStrokeThickness = { let v = d.double(forKey: "vis_wave_stroke"); return v > 0 ? v : 1.5 }()
// Layout
nowPlayingHeightPct = { let v = d.double(forKey: "vis_np_height"); return v > 0 ? v : 0.50 }()
miniPlayerHeight = { let v = d.double(forKey: "vis_mini_height"); return v > 0 ? v : 48.0 }()
// Mini Player
miniOpacity = d.object(forKey: "vis_mini_opacity") as? Double ?? 0.5
miniAmplitude = { let v = d.double(forKey: "vis_mini_amplitude"); return v > 0 ? v : 0.7 }()
miniIdleAmplitude = { let v = d.double(forKey: "vis_mini_idle"); return v > 0 ? v : 0.03 }()
miniDepthOffset = d.object(forKey: "vis_mini_depth") as? Double ?? 8.0
miniDepthOpacity = d.object(forKey: "vis_mini_depth_opacity") as? Double ?? 0.2
// Now Playing
npAmplitude = { let v = d.double(forKey: "vis_np_amplitude"); return v > 0 ? v : 0.45 }()
npBaseLift = d.object(forKey: "vis_np_baselift") as? Double ?? 130.0
}
var effectiveFPS: Double {
ProcessInfo.processInfo.isLowPowerModeEnabled ? min(fps, 24) : fps
}
private func save(_ key: String, _ value: Any) {
// Debounce: update local var instantly for UI, delay disk write
pendingSaves[key] = value
saveTask?.cancel()
saveTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 500_000_000)
guard !Task.isCancelled else { return }
if let pending = self?.pendingSaves {
for (k, v) in pending {
UserDefaults.standard.set(v, forKey: k)
}
self?.pendingSaves.removeAll()
}
}
}
private var pendingSaves: [String: Any] = [:]
private var saveTask: Task<Void, Never>?
}
// MARK: - Main Visualizer View
struct MitsuhaVisualizerView: View {
/// Optional override levels for previews. When nil, pulls from AudioPlayer.
var previewLevels: [Float]? = nil
let isPlaying: Bool
let accentColor: Color
var compact: Bool = false
@ObservedObject var settings = VisualizerSettings.shared
@ObservedObject var albumColors = AlbumColorExtractor.shared
@State private var touchRipples: [TouchRipple] = []
@State private var displayLevels: [Float] = []
struct TouchRipple: Identifiable {
let id = UUID()
let x: CGFloat
let createdAt: Date
}
var body: some View {
if settings.enabled {
GeometryReader { geo in
TimelineView(.animation(minimumInterval: 1.0 / settings.effectiveFPS)) { timeline in
// PULL: Grab latest levels right before rendering no @Published thrashing
let rawLevels = previewLevels ?? AudioPlayer.shared.currentLevels()
let _ = updateDisplayLevelsIfNeeded(newRawLevels: rawLevels)
Canvas(opaque: false) { context, size in
let pts = isPlaying ? displayLevels : Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
guard pts.count >= 2 else { return }
switch settings.style {
case .wave: drawWave(ctx: context, size: size, levels: pts, date: timeline.date)
case .bar: drawBars(ctx: context, size: size, levels: pts)
case .line: drawLine(ctx: context, size: size, levels: pts)
}
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
addRipple(at: value.location.x / geo.size.width)
}
)
}
.opacity(isPlaying ? 1 : 0)
.animation(.easeInOut(duration: 0.6), value: isPlaying)
.onAppear {
displayLevels = Array(repeating: Float(settings.idleAmplitude), count: settings.numberOfPoints)
}
}
}
/// Called every frame inside TimelineView updates displayLevels in-place
/// Returns Void; the `let _ =` assignment is just to run code inside the ViewBuilder
private func updateDisplayLevelsIfNeeded(newRawLevels: [Float]) -> Bool {
// Must dispatch to avoid mutating @State during view render
DispatchQueue.main.async {
self.updateDisplayLevels(newRawLevels: newRawLevels)
}
return true
}
// MARK: - Touch Ripples
private func addRipple(at x: CGFloat) {
let clamped = min(max(x, 0), 1)
if let last = touchRipples.last, Date().timeIntervalSince(last.createdAt) < 0.04 { return }
touchRipples.append(TouchRipple(x: clamped, createdAt: Date()))
let now = Date()
touchRipples.removeAll { now.timeIntervalSince($0.createdAt) > 1.0 }
if touchRipples.count > 25 { touchRipples.removeFirst() }
}
// MARK: - Temporal Smoothing & Log Binning
private func updateDisplayLevels(newRawLevels: [Float]) {
let count = settings.numberOfPoints
guard count > 0, !newRawLevels.isEmpty else { return }
let sens = Float(settings.sensitivity)
let isPreProcessed = AudioPlayer.shared.isUsingOfflineVis
var targetLevels: [Float]
if isPreProcessed {
// Offline vis frames are already normalized (peak 0.8) and log-binned.
// Only apply sensitivity for user control no baseMultiplier needed.
if newRawLevels.count == count {
targetLevels = newRawLevels.map { min(1.0, $0 * sens) }
} else {
// Resample to match display point count via linear interpolation
targetLevels = (0..<count).map { i in
let srcPos = Float(i) / Float(max(count - 1, 1)) * Float(newRawLevels.count - 1)
let lo = Int(srcPos)
let hi = min(lo + 1, newRawLevels.count - 1)
let frac = srcPos - Float(lo)
let val = newRawLevels[lo] * (1.0 - frac) + newRawLevels[hi] * frac
return min(1.0, val * sens)
}
}
} else {
// Raw FFT bins or simulated levels full log binning + EQ boost
targetLevels = [Float](repeating: 0, count: count)
let maxUsefulBin = min(newRawLevels.count - 1, settings.frequencyCutoff)
for i in 0..<count {
let normalizedIndex = Float(i + 1) / Float(count)
let logIndex = log10(normalizedIndex * 9.0 + 1.0)
let centerBin = logIndex * Float(maxUsefulBin)
let binWidth = max(1.0, Float(maxUsefulBin) / Float(count) * logIndex)
let startBin = max(1, Int(centerBin - binWidth / 2))
let endBin = min(maxUsefulBin, Int(centerBin + binWidth / 2))
var sum: Float = 0
var countInBand = 0
for j in startBin...endBin where j < newRawLevels.count {
sum += newRawLevels[j]
countInBand += 1
}
let averageInBand = countInBand > 0 ? (sum / Float(countInBand)) : 0
let eqBoost: Float = 1.0 + (Float(i) / Float(count)) * 3.5
targetLevels[i] = min(1.0, averageInBand * Float(settings.baseMultiplier) * sens * eqBoost)
}
}
// Temporal Smoothing (Viscosity)
if displayLevels.count != count {
displayLevels = targetLevels
} else {
let smoothFactor = Float(settings.viscosity)
for i in 0..<count {
displayLevels[i] = displayLevels[i] + (targetLevels[i] - displayLevels[i]) * smoothFactor
}
}
}
// MARK: - Colors
private var fillColors: [Color] {
let a = settings.alpha
switch settings.colorMode {
case .dynamic: return [accentColor.opacity(a), accentColor.opacity(a * 0.6)]
case .albumArt: return [albumColors.primaryColor.opacity(a), albumColors.secondaryColor.opacity(a * 0.7)]
case .siri: return [.pink.opacity(a), .green.opacity(a), .cyan.opacity(a), .purple.opacity(a), .white.opacity(a * 0.5), .pink.opacity(a)]
case .custom: return [settings.customColor.opacity(a), settings.customColor.opacity(a * 0.6)]
}
}
private var strokeColor: Color {
switch settings.colorMode {
case .dynamic: return accentColor.opacity(min(1, settings.alpha + 0.3))
case .albumArt: return albumColors.primaryColor.opacity(min(1, settings.alpha + 0.3))
case .siri: return Color.cyan.opacity(min(1, settings.alpha + 0.3))
case .custom: return settings.customColor.opacity(min(1, settings.alpha + 0.3))
}
}
// MARK: - Catmull-Rom Spline (tension 0.3 = heavy liquid surface tension)
/// Tension: 0.3 gives rounded, rolling peaks. Lower = tighter. Higher = bouncier.
private let curveTension: CGFloat = 0.3
/// Curve path without initial moveTo used for fill shapes
private func smoothCurve(_ points: [CGPoint]) -> Path {
var p = Path()
guard points.count >= 2 else { return p }
for i in 0..<(points.count - 1) {
let p0 = i > 0 ? points[i - 1] : points[0]
let p1 = points[i]
let p2 = points[i + 1]
let p3 = i < points.count - 2 ? points[i + 2] : p2
let cp1 = CGPoint(
x: p1.x + (p2.x - p0.x) * curveTension,
y: p1.y + (p2.y - p0.y) * curveTension
)
let cp2 = CGPoint(
x: p2.x - (p3.x - p1.x) * curveTension,
y: p2.y - (p3.y - p1.y) * curveTension
)
p.addCurve(to: p2, control1: cp1, control2: cp2)
}
return p
}
/// Full curve path with moveTo used for strokes
private func strokeableCurve(_ points: [CGPoint]) -> Path {
var p = Path()
guard points.count >= 2 else { return p }
p.move(to: points[0])
for i in 0..<(points.count - 1) {
let p0 = i > 0 ? points[i - 1] : points[0]
let p1 = points[i]
let p2 = points[i + 1]
let p3 = i < points.count - 2 ? points[i + 2] : p2
let cp1 = CGPoint(
x: p1.x + (p2.x - p0.x) * curveTension,
y: p1.y + (p2.y - p0.y) * curveTension
)
let cp2 = CGPoint(
x: p2.x - (p3.x - p1.x) * curveTension,
y: p2.y - (p3.y - p1.y) * curveTension
)
p.addCurve(to: p2, control1: cp1, control2: cp2)
}
return p
}
// MARK: - Wave
private func drawWave(ctx: GraphicsContext, size: CGSize, levels: [Float], date: Date) {
let w = size.width
let h = size.height
let idleVal = CGFloat(compact ? settings.miniIdleAmplitude : settings.idleAmplitude)
guard levels.count >= 2 else { return }
let spacing = w / CGFloat(levels.count - 1)
let now = date.timeIntervalSinceReferenceDate
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let baseline = h - baseLift - offset
let ampScale: CGFloat = compact ? CGFloat(settings.miniAmplitude) : CGFloat(settings.npAmplitude)
let points = levels.enumerated().map { i, lev -> CGPoint in
let x = CGFloat(i) * spacing
let nx = x / w
let baseAmp = CGFloat(lev)
var ripple: CGFloat = 0
for rp in touchRipples {
let age = CGFloat(now - rp.createdAt.timeIntervalSinceReferenceDate)
let decay = max(0, 1.0 - age * 1.4)
let dist = abs(nx - rp.x)
let radius: CGFloat = 0.08 + age * 1.2
if dist < radius {
let prox = 1.0 - (dist / radius)
ripple += sin(dist * 30 - age * 12) * prox * decay * 0.35
}
}
// Organic wobble: gentle rolling sine based on time and position
let organicWobble = CGFloat(sin(now * 3.0 + (Double(i) * 0.8)) * 0.03)
let totalAmp = max(idleVal, baseAmp + ripple + organicWobble)
return CGPoint(x: x, y: baseline - (totalAmp * h * ampScale))
}
// Layer 1: True index phase-shifted depth liquid parallax
let ctxDepthOffset = compact ? CGFloat(settings.miniDepthOffset) : CGFloat(settings.depthOffset)
let ctxDepthOpacity = compact ? settings.miniDepthOpacity : settings.depthOpacity
let depthPts = points.enumerated().map { i, pt -> CGPoint in
let shiftedIndex = (i + 2) % points.count
let shiftedPt = points[shiftedIndex]
return CGPoint(x: pt.x, y: shiftedPt.y + ctxDepthOffset + 5)
}
let depthCurve = smoothCurve(depthPts)
var depthFill = Path()
depthFill.move(to: CGPoint(x: 0, y: h))
depthFill.addLine(to: depthPts[0])
depthFill.addPath(depthCurve)
depthFill.addLine(to: CGPoint(x: w, y: h))
depthFill.closeSubpath()
ctx.fill(depthFill, with: .color(strokeColor.opacity(ctxDepthOpacity)))
// Layer 2: Main fill gradient from wave crest to true bottom
let curve = smoothCurve(points)
var fill = Path()
fill.move(to: CGPoint(x: 0, y: h))
fill.addLine(to: points[0])
fill.addPath(curve)
fill.addLine(to: CGPoint(x: w, y: h))
fill.closeSubpath()
let isSiri = settings.colorMode == .siri
if isSiri {
ctx.fill(fill, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: CGPoint(x: 0, y: 0),
endPoint: CGPoint(x: w, y: 0)
))
} else {
ctx.fill(fill, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: CGPoint(x: w / 2, y: baseline - h * 0.3),
endPoint: CGPoint(x: w / 2, y: h)
))
}
// Layer 3: Stroke line
ctx.stroke(strokeableCurve(points), with: .color(strokeColor), lineWidth: CGFloat(settings.waveStrokeThickness))
}
// MARK: - Bars
private func drawBars(ctx: GraphicsContext, size: CGSize, levels: [Float]) {
let w = size.width
let h = size.height
let count = levels.count
guard count > 0 else { return }
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let baseline = h - baseLift - offset
let ampScale: CGFloat = compact ? CGFloat(settings.miniAmplitude) : CGFloat(settings.npAmplitude)
let gap = CGFloat(settings.barSpacing)
let cr = CGFloat(settings.barCornerRadius)
let totalGapSpace = gap * CGFloat(count - 1)
let barW = max(1, (w - totalGapSpace) / CGFloat(count))
let isSiri = settings.colorMode == .siri
for i in 0..<count {
let amp = CGFloat(levels[i])
let barH = max(compact ? 2 : 4, amp * h * ampScale)
let x = CGFloat(i) * (barW + gap)
let barTop = baseline - barH
// Bar extends from top to true bottom of screen
let rect = CGRect(x: x, y: barTop, width: barW, height: h - barTop)
let path = Path(roundedRect: rect, cornerRadius: cr)
let startPoint = isSiri ? CGPoint(x: 0, y: 0) : CGPoint(x: x, y: barTop)
let endPoint = isSiri ? CGPoint(x: w, y: 0) : CGPoint(x: x, y: h)
ctx.fill(path, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: startPoint,
endPoint: endPoint
))
}
}
// MARK: - Line
private func drawLine(ctx: GraphicsContext, size: CGSize, levels: [Float]) {
let w = size.width
let h = size.height
guard levels.count >= 2 else { return }
let spacing = w / CGFloat(levels.count - 1)
let thick = CGFloat(settings.lineThickness)
let isSiri = settings.colorMode == .siri
let baseLift: CGFloat = compact ? 0 : CGFloat(settings.npBaseLift)
let offset = compact ? 0 : CGFloat(settings.waveOffsetTop)
let centerY = compact ? h * 0.5 : h - baseLift - offset
let ampMult: CGFloat = compact ? CGFloat(settings.miniAmplitude) * 0.5 : CGFloat(settings.npAmplitude) * 0.55
let points = levels.enumerated().map { i, lev -> CGPoint in
let dir: CGFloat = i % 2 == 0 ? -1 : 1
let amp = CGFloat(lev) * (h * ampMult)
return CGPoint(x: CGFloat(i) * spacing, y: centerY + (dir * amp))
}
let curve = strokeableCurve(points)
let strokeStyle = StrokeStyle(lineWidth: thick, lineCap: .round, lineJoin: .round)
// Glow
ctx.stroke(curve, with: .color(strokeColor.opacity(0.3)), style: StrokeStyle(lineWidth: thick + 6, lineCap: .round, lineJoin: .round))
// Core line
if isSiri {
ctx.stroke(curve, with: .linearGradient(
Gradient(colors: fillColors),
startPoint: CGPoint(x: 0, y: centerY),
endPoint: CGPoint(x: w, y: centerY)
), style: strokeStyle)
} else {
ctx.stroke(curve, with: .color(strokeColor), style: strokeStyle)
}
}
}
// MARK: - Compact Visualizer (for mini player)
struct CompactVisualizerView: View {
let isPlaying: Bool
let accentColor: Color
let height: CGFloat
var body: some View {
MitsuhaVisualizerView(
isPlaying: isPlaying,
accentColor: accentColor,
compact: true
)
.frame(height: height)
}
}
// MARK: - Visualizer Settings View
struct VisualizerSettingsView: View {
@ObservedObject var settings = VisualizerSettings.shared
@Environment(\.dismiss) private var dismiss
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
Form {
Section {
Toggle("Enabled", isOn: $settings.enabled).tint(pink)
Toggle("Now Playing Screen", isOn: $settings.nowPlayingEnabled).tint(pink).disabled(!settings.enabled)
Toggle("Mini Player", isOn: $settings.miniPlayerEnabled).tint(pink).disabled(!settings.enabled)
} header: { Text("VISUALIZER") } footer: {
Text("Master toggle disables all visualizers. Individual toggles control each location.")
}
Section {
Picker("Style", selection: $settings.style) {
ForEach(VisualizerSettings.Style.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}.pickerStyle(.segmented)
sliderRow("Number of points", value: $settings.numberOfPoints, range: 4...24, step: 1)
} header: { Text("STYLE") } footer: {
Text("Points control how many data points the wave is built from. Fewer points = bigger, rounder ocean swells. More points = detailed, ripply surface. 812 is a good starting point.")
}
if settings.style == .wave {
Section {
sliderRowDouble("Stroke thickness", value: $settings.waveStrokeThickness, range: 0.5...5.0, step: 0.5, format: "%.1f")
} header: { Text("WAVE") } footer: {
Text("The white line on top of the wave fill. At 0.5, barely visible. At 3+, a bold outline.")
}
}
if settings.style == .bar {
Section {
sliderRowDouble("Bar spacing", value: $settings.barSpacing, range: 0...20, step: 1, format: "%.0f")
sliderRowDouble("Corner radius", value: $settings.barCornerRadius, range: 0...15, step: 1, format: "%.0f")
} header: { Text("BAR") } footer: {
Text("Spacing controls the gap between bars. Corner radius rounds the bar tops — at 0 they're sharp rectangles, at 15 they're rounded pills.")
}
}
if settings.style == .line {
Section {
sliderRowDouble("Line thickness", value: $settings.lineThickness, range: 1...12, step: 0.5, format: "%.1f")
} header: { Text("LINE") } footer: {
Text("How thick the oscillating line is. Includes an automatic glow effect that scales with thickness.")
}
}
Section {
Picker("Color", selection: $settings.colorMode) {
ForEach(VisualizerSettings.ColorMode.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}.pickerStyle(.segmented)
sliderRowDouble("Color alpha", value: $settings.alpha, range: 0.1...1.0, step: 0.05, format: "%.2f")
if settings.colorMode == .custom { ColorPicker("Wave Color", selection: $settings.customColor) }
} header: { Text("COLOR") } footer: {
switch settings.colorMode {
case .dynamic: Text("Uses the app's accent color.")
case .albumArt: Text("Extracts dominant and secondary colors from the current album cover. The wave changes color with every song.")
case .siri: Text("Rainbow gradient (pink → green → cyan → purple → white) applied horizontally across the wave.")
case .custom: Text("Pick any color with the color picker above.")
}
}
Section {
NavigationLink {
NowPlayingVisSettingsView(settings: settings)
} label: {
HStack {
Image(systemName: "play.rectangle.fill").foregroundColor(pink).frame(width: 28)
Text("Now Playing")
Spacer()
Text("\(Int(settings.npAmplitude * 100))% amp").font(.caption).foregroundColor(.gray)
}
}
NavigationLink {
MiniPlayerVisSettingsView(settings: settings)
} label: {
HStack {
Image(systemName: "rectangle.bottomhalf.filled").foregroundColor(pink).frame(width: 28)
Text("Mini Player")
Spacer()
Text("\(Int(settings.miniAmplitude * 100))% amp").font(.caption).foregroundColor(.gray)
}
}
} header: { Text("PER-VIEW SETTINGS") } footer: {
Text("Each view has its own amplitude, depth, idle, and layout controls. Tap to configure individually.")
}
Section {
sliderRowDouble("Viscosity", value: $settings.viscosity, range: 0.05...1.0, step: 0.05, format: "%.2f")
sliderRow("Frequency cutoff", value: $settings.frequencyCutoff, range: 40...200, step: 10)
sliderRowDouble("Base multiplier", value: $settings.baseMultiplier, range: 10.0...100.0, step: 5.0, format: "%.0f")
sliderRowDouble("Sensitivity", value: $settings.sensitivity, range: 0.1...4.0, step: 0.1, format: "%.1f")
sliderRowDouble("FPS", value: $settings.fps, range: 15...60, step: 1, format: "%.0f")
Toggle("Real Audio Analysis", isOn: $settings.realAudioAnalysis).tint(pink)
} header: { Text("SHARED / ADVANCED") } footer: {
Text("Viscosity is the most important slider — it controls how quickly the wave reacts. 0.150.25 = heavy, slow liquid (cinematic). 0.5 = responsive. 0.8+ = snappy EQ meter.\n\nSensitivity multiplies the incoming audio. Push up if the wave looks flat.\n\nBase multiplier is a second gain stage for FFT data. Higher values reveal quiet passages.\n\nFrequency cutoff limits how many FFT bins are used. At 80 (default), mostly bass/mids. At 150+, treble detail like hi-hats shows up.\n\nReal Audio Analysis uses FFT on downloaded songs. Streams always use simulated animation.\n\nFPS drops to 24 automatically in Low Power Mode.")
}
// Presets
Section {
Button(action: applyDeepOcean) {
HStack {
Image(systemName: "water.waves").foregroundColor(.cyan)
VStack(alignment: .leading) {
Text("Deep Ocean Swell").foregroundColor(.white)
Text("Slow, massive rolling waves").font(.caption2).foregroundColor(.gray)
}
}
}
Button(action: applyReactiveEQ) {
HStack {
Image(systemName: "waveform").foregroundColor(.green)
VStack(alignment: .leading) {
Text("Reactive EQ").foregroundColor(.white)
Text("Detailed, fast response").font(.caption2).foregroundColor(.gray)
}
}
}
Button(action: applySubtleAmbient) {
HStack {
Image(systemName: "moonphase.waning.crescent").foregroundColor(.purple)
VStack(alignment: .leading) {
Text("Subtle Ambient").foregroundColor(.white)
Text("Gentle, transparent shimmer").font(.caption2).foregroundColor(.gray)
}
}
}
Button(action: applyHeavyMercury) {
HStack {
Image(systemName: "drop.fill").foregroundColor(.gray)
VStack(alignment: .leading) {
Text("Heavy Liquid Mercury").foregroundColor(.white)
Text("Dense, weighty metallic flow").font(.caption2).foregroundColor(.gray)
}
}
}
} header: { Text("PRESETS") } footer: {
Text("Apply a preset to set multiple sliders at once. You can fine-tune afterwards.")
}
Section { Button("Reset to Defaults", role: .destructive) { resetDefaults() } }
}
.navigationTitle("Visualizer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.foregroundColor(pink)
}
}
}
}
// MARK: - Presets
private func applyDeepOcean() {
settings.numberOfPoints = 6; settings.viscosity = 0.15; settings.sensitivity = 2.0
settings.npAmplitude = 0.8; settings.depthOffset = 30; settings.frequencyCutoff = 50
}
private func applyReactiveEQ() {
settings.numberOfPoints = 20; settings.viscosity = 0.7; settings.sensitivity = 1.5
settings.npAmplitude = 0.5; settings.depthOffset = 5; settings.frequencyCutoff = 150
}
private func applySubtleAmbient() {
settings.numberOfPoints = 8; settings.viscosity = 0.20; settings.sensitivity = 1.0
settings.npAmplitude = 0.25; settings.alpha = 0.3; settings.idleAmplitude = 0.05
}
private func applyHeavyMercury() {
settings.numberOfPoints = 10; settings.viscosity = 0.12; settings.sensitivity = 2.5
settings.baseMultiplier = 60; settings.npAmplitude = 0.6; settings.depthOffset = 20
}
// MARK: - Slider Helpers
func sliderRow(_ label: String, value: Binding<Int>, range: ClosedRange<Int>, step: Int) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: Binding(get: { Double(value.wrappedValue) }, set: { value.wrappedValue = Int($0) }),
in: Double(range.lowerBound)...Double(range.upperBound), step: Double(step)).tint(pink)
Text("\(value.wrappedValue)").foregroundColor(.gray).frame(width: 44, alignment: .trailing)
}
}
}
func sliderRowDouble(_ label: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, format: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: value, in: range, step: step).tint(pink)
Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 44, alignment: .trailing)
}
}
}
var previewLevels: [Float] { (0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) } }
private func resetDefaults() {
settings.enabled = true; settings.nowPlayingEnabled = true; settings.miniPlayerEnabled = true
settings.style = .wave; settings.numberOfPoints = 10; settings.sensitivity = 1.5
settings.fps = 60; settings.realAudioAnalysis = true; settings.waveOffsetTop = 0
settings.barSpacing = 5; settings.barCornerRadius = 0; settings.lineThickness = 5
settings.colorMode = .dynamic; settings.alpha = 0.6; settings.viscosity = 0.25
settings.frequencyCutoff = 80; settings.baseMultiplier = 40.0
settings.depthOffset = 15.0; settings.depthOpacity = 0.2; settings.idleAmplitude = 0.03
settings.waveStrokeThickness = 1.5; settings.nowPlayingHeightPct = 0.50; settings.miniPlayerHeight = 48.0
settings.miniOpacity = 0.5; settings.miniAmplitude = 0.7; settings.miniIdleAmplitude = 0.03
settings.miniDepthOffset = 8.0; settings.miniDepthOpacity = 0.2
settings.npAmplitude = 0.45; settings.npBaseLift = 130.0
}
}
// MARK: - Now Playing Sub-Settings
struct NowPlayingVisSettingsView: View {
@ObservedObject var settings: VisualizerSettings
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
Form {
Section {
VStack(alignment: .leading, spacing: 4) {
Text("Screen height %").font(.body)
HStack {
Slider(value: $settings.nowPlayingHeightPct, in: 0.2...0.8, step: 0.05).tint(pink)
Text("\(Int(settings.nowPlayingHeightPct * 100))%").foregroundColor(.gray).frame(width: 52, alignment: .trailing)
}
}
sd("Amplitude", value: $settings.npAmplitude, range: 0.1...1.5, step: 0.05, format: "%.2f")
sd("Base lift (from bottom)", value: $settings.npBaseLift, range: 0...300, step: 5, format: "%.0f pt")
sd("Wave offset (top)", value: $settings.waveOffsetTop, range: -100...300, step: 5, format: "%.0f")
} header: { Text("LAYOUT & AMPLITUDE") } footer: {
Text("Screen height controls how much of the Now Playing screen the visualizer fills. At 50%, it covers the bottom half. At 70%, it reaches behind the album art.\n\nAmplitude controls how tall wave peaks are. At 0.45, peaks reach about halfway. Push to 0.8+ for dramatic waves. Drop to 0.2 for a subtle accent.\n\nBase lift moves the wave baseline up from the very bottom of the screen. At 130 (default), the wave sits above the transport controls. Set to 0 for the absolute bottom edge.\n\nWave offset adds additional vertical shift on top of base lift. Use negative values to push down, positive to push up.")
}
Section {
sd("Depth offset", value: $settings.depthOffset, range: 0...50, step: 1, format: "%.0f")
sd("Depth opacity", value: $settings.depthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f")
sd("Idle amplitude", value: $settings.idleAmplitude, range: 0.0...0.15, step: 0.005, format: "%.3f")
} header: { Text("DEPTH & IDLE") } footer: {
Text("Depth offset controls how far below the main wave the shadow layer is drawn. At 15, there's visible parallax. At 0, they overlap. At 40+, the shadow looks like a distant reflection.\n\nDepth opacity sets how visible the shadow wave is. At 0.2, it's subtle. At 0, invisible.\n\nIdle amplitude is the minimum wave height during quiet moments. Prevents the wave from going completely flat. At 0.03, there's always gentle surface tension.")
}
Section {
ZStack {
Color.black
MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true, accentColor: pink)
}
.frame(height: 180).listRowInsets(EdgeInsets()).listRowBackground(Color.black)
} header: { Text("PREVIEW") }
}
.navigationTitle("Now Playing").navigationBarTitleDisplayMode(.inline)
}
private var previewLevels: [Float] { (0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) } }
private func sd(_ label: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, format: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: value, in: range, step: step).tint(pink)
Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 52, alignment: .trailing)
}
}
}
}
// MARK: - Mini Player Sub-Settings
struct MiniPlayerVisSettingsView: View {
@ObservedObject var settings: VisualizerSettings
private let pink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
Form {
Section {
sd("Height (points)", value: $settings.miniPlayerHeight, range: 24...80, step: 4, format: "%.0f pt")
sd("Amplitude", value: $settings.miniAmplitude, range: 0.1...2.0, step: 0.05, format: "%.2f")
sd("Opacity", value: $settings.miniOpacity, range: 0.1...1.0, step: 0.05, format: "%.2f")
} header: { Text("LAYOUT & AMPLITUDE") } footer: {
Text("Height is the actual size of the mini player visualizer frame in points. Larger values give the wave more room.\n\nAmplitude is higher than Now Playing by default (0.7) because the mini player is much smaller. Push to 1.5+ if you want the wave to fill the entire height.\n\nOpacity controls transparency behind the mini player controls. At 0.5, the wave is visible but song title and buttons are readable. At 1.0, fully opaque. At 0.2, a subtle shimmer.")
}
Section {
sd("Depth offset", value: $settings.miniDepthOffset, range: 0...30, step: 1, format: "%.0f")
sd("Depth opacity", value: $settings.miniDepthOpacity, range: 0.0...0.5, step: 0.05, format: "%.2f")
sd("Idle amplitude", value: $settings.miniIdleAmplitude, range: 0.0...0.15, step: 0.005, format: "%.3f")
} header: { Text("DEPTH & IDLE") } footer: {
Text("Depth offset controls the shadow wave distance. Keep lower than Now Playing since the mini player is smaller — 8 is a good default.\n\nIdle amplitude prevents the wave from flatlining during quiet moments.")
}
Section {
ZStack {
Color(white: 0.12)
MitsuhaVisualizerView(previewLevels: previewLevels, isPlaying: true, accentColor: pink, compact: true)
.frame(height: settings.miniPlayerHeight)
.opacity(settings.miniOpacity)
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 4).fill(Color.gray.opacity(0.3)).frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 2) {
Text("Song Title").font(.system(size: 14, weight: .medium)).foregroundColor(.white)
Text("Artist").font(.system(size: 12)).foregroundColor(.gray)
}
Spacer()
Image(systemName: "pause.fill").foregroundColor(.white)
Image(systemName: "forward.fill").foregroundColor(.white)
}.padding(.horizontal, 12)
}
.frame(height: 64).listRowInsets(EdgeInsets()).listRowBackground(Color(white: 0.12))
} header: { Text("PREVIEW") }
}
.navigationTitle("Mini Player").navigationBarTitleDisplayMode(.inline)
}
private var previewLevels: [Float] { (0..<30).map { Float(0.2 + 0.5 * sin(Float($0) * 0.4)) } }
private func sd(_ label: String, value: Binding<Double>, range: ClosedRange<Double>, step: Double, format: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(.body)
HStack {
Slider(value: value, in: range, step: step).tint(pink)
Text(String(format: format, value.wrappedValue)).foregroundColor(.gray).frame(width: 52, alignment: .trailing)
}
}
}
}

View file

@ -0,0 +1,197 @@
import Foundation
import AVFoundation
import Accelerate
/// Processes an entire audio file faster than real-time, producing per-frame FFT data
/// that can be cached and played back in sync with the audio.
actor OfflineAudioAnalyzer {
static let shared = OfflineAudioAnalyzer()
/// Progress callback (0.0 to 1.0)
typealias ProgressCallback = @Sendable (Float) -> Void
/// Analyze an audio file and return an array of FFT frames.
/// Each frame is an array of `pointsCount` floats (0.0-1.0) representing frequency band amplitudes.
func analyze(
url: URL,
pointsCount: Int = 20,
fps: Double = 30.0,
cutoff: Int = 90,
eqBoostFactor: Float = 3.5,
progress: ProgressCallback? = nil
) throws -> [[Float]] {
let file = try AVAudioFile(forReading: url)
let format = file.processingFormat
let sampleRate = format.sampleRate
let totalFrames = file.length
// How many audio frames per visualizer frame
let audioFramesPerVisFrame = AVAudioFrameCount(sampleRate / fps)
// Use power-of-2 buffer for FFT
let fftSize = 1024
let bufferSize = max(AVAudioFrameCount(fftSize), audioFramesPerVisFrame)
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else {
throw NSError(domain: "OfflineAnalyzer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to create buffer"])
}
let log2n = vDSP_Length(log2(Double(fftSize)))
guard let fftSetup = vDSP_create_fftsetup(log2n, Int32(kFFTRadix2)) else {
throw NSError(domain: "OfflineAnalyzer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to create FFT setup"])
}
defer { vDSP_destroy_fftsetup(fftSetup) }
let halfSize = fftSize / 2
var visualizerData: [[Float]] = []
// Estimate total frames for progress
let estimatedVisFrames = Int(Double(totalFrames) / Double(audioFramesPerVisFrame))
visualizerData.reserveCapacity(estimatedVisFrames)
// Reusable buffers
var window = [Float](repeating: 0, count: fftSize)
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
var frameIndex = 0
while file.framePosition < totalFrames {
// Read a chunk
let framesToRead = min(bufferSize, AVAudioFrameCount(totalFrames - file.framePosition))
buffer.frameLength = 0
try file.read(into: buffer, frameCount: framesToRead)
guard let channelData = buffer.floatChannelData?[0] else { continue }
let actualFrames = Int(buffer.frameLength)
guard actualFrames >= fftSize else {
// Pad with zeros for the last partial buffer
if actualFrames > 0 {
let frame = processFFTFrame(
channelData: channelData,
frameCount: actualFrames,
fftSize: fftSize,
halfSize: halfSize,
window: window,
fftSetup: fftSetup,
pointsCount: pointsCount,
cutoff: cutoff,
eqBoostFactor: eqBoostFactor
)
visualizerData.append(frame)
}
break
}
// Process one or more vis frames from this buffer
var sampleOffset = 0
while sampleOffset + fftSize <= actualFrames {
let frame = processFFTFrame(
channelData: channelData.advanced(by: sampleOffset),
frameCount: fftSize,
fftSize: fftSize,
halfSize: halfSize,
window: window,
fftSetup: fftSetup,
pointsCount: pointsCount,
cutoff: cutoff,
eqBoostFactor: eqBoostFactor
)
visualizerData.append(frame)
sampleOffset += Int(audioFramesPerVisFrame)
frameIndex += 1
// Report progress every 50 frames
if frameIndex % 50 == 0, let progress = progress {
let pct = Float(file.framePosition) / Float(totalFrames)
progress(pct)
}
}
}
progress?(1.0)
return visualizerData
}
/// Process a single FFT frame from raw audio samples
private func processFFTFrame(
channelData: UnsafePointer<Float>,
frameCount: Int,
fftSize: Int,
halfSize: Int,
window: [Float],
fftSetup: FFTSetup,
pointsCount: Int,
cutoff: Int,
eqBoostFactor: Float
) -> [Float] {
let n = min(frameCount, fftSize)
// 1. Apply Hann window
var windowed = [Float](repeating: 0, count: fftSize)
if n < fftSize {
// Zero-pad if short
for i in 0..<n { windowed[i] = channelData[i] * window[i] }
} else {
vDSP_vmul(channelData, 1, window, 1, &windowed, 1, vDSP_Length(fftSize))
}
// 2. FFT
var realp = [Float](repeating: 0, count: halfSize)
var imagp = [Float](repeating: 0, count: halfSize)
var magnitudes = [Float](repeating: 0, count: halfSize)
realp.withUnsafeMutableBufferPointer { realpBuf in
imagp.withUnsafeMutableBufferPointer { imagpBuf in
var splitComplex = DSPSplitComplex(
realp: realpBuf.baseAddress!,
imagp: imagpBuf.baseAddress!
)
windowed.withUnsafeBytes { raw in
let ptr = raw.bindMemory(to: DSPComplex.self).baseAddress!
vDSP_ctoz(ptr, 2, &splitComplex, 1, vDSP_Length(halfSize))
}
vDSP_fft_zrip(fftSetup, &splitComplex, 1, vDSP_Length(log2(Double(fftSize))), FFTDirection(FFT_FORWARD))
vDSP_zvmags(&splitComplex, 1, &magnitudes, 1, vDSP_Length(halfSize))
}
}
// 3. Normalize
let fftSizeF = Float(fftSize)
var scale: Float = 1.0 / (fftSizeF * fftSizeF)
vDSP_vsmul(magnitudes, 1, &scale, &magnitudes, 1, vDSP_Length(halfSize))
// sqrt for perceptual amplitude
for i in 0..<halfSize {
magnitudes[i] = sqrt(magnitudes[i])
}
// 4. Logarithmic binning with EQ boost
var framePoints = [Float](repeating: 0, count: pointsCount)
let maxUsefulBin = min(halfSize - 1, cutoff)
for i in 0..<pointsCount {
let normalizedIndex = Float(i + 1) / Float(pointsCount)
let logIndex = log10(normalizedIndex * 9.0 + 1.0)
let centerBin = logIndex * Float(maxUsefulBin)
let binWidth = max(1.0, Float(maxUsefulBin) / Float(pointsCount) * logIndex)
let startBin = max(1, Int(centerBin - binWidth / 2))
let endBin = min(maxUsefulBin, Int(centerBin + binWidth / 2))
var sum: Float = 0
var countInBand = 0
for j in startBin...endBin where j < magnitudes.count {
sum += magnitudes[j]
countInBand += 1
}
let average = countInBand > 0 ? (sum / Float(countInBand)) : 0
let eqBoost: Float = 1.0 + (Float(i) / Float(pointsCount)) * eqBoostFactor
framePoints[i] = average * eqBoost
}
return framePoints
}
}

View file

@ -0,0 +1,96 @@
import Foundation
/// Caches pre-computed visualizer FFT frames to disk for instant playback
actor VisualizerStorageManager {
static let shared = VisualizerStorageManager()
private let fileManager = FileManager.default
private var cacheDir: URL {
let dir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("VisualizerCache", isDirectory: true)
if !fileManager.fileExists(atPath: dir.path) {
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
}
return dir
}
private func fileURL(for trackId: String) -> URL {
cacheDir.appendingPathComponent("mitsuha_\(trackId).bin")
}
func hasCache(for trackId: String) -> Bool {
fileManager.fileExists(atPath: fileURL(for: trackId).path)
}
func saveCache(frames: [[Float]], for trackId: String) throws {
// Binary format: [frameCount: UInt32][pointsPerFrame: UInt32][float data...]
let frameCount = frames.count
let pointsPerFrame = frames.first?.count ?? 0
guard frameCount > 0, pointsPerFrame > 0 else { return }
var data = Data()
var fc = UInt32(frameCount)
var ppf = UInt32(pointsPerFrame)
data.append(Data(bytes: &fc, count: 4))
data.append(Data(bytes: &ppf, count: 4))
for frame in frames {
frame.withUnsafeBytes { data.append(contentsOf: $0) }
}
try data.write(to: fileURL(for: trackId), options: .atomic)
}
func loadCache(for trackId: String) throws -> [[Float]] {
let data = try Data(contentsOf: fileURL(for: trackId))
guard data.count >= 8 else { return [] }
let frameCount = data.withUnsafeBytes { $0.load(fromByteOffset: 0, as: UInt32.self) }
let pointsPerFrame = data.withUnsafeBytes { $0.load(fromByteOffset: 4, as: UInt32.self) }
let floatSize = MemoryLayout<Float>.size
let expectedSize = 8 + Int(frameCount) * Int(pointsPerFrame) * floatSize
guard data.count >= expectedSize else { return [] }
var frames: [[Float]] = []
frames.reserveCapacity(Int(frameCount))
var offset = 8
for _ in 0..<frameCount {
let frame: [Float] = data.withUnsafeBytes { raw in
let ptr = raw.baseAddress!.advanced(by: offset).assumingMemoryBound(to: Float.self)
return Array(UnsafeBufferPointer(start: ptr, count: Int(pointsPerFrame)))
}
frames.append(frame)
offset += Int(pointsPerFrame) * floatSize
}
return frames
}
/// Total cache size on disk
func cacheSize() -> Int64 {
guard let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: [.fileSizeKey]) else {
return 0
}
return files.reduce(0) { sum, url in
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
return sum + Int64(size)
}
}
/// Number of cached tracks
func cachedTrackCount() -> Int {
(try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil).count) ?? 0
}
func clearCache() {
if let files = try? fileManager.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) {
for file in files { try? fileManager.removeItem(at: file) }
}
}
func removeCache(for trackId: String) {
try? fileManager.removeItem(at: fileURL(for: trackId))
}
}

80
project.yml Normal file
View file

@ -0,0 +1,80 @@
name: NavidromePlayer
options:
bundleIdPrefix: com.navidromeplayer
deploymentTarget:
iOS: "26.0"
watchOS: "26.0"
xcodeVersion: "26.0"
groupSortPosition: top
createIntermediateGroups: true
settings:
base:
SWIFT_VERSION: "5.9"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: 1
DEAD_CODE_STRIPPING: true
ENABLE_USER_SCRIPT_SANDBOXING: true
DEVELOPMENT_TEAM: E9C9AGS9K6
targets:
# ─────────────────────────────────────
# iOS App
# ─────────────────────────────────────
NavidromePlayer:
type: application
platform: iOS
deploymentTarget: "26.0"
sources:
- path: iOS
- path: Shared
resources:
- path: iOS/Resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.navidromeplayer.app
INFOPLIST_FILE: iOS/Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: iOS/Resources/NavidromePlayer.entitlements
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
TARGETED_DEVICE_FAMILY: "1,2"
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: true
dependencies:
- target: NavidromeWatch
# ─────────────────────────────────────
# watchOS App
# ─────────────────────────────────────
NavidromeWatch:
type: application
platform: watchOS
deploymentTarget: "26.0"
sources:
- path: watchOS
- path: Shared
resources:
- path: watchOS/Resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.navidromeplayer.app.watchkitapp
INFOPLIST_FILE: watchOS/Resources/Info.plist
CODE_SIGN_ENTITLEMENTS: watchOS/Resources/NavidromeWatch.entitlements
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
WATCHOS_DEPLOYMENT_TARGET: "26.0"
SKIP_INSTALL: false
schemes:
NavidromePlayer:
build:
targets:
NavidromePlayer: all
NavidromeWatch: all
run:
config: Debug
test:
config: Debug
profile:
config: Release
analyze:
config: Debug
archive:
config: Release

View file

@ -0,0 +1,32 @@
import SwiftUI
import WatchConnectivity
@main
struct NavidromeWatchApp: App {
@StateObject private var watchManager = WatchSessionManager.shared
@StateObject private var audioPlayer = WatchAudioPlayer.shared
@StateObject private var offlineStore = WatchOfflineStore.shared
var body: some Scene {
WindowGroup {
WatchRootView()
.environmentObject(watchManager)
.environmentObject(audioPlayer)
.environmentObject(offlineStore)
}
}
}
struct WatchRootView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var offlineStore: WatchOfflineStore
var body: some View {
if offlineStore.songs.isEmpty && watchManager.servers.isEmpty {
WatchSetupView()
} else {
WatchLibraryView()
}
}
}

View file

@ -0,0 +1,179 @@
import Foundation
import Combine
/// Manages offline songs stored directly on the Apple Watch
class WatchOfflineStore: ObservableObject {
static let shared = WatchOfflineStore()
@Published var songs: [WatchOfflineSong] = []
@Published var totalSize: Int64 = 0
@Published var isDownloading = false
@Published var downloadProgress: [String: Double] = [:]
struct WatchOfflineSong: Codable, Identifiable {
let id: String
let song: Song
let localPath: String
let fileSize: Int64
let dateAdded: Date
}
private let catalogKey = "watch_offline_songs"
private let fileManager = FileManager.default
private var musicDirectory: URL {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let dir = docs.appendingPathComponent("OfflineMusic", isDirectory: true)
if !fileManager.fileExists(atPath: dir.path) {
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
}
return dir
}
private init() {
loadCatalog()
}
// MARK: - Song Management
func addSong(_ song: Song, localPath: String) {
guard !songs.contains(where: { $0.id == song.id }) else { return }
let fileSize: Int64
if let attrs = try? fileManager.attributesOfItem(atPath: localPath),
let size = attrs[.size] as? Int64 {
fileSize = size
} else {
fileSize = 0
}
let offlineSong = WatchOfflineSong(
id: song.id,
song: song,
localPath: localPath,
fileSize: fileSize,
dateAdded: Date()
)
songs.append(offlineSong)
totalSize += fileSize
saveCatalog()
}
func removeSong(_ songId: String) {
guard let idx = songs.firstIndex(where: { $0.id == songId }) else { return }
let song = songs[idx]
// Reconstruct path from filename
let filename = (song.localPath as NSString).lastPathComponent
let url = musicDirectory.appendingPathComponent(filename)
try? fileManager.removeItem(at: url)
totalSize -= song.fileSize
songs.remove(at: idx)
saveCatalog()
}
func removeAll() {
for song in songs {
try? fileManager.removeItem(atPath: song.localPath)
}
songs.removeAll()
totalSize = 0
saveCatalog()
}
func localURL(for songId: String) -> URL? {
guard let song = songs.first(where: { $0.id == songId }) else { return nil }
// Reconstruct path from filename to handle sandbox UUID changes
let filename = (song.localPath as NSString).lastPathComponent
let url = musicDirectory.appendingPathComponent(filename)
return fileManager.fileExists(atPath: url.path) ? url : nil
}
func isSongAvailable(_ songId: String) -> Bool {
return localURL(for: songId) != nil
}
// MARK: - Download directly from server (WiFi)
func downloadFromServer(song: Song) async {
guard !songs.contains(where: { $0.id == song.id }) else { return }
await MainActor.run {
isDownloading = true
downloadProgress[song.id] = 0
}
do {
// WatchSessionManager.downloadSong already transcodes at 192kbps MP3
let (data, _) = try await WatchSessionManager.shared.downloadSong(id: song.id)
// Always mp3 since watch downloads are transcoded
let filename = "\(song.id).mp3"
let localURL = musicDirectory.appendingPathComponent(filename)
try data.write(to: localURL)
await MainActor.run {
addSong(song, localPath: localURL.path)
downloadProgress.removeValue(forKey: song.id)
isDownloading = downloadProgress.isEmpty ? false : true
}
} catch {
await MainActor.run {
downloadProgress.removeValue(forKey: song.id)
isDownloading = downloadProgress.isEmpty ? false : true
}
print("Watch download failed: \(error)")
}
}
func downloadAlbum(_ album: AlbumWithSongs) async {
guard let albumSongs = album.song else { return }
for song in albumSongs {
await downloadFromServer(song: song)
}
}
// MARK: - Update catalog from phone sync
func updateCatalog(_ phoneSongs: [Song]) {
// This updates the expected catalog; actual files arrive via file transfer
// We can show which songs are expected vs available
}
// MARK: - Grouped Access
var songsByAlbum: [String: [WatchOfflineSong]] {
Dictionary(grouping: songs) { $0.song.album ?? "Unknown Album" }
}
var songsByArtist: [String: [WatchOfflineSong]] {
Dictionary(grouping: songs) { $0.song.artist ?? "Unknown Artist" }
}
var formattedSize: String {
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
}
// MARK: - Persistence
private func saveCatalog() {
if let data = try? JSONEncoder().encode(songs) {
UserDefaults.standard.set(data, forKey: catalogKey)
}
}
private func loadCatalog() {
if let data = UserDefaults.standard.data(forKey: catalogKey),
let decoded = try? JSONDecoder().decode([WatchOfflineSong].self, from: data) {
// Reconstruct paths from filenames to handle sandbox UUID changes
songs = decoded.filter { song in
let filename = (song.localPath as NSString).lastPathComponent
let url = musicDirectory.appendingPathComponent(filename)
return fileManager.fileExists(atPath: url.path)
}
totalSize = songs.reduce(0) { $0 + $1.fileSize }
}
}
}

View file

@ -0,0 +1,362 @@
import Foundation
import WatchConnectivity
import Combine
/// watchOS-side WatchConnectivity session manager
class WatchSessionManager: NSObject, ObservableObject {
static let shared = WatchSessionManager()
@Published var servers: [ServerConfig] = []
@Published var activeServer: ServerConfig?
@Published var isPhoneReachable = false
@Published var isSyncing = false
// Now playing from iPhone
@Published var phoneNowPlaying: PhoneNowPlaying?
struct PhoneNowPlaying {
var title: String
var artist: String
var album: String
var isPlaying: Bool
var currentTime: TimeInterval
var duration: TimeInterval
var coverArtId: String?
}
private var session: WCSession?
private let client = SubsonicClient()
private override init() {
super.init()
if WCSession.isSupported() {
session = WCSession.default
session?.delegate = self
session?.activate()
}
loadLocalServers()
}
// MARK: - Server Management
func addServer(_ server: ServerConfig) {
servers.append(server)
saveLocalServers()
}
func setActive(_ server: ServerConfig) {
activeServer = server
client.currentServer = server
}
func testConnection(_ server: ServerConfig) async -> Bool {
do {
return try await client.ping(server: server)
} catch {
return false
}
}
// MARK: - Request data from phone
func requestServersFromPhone() {
guard let session = session, session.isReachable else { return }
session.sendMessage(["type": "requestServers"], replyHandler: { reply in
if let data = reply["servers"] as? Data,
let servers = try? JSONDecoder().decode([ServerConfig].self, from: data) {
DispatchQueue.main.async {
self.servers = servers
self.activeServer = servers.first
if let first = servers.first {
self.client.currentServer = first
}
self.saveLocalServers()
}
}
}, errorHandler: { error in
print("Request servers failed: \(error)")
})
}
func sendPlayCommand() {
session?.sendMessage(["type": "playCommand"], replyHandler: nil, errorHandler: nil)
}
func sendNextCommand() {
session?.sendMessage(["type": "nextCommand"], replyHandler: nil, errorHandler: nil)
}
func sendPreviousCommand() {
session?.sendMessage(["type": "previousCommand"], replyHandler: nil, errorHandler: nil)
}
func requestDownload(songId: String) {
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: nil, errorHandler: nil)
}
// MARK: - Direct API Access (watch can also talk to server directly)
func getAlbumList(type: String = "newest", size: Int = 20) async throws -> [Album] {
guard activeServer != nil else { return [] }
return try await client.getAlbumList2(type: type, size: size)
}
func getAlbum(id: String) async throws -> AlbumWithSongs? {
return try await client.getAlbum(id: id)
}
func getPlaylists() async throws -> [Playlist] {
return try await client.getPlaylists()
}
func getPlaylist(id: String) async throws -> PlaylistWithSongs? {
return try await client.getPlaylist(id: id)
}
func search(query: String) async throws -> SearchResult3? {
return try await client.search3(query: query)
}
func streamURL(songId: String, maxBitRate: Int? = 192) -> URL? {
return client.streamURL(songId: songId, format: maxBitRate != nil ? "mp3" : nil, maxBitRate: maxBitRate)
}
func coverArtURL(id: String, size: Int = 100) -> URL? {
return client.coverArtURL(id: id, size: size)
}
/// Downloads a song at 192kbps MP3 for watch storage efficiency
func downloadSong(id: String) async throws -> (Data, URLResponse) {
let params = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "format", value: "mp3"),
URLQueryItem(name: "maxBitRate", value: "192")
]
return try await client.requestData(endpoint: "stream", params: params)
}
// MARK: - Persistence
private func saveLocalServers() {
if let data = try? JSONEncoder().encode(servers) {
UserDefaults.standard.set(data, forKey: "watch_servers")
}
if let data = try? JSONEncoder().encode(activeServer) {
UserDefaults.standard.set(data, forKey: "watch_active_server")
}
}
private func loadLocalServers() {
if let data = UserDefaults.standard.data(forKey: "watch_servers"),
let decoded = try? JSONDecoder().decode([ServerConfig].self, from: data) {
servers = decoded
}
if let data = UserDefaults.standard.data(forKey: "watch_active_server"),
let decoded = try? JSONDecoder().decode(ServerConfig.self, from: data) {
activeServer = decoded
client.currentServer = decoded
} else if let first = servers.first {
activeServer = first
client.currentServer = first
}
}
}
// MARK: - WCSessionDelegate
extension WatchSessionManager: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
DispatchQueue.main.async {
self.isPhoneReachable = session.isReachable
}
if activationState == .activated {
// Check existing application context (already sent before watch app opened)
let existingContext = session.receivedApplicationContext
if let data = existingContext["servers"] as? Data,
let decoded = try? JSONDecoder().decode([ServerConfig].self, from: data) {
DispatchQueue.main.async {
if self.servers.isEmpty {
self.servers = decoded
if self.activeServer == nil, let first = decoded.first {
self.activeServer = first
self.client.currentServer = first
}
self.saveLocalServers()
}
}
}
// Also proactively request from phone if reachable
if session.isReachable && self.servers.isEmpty {
self.requestServersFromPhone()
}
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
DispatchQueue.main.async {
self.isPhoneReachable = session.isReachable
}
}
// Receive application context (server configs from iPhone)
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
if let data = applicationContext["servers"] as? Data,
let servers = try? JSONDecoder().decode([ServerConfig].self, from: data) {
DispatchQueue.main.async {
self.servers = servers
if self.activeServer == nil, let first = servers.first {
self.activeServer = first
self.client.currentServer = first
}
self.saveLocalServers()
}
}
if let catalogData = applicationContext["offlineCatalog"] as? Data,
let songs = try? JSONDecoder().decode([Song].self, from: catalogData) {
DispatchQueue.main.async {
// Update offline store with catalog
WatchOfflineStore.shared.updateCatalog(songs)
}
}
}
// Receive messages (now playing info from iPhone)
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let type = message["type"] as? String else { return }
if type == "nowPlaying" {
DispatchQueue.main.async {
self.phoneNowPlaying = PhoneNowPlaying(
title: message["title"] as? String ?? "",
artist: message["artist"] as? String ?? "",
album: message["album"] as? String ?? "",
isPlaying: message["isPlaying"] as? Bool ?? false,
currentTime: message["currentTime"] as? TimeInterval ?? 0,
duration: message["duration"] as? TimeInterval ?? 0,
coverArtId: message["coverArtId"] as? String
)
}
}
}
// Messages that need a reply
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
guard let type = message["type"] as? String else {
replyHandler(["error": "no type"])
return
}
switch type {
case "requestSongList":
let store = WatchOfflineStore.shared
struct WatchSongInfoEnc: Codable {
let id: String; let title: String; let artist: String; let album: String; let fileSize: Int64
}
let encoded = store.songs.map {
WatchSongInfoEnc(id: $0.id, title: $0.song.title, artist: $0.song.artist ?? "Unknown", album: $0.song.album ?? "Unknown", fileSize: $0.fileSize)
}
if let data = try? JSONEncoder().encode(encoded) {
replyHandler(["songs": data])
} else {
replyHandler(["songs": Data()])
}
print("[Watch] Sent song list: \(store.songs.count) songs")
case "deleteSong":
if let songId = message["songId"] as? String {
DispatchQueue.main.async {
WatchOfflineStore.shared.removeSong(songId)
}
print("[Watch] Deleted song: \(songId)")
replyHandler(["ok": true])
} else {
replyHandler(["error": "no songId"])
}
case "deleteAllSongs":
DispatchQueue.main.async {
WatchOfflineStore.shared.removeAll()
}
print("[Watch] Deleted all songs")
replyHandler(["ok": true])
default:
replyHandler(["error": "unknown type"])
}
}
// Receive user info (wake signals, queued data from phone)
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
if let type = userInfo["type"] as? String {
print("[Watch] Received userInfo: \(type)")
if type == "wake" {
// Phone pinged us we're alive now and ready to receive files
print("[Watch] Wake signal received — app is active")
}
}
}
// Receive files (downloaded songs from iPhone)
func session(_ session: WCSession, didReceive file: WCSessionFile) {
let metadata = file.metadata ?? [:]
print("[Watch] Received file transfer. Keys: \(metadata.keys.sorted())")
// Support both "songId" and "id" keys for compatibility
guard let songId = (metadata["songId"] as? String) ?? (metadata["id"] as? String),
let suffix = metadata["suffix"] as? String else {
print("[Watch] ERROR: Missing songId or suffix in metadata: \(metadata)")
return
}
print("[Watch] Processing song: \(songId), suffix: \(suffix)")
// Move file to permanent location
let documentsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let musicDir = documentsDir.appendingPathComponent("OfflineMusic", isDirectory: true)
try? FileManager.default.createDirectory(at: musicDir, withIntermediateDirectories: true)
let destURL = musicDir.appendingPathComponent("\(songId).\(suffix)")
do {
if FileManager.default.fileExists(atPath: destURL.path) {
try FileManager.default.removeItem(at: destURL)
}
try FileManager.default.moveItem(at: file.fileURL, to: destURL)
let fileSize = (try? FileManager.default.attributesOfItem(atPath: destURL.path)[.size] as? Int64) ?? 0
print("[Watch] Saved: \(destURL.lastPathComponent) (\(ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file)))")
// Support both key naming conventions
let coverArtId = (metadata["coverArtId"] as? String) ?? (metadata["coverArt"] as? String)
let song = Song(
id: songId,
parent: nil, isDir: false,
title: metadata["title"] as? String ?? "Unknown",
album: metadata["album"] as? String,
artist: metadata["artist"] as? String,
track: nil, year: nil, genre: nil,
coverArt: coverArtId,
size: nil, contentType: nil,
suffix: suffix,
transcodedContentType: nil, transcodedSuffix: nil,
duration: metadata["duration"] as? Int,
bitRate: nil, path: nil, playCount: nil,
discNumber: nil, created: nil,
albumId: nil, artistId: nil,
type: nil, starred: nil, bpm: nil, musicBrainzId: nil
)
DispatchQueue.main.async {
WatchOfflineStore.shared.addSong(song, localPath: destURL.path)
print("[Watch] Added to offline store: \(song.title)")
}
} catch {
print("[Watch] ERROR saving file: \(error.localizedDescription)")
}
}
}

View file

@ -0,0 +1,482 @@
import Foundation
import AVFoundation
import Combine
import WatchKit
import MediaPlayer
import HealthKit
/// watchOS audio player with dual-mode output:
///
/// **Bluetooth Mode** (default):
/// Uses `.longFormAudio` policy. Requires Bluetooth/AirPlay. Background playback is
/// handled by the audio session itself.
///
/// **Speaker Mode** (Watch Ultra):
/// Uses `.default` policy which routes to the built-in speaker. Background runtime is
/// maintained by a silent `HKWorkoutSession`. watchOS keeps workout apps alive indefinitely
/// even when the screen sleeps. A green workout indicator appears on the watch face.
///
/// Toggle between modes in the Now Playing route indicator.
class WatchAudioPlayer: NSObject, ObservableObject {
static let shared = WatchAudioPlayer()
// MARK: - Published State
@Published var currentSong: Song?
@Published var queue: [Song] = []
@Published var queueIndex: Int = 0
@Published var isPlaying = false
@Published var currentTime: TimeInterval = 0
@Published var duration: TimeInterval = 0
@Published var volume: Float = 1.0
@Published var repeatMode: RepeatMode = .off
@Published var shuffleEnabled = false
@Published var audioLevels: [Float] = Array(repeating: 0, count: 8)
// Audio route state
@Published var isSessionActive = false
@Published var isBluetoothConnected = false
@Published var currentRouteName: String = "No Output"
@Published var activationError: String?
@Published var useSpeakerMode = false {
didSet {
UserDefaults.standard.set(useSpeakerMode, forKey: "watch_speaker_mode")
reconfigureAudioSession()
}
}
@Published var isSpeakerAvailable = false
enum RepeatMode {
case off, all, one
}
// MARK: - Private
private var player: AVPlayer?
private var playerItem: AVPlayerItem?
private var timeObserver: Any?
private var levelTimer: Timer?
private var pendingPlayURL: URL?
private var pendingSong: Song?
// HealthKit workout session for speaker background runtime
private let healthStore = HKHealthStore()
private var workoutSession: HKWorkoutSession?
private var workoutBuilder: HKLiveWorkoutBuilder?
private override init() {
super.init()
useSpeakerMode = UserDefaults.standard.bool(forKey: "watch_speaker_mode")
isSpeakerAvailable = checkSpeakerAvailable()
configureAudioSession()
setupRemoteControls()
observeRouteChanges()
}
// MARK: - Speaker Detection
private func checkSpeakerAvailable() -> Bool {
// Check if device has a speaker by looking at available outputs
let route = AVAudioSession.sharedInstance().currentRoute
let hasBuiltIn = route.outputs.contains { $0.portType == .builtInSpeaker }
if hasBuiltIn { return true }
// Watch Ultra always has a speaker even if not currently routed
// Check model name heuristic
let model = WKInterfaceDevice.current().model.lowercased()
return model.contains("ultra")
}
// MARK: - Audio Session Configuration
private func configureAudioSession() {
let session = AVAudioSession.sharedInstance()
do {
if useSpeakerMode {
// Speaker mode: .default policy allows speaker output
try session.setCategory(.playback, mode: .default, policy: .default)
print("[WatchAudio] Session: .playback / .default (speaker mode)")
} else {
// Bluetooth mode: .longFormAudio requires BT/AirPlay
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
print("[WatchAudio] Session: .playback / .longFormAudio (bluetooth mode)")
}
updateRouteInfo()
} catch {
print("[WatchAudio] Session config FAILED: \(error.localizedDescription)")
}
}
private func reconfigureAudioSession() {
let wasPlaying = isPlaying
if wasPlaying { player?.pause() }
configureAudioSession()
if useSpeakerMode {
startWorkoutSession()
} else {
stopWorkoutSession()
}
if wasPlaying { player?.play() }
updateRouteInfo()
}
// MARK: - HKWorkoutSession (keeps app alive for speaker mode)
private func startWorkoutSession() {
guard workoutSession == nil else { return }
guard HKHealthStore.isHealthDataAvailable() else {
print("[WatchAudio] HealthKit not available")
return
}
let config = HKWorkoutConfiguration()
config.activityType = .mindAndBody // Least intrusive workout type
config.locationType = .indoor
do {
let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: config)
session.delegate = self
builder.delegate = self
session.startActivity(with: Date())
builder.beginCollection(withStart: Date()) { success, error in
if let error = error {
print("[WatchAudio] Workout builder start failed: \(error.localizedDescription)")
} else {
print("[WatchAudio] Workout session started (speaker background mode)")
}
}
workoutSession = session
workoutBuilder = builder
} catch {
print("[WatchAudio] Failed to start workout session: \(error.localizedDescription)")
}
}
private func stopWorkoutSession() {
guard let session = workoutSession else { return }
session.end()
workoutBuilder?.endCollection(withEnd: Date()) { [weak self] success, error in
self?.workoutBuilder?.finishWorkout { workout, error in
print("[WatchAudio] Workout session ended")
}
}
workoutSession = nil
workoutBuilder = nil
}
// MARK: - Session Activation (Bluetooth mode only)
func activateSession() async -> Bool {
if useSpeakerMode {
// Speaker mode doesn't need activation just start workout
await MainActor.run {
startWorkoutSession()
isSessionActive = true
updateRouteInfo()
}
return true
}
let session = AVAudioSession.sharedInstance()
let result = await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in
session.activate(options: []) { success, error in
continuation.resume(returning: (success, error?.localizedDescription))
}
}
await MainActor.run {
if result.0 {
self.isSessionActive = true
self.activationError = nil
self.updateRouteInfo()
print("[WatchAudio] Session activated OK. Route: \(self.currentRouteName)")
} else {
self.isSessionActive = false
self.activationError = result.1 ?? "Unknown error"
print("[WatchAudio] Session activation FAILED: \(result.1 ?? "?")")
}
}
return result.0
}
// MARK: - Route Monitoring
private func observeRouteChanges() {
NotificationCenter.default.addObserver(
self, selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification, object: nil
)
NotificationCenter.default.addObserver(
self, selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification, object: nil
)
}
@objc private func handleRouteChange(_ notification: Notification) {
guard let info = notification.userInfo,
let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
DispatchQueue.main.async {
self.updateRouteInfo()
switch reason {
case .oldDeviceUnavailable:
if !self.useSpeakerMode {
print("[WatchAudio] Bluetooth disconnected — pausing")
self.pause()
}
case .newDeviceAvailable:
print("[WatchAudio] New route: \(self.currentRouteName)")
default:
print("[WatchAudio] Route changed: \(self.currentRouteName)")
}
}
}
@objc private func handleInterruption(_ notification: Notification) {
guard let info = notification.userInfo,
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
DispatchQueue.main.async {
switch type {
case .began:
self.pause()
case .ended:
if let opts = info[AVAudioSessionInterruptionOptionKey] as? UInt {
if AVAudioSession.InterruptionOptions(rawValue: opts).contains(.shouldResume) {
self.resume()
}
}
@unknown default: break
}
}
}
private func updateRouteInfo() {
let route = AVAudioSession.sharedInstance().currentRoute
if let output = route.outputs.first {
currentRouteName = output.portName
isBluetoothConnected = [.bluetoothA2DP, .bluetoothLE, .bluetoothHFP, .airPlay].contains(output.portType)
} else {
currentRouteName = useSpeakerMode ? "Speaker" : "No Output"
isBluetoothConnected = false
}
}
// MARK: - Playback
func play(song: Song, fromQueue: [Song]? = nil, at index: Int = 0) {
if let q = fromQueue {
queue = shuffleEnabled ? q.shuffled() : q
queueIndex = index
}
currentSong = song
let url: URL?
if let localURL = WatchOfflineStore.shared.localURL(for: song.id) {
url = localURL
} else {
url = WatchSessionManager.shared.streamURL(songId: song.id, maxBitRate: 192)
}
guard let playURL = url else {
print("[WatchAudio] No URL for: \(song.title)")
return
}
if useSpeakerMode {
// Speaker mode: start workout if needed, then play
startWorkoutSession()
isSessionActive = true
playFromURL(playURL)
return
}
// Bluetooth mode: activate session if needed
if isSessionActive && isBluetoothConnected {
playFromURL(playURL)
return
}
pendingPlayURL = playURL
pendingSong = song
Task {
_ = await activateSession()
await MainActor.run {
if let url = self.pendingPlayURL {
self.playFromURL(url)
}
self.pendingPlayURL = nil
self.pendingSong = nil
}
}
}
private func playFromURL(_ url: URL) {
levelTimer?.invalidate()
if let observer = timeObserver { player?.removeTimeObserver(observer); timeObserver = nil }
let asset = AVURLAsset(url: url)
playerItem = AVPlayerItem(asset: asset)
if player == nil { player = AVPlayer(playerItem: playerItem) }
else { player?.replaceCurrentItem(with: playerItem) }
player?.volume = volume
player?.play()
isPlaying = true
let interval = CMTime(seconds: 1.0, preferredTimescale: 1)
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
guard let self = self else { return }
self.currentTime = time.seconds
if let dur = self.playerItem?.duration.seconds, !dur.isNaN { self.duration = dur }
self.updateNowPlayingInfo()
}
levelTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in
guard let self = self, self.isPlaying else {
self?.audioLevels = Array(repeating: 0, count: 8)
return
}
self.audioLevels = (0..<8).map { i in
let target = Float.random(in: 0.1...0.85)
let current = self.audioLevels.count > i ? self.audioLevels[i] : 0.1
return current + (target - current) * 0.2
}
}
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
updateNowPlayingInfo()
print("[WatchAudio] Playing: \(currentSong?.title ?? "?") via \(currentRouteName) (\(useSpeakerMode ? "speaker" : "bluetooth"))")
}
// MARK: - Transport
func togglePlayPause() { if isPlaying { pause() } else { resume() } }
func pause() {
player?.pause()
isPlaying = false
updateNowPlayingInfo()
}
func resume() {
if useSpeakerMode { startWorkoutSession() }
player?.play()
isPlaying = true
updateNowPlayingInfo()
}
func next() {
guard !queue.isEmpty else { return }
if queueIndex < queue.count - 1 { queueIndex += 1 }
else if repeatMode == .all { queueIndex = 0 }
else { pause(); return }
play(song: queue[queueIndex])
}
func previous() {
if currentTime > 3 { seek(to: 0); return }
guard !queue.isEmpty else { return }
if queueIndex > 0 { queueIndex -= 1 }
else if repeatMode == .all { queueIndex = queue.count - 1 }
play(song: queue[queueIndex])
}
func seek(to time: TimeInterval) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600)) { [weak self] _ in
self?.updateNowPlayingInfo()
}
currentTime = time
}
func setVolume(_ vol: Float) { volume = vol; player?.volume = vol }
func toggleShuffle() { shuffleEnabled.toggle() }
func cycleRepeat() {
switch repeatMode { case .off: repeatMode = .all; case .all: repeatMode = .one; case .one: repeatMode = .off }
}
@objc private func playerDidFinish() {
if repeatMode == .one { seek(to: 0); player?.play() } else { next() }
}
func stop() {
player?.pause()
isPlaying = false
currentSong = nil
levelTimer?.invalidate(); levelTimer = nil
audioLevels = Array(repeating: 0, count: 8)
if let observer = timeObserver { player?.removeTimeObserver(observer); timeObserver = nil }
if useSpeakerMode { stopWorkoutSession() }
}
// MARK: - Now Playing
private func updateNowPlayingInfo() {
var info: [String: Any] = [:]
info[MPMediaItemPropertyTitle] = currentSong?.title ?? "Unknown"
info[MPMediaItemPropertyArtist] = currentSong?.artist ?? "Unknown"
info[MPMediaItemPropertyAlbumTitle] = currentSong?.album ?? ""
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
info[MPMediaItemPropertyPlaybackDuration] = duration
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
// MARK: - Remote Controls
private func setupRemoteControls() {
let c = MPRemoteCommandCenter.shared()
c.playCommand.addTarget { [weak self] _ in self?.resume(); return .success }
c.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success }
c.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success }
c.nextTrackCommand.addTarget { [weak self] _ in self?.next(); return .success }
c.previousTrackCommand.addTarget { [weak self] _ in self?.previous(); return .success }
c.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
self?.seek(to: e.positionTime); return .success
}
}
// MARK: - Helpers
var progressPercent: Double { guard duration > 0 else { return 0 }; return currentTime / duration }
var currentTimeFormatted: String { formatTime(currentTime) }
var remainingTimeFormatted: String { "-" + formatTime(max(0, duration - currentTime)) }
private func formatTime(_ seconds: TimeInterval) -> String {
let s = Int(max(0, seconds)); return String(format: "%d:%02d", s / 60, s % 60)
}
}
// MARK: - HKWorkoutSession Delegate
extension WatchAudioPlayer: HKWorkoutSessionDelegate {
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
print("[WatchAudio] Workout state: \(fromState.rawValue)\(toState.rawValue)")
if toState == .ended {
DispatchQueue.main.async {
self.workoutSession = nil
self.workoutBuilder = nil
}
}
}
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
print("[WatchAudio] Workout session failed: \(error.localizedDescription)")
}
}
// MARK: - HKLiveWorkoutBuilder Delegate
extension WatchAudioPlayer: HKLiveWorkoutBuilderDelegate {
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {}
}

View file

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.333",
"green" : "0.176",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Navidrome</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.navidromeplayer.app</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>WKBackgroundModes</key>
<array>
<string>self-care</string>
<string>workout-processing</string>
</array>
<key>WKRunsIndependentlyOfCompanionApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSHealthShareUsageDescription</key>
<string>Used to maintain background audio playback through the speaker.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Used to maintain background audio playback through the speaker.</string>
</dict>
</plist>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.navidromeplayer.shared</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
</dict>
</plist>

View file

@ -0,0 +1,611 @@
import SwiftUI
struct WatchLibraryView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@EnvironmentObject var offlineStore: WatchOfflineStore
var body: some View {
TabView {
// Now Playing
WatchNowPlayingView()
// Offline Library
WatchOfflineLibraryView()
// Browse Server
WatchBrowseView()
// Settings
WatchSettingsView()
}
.tabViewStyle(.verticalPage)
}
}
// MARK: - Offline Library
struct WatchOfflineLibraryView: View {
@EnvironmentObject var offlineStore: WatchOfflineStore
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@State private var showDeleteAll = false
var body: some View {
NavigationView {
if offlineStore.songs.isEmpty {
VStack(spacing: 8) {
Image(systemName: "arrow.down.circle")
.font(.title2)
.foregroundColor(.gray)
Text("No Offline Songs")
.font(.caption)
.foregroundColor(.gray)
Text("Send songs from your iPhone or download from Browse")
.font(.caption2)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
} else {
List {
// Play controls
Section {
Button(action: playAllOffline) {
HStack {
Image(systemName: "play.fill")
Text("Play All")
}
.foregroundColor(.pink)
}
Button(action: shuffleOffline) {
HStack {
Image(systemName: "shuffle")
Text("Shuffle All")
}
.foregroundColor(.pink)
}
}
// Songs by album (swipe to delete)
ForEach(Array(offlineStore.songsByAlbum.keys.sorted()), id: \.self) { album in
Section(album) {
ForEach(offlineStore.songsByAlbum[album] ?? []) { offline in
WatchSongRow(
song: offline.song,
isPlaying: audioPlayer.currentSong?.id == offline.id
) {
let albumSongs = (offlineStore.songsByAlbum[album] ?? []).map { $0.song }
let idx = albumSongs.firstIndex(where: { $0.id == offline.id }) ?? 0
audioPlayer.play(song: offline.song, fromQueue: albumSongs, at: idx)
}
}
.onDelete { indexSet in
let albumSongs = offlineStore.songsByAlbum[album] ?? []
for idx in indexSet {
if idx < albumSongs.count {
offlineStore.removeSong(albumSongs[idx].id)
}
}
}
}
}
// Storage info + delete all
Section {
HStack {
Text("\(offlineStore.songs.count) songs")
.font(.caption)
.foregroundColor(.gray)
Spacer()
Text(offlineStore.formattedSize)
.font(.caption)
.foregroundColor(.gray)
}
Button(role: .destructive, action: { showDeleteAll = true }) {
HStack {
Image(systemName: "trash")
Text("Remove All")
}
.font(.caption)
}
}
}
.navigationTitle("Library")
.alert("Remove All Songs?", isPresented: $showDeleteAll) {
Button("Remove", role: .destructive) { offlineStore.removeAll() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This will delete \(offlineStore.songs.count) songs (\(offlineStore.formattedSize)) from your watch.")
}
}
}
}
private func playAllOffline() {
let songs = offlineStore.songs.map { $0.song }
guard let first = songs.first else { return }
audioPlayer.play(song: first, fromQueue: songs)
}
private func shuffleOffline() {
let songs = offlineStore.songs.map { $0.song }.shuffled()
guard let first = songs.first else { return }
audioPlayer.shuffleEnabled = true
audioPlayer.play(song: first, fromQueue: songs)
}
}
// MARK: - Browse Server
struct WatchBrowseView: View {
@EnvironmentObject var watchManager: WatchSessionManager
var body: some View {
NavigationView {
List {
NavigationLink(destination: WatchRecentAlbumsView()) {
Label("Recent Albums", systemImage: "clock")
}
NavigationLink(destination: WatchPlaylistsListView()) {
Label("Playlists", systemImage: "list.bullet")
}
NavigationLink(destination: WatchSearchView()) {
Label("Search", systemImage: "magnifyingglass")
}
// Server info
if let server = watchManager.activeServer {
Section {
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.font(.caption)
Text(server.url)
.font(.caption2)
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
}
.navigationTitle("Browse")
}
}
}
// MARK: - Recent Albums on Watch
struct WatchRecentAlbumsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var albums: [Album] = []
@State private var isLoading = true
var body: some View {
Group {
if isLoading {
ProgressView()
} else {
List(albums) { album in
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.caption)
.lineLimit(1)
Text(album.artist ?? "")
.font(.caption2)
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
}
}
.navigationTitle("Recent")
.task {
do {
albums = try await watchManager.getAlbumList(type: "newest", size: 30)
isLoading = false
} catch {
isLoading = false
}
}
}
}
// MARK: - Album Detail on Watch
struct WatchAlbumDetailView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@EnvironmentObject var offlineStore: WatchOfflineStore
let albumId: String
@State private var album: AlbumWithSongs?
@State private var isLoading = true
@State private var isDownloading = false
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let album = album {
List {
// Album header
VStack(spacing: 4) {
Text(album.name)
.font(.caption)
.fontWeight(.bold)
.multilineTextAlignment(.center)
Text(album.artist ?? "")
.font(.caption2)
.foregroundColor(.pink)
}
.frame(maxWidth: .infinity)
.listRowBackground(Color.clear)
// Play / Download buttons
Button(action: { playAlbum(shuffle: false) }) {
Label("Play", systemImage: "play.fill")
.foregroundColor(.pink)
}
Button(action: { playAlbum(shuffle: true) }) {
Label("Shuffle", systemImage: "shuffle")
.foregroundColor(.pink)
}
Button(action: downloadAll) {
HStack {
if isDownloading {
ProgressView().scaleEffect(0.6)
}
Label(
isDownloading ? "Downloading..." : "Download for Offline",
systemImage: "arrow.down.circle"
)
}
.foregroundColor(.blue)
}
.disabled(isDownloading)
// Songs
ForEach(album.song ?? []) { song in
WatchSongRow(
song: song,
isPlaying: audioPlayer.currentSong?.id == song.id,
isOffline: offlineStore.isSongAvailable(song.id)
) {
let songs = album.song ?? []
let idx = songs.firstIndex(where: { $0.id == song.id }) ?? 0
audioPlayer.play(song: song, fromQueue: songs, at: idx)
}
}
}
}
}
.task {
do {
album = try await watchManager.getAlbum(id: albumId)
isLoading = false
} catch {
isLoading = false
}
}
}
private func playAlbum(shuffle: Bool) {
guard let songs = album?.song, let first = songs.first else { return }
if shuffle { audioPlayer.shuffleEnabled = true }
audioPlayer.play(song: first, fromQueue: songs)
}
private func downloadAll() {
guard let album = album else { return }
isDownloading = true
Task {
await offlineStore.downloadAlbum(album)
await MainActor.run { isDownloading = false }
}
}
}
// MARK: - Playlists on Watch
struct WatchPlaylistsListView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var playlists: [Playlist] = []
@State private var isLoading = true
var body: some View {
Group {
if isLoading {
ProgressView()
} else if playlists.isEmpty {
Text("No Playlists")
.font(.caption)
.foregroundColor(.gray)
} else {
List(playlists) { playlist in
NavigationLink(destination: WatchPlaylistDetailView(playlistId: playlist.id)) {
VStack(alignment: .leading, spacing: 2) {
Text(playlist.name)
.font(.caption)
.lineLimit(1)
Text("\(playlist.songCount ?? 0) songs")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
}
}
.navigationTitle("Playlists")
.task {
do {
playlists = try await watchManager.getPlaylists()
isLoading = false
} catch { isLoading = false }
}
}
}
// MARK: - Playlist Detail on Watch
struct WatchPlaylistDetailView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@EnvironmentObject var offlineStore: WatchOfflineStore
let playlistId: String
@State private var playlist: PlaylistWithSongs?
@State private var isLoading = true
@State private var isDownloading = false
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let playlist = playlist {
List {
Button(action: {
let songs = playlist.entry ?? []
guard let first = songs.first else { return }
audioPlayer.play(song: first, fromQueue: songs)
}) {
Label("Play All", systemImage: "play.fill")
.foregroundColor(.pink)
}
Button(action: downloadPlaylist) {
HStack {
if isDownloading { ProgressView().scaleEffect(0.6) }
Label(isDownloading ? "Downloading..." : "Download All", systemImage: "arrow.down.circle")
}
.foregroundColor(.blue)
}
.disabled(isDownloading)
ForEach(playlist.entry ?? []) { song in
WatchSongRow(
song: song,
isPlaying: audioPlayer.currentSong?.id == song.id,
isOffline: offlineStore.isSongAvailable(song.id)
) {
let songs = playlist.entry ?? []
let idx = songs.firstIndex(where: { $0.id == song.id }) ?? 0
audioPlayer.play(song: song, fromQueue: songs, at: idx)
}
}
}
.navigationTitle(playlist.name)
}
}
.task {
do {
playlist = try await watchManager.getPlaylist(id: playlistId)
isLoading = false
} catch { isLoading = false }
}
}
private func downloadPlaylist() {
guard let entries = playlist?.entry else { return }
isDownloading = true
Task {
for song in entries {
await offlineStore.downloadFromServer(song: song)
}
await MainActor.run { isDownloading = false }
}
}
}
// MARK: - Search on Watch
struct WatchSearchView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@State private var query = ""
@State private var results: SearchResult3?
@State private var isSearching = false
var body: some View {
VStack {
TextField("Search", text: $query)
.onSubmit { performSearch() }
if isSearching {
ProgressView()
} else if let results = results {
List {
if let songs = results.song, !songs.isEmpty {
Section("Songs") {
ForEach(songs.prefix(10)) { song in
WatchSongRow(
song: song,
isPlaying: audioPlayer.currentSong?.id == song.id
) {
audioPlayer.play(song: song, fromQueue: songs)
}
}
}
}
if let albums = results.album, !albums.isEmpty {
Section("Albums") {
ForEach(albums.prefix(5)) { album in
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading) {
Text(album.name).font(.caption).lineLimit(1)
Text(album.artist ?? "").font(.caption2).foregroundColor(.gray)
}
}
}
}
}
}
}
}
.navigationTitle("Search")
}
private func performSearch() {
guard !query.isEmpty else { return }
isSearching = true
Task {
do {
results = try await watchManager.search(query: query)
isSearching = false
} catch { isSearching = false }
}
}
}
// MARK: - Reusable Song Row
struct WatchSongRow: View {
let song: Song
let isPlaying: Bool
var isOffline: Bool = false
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 6) {
if isPlaying {
Image(systemName: "waveform")
.font(.caption2)
.foregroundColor(.pink)
.frame(width: 14)
} else if isOffline {
Image(systemName: "checkmark.circle.fill")
.font(.caption2)
.foregroundColor(.green)
.frame(width: 14)
}
VStack(alignment: .leading, spacing: 1) {
Text(song.title)
.font(.caption)
.foregroundColor(isPlaying ? .pink : .white)
.lineLimit(1)
Text(song.artist ?? "")
.font(.caption2)
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Text(song.durationFormatted)
.font(.caption2)
.foregroundColor(.gray)
}
}
}
}
// MARK: - Watch Settings
struct WatchSettingsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var offlineStore: WatchOfflineStore
@State private var showAddServer = false
var body: some View {
NavigationView {
List {
Section("Server") {
ForEach(watchManager.servers) { server in
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(server.name)
.font(.caption)
if watchManager.activeServer?.id == server.id {
Image(systemName: "checkmark")
.font(.caption2)
.foregroundColor(.pink)
}
}
Text(server.url)
.font(.caption2)
.foregroundColor(.gray)
.lineLimit(1)
}
.contentShape(Rectangle())
.onTapGesture {
watchManager.setActive(server)
}
}
Button("Add Server") { showAddServer = true }
.font(.caption)
.foregroundColor(.pink)
Button("Sync from iPhone") {
watchManager.requestServersFromPhone()
}
.font(.caption)
}
Section("Storage") {
HStack {
Text("Offline Songs")
.font(.caption)
Spacer()
Text("\(offlineStore.songs.count)")
.font(.caption)
.foregroundColor(.gray)
}
HStack {
Text("Used")
.font(.caption)
Spacer()
Text(offlineStore.formattedSize)
.font(.caption)
.foregroundColor(.gray)
}
if !offlineStore.songs.isEmpty {
Button("Remove All Downloads", role: .destructive) {
offlineStore.removeAll()
}
.font(.caption)
}
}
Section("Phone") {
HStack {
Circle()
.fill(watchManager.isPhoneReachable ? .green : .orange)
.frame(width: 6, height: 6)
Text(watchManager.isPhoneReachable ? "Connected" : "Not Reachable")
.font(.caption)
.foregroundColor(.gray)
}
}
}
.navigationTitle("Settings")
}
.sheet(isPresented: $showAddServer) {
WatchAddServerView()
}
}
}

View file

@ -0,0 +1,387 @@
import SwiftUI
struct WatchNowPlayingView: View {
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@EnvironmentObject var watchManager: WatchSessionManager
@State private var crownVolume: Double = 0.8
@State private var showRouteInfo = false
@FocusState private var isCrownFocused: Bool
var body: some View {
if let song = audioPlayer.currentSong {
localNowPlaying(song)
} else if let phone = watchManager.phoneNowPlaying {
remoteNowPlaying(phone)
} else {
emptyState
}
}
// MARK: - Local Playback (watch is playing)
private func localNowPlaying(_ song: Song) -> some View {
VStack(spacing: 4) {
// Audio route indicator
routeIndicator
// Song info
VStack(spacing: 2) {
Text(song.title)
.font(.system(size: 14, weight: .semibold))
.lineLimit(1)
.foregroundColor(.white)
Text(song.artist ?? "")
.font(.system(size: 11))
.foregroundColor(.pink)
.lineLimit(1)
}
.padding(.top, 2)
// Visualizer
WatchVisualizerView(
levels: audioPlayer.audioLevels,
isPlaying: audioPlayer.isPlaying
)
.frame(height: 28)
.padding(.horizontal, 4)
// Progress bar
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: 3)
Capsule()
.fill(Color.pink)
.frame(width: geo.size.width * audioPlayer.progressPercent, height: 3)
}
}
.frame(height: 3)
.padding(.horizontal, 8)
// Time labels
HStack {
Text(audioPlayer.currentTimeFormatted)
.font(.system(size: 9))
.foregroundColor(.gray)
Spacer()
Text(audioPlayer.remainingTimeFormatted)
.font(.system(size: 9))
.foregroundColor(.gray)
}
.padding(.horizontal, 8)
// Transport controls
HStack(spacing: 0) {
Spacer()
Button(action: { audioPlayer.previous() }) {
Image(systemName: "backward.fill")
.font(.system(size: 16))
.foregroundColor(.white)
}
.buttonStyle(.plain)
.frame(width: 40, height: 36)
Spacer()
Button(action: { audioPlayer.togglePlayPause() }) {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24))
.foregroundColor(.white)
}
.buttonStyle(.plain)
.frame(width: 50, height: 44)
Spacer()
Button(action: { audioPlayer.next() }) {
Image(systemName: "forward.fill")
.font(.system(size: 16))
.foregroundColor(.white)
}
.buttonStyle(.plain)
.frame(width: 40, height: 36)
Spacer()
}
// Bottom row
HStack(spacing: 14) {
Button(action: { audioPlayer.toggleShuffle() }) {
Image(systemName: "shuffle")
.font(.system(size: 11))
.foregroundColor(audioPlayer.shuffleEnabled ? .pink : .gray)
}
.buttonStyle(.plain)
Button(action: { audioPlayer.cycleRepeat() }) {
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
.font(.system(size: 11))
.foregroundColor(audioPlayer.repeatMode != .off ? .pink : .gray)
}
.buttonStyle(.plain)
// Volume
HStack(spacing: 2) {
Image(systemName: "speaker.fill")
.font(.system(size: 7))
.foregroundColor(.gray)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.15)).frame(height: 2)
Capsule().fill(Color.white.opacity(0.6))
.frame(width: geo.size.width * CGFloat(audioPlayer.volume), height: 2)
}
}
.frame(height: 8)
Image(systemName: "speaker.wave.2.fill")
.font(.system(size: 7))
.foregroundColor(.gray)
}
.frame(width: 60)
}
}
.padding(.horizontal, 4)
.focused($isCrownFocused)
.digitalCrownRotation(
$crownVolume,
from: 0, through: 1, by: 0.05,
sensitivity: .medium,
isContinuous: false,
isHapticFeedbackEnabled: true
)
.onChange(of: crownVolume) { _, newValue in
audioPlayer.setVolume(Float(newValue))
}
.onAppear {
crownVolume = Double(audioPlayer.volume)
isCrownFocused = true
}
.sheet(isPresented: $showRouteInfo) {
audioRouteSheet
}
}
// MARK: - Audio Route Indicator
private var routeIndicator: some View {
Button(action: { showRouteInfo = true }) {
HStack(spacing: 4) {
Image(systemName: audioPlayer.useSpeakerMode ? "speaker.wave.2.fill" :
(audioPlayer.isBluetoothConnected ? "airpodspro" : "airpodspro"))
.font(.system(size: 9))
.foregroundColor(routeColor)
Text(routeLabel)
.font(.system(size: 9, weight: .medium))
.foregroundColor(routeColor)
.lineLimit(1)
}
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Capsule().fill(routeColor.opacity(0.15)))
}
.buttonStyle(.plain)
}
private var routeColor: Color {
if audioPlayer.useSpeakerMode { return .cyan }
return audioPlayer.isBluetoothConnected ? .green : .orange
}
private var routeLabel: String {
if audioPlayer.useSpeakerMode { return "Speaker" }
return audioPlayer.isBluetoothConnected ? audioPlayer.currentRouteName : "Connect Audio"
}
// MARK: - Audio Route Sheet
private var audioRouteSheet: some View {
ScrollView {
VStack(spacing: 14) {
Text("Audio Output")
.font(.system(size: 16, weight: .bold))
.padding(.top, 8)
// Speaker option
if audioPlayer.isSpeakerAvailable {
Button(action: {
audioPlayer.useSpeakerMode = true
showRouteInfo = false
}) {
HStack(spacing: 10) {
Image(systemName: "speaker.wave.2.fill")
.font(.system(size: 16))
.foregroundColor(.cyan)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text("Built-in Speaker")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
Text("Plays through watch speaker")
.font(.system(size: 10))
.foregroundColor(.gray)
}
Spacer()
if audioPlayer.useSpeakerMode {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.cyan)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(audioPlayer.useSpeakerMode ? Color.cyan.opacity(0.12) : Color.white.opacity(0.06))
)
}
.buttonStyle(.plain)
}
// Bluetooth option
Button(action: {
audioPlayer.useSpeakerMode = false
showRouteInfo = false
Task { await audioPlayer.activateSession() }
}) {
HStack(spacing: 10) {
Image(systemName: "airpodspro")
.font(.system(size: 16))
.foregroundColor(.green)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text("Bluetooth / AirPlay")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
Text(audioPlayer.isBluetoothConnected ? audioPlayer.currentRouteName : "Tap to connect")
.font(.system(size: 10))
.foregroundColor(.gray)
}
Spacer()
if !audioPlayer.useSpeakerMode {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(!audioPlayer.useSpeakerMode ? Color.green.opacity(0.12) : Color.white.opacity(0.06))
)
}
.buttonStyle(.plain)
if audioPlayer.useSpeakerMode {
Text("Speaker mode uses a background workout session to keep the app alive. A green ring indicator will appear on your watch face.")
.font(.system(size: 10))
.foregroundColor(.gray)
.multilineTextAlignment(.center)
.padding(.horizontal, 8)
}
}
.padding(.horizontal, 4)
}
}
// MARK: - Remote Control (iPhone is playing)
private func remoteNowPlaying(_ nowPlaying: WatchSessionManager.PhoneNowPlaying) -> some View {
VStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: "iphone")
.font(.system(size: 10))
.foregroundColor(.blue)
Text("Playing on iPhone")
.font(.system(size: 10))
.foregroundColor(.blue)
}
VStack(spacing: 2) {
Text(nowPlaying.title)
.font(.system(size: 14, weight: .semibold))
.lineLimit(1)
Text(nowPlaying.artist)
.font(.system(size: 11))
.foregroundColor(.pink)
.lineLimit(1)
}
GeometryReader { geo in
let pct = nowPlaying.duration > 0 ? nowPlaying.currentTime / nowPlaying.duration : 0
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.2)).frame(height: 3)
Capsule().fill(Color.pink)
.frame(width: geo.size.width * pct, height: 3)
}
}
.frame(height: 3)
.padding(.horizontal, 8)
HStack(spacing: 20) {
Button(action: { watchManager.sendPreviousCommand() }) {
Image(systemName: "backward.fill")
.font(.system(size: 16))
}
.buttonStyle(.plain)
Button(action: { watchManager.sendPlayCommand() }) {
Image(systemName: nowPlaying.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24))
}
.buttonStyle(.plain)
Button(action: { watchManager.sendNextCommand() }) {
Image(systemName: "forward.fill")
.font(.system(size: 16))
}
.buttonStyle(.plain)
}
}
.padding()
}
// MARK: - Empty State
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "music.note")
.font(.title2)
.foregroundColor(.gray)
Text("Not Playing")
.font(.caption)
.foregroundColor(.gray)
// Show connect button if no Bluetooth
if !audioPlayer.isBluetoothConnected {
Button(action: {
Task { await audioPlayer.activateSession() }
}) {
HStack(spacing: 4) {
Image(systemName: "airpodspro")
.font(.system(size: 11))
Text("Connect AirPods")
.font(.system(size: 12))
}
.foregroundColor(.pink)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().stroke(Color.pink, lineWidth: 1))
}
.buttonStyle(.plain)
.padding(.top, 4)
}
if let error = audioPlayer.activationError {
Text(error)
.font(.system(size: 9))
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
}
}

View file

@ -0,0 +1,172 @@
import SwiftUI
struct WatchSetupView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var showManualAdd = false
@State private var isSyncing = false
@State private var syncMessage = ""
var body: some View {
ScrollView {
VStack(spacing: 16) {
Image(systemName: "music.note.house.fill")
.font(.system(size: 36))
.foregroundColor(.pink)
Text("Navidrome")
.font(.headline)
Text("Set up your server to play music on your watch.")
.font(.caption2)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
.padding(.horizontal)
// Sync from iPhone
Button(action: syncFromPhone) {
HStack {
if isSyncing {
ProgressView()
.scaleEffect(0.7)
}
Text(isSyncing ? "Syncing..." : "Sync from iPhone")
}
}
.buttonStyle(.borderedProminent)
.tint(.pink)
.disabled(isSyncing)
if !syncMessage.isEmpty {
Text(syncMessage)
.font(.caption2)
.foregroundColor(syncMessage.contains("Error") ? .red : .green)
}
Divider()
// Manual add
Button("Add Server Manually") {
showManualAdd = true
}
.font(.caption)
if watchManager.isPhoneReachable {
HStack(spacing: 4) {
Circle()
.fill(.green)
.frame(width: 6, height: 6)
Text("iPhone connected")
.font(.caption2)
.foregroundColor(.gray)
}
} else {
HStack(spacing: 4) {
Circle()
.fill(.orange)
.frame(width: 6, height: 6)
Text("iPhone not reachable")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
.padding()
}
.sheet(isPresented: $showManualAdd) {
WatchAddServerView()
}
}
private func syncFromPhone() {
isSyncing = true
syncMessage = ""
watchManager.requestServersFromPhone()
// Check result after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
isSyncing = false
if !watchManager.servers.isEmpty {
syncMessage = "Synced \(watchManager.servers.count) server(s)"
} else if !watchManager.isPhoneReachable {
syncMessage = "Error: iPhone not reachable"
} else {
syncMessage = "Error: No servers found"
}
}
}
}
// MARK: - Manual Add Server
struct WatchAddServerView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var url = ""
@State private var username = ""
@State private var password = ""
@State private var isTesting = false
@State private var testResult = ""
var body: some View {
ScrollView {
VStack(spacing: 10) {
Text("Add Server")
.font(.headline)
TextField("Name", text: $name)
TextField("URL", text: $url)
.textContentType(.URL)
TextField("Username", text: $username)
SecureField("Password", text: $password)
Button(action: testAndSave) {
if isTesting {
ProgressView().scaleEffect(0.7)
} else {
Text("Connect")
}
}
.buttonStyle(.borderedProminent)
.tint(.pink)
.disabled(url.isEmpty || username.isEmpty || isTesting)
if !testResult.isEmpty {
Text(testResult)
.font(.caption2)
.foregroundColor(testResult.contains("Success") ? .green : .red)
}
}
.padding()
}
}
private func testAndSave() {
isTesting = true
testResult = ""
let server = ServerConfig(
name: name.isEmpty ? "My Server" : name,
url: url.trimmingCharacters(in: CharacterSet(charactersIn: "/")),
username: username,
password: password
)
Task {
let ok = await watchManager.testConnection(server)
await MainActor.run {
isTesting = false
if ok {
testResult = "Success!"
watchManager.addServer(server)
watchManager.setActive(server)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
dismiss()
}
} else {
testResult = "Connection failed"
}
}
}
}
}

View file

@ -0,0 +1,126 @@
import SwiftUI
/// Compact Siri-style wave visualizer for watchOS
struct WatchVisualizerView: View {
let levels: [Float]
let isPlaying: Bool
private let greenColor = Color(red: 0.3, green: 0.85, blue: 0.45)
private let blueColor = Color(red: 0.2, green: 0.55, blue: 1.0)
private let purpleColor = Color(red: 0.85, green: 0.25, blue: 0.85)
var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in
Canvas { context, size in
drawVisualizerContent(context: context, size: size, date: timeline.date)
}
}
}
// MARK: - Main draw (broken out for type-checker)
private func drawVisualizerContent(context: GraphicsContext, size: CGSize, date: Date) {
let w = size.width
let h = size.height
let count = levels.count
guard count >= 2 else { return }
let time = date.timeIntervalSinceReferenceDate
let spacing = w / CGFloat(count - 1)
let layerData: [(Color, Double)] = [
(purpleColor, 0.3),
(blueColor, 0.15),
(greenColor, 0.0),
]
for (color, offset) in layerData {
drawWaveLayer(
context: context,
w: w, h: h,
count: count,
spacing: spacing,
time: time,
offset: offset,
color: color
)
}
}
// MARK: - Single wave layer
private func drawWaveLayer(
context: GraphicsContext,
w: CGFloat, h: CGFloat,
count: Int,
spacing: CGFloat,
time: TimeInterval,
offset: Double,
color: Color
) {
let points = buildPoints(
count: count, spacing: spacing,
h: h, time: time, offset: offset
)
let curvePath = buildCurve(points: points)
var fillPath = Path()
fillPath.move(to: CGPoint(x: 0, y: h))
fillPath.addLine(to: points[0])
fillPath.addPath(curvePath)
fillPath.addLine(to: CGPoint(x: w, y: h))
fillPath.closeSubpath()
context.fill(fillPath, with: .color(color.opacity(0.55)))
var strokePath = Path()
strokePath.move(to: points[0])
strokePath.addPath(curvePath)
context.stroke(strokePath, with: .color(color.opacity(0.6)), lineWidth: 1.2)
}
// MARK: - Build points array
private func buildPoints(
count: Int, spacing: CGFloat,
h: CGFloat, time: TimeInterval, offset: Double
) -> [CGPoint] {
var points: [CGPoint] = []
for i in 0..<count {
let x = CGFloat(i) * spacing
let baseAmp: CGFloat = isPlaying ? CGFloat(levels[i]) : 0.03
let sinArg = time * 1.8 + offset * 10 + Double(i) * 0.4
let breath = CGFloat(sin(sinArg)) * 0.05
let amp = max(0.02, baseAmp + breath)
let y = h - (h * amp * 0.85)
points.append(CGPoint(x: x, y: y))
}
return points
}
// MARK: - Catmull-Rom spline
private func buildCurve(points: [CGPoint]) -> Path {
var path = Path()
guard points.count >= 2 else { return path }
for i in 0..<(points.count - 1) {
let p0 = i > 0 ? points[i - 1] : points[i]
let p1 = points[i]
let p2 = points[i + 1]
let p3 = (i + 2 < points.count) ? points[i + 2] : p2
let cp1x = p1.x + (p2.x - p0.x) / 6.0
let cp1y = p1.y + (p2.y - p0.y) / 6.0
let cp2x = p2.x - (p3.x - p1.x) / 6.0
let cp2y = p2.y - (p3.y - p1.y) / 6.0
path.addCurve(
to: p2,
control1: CGPoint(x: cp1x, y: cp1y),
control2: CGPoint(x: cp2x, y: cp2y)
)
}
return path
}
}