diff --git a/Shared/Storage/WatchConnectivityManager.swift b/Shared/Storage/WatchConnectivityManager.swift index 3bc067f..a790459 100644 --- a/Shared/Storage/WatchConnectivityManager.swift +++ b/Shared/Storage/WatchConnectivityManager.swift @@ -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 diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index 55745da..0000000 Binary files a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index cff1680..cefcc87 100644 --- a/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "1024.png", + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..02641c4 --- /dev/null +++ b/update.sh @@ -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" diff --git a/watchOS/App/WatchSessionManager.swift b/watchOS/App/WatchSessionManager.swift index ec09860..e7d33d2 100644 --- a/watchOS/App/WatchSessionManager.swift +++ b/watchOS/App/WatchSessionManager.swift @@ -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(_ data: T, key: String) { + if let encoded = try? JSONEncoder().encode(data) { + UserDefaults.standard.set(encoded, forKey: cachePrefix + key) + } + } + + func cacheDecode(_ 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) } } } diff --git a/watchOS/Resources/Assets.xcassets/AppIcon.appiconset/1024.png b/watchOS/Resources/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index 55745da..0000000 Binary files a/watchOS/Resources/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/watchOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/watchOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 113d821..74cc725 100644 --- a/watchOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/watchOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "1024.png", + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" diff --git a/watchOS/Views/WatchLibraryView.swift b/watchOS/Views/WatchLibraryView.swift index f4ec518..408bfef 100644 --- a/watchOS/Views/WatchLibraryView.swift +++ b/watchOS/Views/WatchLibraryView.swift @@ -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) } } }