Merge branch 'main' of ssh://dallasgroot@10.0.0.224:22/Users/dallasgroot/NavidromePlayer/.git

This commit is contained in:
Dallas Groot 2026-03-28 17:04:35 -07:00
commit f1d3d41340
8 changed files with 671 additions and 287 deletions

View file

@ -105,64 +105,44 @@ class WatchConnectivityManager: NSObject, ObservableObject {
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")
}
}
// Send a message telling the watch to download directly from server.
// The watch has its own server credentials and can stream at 192kbps MP3
// without needing the iPhone to download and transfer.
let metadata: [String: Any] = [
"type": "downloadSong",
"songId": song.id,
"title": song.title,
"artist": song.artist ?? "Unknown",
"album": song.album ?? "Unknown",
"duration": song.duration ?? 0,
"coverArt": song.coverArt ?? "",
"suffix": song.suffix ?? "mp3"
]
// Otherwise, download a transcoded MP3 192kbps from server
wcLog("Transcoding to MP3 192kbps via server stream endpoint")
if session.isReachable {
// Watch is active send interactive message (faster)
session.sendMessage(metadata, replyHandler: { reply in
self.wcLog("Watch acknowledged download: \(reply)")
}, errorHandler: { error in
self.wcLog("Watch message failed: \(error.localizedDescription) — falling back to userInfo")
// Fall back to transferUserInfo (queued, delivered when watch wakes)
session.transferUserInfo(metadata)
})
} else {
// Watch not reachable queue for delivery when it wakes
session.transferUserInfo(metadata)
wcLog("Watch not reachable — queued download command via transferUserInfo")
}
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 {
// Auto-remove from transferring after 30s since we can't track watch download progress
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

View file

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "1024.png",
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

93
update.sh Executable file
View file

@ -0,0 +1,93 @@
#!/bin/bash
# update.sh - Unzips a new NavidromePlayer.zip and replaces all project files
# while preserving your .git history.
#
# Usage: Place this script in your NavidromePlayer/ root directory.
# Drop the new NavidromePlayer.zip in the SAME directory.
# Then run: ./update.sh
#
# It will:
# 1. Auto-commit any uncommitted changes
# 2. Delete all project files (keeps .git and this script)
# 3. Unzip the new version
# 4. Show you what changed
# 5. Commit with the zip's git message or a default
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
ZIP_FILE="NavidromePlayer.zip"
# Check zip exists
if [ ! -f "$ZIP_FILE" ]; then
echo "$ZIP_FILE not found in $(pwd)"
echo " Drop the zip file here and run again."
exit 1
fi
# Check we're in a git repo
if [ ! -d ".git" ]; then
echo "❌ No .git directory found. Run this from your NavidromePlayer repo root."
exit 1
fi
echo "📦 Updating NavidromePlayer from $ZIP_FILE"
echo ""
# Step 1: Auto-commit any uncommitted work
if [ -n "$(git status --porcelain)" ]; then
echo "💾 Committing your uncommitted changes first..."
git add -A
git commit -m "Auto-commit before update ($(date '+%Y-%m-%d %H:%M'))"
echo ""
fi
# Step 2: Delete everything except .git, this script, and the zip
echo "🗑 Removing old project files..."
find . -maxdepth 1 \
-not -name '.' \
-not -name '.git' \
-not -name "$ZIP_FILE" \
-not -name 'update.sh' \
-exec rm -rf {} \;
# Step 3: Unzip
echo "📂 Extracting $ZIP_FILE..."
unzip -q -o "$ZIP_FILE"
# Move files out of the NavidromePlayer/ wrapper folder
if [ -d "NavidromePlayer" ]; then
# Use rsync to handle dotfiles properly
shopt -s dotglob
mv NavidromePlayer/* . 2>/dev/null || true
shopt -u dotglob
rmdir NavidromePlayer 2>/dev/null || rm -rf NavidromePlayer
fi
# Step 4: Show diff
echo ""
echo "📊 Changes:"
git add -A
git diff --cached --stat
echo ""
# Step 5: Count changes
CHANGED=$(git diff --cached --numstat | wc -l | tr -d ' ')
if [ "$CHANGED" -eq "0" ]; then
echo "✅ No changes - you're already up to date."
git reset HEAD . > /dev/null 2>&1
else
# Commit
echo "💾 Committing $CHANGED changed files..."
git commit -m "Update from NavidromePlayer.zip ($(date '+%Y-%m-%d %H:%M'))"
echo ""
echo "✅ Done! Latest commits:"
git log --oneline -5
fi
echo ""
echo "🔨 Don't forget to run: ./generate.sh"

View file

@ -97,15 +97,49 @@ class WatchSessionManager: NSObject, ObservableObject {
// MARK: - Direct API Access (watch can also talk to server directly)
func getAlbumList(type: String = "newest", size: Int = 20) async throws -> [Album] {
func getArtists() async throws -> [ArtistIndex] {
guard activeServer != nil else { return [] }
return try await client.getAlbumList2(type: type, size: size)
return try await client.getArtists()
}
func getArtist(id: String) async throws -> ArtistWithAlbums? {
return try await client.getArtist(id: id)
}
func getGenres() async throws -> [Genre] {
guard activeServer != nil else { return [] }
return try await client.getGenres()
}
func getAlbumList(type: String = "newest", size: Int = 20, offset: Int = 0) async throws -> [Album] {
guard activeServer != nil else { return [] }
return try await client.getAlbumList2(type: type, size: size, offset: offset)
}
/// Fetch ALL albums (paginated until exhausted)
func getAllAlbums() async throws -> [Album] {
guard activeServer != nil else { return [] }
var all: [Album] = []
var offset = 0
let pageSize = 500
while true {
let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset)
all.append(contentsOf: page)
if page.count < pageSize { break }
offset += pageSize
}
return all
}
func getAlbum(id: String) async throws -> AlbumWithSongs? {
return try await client.getAlbum(id: id)
}
func getAlbumsByGenre(_ genre: String) async throws -> [Album] {
guard activeServer != nil else { return [] }
return try await client.getAlbumList2(type: "byGenre", size: 500, genre: genre)
}
func getPlaylists() async throws -> [Playlist] {
return try await client.getPlaylists()
}
@ -136,6 +170,117 @@ class WatchSessionManager: NSObject, ObservableObject {
return try await client.requestData(endpoint: "stream", params: params)
}
// MARK: - Watch Library Cache (UserDefaults, persists across launches)
private let cachePrefix = "wcache_"
func cacheEncode<T: Encodable>(_ data: T, key: String) {
if let encoded = try? JSONEncoder().encode(data) {
UserDefaults.standard.set(encoded, forKey: cachePrefix + key)
}
}
func cacheDecode<T: Decodable>(_ type: T.Type, key: String) -> T? {
guard let data = UserDefaults.standard.data(forKey: cachePrefix + key) else { return nil }
return try? JSONDecoder().decode(type, from: data)
}
/// Fetch with cache-first pattern: returns cached immediately, refreshes in background
func cachedArtists() -> [ArtistIndex] {
return cacheDecode([ArtistIndex].self, key: "artists") ?? []
}
func cachedGenres() -> [Genre] {
return cacheDecode([Genre].self, key: "genres") ?? []
}
func cachedAlbums() -> [Album] {
return cacheDecode([Album].self, key: "all_albums") ?? []
}
func cachedPlaylists() -> [Playlist] {
return cacheDecode([Playlist].self, key: "playlists") ?? []
}
func cachedAlbumDetail(id: String) -> AlbumWithSongs? {
return cacheDecode(AlbumWithSongs.self, key: "album_\(id)")
}
func cachedArtistDetail(id: String) -> ArtistWithAlbums? {
return cacheDecode(ArtistWithAlbums.self, key: "artist_\(id)")
}
/// Full sync called on launch, caches everything
func syncLibrary() async {
guard activeServer != nil else { return }
await MainActor.run { isSyncing = true }
do {
let artists = try await getArtists()
cacheEncode(artists, key: "artists")
let genres = try await getGenres()
cacheEncode(genres, key: "genres")
let albums = try await getAllAlbums()
cacheEncode(albums, key: "all_albums")
let playlists = try await getPlaylists()
cacheEncode(playlists, key: "playlists")
let recent = try await getAlbumList(type: "newest", size: 30)
cacheEncode(recent, key: "recent")
print("[Watch] Library synced: \(artists.flatMap { $0.artist ?? [] }.count) artists, \(albums.count) albums, \(genres.count) genres")
} catch {
print("[Watch] Library sync failed: \(error.localizedDescription)")
}
await MainActor.run { isSyncing = false }
}
// MARK: - Server-Direct Download Handler
/// Called when iPhone tells us to download a song directly from server.
/// Watch downloads at 192kbps MP3 no iPhone storage or transfer needed.
private func handleDownloadCommand(_ message: [String: Any]) {
guard let songId = message["songId"] as? String else {
print("[Watch] downloadSong: missing songId")
return
}
// Skip if already downloaded
if WatchOfflineStore.shared.isSongAvailable(songId) {
print("[Watch] Already downloaded: \(songId)")
return
}
let song = Song(
id: songId,
parent: nil, isDir: false,
title: message["title"] as? String ?? "Unknown",
album: message["album"] as? String,
artist: message["artist"] as? String,
track: nil, year: nil, genre: nil,
coverArt: message["coverArt"] as? String,
size: nil, contentType: nil,
suffix: message["suffix"] as? String ?? "mp3",
transcodedContentType: nil, transcodedSuffix: nil,
duration: message["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
)
print("[Watch] Downloading from server: \(song.title)")
Task {
await WatchOfflineStore.shared.downloadFromServer(song: song)
print("[Watch] Download complete: \(song.title)")
}
}
// MARK: - Persistence
private func saveLocalServers() {
@ -224,7 +369,7 @@ extension WatchSessionManager: WCSessionDelegate {
}
}
// Receive messages (now playing info from iPhone)
// Receive messages (now playing info + download commands from iPhone)
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let type = message["type"] as? String else { return }
@ -240,6 +385,8 @@ extension WatchSessionManager: WCSessionDelegate {
coverArtId: message["coverArtId"] as? String
)
}
} else if type == "downloadSong" {
handleDownloadCommand(message)
}
}
@ -251,6 +398,10 @@ extension WatchSessionManager: WCSessionDelegate {
}
switch type {
case "downloadSong":
handleDownloadCommand(message)
replyHandler(["status": "downloading"])
case "requestSongList":
let store = WatchOfflineStore.shared
struct WatchSongInfoEnc: Codable {
@ -289,13 +440,14 @@ extension WatchSessionManager: WCSessionDelegate {
}
}
// Receive user info (wake signals, queued data from phone)
// Receive user info (wake signals, queued download commands 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")
} else if type == "downloadSong" {
handleDownloadCommand(userInfo)
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

View file

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "1024.png",
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"

View file

@ -1,5 +1,7 @@
import SwiftUI
// MARK: - Main Tab View
struct WatchLibraryView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@ -7,23 +9,283 @@ struct WatchLibraryView: View {
var body: some View {
TabView {
// Now Playing
WatchNowPlayingView()
// Offline Library
WatchMyMusicView()
WatchOfflineLibraryView()
// Browse Server
WatchBrowseView()
// Settings
WatchSettingsView()
}
.tabViewStyle(.verticalPage)
.task {
// Sync library in background on launch
await watchManager.syncLibrary()
}
}
}
// MARK: - My Music (Artists, Albums, Genres, Songs)
struct WatchMyMusicView: View {
@EnvironmentObject var watchManager: WatchSessionManager
var body: some View {
NavigationView {
List {
NavigationLink(destination: WatchArtistsView()) {
Label("Artists", systemImage: "music.mic")
}
NavigationLink(destination: WatchAlbumsView()) {
Label("Albums", systemImage: "square.stack")
}
NavigationLink(destination: WatchGenresView()) {
Label("Genres", systemImage: "guitars")
}
NavigationLink(destination: WatchPlaylistsListView()) {
Label("Playlists", systemImage: "list.bullet")
}
NavigationLink(destination: WatchSearchView()) {
Label("Search", systemImage: "magnifyingglass")
}
}
.navigationTitle("My Music")
}
}
}
// MARK: - Artists
struct WatchArtistsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var artistIndexes: [ArtistIndex] = []
@State private var isLoading = true
var body: some View {
Group {
if artistIndexes.isEmpty && isLoading {
ProgressView()
} else {
List {
ForEach(artistIndexes) { index in
if let artists = index.artist, !artists.isEmpty {
Section(index.name) {
ForEach(artists) { artist in
NavigationLink(destination: WatchArtistDetailView(artistId: artist.id, artistName: artist.name)) {
Text(artist.name)
.font(.caption)
.lineLimit(1)
}
}
}
}
}
}
}
}
.navigationTitle("Artists")
.task {
// Cache first
let cached = watchManager.cachedArtists()
if !cached.isEmpty {
artistIndexes = cached
isLoading = false
}
// Refresh
do {
let fresh = try await watchManager.getArtists()
watchManager.cacheEncode(fresh, key: "artists")
await MainActor.run {
artistIndexes = fresh
isLoading = false
}
} catch {
isLoading = false
}
}
}
}
struct WatchArtistDetailView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@EnvironmentObject var offlineStore: WatchOfflineStore
let artistId: String
let artistName: String
@State private var artist: ArtistWithAlbums?
@State private var isLoading = true
var body: some View {
Group {
if let artist = artist {
List {
if let albums = artist.album, !albums.isEmpty {
ForEach(albums) { album in
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.caption)
.lineLimit(1)
HStack(spacing: 4) {
if let year = album.year {
Text("\(year)")
.font(.caption2)
.foregroundColor(.gray)
}
Text("\(album.songCount ?? 0) songs")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
}
}
}
} else if isLoading {
ProgressView()
} else {
Text("Not available")
.font(.caption)
.foregroundColor(.gray)
}
}
.navigationTitle(artistName)
.task {
if let cached = watchManager.cachedArtistDetail(id: artistId) {
artist = cached
isLoading = false
}
do {
let fresh = try await watchManager.getArtist(id: artistId)
if let fresh {
watchManager.cacheEncode(fresh, key: "artist_\(artistId)")
await MainActor.run { artist = fresh; isLoading = false }
}
} catch { isLoading = false }
}
}
}
// MARK: - Albums (All)
struct WatchAlbumsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var albums: [Album] = []
@State private var isLoading = true
var body: some View {
Group {
if albums.isEmpty && 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("Albums")
.task {
let cached = watchManager.cachedAlbums()
if !cached.isEmpty {
albums = cached
isLoading = false
}
do {
let fresh = try await watchManager.getAllAlbums()
watchManager.cacheEncode(fresh, key: "all_albums")
await MainActor.run { albums = fresh; isLoading = false }
} catch { isLoading = false }
}
}
}
// MARK: - Genres
struct WatchGenresView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var genres: [Genre] = []
@State private var isLoading = true
var body: some View {
Group {
if genres.isEmpty && isLoading {
ProgressView()
} else {
List(genres) { genre in
NavigationLink(destination: WatchGenreDetailView(genreName: genre.value)) {
HStack {
Text(genre.value)
.font(.caption)
.lineLimit(1)
Spacer()
Text("\(genre.albumCount ?? 0)")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
}
}
.navigationTitle("Genres")
.task {
let cached = watchManager.cachedGenres()
if !cached.isEmpty {
genres = cached
isLoading = false
}
do {
let fresh = try await watchManager.getGenres()
watchManager.cacheEncode(fresh, key: "genres")
await MainActor.run { genres = fresh; isLoading = false }
} catch { isLoading = false }
}
}
}
struct WatchGenreDetailView: View {
@EnvironmentObject var watchManager: WatchSessionManager
let genreName: String
@State private var albums: [Album] = []
@State private var isLoading = true
var body: some View {
Group {
if albums.isEmpty && 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(genreName)
.task {
do {
albums = try await watchManager.getAlbumsByGenre(genreName)
isLoading = false
} catch { isLoading = false }
}
}
}
// MARK: - Offline Library
struct WatchOfflineLibraryView: View {
@EnvironmentObject var offlineStore: WatchOfflineStore
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@ -39,39 +301,29 @@ struct WatchOfflineLibraryView: View {
Text("No Offline Songs")
.font(.caption)
.foregroundColor(.gray)
Text("Send songs from your iPhone or download from Browse")
Text("Download from Browse or send from iPhone")
.font(.caption2)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
} else {
List {
// Play controls
Section {
Button(action: playAllOffline) {
HStack {
Image(systemName: "play.fill")
Text("Play All")
}
.foregroundColor(.pink)
HStack { Image(systemName: "play.fill"); Text("Play All") }.foregroundColor(.pink)
}
Button(action: shuffleOffline) {
HStack {
Image(systemName: "shuffle")
Text("Shuffle All")
}
.foregroundColor(.pink)
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
isPlaying: audioPlayer.currentSong?.id == offline.id,
isOffline: true
) {
let albumSongs = (offlineStore.songsByAlbum[album] ?? []).map { $0.song }
let idx = albumSongs.firstIndex(where: { $0.id == offline.id }) ?? 0
@ -80,42 +332,26 @@ struct WatchOfflineLibraryView: View {
}
.onDelete { indexSet in
let albumSongs = offlineStore.songsByAlbum[album] ?? []
for idx in indexSet {
if idx < albumSongs.count {
offlineStore.removeSong(albumSongs[idx].id)
}
}
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)
Text("\(offlineStore.songs.count) songs").font(.caption).foregroundColor(.gray)
Spacer()
Text(offlineStore.formattedSize)
.font(.caption)
.foregroundColor(.gray)
Text(offlineStore.formattedSize).font(.caption).foregroundColor(.gray)
}
Button(role: .destructive, action: { showDeleteAll = true }) {
HStack {
Image(systemName: "trash")
Text("Remove All")
}
.font(.caption)
HStack { Image(systemName: "trash"); Text("Remove All") }.font(.caption)
}
}
}
.navigationTitle("Library")
.navigationTitle("Offline")
.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.")
}
}
}
@ -136,6 +372,7 @@ struct WatchOfflineLibraryView: View {
}
// MARK: - Browse Server
struct WatchBrowseView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@ -145,25 +382,18 @@ struct WatchBrowseView: View {
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)
Text(server.name).font(.caption)
Text(server.url).font(.caption2).foregroundColor(.gray).lineLimit(1)
}
}
}
@ -173,7 +403,8 @@ struct WatchBrowseView: View {
}
}
// MARK: - Recent Albums on Watch
// MARK: - Recent Albums
struct WatchRecentAlbumsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var albums: [Album] = []
@ -181,19 +412,14 @@ struct WatchRecentAlbumsView: View {
var body: some View {
Group {
if isLoading {
if albums.isEmpty && 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)
Text(album.name).font(.caption).lineLimit(1)
Text(album.artist ?? "").font(.caption2).foregroundColor(.gray).lineLimit(1)
}
}
}
@ -201,103 +427,86 @@ struct WatchRecentAlbumsView: View {
}
.navigationTitle("Recent")
.task {
do {
albums = try await watchManager.getAlbumList(type: "newest", size: 30)
isLoading = false
} catch {
if let cached: [Album] = watchManager.cacheDecode([Album].self, key: "recent") {
albums = cached
isLoading = false
}
do {
let fresh = try await watchManager.getAlbumList(type: "newest", size: 30)
watchManager.cacheEncode(fresh, key: "recent")
await MainActor.run { albums = fresh; isLoading = false }
} catch { isLoading = false }
}
}
}
// MARK: - Album Detail on Watch
// MARK: - Album Detail
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 {
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)
// Download button
Section {
Button(action: downloadAlbum) {
HStack {
Image(systemName: isDownloading ? "arrow.down.circle.fill" : "arrow.down.circle")
.foregroundColor(isDownloading ? .gray : .pink)
Text(isDownloading ? "Downloading..." : "Download Album")
.font(.caption)
}
Label(
isDownloading ? "Downloading..." : "Download for Offline",
systemImage: "arrow.down.circle"
)
}
.foregroundColor(.blue)
.disabled(isDownloading)
}
.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)
if let songs = album.song {
ForEach(songs) { song in
WatchSongRow(
song: song,
isPlaying: audioPlayer.currentSong?.id == song.id,
isOffline: offlineStore.isSongAvailable(song.id)
) {
audioPlayer.play(song: song, fromQueue: songs)
}
}
}
}
.navigationTitle(album.name)
} else if isLoading {
ProgressView()
} else {
Text("Not available").font(.caption).foregroundColor(.gray)
}
}
.task {
do {
album = try await watchManager.getAlbum(id: albumId)
isLoading = false
} catch {
// Cache first
if let cached = watchManager.cachedAlbumDetail(id: albumId) {
album = cached
isLoading = false
}
do {
let fresh = try await watchManager.getAlbum(id: albumId)
if let fresh {
watchManager.cacheEncode(fresh, key: "album_\(albumId)")
await MainActor.run { album = fresh; 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() {
private func downloadAlbum() {
guard let album = album else { return }
isDownloading = true
Task {
@ -307,7 +516,8 @@ struct WatchAlbumDetailView: View {
}
}
// MARK: - Playlists on Watch
// MARK: - Playlists
struct WatchPlaylistsListView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var playlists: [Playlist] = []
@ -315,22 +525,14 @@ struct WatchPlaylistsListView: View {
var body: some View {
Group {
if isLoading {
if playlists.isEmpty && 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)
Text(playlist.name).font(.caption).lineLimit(1)
Text("\(playlist.songCount ?? 0) songs").font(.caption2).foregroundColor(.gray)
}
}
}
@ -338,62 +540,57 @@ struct WatchPlaylistsListView: View {
}
.navigationTitle("Playlists")
.task {
let cached = watchManager.cachedPlaylists()
if !cached.isEmpty { playlists = cached; isLoading = false }
do {
playlists = try await watchManager.getPlaylists()
isLoading = false
let fresh = try await watchManager.getPlaylists()
watchManager.cacheEncode(fresh, key: "playlists")
await MainActor.run { playlists = fresh; 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 {
if let playlist = playlist, let songs = playlist.entry {
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")
Section {
Button(action: downloadPlaylist) {
HStack {
Image(systemName: "arrow.down.circle")
Text(isDownloading ? "Downloading..." : "Download All")
.font(.caption)
}
.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)
}
.disabled(isDownloading)
ForEach(playlist.entry ?? []) { song in
ForEach(songs) { 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)
audioPlayer.play(song: song, fromQueue: songs)
}
}
}
.navigationTitle(playlist.name)
} else if isLoading {
ProgressView()
}
}
.task {
@ -408,15 +605,14 @@ struct WatchPlaylistDetailView: View {
guard let entries = playlist?.entry else { return }
isDownloading = true
Task {
for song in entries {
await offlineStore.downloadFromServer(song: song)
}
for song in entries { await offlineStore.downloadFromServer(song: song) }
await MainActor.run { isDownloading = false }
}
}
}
// MARK: - Search on Watch
// MARK: - Search
struct WatchSearchView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@ -437,16 +633,12 @@ struct WatchSearchView: View {
if let songs = results.song, !songs.isEmpty {
Section("Songs") {
ForEach(songs.prefix(10)) { song in
WatchSongRow(
song: song,
isPlaying: audioPlayer.currentSong?.id == song.id
) {
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
@ -469,15 +661,14 @@ struct WatchSearchView: View {
guard !query.isEmpty else { return }
isSearching = true
Task {
do {
results = try await watchManager.search(query: query)
isSearching = false
} catch { isSearching = false }
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
@ -489,14 +680,10 @@ struct WatchSongRow: View {
HStack(spacing: 6) {
if isPlaying {
Image(systemName: "waveform")
.font(.caption2)
.foregroundColor(.pink)
.frame(width: 14)
.font(.caption2).foregroundColor(.pink).frame(width: 14)
} else if isOffline {
Image(systemName: "checkmark.circle.fill")
.font(.caption2)
.foregroundColor(.green)
.frame(width: 14)
.font(.caption2).foregroundColor(.green).frame(width: 14)
}
VStack(alignment: .leading, spacing: 1) {
@ -504,28 +691,24 @@ struct WatchSongRow: View {
.font(.caption)
.foregroundColor(isPlaying ? .pink : .white)
.lineLimit(1)
Text(song.artist ?? "")
.font(.caption2)
.foregroundColor(.gray)
.lineLimit(1)
.font(.caption2).foregroundColor(.gray).lineLimit(1)
}
Spacer()
Text(song.durationFormatted)
.font(.caption2)
.foregroundColor(.gray)
.font(.caption2).foregroundColor(.gray)
}
}
}
}
// MARK: - Watch Settings
// MARK: - Settings
struct WatchSettingsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var offlineStore: WatchOfflineStore
@State private var showAddServer = false
var body: some View {
@ -535,70 +718,46 @@ struct WatchSettingsView: View {
ForEach(watchManager.servers) { server in
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(server.name)
.font(.caption)
Text(server.name).font(.caption)
if watchManager.activeServer?.id == server.id {
Image(systemName: "checkmark")
.font(.caption2)
.foregroundColor(.pink)
Image(systemName: "checkmark").font(.caption2).foregroundColor(.pink)
}
}
Text(server.url)
.font(.caption2)
.foregroundColor(.gray)
.lineLimit(1)
Text(server.url).font(.caption2).foregroundColor(.gray).lineLimit(1)
}
.contentShape(Rectangle())
.onTapGesture {
watchManager.setActive(server)
.onTapGesture { watchManager.setActive(server) }
}
Button("Add Server") { showAddServer = true }.font(.caption).foregroundColor(.pink)
Button("Sync from iPhone") { watchManager.requestServersFromPhone() }.font(.caption)
if watchManager.isSyncing {
HStack {
ProgressView().scaleEffect(0.6)
Text("Syncing library...").font(.caption2).foregroundColor(.gray)
}
}
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)
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)
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)
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)
Circle().fill(watchManager.isPhoneReachable ? .green : .orange).frame(width: 6, height: 6)
Text(watchManager.isPhoneReachable ? "Connected" : "Not Reachable").font(.caption).foregroundColor(.gray)
}
}
}