From 16097f5ff2a8b8d2437fa111291845b1f1bc6b7b Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Fri, 3 Apr 2026 19:20:55 -0700 Subject: [PATCH] Watch overhaul other changes --- Shared/Storage/WatchConnectivityManager.swift | 22 + iOS/App/NavidromePlayerApp.swift | 73 +- iOS/Resources/Info.plist | 6 + iOS/Views/Common/MainTabView.swift | 172 +++- iOS/Views/Library/AlbumDetailView.swift | 21 +- iOS/Views/Library/MyMusicView.swift | 175 +++-- iOS/Views/Library/PlaylistsView.swift | 57 +- iOS/Views/NowPlaying/NowPlayingView.swift | 59 +- watchOS/App/WatchOfflineStore.swift | 188 +++-- watchOS/App/WatchSessionManager.swift | 5 +- watchOS/Audio/WatchAudioPlayer.swift | 9 + watchOS/Views/WatchLibraryView.swift | 736 ++++++++---------- 12 files changed, 970 insertions(+), 553 deletions(-) diff --git a/Shared/Storage/WatchConnectivityManager.swift b/Shared/Storage/WatchConnectivityManager.swift index a790459..364ea80 100644 --- a/Shared/Storage/WatchConnectivityManager.swift +++ b/Shared/Storage/WatchConnectivityManager.swift @@ -465,6 +465,28 @@ extension WatchConnectivityManager: WCSessionDelegate { // MARK: - Receive messages + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + guard let type = message["type"] as? String else { return } + + #if os(iOS) + if type == "watchDownloadStatus" { + // Watch completed or removed a download — update our tracking + if let songId = message["songId"] as? String, + let downloaded = message["downloaded"] as? Bool { + DispatchQueue.main.async { + if downloaded { + self.watchSongIds.insert(songId) + } else { + self.watchSongIds.remove(songId) + } + self.objectWillChange.send() + } + wcLog("Watch download status: \(songId) → \(downloaded ? "downloaded" : "removed")") + } + } + #endif + } + 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"]) diff --git a/iOS/App/NavidromePlayerApp.swift b/iOS/App/NavidromePlayerApp.swift index aa1370e..4981714 100644 --- a/iOS/App/NavidromePlayerApp.swift +++ b/iOS/App/NavidromePlayerApp.swift @@ -1,8 +1,24 @@ import SwiftUI +import BackgroundTasks -// MARK: - App Delegate (Background Upload Session) +// MARK: - App Delegate (Background Sessions + BGTask Registration) class AppDelegate: NSObject, UIApplicationDelegate { + + static let smartDJTaskId = "com.navidromeplayer.smartdj.refresh" + static let syncTaskId = "com.navidromeplayer.library.sync" + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + // Register background tasks + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.smartDJTaskId, using: nil) { task in + self.handleSmartDJRefresh(task: task as! BGAppRefreshTask) + } + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.syncTaskId, using: nil) { task in + self.handleLibrarySync(task: task as! BGProcessingTask) + } + return true + } + func application( _ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, @@ -13,6 +29,57 @@ class AppDelegate: NSObject, UIApplicationDelegate { DebugLogger.shared.log("Background session woke app: \(identifier)", category: "Upload") } } + + // MARK: - Smart DJ Background Refresh + + private func handleSmartDJRefresh(task: BGAppRefreshTask) { + scheduleSmartDJRefresh() // Schedule next + + let refreshTask = Task { + do { + // Pre-analyze upcoming queue tracks via Companion API + if CompanionSettings.shared.isEnabled { + let queue = AudioPlayer.shared.queue + let idx = AudioPlayer.shared.queueIndex + let upcoming = Array(queue.dropFirst(idx + 1).prefix(5)) + + for song in upcoming { + guard let path = song.path else { continue } + _ = try? await CompanionAPIService().fetchProfile(relativePath: path) + } + } + + // Also do a delta sync + SyncEngine.shared.syncIfNeeded() + OptimisticActionQueue.shared.flush() + + task.setTaskCompleted(success: true) + } + } + + task.expirationHandler = { refreshTask.cancel() } + } + + private func handleLibrarySync(task: BGProcessingTask) { + let syncTask = Task { + SyncEngine.shared.syncIfNeeded() + task.setTaskCompleted(success: true) + } + task.expirationHandler = { syncTask.cancel() } + } + + static func scheduleSmartDJRefresh() { + let request = BGAppRefreshTaskRequest(identifier: smartDJTaskId) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min + try? BGTaskScheduler.shared.submit(request) + } + + static func scheduleLibrarySync() { + let request = BGProcessingTaskRequest(identifier: syncTaskId) + request.requiresNetworkConnectivity = true + request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // 1 hour + try? BGTaskScheduler.shared.submit(request) + } } @main @@ -74,6 +141,10 @@ struct RootView: View { if CompanionSettings.shared.isEnabled { CompanionPushClient.shared.connect() } + + // Schedule background tasks + AppDelegate.scheduleSmartDJRefresh() + AppDelegate.scheduleLibrarySync() } } } diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist index 0cbaf63..9f973cd 100644 --- a/iOS/Resources/Info.plist +++ b/iOS/Resources/Info.plist @@ -26,6 +26,12 @@ audio fetch + processing + + BGTaskSchedulerPermittedIdentifiers + + com.navidromeplayer.smartdj.refresh + com.navidromeplayer.library.sync UILaunchStoryboardName LaunchScreen diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift index da34ca3..dcff141 100644 --- a/iOS/Views/Common/MainTabView.swift +++ b/iOS/Views/Common/MainTabView.swift @@ -11,6 +11,12 @@ struct MainTabView: View { @State private var navigateToArtistId: String? @State var showNowPlaying = false + // Dynamic Island morph + @Namespace private var playerNamespace + @State private var isDynamicIsland = false + @State private var dragOffset: CGFloat = 0 + @State private var isDragging = false + // Debug panel (docked) @State private var debugPanelHeight: CGFloat = 250 @State private var debugDragOffset: CGFloat = 0 @@ -62,21 +68,20 @@ struct MainTabView: View { } .tag(3) - VisualizerSettingsView() - .tabItem { - Image(systemName: "waveform") - Text("Visualizer") - } - .tag(4) - SettingsView() .tabItem { Image(systemName: "gear") Text("Settings") } - .tag(5) + .tag(4) } .tint(accentPink) + .safeAreaInset(edge: .bottom) { + // Reserve space for MiniPlayerBar so content never gets obscured + if audioPlayer.currentSong != nil && !showNowPlaying { + Color.clear.frame(height: 80) + } + } // Debug panel (docked) — only when enabled and NOT in PiP mode if debugLogger.isEnabled && !debugPipMode { @@ -88,10 +93,59 @@ struct MainTabView: View { if audioPlayer.currentSong != nil { let effectiveDebugHeight = max(debugPanelHeight - debugDragOffset, 120) let showDocked = debugLogger.isEnabled && !debugPipMode - MiniPlayerBar(showNowPlaying: $showNowPlaying) + + if isDynamicIsland { + // Dynamic Island layout at top + DynamicIslandView( + namespace: playerNamespace, + showNowPlaying: $showNowPlaying, + isDynamicIsland: $isDynamicIsland + ) + .zIndex(3) + } else { + // Standard MiniPlayerBar at bottom with drag-to-morph + MiniPlayerBar( + showNowPlaying: $showNowPlaying, + namespace: playerNamespace + ) .padding(.bottom, showDocked ? 49 + effectiveDebugHeight : 49) .animation(.easeInOut(duration: 0.25), value: showDocked) + .offset(y: dragOffset) + .gesture( + DragGesture(minimumDistance: 10) + .onChanged { value in + // Only allow upward drag + let translation = min(value.translation.height, 0) + dragOffset = translation + isDragging = true + } + .onEnded { value in + isDragging = false + let screenH = UIScreen.main.bounds.height + let velocity = value.predictedEndTranslation.height + let dragPct = abs(value.translation.height) / screenH + + if dragPct > 0.35 || velocity < -800 { + // Morph to Dynamic Island + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + isDynamicIsland = true + dragOffset = 0 + } + } else if value.translation.height < -20 { + // Small upward swipe — open NowPlaying + dragOffset = 0 + withAnimation(.easeInOut(duration: 0.35)) { + showNowPlaying = true + } + } else { + withAnimation(.spring(response: 0.3)) { + dragOffset = 0 + } + } + } + ) .zIndex(3) + } } } @@ -433,6 +487,7 @@ struct MiniPlayerBar: View { @EnvironmentObject var audioPlayer: AudioPlayer @StateObject private var colorExtractor = AlbumColorExtractor.shared @Binding var showNowPlaying: Bool + var namespace: Namespace.ID @State private var isScrubbing = false @State private var scrubPosition: Double = 0 @State private var playbackTime: TimeInterval = 0 @@ -523,6 +578,7 @@ struct MiniPlayerBar: View { .frame(width: 44, height: 44) .cornerRadius(6) .shadow(radius: 3) + .matchedGeometryEffect(id: "albumArt", in: namespace) VStack(alignment: .leading, spacing: 1) { Text(audioPlayer.currentSong?.title ?? "") @@ -586,6 +642,104 @@ struct MiniPlayerBar: View { } } +// MARK: - Dynamic Island Layout + +struct DynamicIslandView: View { + @EnvironmentObject var audioPlayer: AudioPlayer + @StateObject private var colorExtractor = AlbumColorExtractor.shared + var namespace: Namespace.ID + @Binding var showNowPlaying: Bool + @Binding var isDynamicIsland: Bool + + private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + + var body: some View { + VStack { + HStack(spacing: 0) { + // Leading: compact album art pill + Group { + if let song = audioPlayer.currentSong { + AsyncCoverArt(coverArtId: song.coverArt, size: 48) + } else { + Color.gray.opacity(0.3) + } + } + .frame(width: 28, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .matchedGeometryEffect(id: "albumArt", in: namespace) + .padding(.leading, 8) + + // Song info (compact) + VStack(alignment: .leading, spacing: 0) { + Text(audioPlayer.currentSong?.title ?? "") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + Text(audioPlayer.currentSong?.artist ?? "") + .font(.system(size: 9)) + .foregroundColor(.white.opacity(0.5)) + .lineLimit(1) + } + .padding(.leading, 6) + .frame(maxWidth: 100, alignment: .leading) + + // Center spacer (hardware cutout zone) + Spacer() + + // Trailing: compact visualizer + if VisualizerSettings.shared.enabled { + CompactVisualizerView( + isPlaying: audioPlayer.isPlaying, + accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink, + height: 28 + ) + .frame(width: 60, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + // Play/pause when no visualizer + Button(action: { audioPlayer.togglePlayPause() }) { + Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 14)) + .foregroundColor(.white) + } + .frame(width: 36, height: 28) + } + + Spacer().frame(width: 8) + } + .frame(height: 40) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(.black) + .shadow(color: .black.opacity(0.5), radius: 10, y: 2) + ) + .padding(.horizontal, 40) + .padding(.top, 10) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.35)) { + showNowPlaying = true + isDynamicIsland = false + } + } + .gesture( + DragGesture(minimumDistance: 10) + .onEnded { value in + if value.translation.height > 40 { + // Drag down — return to mini player + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + isDynamicIsland = false + } + } + } + ) + + Spacer() + } + .ignoresSafeArea(edges: .top) + } +} + // MARK: - Keyboard Dismiss /// Dismiss keyboard from anywhere diff --git a/iOS/Views/Library/AlbumDetailView.swift b/iOS/Views/Library/AlbumDetailView.swift index 7ac5e4b..d551361 100644 --- a/iOS/Views/Library/AlbumDetailView.swift +++ b/iOS/Views/Library/AlbumDetailView.swift @@ -334,14 +334,6 @@ struct AlbumDetailView: View { }) { 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 { @@ -352,6 +344,17 @@ struct AlbumDetailView: View { } } + // Send to Watch — always visible + let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) + Button(action: { + if !isOnWatch { _ = WatchConnectivityManager.shared.sendSongToWatch(song) } + }) { + Label(isOnWatch ? "On Watch ✓" : "Send to Watch", + systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward") + } + .disabled(isOnWatch) + + // Favourite Button(action: { Task { if song.starred != nil { @@ -361,7 +364,7 @@ struct AlbumDetailView: View { } } }) { - Label(song.starred != nil ? "Unstar" : "Star", systemImage: song.starred != nil ? "heart.slash" : "heart") + Label(song.starred != nil ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart") } Divider() diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift index b9d13fd..5527262 100644 --- a/iOS/Views/Library/MyMusicView.swift +++ b/iOS/Views/Library/MyMusicView.swift @@ -23,12 +23,6 @@ struct MyMusicView: View { @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 = [] @@ -42,6 +36,15 @@ struct MyMusicView: View { @State private var artistCoverTargetId: String? @ObservedObject private var artistCoverStore = ArtistCoverStore.shared + // Song info / edit + @State private var getInfoSong: Song? + @State private var trackEditorSong: Song? + + // Playlist rename + @State private var showRenamePlaylist = false + @State private var renamePlaylistId: String? + @State private var renamePlaylistName = "" + private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) enum SortMode: String, CaseIterable { @@ -162,6 +165,18 @@ struct MyMusicView: View { } Button("Cancel", role: .cancel) { newPlaylistName = "" } } + .alert("Rename Playlist", isPresented: $showRenamePlaylist) { + TextField("Name", text: $renamePlaylistName) + Button("Save") { + let newName = renamePlaylistName + guard let pid = renamePlaylistId else { return } + Task { + try? await serverManager.client.updatePlaylist(id: pid, name: newName) + await loadData() + } + } + Button("Cancel", role: .cancel) { } + } .task { await loadData() } .refreshable { await loadData() } .sheet(isPresented: $showBatchAlbumEditor) { @@ -175,6 +190,12 @@ struct MyMusicView: View { MultiAlbumEditorSheet(albumIds: [albumId]) } } + .sheet(item: $getInfoSong) { song in + SongInfoSheet(song: song) + } + .sheet(item: $trackEditorSong) { song in + TrackEditorView(song: song) + } .navigationDestination(isPresented: Binding( get: { navigateToPlaylistId != nil }, set: { if !$0 { navigateToPlaylistId = nil } } @@ -374,6 +395,14 @@ struct MyMusicView: View { .padding(.vertical, 10) } .contextMenu { + Button(action: { + renamePlaylistId = playlist.id + renamePlaylistName = playlist.name + showRenamePlaylist = true + }) { + Label("Rename Playlist", systemImage: "pencil") + } + Button(role: .destructive, action: { Task { try? await serverManager.client.deletePlaylist(id: playlist.id) @@ -678,16 +707,6 @@ struct MyMusicView: View { } .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") } @@ -731,16 +750,6 @@ struct MyMusicView: View { .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") } @@ -779,7 +788,7 @@ struct MyMusicView: View { if isOnWatch { Image(systemName: "applewatch") .font(.system(size: 9)) - .foregroundColor(.blue.opacity(0.7)) + .foregroundColor(.green) } if isDownloaded { Image(systemName: "arrow.down.circle.fill") @@ -805,12 +814,42 @@ struct MyMusicView: View { 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) { + + // Favourite toggle + Button(action: { + Task { + if song.starred != nil { + try? await serverManager.client.unstar(id: song.id) + await MainActor.run { + favouriteSongs.removeAll { $0.id == song.id } + LibraryCache.shared.save(favouriteSongs, key: "starred_songs") + } + } else { + try? await serverManager.client.star(id: song.id) + await MainActor.run { + if !favouriteSongs.contains(where: { $0.id == song.id }) { + favouriteSongs.append(song) + LibraryCache.shared.save(favouriteSongs, key: "starred_songs") + } + } + } + } + }) { + Label(song.starred != nil ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart") + } + + Divider() + + // Download + if isDownloaded { Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) { Label("Remove Download", systemImage: "trash") } @@ -823,6 +862,29 @@ struct MyMusicView: View { Label("Download", systemImage: "arrow.down.circle") } } + + // Send to Watch (always visible) + Button(action: { + _ = WatchConnectivityManager.shared.sendSongToWatch(song) + }) { + Label( + isOnWatch ? "On Watch ✓" : "Send to Watch", + systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward" + ) + } + .disabled(isOnWatch) + + Divider() + + Button(action: { getInfoSong = song }) { + Label("Get Info", systemImage: "info.circle") + } + + if CompanionSettings.shared.isEnabled { + Button(action: { trackEditorSong = song }) { + Label("Edit Tags", systemImage: "tag") + } + } } } @@ -993,15 +1055,16 @@ struct MyMusicView: View { 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) async let starredReq = client.getStarred2() - let (albums, playlistList, artistList, albumsFull, genreList, searchResult, starred) = try await ( - albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq, starredReq + let (albums, playlistList, artistList, genreList, starred) = try await ( + albumsReq, playlistsReq, artistsReq, genresReq, starredReq ) + // Fetch ALL albums (paginated) + let albumsFull = try await fetchAllAlbums(client: client) + cache.cacheAlbums(albums) cache.cachePlaylists(playlistList) cache.cacheArtists(artistList) @@ -1016,12 +1079,8 @@ struct MyMusicView: View { 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.allSongs = [] // Songs loaded on-demand via search if let starredSongs = starred?.song { self.favouriteSongs = starredSongs } @@ -1033,27 +1092,27 @@ struct MyMusicView: View { } } - 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 { } + /// Paginate all albums from the server + private func fetchAllAlbums(client: SubsonicClient) async throws -> [Album] { + 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 + } + // Sort: alpha first, then numerals, then special characters + return all.sorted { a, b in + let aFirst = a.name.first ?? Character("\0") + let bFirst = b.name.first ?? Character("\0") + let aIsLetter = aFirst.isLetter + let bIsLetter = bFirst.isLetter + if aIsLetter && !bIsLetter { return true } + if !aIsLetter && bIsLetter { return false } + return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending + } } } diff --git a/iOS/Views/Library/PlaylistsView.swift b/iOS/Views/Library/PlaylistsView.swift index e8e6dc8..477cd4b 100644 --- a/iOS/Views/Library/PlaylistsView.swift +++ b/iOS/Views/Library/PlaylistsView.swift @@ -126,8 +126,8 @@ struct PlaylistDetailView: View { @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 trackEditorSong: Song? private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) @@ -152,8 +152,11 @@ struct PlaylistDetailView: View { .sheet(isPresented: $showPlaylistPicker) { AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists) } - .sheet(isPresented: $showGetInfo) { - if let song = getInfoSong { SongInfoSheet(song: song) } + .sheet(item: $getInfoSong) { song in + SongInfoSheet(song: song) + } + .sheet(item: $trackEditorSong) { song in + TrackEditorView(song: song) } .task { do { @@ -372,6 +375,8 @@ struct PlaylistDetailView: View { @ViewBuilder private func songContextMenu(song: Song, index: Int) -> some View { + let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) + Button(action: { audioPlayer.playNow(song) }) { Label("Play Now", systemImage: "play.fill") } @@ -395,6 +400,23 @@ struct PlaylistDetailView: View { Label("Add to Playlist...", systemImage: "text.badge.plus") } Divider() + + // Favourite + 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 ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart") + } + + Divider() + + // Remove from playlist (only in playlist context) Button(role: .destructive, action: { Task { try? await serverManager.client.updatePlaylist(id: playlistId, songIndexesToRemove: [index]) @@ -403,18 +425,13 @@ struct PlaylistDetailView: View { }) { 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 { @@ -424,8 +441,26 @@ struct PlaylistDetailView: View { Label("Download", systemImage: "arrow.down.circle") } } - Button(action: { getInfoSong = song; showGetInfo = true }) { + + // Send to Watch — always visible + Button(action: { + if !isOnWatch { _ = WatchConnectivityManager.shared.sendSongToWatch(song) } + }) { + Label(isOnWatch ? "On Watch ✓" : "Send to Watch", + systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward") + } + .disabled(isOnWatch) + + Divider() + + Button(action: { getInfoSong = song }) { Label("Get Info", systemImage: "info.circle") } + + if CompanionSettings.shared.isEnabled { + Button(action: { trackEditorSong = song }) { + Label("Edit Tags", systemImage: "tag") + } + } } } diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index 113acdd..d4f6c3a 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -177,6 +177,7 @@ struct NowPlayingView: View { @State private var isDraggingSlider = false @State private var dragPosition: Double = 0 @State private var showQueue = false + @State private var showVisualizerSettings = false @State private var showPlaylistPicker = false @State private var showGetInfo = false @State private var showTrackEditor = false @@ -301,6 +302,9 @@ struct NowPlayingView: View { TrackEditorView(song: song) } } + .sheet(isPresented: $showVisualizerSettings) { + VisualizerSettingsView() + } .sheet(isPresented: $showShazamResult) { ShazamResultSheet( title: shazamResult ?? "", @@ -486,6 +490,25 @@ struct NowPlayingView: View { NavigationStack { ScrollView { VStack(spacing: 16) { + // Visualizer Settings (centered, full width) + Button(action: { + showEllipsisMenu = false + showVisualizerSettings = true + }) { + HStack(spacing: 8) { + Image(systemName: "waveform") + .font(.system(size: 18)) + .foregroundColor(accentPink) + Text("Visualizer Settings") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity, minHeight: 44) + .background(Color.white.opacity(0.08)) + .cornerRadius(12) + } + .padding(.horizontal, 16) + if !isRadio { // Row 1: Instant Mix | Add to Playlist gridRow( @@ -520,9 +543,10 @@ struct NowPlayingView: View { }) ) - // Row 3: Download/Remove | Send to Watch + // Row 3: Download/Remove | Send to Watch (always visible) if let song = audioPlayer.currentSong { let isDownloaded = offlineManager.isSongDownloaded(song.id) + let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id) gridRow( left: isDownloaded ? ("Remove Download", "trash", { showEllipsisMenu = false; offlineManager.removeSong(song.id) }) @@ -530,12 +554,35 @@ struct NowPlayingView: View { showEllipsisMenu = false if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) } }), - right: WatchConnectivityManager.shared.isWatchAvailable - ? ("Send to Watch", "applewatch.and.arrow.forward", { + right: ( + isOnWatch ? "On Watch ✓" : "Send to Watch", + isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward", + { showEllipsisMenu = false - _ = WatchConnectivityManager.shared.sendSongToWatch(song) - }) - : nil + if !isOnWatch { + _ = WatchConnectivityManager.shared.sendSongToWatch(song) + } + } + ) + ) + + // Row 3b: Favourite toggle + gridRow( + left: ( + song.starred != nil ? "Unfavourite" : "Favourite", + song.starred != nil ? "heart.slash.fill" : "heart", + { + showEllipsisMenu = false + Task { + if song.starred != nil { + try? await serverManager.client.unstar(id: song.id) + } else { + try? await serverManager.client.star(id: song.id) + } + } + } + ), + right: nil ) } diff --git a/watchOS/App/WatchOfflineStore.swift b/watchOS/App/WatchOfflineStore.swift index 8486950..8ea436d 100644 --- a/watchOS/App/WatchOfflineStore.swift +++ b/watchOS/App/WatchOfflineStore.swift @@ -1,15 +1,19 @@ import Foundation import Combine +import WatchKit +import WatchConnectivity -/// Manages offline songs stored directly on the Apple Watch -class WatchOfflineStore: ObservableObject { +/// Manages offline songs stored directly on the Apple Watch. +/// Downloads use URLSession background configuration for reliability. +/// Progress tracked per-song; haptic feedback on completion. +class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate { static let shared = WatchOfflineStore() @Published var songs: [WatchOfflineSong] = [] @Published var totalSize: Int64 = 0 - @Published var isDownloading = false - @Published var downloadProgress: [String: Double] = [:] + @Published var downloadProgress: [String: Double] = [:] // songId → 0.0–1.0 + @Published var downloadComplete: [String: Bool] = [:] // songId → true when done struct WatchOfflineSong: Codable, Identifiable { let id: String @@ -21,8 +25,17 @@ class WatchOfflineStore: ObservableObject { private let catalogKey = "watch_offline_songs" private let fileManager = FileManager.default + private var pendingSongs: [String: Song] = [:] // taskId → Song + private var taskToSongId: [Int: String] = [] // URLSession task ID → songId - private var musicDirectory: URL { + private lazy var backgroundSession: URLSession = { + let config = URLSessionConfiguration.background(withIdentifier: "com.navidromeplayer.watch.download") + config.isDiscretionary = false + config.sessionSendsLaunchEvents = true + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + 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) { @@ -31,7 +44,8 @@ class WatchOfflineStore: ObservableObject { return dir } - private init() { + override init() { + super.init() loadCatalog() } @@ -39,23 +53,8 @@ class WatchOfflineStore: ObservableObject { 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() - ) - + let fileSize: Int64 = (try? fileManager.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0 + let offlineSong = WatchOfflineSong(id: song.id, song: song, localPath: localPath, fileSize: fileSize, dateAdded: Date()) songs.append(offlineSong) totalSize += fileSize saveCatalog() @@ -64,18 +63,19 @@ class WatchOfflineStore: ObservableObject { 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() + notifyiOS(songId: songId, downloaded: false) } func removeAll() { for song in songs { - try? fileManager.removeItem(atPath: song.localPath) + let filename = (song.localPath as NSString).lastPathComponent + try? fileManager.removeItem(at: musicDirectory.appendingPathComponent(filename)) } songs.removeAll() totalSize = 0 @@ -84,62 +84,116 @@ class WatchOfflineStore: ObservableObject { 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 - } + func isSongAvailable(_ songId: String) -> Bool { localURL(for: songId) != nil } - // MARK: - Download directly from server (WiFi) + // MARK: - Direct Server Download (Background URLSession) - func downloadFromServer(song: Song) async { + func downloadFromServer(song: Song) { guard !songs.contains(where: { $0.id == song.id }) else { return } + guard downloadProgress[song.id] == nil else { return } // already downloading - await MainActor.run { - isDownloading = true - downloadProgress[song.id] = 0 + guard let url = WatchSessionManager.shared.streamURL(songId: song.id, maxBitRate: 128) else { + print("[Watch] No stream URL for \(song.id)") + return } - 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)") + DispatchQueue.main.async { + self.downloadProgress[song.id] = 0.0 + self.downloadComplete[song.id] = false } + + pendingSongs[song.id] = song + + let task = backgroundSession.downloadTask(with: url) + taskToSongId[task.taskIdentifier] = song.id + task.resume() + + print("[Watch] Download started: \(song.title)") } - func downloadAlbum(_ album: AlbumWithSongs) async { + func downloadAlbum(_ album: AlbumWithSongs) { guard let albumSongs = album.song else { return } for song in albumSongs { - await downloadFromServer(song: song) + downloadFromServer(song: song) } } - // MARK: - Update catalog from phone sync + // MARK: - URLSession Download Delegate - func updateCatalog(_ phoneSongs: [Song]) { - // This updates the expected catalog; actual files arrive via file transfer - // We can show which songs are expected vs available + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + guard let songId = taskToSongId[downloadTask.taskIdentifier] else { return } + let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0 + DispatchQueue.main.async { + self.downloadProgress[songId] = progress + } + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + guard let songId = taskToSongId[downloadTask.taskIdentifier], + let song = pendingSongs[songId] else { return } + + let filename = "\(songId).mp3" + let destURL = musicDirectory.appendingPathComponent(filename) + + do { + if fileManager.fileExists(atPath: destURL.path) { + try fileManager.removeItem(at: destURL) + } + try fileManager.moveItem(at: location, to: destURL) + + DispatchQueue.main.async { + self.addSong(song, localPath: destURL.path) + self.downloadProgress.removeValue(forKey: songId) + self.downloadComplete[songId] = true + self.pendingSongs.removeValue(forKey: songId) + self.taskToSongId.removeValue(forKey: downloadTask.taskIdentifier) + + // Haptic feedback + WKInterfaceDevice.current().play(.success) + + // Notify iOS + self.notifyiOS(songId: songId, downloaded: true) + + // Clear completion indicator after 2s + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.downloadComplete.removeValue(forKey: songId) + } + } + + print("[Watch] Download complete: \(song.title)") + } catch { + print("[Watch] Save failed: \(error)") + DispatchQueue.main.async { + self.downloadProgress.removeValue(forKey: songId) + self.pendingSongs.removeValue(forKey: songId) + } + } + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let error, let songId = taskToSongId[task.taskIdentifier] else { return } + print("[Watch] Download error for \(songId): \(error.localizedDescription)") + DispatchQueue.main.async { + self.downloadProgress.removeValue(forKey: songId) + self.pendingSongs.removeValue(forKey: songId) + self.taskToSongId.removeValue(forKey: task.taskIdentifier) + } + } + + // MARK: - iOS Notification + + private func notifyiOS(songId: String, downloaded: Bool) { + guard WCSession.default.isReachable else { return } + WCSession.default.sendMessage([ + "type": "watchDownloadStatus", + "songId": songId, + "downloaded": downloaded + ], replyHandler: nil, errorHandler: nil) } // MARK: - Grouped Access @@ -148,10 +202,6 @@ class WatchOfflineStore: ObservableObject { 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) } @@ -167,7 +217,6 @@ class WatchOfflineStore: ObservableObject { 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) @@ -176,4 +225,11 @@ class WatchOfflineStore: ObservableObject { totalSize = songs.reduce(0) { $0 + $1.fileSize } } } + + // MARK: - Cache Management + + var cacheSize: String { + let size = songs.reduce(Int64(0)) { $0 + $1.fileSize } + return ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } } diff --git a/watchOS/App/WatchSessionManager.swift b/watchOS/App/WatchSessionManager.swift index e7d33d2..7962459 100644 --- a/watchOS/App/WatchSessionManager.swift +++ b/watchOS/App/WatchSessionManager.swift @@ -275,9 +275,8 @@ class WatchSessionManager: NSObject, ObservableObject { print("[Watch] Downloading from server: \(song.title)") - Task { - await WatchOfflineStore.shared.downloadFromServer(song: song) - print("[Watch] Download complete: \(song.title)") + DispatchQueue.main.async { + WatchOfflineStore.shared.downloadFromServer(song: song) } } diff --git a/watchOS/Audio/WatchAudioPlayer.swift b/watchOS/Audio/WatchAudioPlayer.swift index b06aae9..0444f57 100644 --- a/watchOS/Audio/WatchAudioPlayer.swift +++ b/watchOS/Audio/WatchAudioPlayer.swift @@ -404,6 +404,15 @@ class WatchAudioPlayer: NSObject, ObservableObject { switch repeatMode { case .off: repeatMode = .all; case .all: repeatMode = .one; case .one: repeatMode = .off } } + func playNext(_ song: Song) { + let insertAt = min(queueIndex + 1, queue.count) + queue.insert(song, at: insertAt) + } + + func playLater(_ song: Song) { + queue.append(song) + } + @objc private func playerDidFinish() { if repeatMode == .one { seek(to: 0); player?.play() } else { next() } } diff --git a/watchOS/Views/WatchLibraryView.swift b/watchOS/Views/WatchLibraryView.swift index 408bfef..cab5484 100644 --- a/watchOS/Views/WatchLibraryView.swift +++ b/watchOS/Views/WatchLibraryView.swift @@ -1,4 +1,5 @@ import SwiftUI +import WatchKit // MARK: - Main Tab View @@ -11,19 +12,14 @@ struct WatchLibraryView: View { TabView { WatchNowPlayingView() WatchMyMusicView() - WatchOfflineLibraryView() - WatchBrowseView() WatchSettingsView() } .tabViewStyle(.verticalPage) - .task { - // Sync library in background on launch - await watchManager.syncLibrary() - } + .task { await watchManager.syncLibrary() } } } -// MARK: - My Music (Artists, Albums, Genres, Songs) +// MARK: - My Music struct WatchMyMusicView: View { @EnvironmentObject var watchManager: WatchSessionManager @@ -31,6 +27,9 @@ struct WatchMyMusicView: View { var body: some View { NavigationView { List { + NavigationLink(destination: WatchOnMyWristView()) { + Label("On My Wrist", systemImage: "applewatch") + } NavigationLink(destination: WatchArtistsView()) { Label("Artists", systemImage: "music.mic") } @@ -52,6 +51,127 @@ struct WatchMyMusicView: View { } } +// MARK: - On My Wrist (Offline Library) + +struct WatchOnMyWristView: View { + @EnvironmentObject var offlineStore: WatchOfflineStore + @EnvironmentObject var audioPlayer: WatchAudioPlayer + @State private var expandedAlbum: String? + + var body: some View { + if offlineStore.songs.isEmpty { + VStack(spacing: 8) { + Image(systemName: "applewatch.and.arrow.forward") + .font(.title2).foregroundColor(.gray) + Text("No Songs on Watch") + .font(.caption).foregroundColor(.gray) + Text("Download from Albums or send from iPhone") + .font(.caption2).foregroundColor(.gray).multilineTextAlignment(.center) + } + .navigationTitle("On My Wrist") + } else { + List { + Section { + Button(action: { playAll(shuffle: false) }) { + HStack { Image(systemName: "play.fill"); Text("Play All") }.foregroundColor(.pink) + } + Button(action: { playAll(shuffle: true) }) { + HStack { Image(systemName: "shuffle"); Text("Shuffle") }.foregroundColor(.pink) + } + } + + ForEach(sortedAlbumNames, id: \.self) { albumName in + let albumSongs = offlineStore.songsByAlbum[albumName] ?? [] + Section { + // Album header — tap to expand + Button(action: { + withAnimation { expandedAlbum = expandedAlbum == albumName ? nil : albumName } + }) { + HStack(spacing: 8) { + Text(albumName) + .font(.caption).fontWeight(.medium).foregroundColor(.white).lineLimit(1) + Spacer() + Text("\(albumSongs.count)") + .font(.caption2).foregroundColor(.gray) + Image(systemName: expandedAlbum == albumName ? "chevron.down" : "chevron.right") + .font(.system(size: 9)).foregroundColor(.gray) + } + } + .contextMenu { + Button(role: .destructive) { + for s in albumSongs { offlineStore.removeSong(s.id) } + } label: { + Label("Remove Album", systemImage: "trash") + } + } + + // Expanded songs + if expandedAlbum == albumName { + ForEach(albumSongs) { offline in + WatchOfflineSongRow(offline: offline, allSongs: albumSongs.map(\.song)) + } + } + } + } + + // Storage info + Section { + HStack { + Text("\(offlineStore.songs.count) songs").font(.caption).foregroundColor(.gray) + Spacer() + Text(offlineStore.formattedSize).font(.caption).foregroundColor(.gray) + } + } + } + .navigationTitle("On My Wrist") + } + } + + private var sortedAlbumNames: [String] { + offlineStore.songsByAlbum.keys.sorted() + } + + private func playAll(shuffle: Bool) { + var songs = offlineStore.songs.map(\.song) + if shuffle { songs.shuffle() } + guard let first = songs.first else { return } + audioPlayer.shuffleEnabled = shuffle + audioPlayer.play(song: first, fromQueue: songs) + } +} + +/// Song row for offline library with delete context menu +struct WatchOfflineSongRow: View { + @EnvironmentObject var audioPlayer: WatchAudioPlayer + @EnvironmentObject var offlineStore: WatchOfflineStore + let offline: WatchOfflineStore.WatchOfflineSong + let allSongs: [Song] + + var body: some View { + Button(action: { + let idx = allSongs.firstIndex(where: { $0.id == offline.id }) ?? 0 + audioPlayer.play(song: offline.song, fromQueue: allSongs, at: idx) + }) { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 10)).foregroundColor(.green).frame(width: 14) + Text(offline.song.title) + .font(.caption).foregroundColor(audioPlayer.currentSong?.id == offline.id ? .pink : .white).lineLimit(1) + Spacer() + Text(offline.song.durationFormatted) + .font(.caption2).foregroundColor(.gray) + } + } + .contextMenu { + Button(role: .destructive) { + offlineStore.removeSong(offline.id) + } label: { + Label("Remove from Watch", systemImage: "xmark.bin") + } + } + } +} + // MARK: - Artists struct WatchArtistsView: View { @@ -61,18 +181,15 @@ struct WatchArtistsView: View { var body: some View { Group { - if artistIndexes.isEmpty && isLoading { - ProgressView() - } else { + 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) + Text(artist.name).font(.caption).lineLimit(1) } } } @@ -83,35 +200,21 @@ struct WatchArtistsView: View { } .navigationTitle("Artists") .task { - // Cache first let cached = watchManager.cachedArtists() - if !cached.isEmpty { - artistIndexes = cached - isLoading = false - } - // Refresh + if !cached.isEmpty { artistIndexes = cached; isLoading = false } do { let fresh = try await watchManager.getArtists() watchManager.cacheEncode(fresh, key: "artists") - await MainActor.run { - artistIndexes = fresh - isLoading = false - } - } catch { - isLoading = false - } + 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 @@ -123,41 +226,23 @@ struct WatchArtistDetailView: View { 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) + Text(album.name).font(.caption).lineLimit(1) + if let year = album.year { + Text("\(year)").font(.caption2).foregroundColor(.gray) } } } } } } - } else if isLoading { - ProgressView() - } else { - Text("Not available") - .font(.caption) - .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 - } + if let cached = watchManager.cachedArtistDetail(id: artistId) { artist = cached; isLoading = false } do { - let fresh = try await watchManager.getArtist(id: artistId) - if let fresh { + if let fresh = try await watchManager.getArtist(id: artistId) { watchManager.cacheEncode(fresh, key: "artist_\(artistId)") await MainActor.run { artist = fresh; isLoading = false } } @@ -166,28 +251,32 @@ struct WatchArtistDetailView: View { } } -// MARK: - Albums (All) +// MARK: - Albums (A–Z list with art, expandable songs) struct WatchAlbumsView: View { @EnvironmentObject var watchManager: WatchSessionManager + @EnvironmentObject var offlineStore: WatchOfflineStore @State private var albums: [Album] = [] @State private var isLoading = true var body: some View { Group { - if albums.isEmpty && isLoading { - ProgressView() - } else { + 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) + HStack(spacing: 8) { + AsyncCoverArt(coverArtId: album.coverArt, size: 40) + .frame(width: 36, height: 36).cornerRadius(4) + VStack(alignment: .leading, spacing: 1) { + Text(album.name).font(.caption).foregroundColor(.white).lineLimit(1) + Text(album.artist ?? "").font(.caption2).foregroundColor(.gray).lineLimit(1) + } + } + } + .contextMenu { + Button(action: { downloadAlbum(album) }) { + Label("Download", systemImage: "arrow.down.circle") } } } @@ -196,10 +285,7 @@ struct WatchAlbumsView: View { .navigationTitle("Albums") .task { let cached = watchManager.cachedAlbums() - if !cached.isEmpty { - albums = cached - isLoading = false - } + if !cached.isEmpty { albums = cached; isLoading = false } do { let fresh = try await watchManager.getAllAlbums() watchManager.cacheEncode(fresh, key: "all_albums") @@ -207,6 +293,148 @@ struct WatchAlbumsView: View { } catch { isLoading = false } } } + + private func downloadAlbum(_ album: Album) { + Task { + if let detail = try? await watchManager.getAlbum(id: album.id) { + offlineStore.downloadAlbum(detail) + } + } + } +} + +// MARK: - Album Detail (songs with download indicators) + +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 + + var body: some View { + Group { + if let album = album, let songs = album.song { + List { + Section { + Button(action: { offlineStore.downloadAlbum(album) }) { + HStack { + Image(systemName: "arrow.down.circle") + Text("Download Album").font(.caption) + }.foregroundColor(.pink) + } + Button(action: { playAlbum(songs) }) { + HStack { + Image(systemName: "play.fill") + Text("Play All").font(.caption) + }.foregroundColor(.pink) + } + } + + ForEach(songs) { song in + WatchServerSongRow(song: song, allSongs: songs) + } + } + .navigationTitle(album.name) + } else if isLoading { ProgressView() } + else { Text("Not available").font(.caption).foregroundColor(.gray) } + } + .task { + if let cached = watchManager.cachedAlbumDetail(id: albumId) { album = cached; isLoading = false } + do { + if let fresh = try await watchManager.getAlbum(id: albumId) { + watchManager.cacheEncode(fresh, key: "album_\(albumId)") + await MainActor.run { album = fresh; isLoading = false } + } + } catch { isLoading = false } + } + } + + private func playAlbum(_ songs: [Song]) { + guard let first = songs.first else { return } + audioPlayer.play(song: first, fromQueue: songs) + } +} + +/// Song row with download progress indicator +struct WatchServerSongRow: View { + @EnvironmentObject var audioPlayer: WatchAudioPlayer + @EnvironmentObject var offlineStore: WatchOfflineStore + let song: Song + let allSongs: [Song] + + var body: some View { + Button(action: { + let idx = allSongs.firstIndex(where: { $0.id == song.id }) ?? 0 + audioPlayer.play(song: song, fromQueue: allSongs, at: idx) + }) { + HStack(spacing: 6) { + // Download status indicator + WatchDownloadIndicator(songId: song.id) + .frame(width: 18, height: 18) + + VStack(alignment: .leading, spacing: 1) { + Text(song.title) + .font(.caption) + .foregroundColor(audioPlayer.currentSong?.id == song.id ? .pink : .white) + .lineLimit(1) + Text(song.artist ?? "").font(.caption2).foregroundColor(.gray).lineLimit(1) + } + Spacer() + Text(song.durationFormatted).font(.caption2).foregroundColor(.gray) + } + } + .contextMenu { + if offlineStore.isSongAvailable(song.id) { + Button(role: .destructive) { + offlineStore.removeSong(song.id) + } label: { + Label("Remove from Watch", systemImage: "xmark.bin") + } + } else { + Button(action: { offlineStore.downloadFromServer(song: song) }) { + Label("Download", systemImage: "arrow.down.circle") + } + } + 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") + } + } + } +} + +/// Circle download indicator: empty → blue fill progress → green checkmark +struct WatchDownloadIndicator: View { + @EnvironmentObject var offlineStore: WatchOfflineStore + let songId: String + + var body: some View { + if offlineStore.downloadComplete[songId] == true { + // Completed — green checkmark + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 14)).foregroundColor(.green) + .transition(.scale.combined(with: .opacity)) + } else if let progress = offlineStore.downloadProgress[songId] { + // Downloading — blue circle fill + ZStack { + Circle().stroke(Color.gray.opacity(0.3), lineWidth: 2) + Circle().trim(from: 0, to: progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + } else if offlineStore.isSongAvailable(songId) { + // Already downloaded + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 14)).foregroundColor(.green) + } else { + // Not downloaded + Circle().stroke(Color.gray.opacity(0.2), lineWidth: 1) + } + } } // MARK: - Genres @@ -218,19 +446,14 @@ struct WatchGenresView: View { var body: some View { Group { - if genres.isEmpty && isLoading { - ProgressView() - } else { + if genres.isEmpty && isLoading { ProgressView() } + else { List(genres) { genre in NavigationLink(destination: WatchGenreDetailView(genreName: genre.value)) { HStack { - Text(genre.value) - .font(.caption) - .lineLimit(1) + Text(genre.value).font(.caption).lineLimit(1) Spacer() - Text("\(genre.albumCount ?? 0)") - .font(.caption2) - .foregroundColor(.gray) + Text("\(genre.albumCount ?? 0)").font(.caption2).foregroundColor(.gray) } } } @@ -239,10 +462,7 @@ struct WatchGenresView: View { .navigationTitle("Genres") .task { let cached = watchManager.cachedGenres() - if !cached.isEmpty { - genres = cached - isLoading = false - } + if !cached.isEmpty { genres = cached; isLoading = false } do { let fresh = try await watchManager.getGenres() watchManager.cacheEncode(fresh, key: "genres") @@ -255,15 +475,13 @@ struct WatchGenresView: View { 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 { + if albums.isEmpty && isLoading { ProgressView() } + else { List(albums) { album in NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) { VStack(alignment: .leading, spacing: 2) { @@ -276,242 +494,8 @@ struct WatchGenreDetailView: View { } .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 - @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("Download from Browse or send from iPhone") - .font(.caption2) - .foregroundColor(.gray) - .multilineTextAlignment(.center) - } - } else { - List { - 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) - } - } - - 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, - isOffline: true - ) { - 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) } } - } - } - } - - 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("Offline") - .alert("Remove All Songs?", isPresented: $showDeleteAll) { - Button("Remove", role: .destructive) { offlineStore.removeAll() } - Button("Cancel", role: .cancel) { } - } - } - } - } - - 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") - } - - 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 - -struct WatchRecentAlbumsView: 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("Recent") - .task { - 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 - -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 let album = album { - List { - // 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) - } - } - .disabled(isDownloading) - } - - // Songs - 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 { - // 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 downloadAlbum() { - guard let album = album else { return } - isDownloading = true - Task { - await offlineStore.downloadAlbum(album) - await MainActor.run { isDownloading = false } + do { albums = try await watchManager.getAlbumsByGenre(genreName); isLoading = false } + catch { isLoading = false } } } } @@ -525,9 +509,8 @@ struct WatchPlaylistsListView: View { var body: some View { Group { - if playlists.isEmpty && isLoading { - ProgressView() - } else { + if playlists.isEmpty && isLoading { ProgressView() } + else { List(playlists) { playlist in NavigationLink(destination: WatchPlaylistDetailView(playlistId: playlist.id)) { VStack(alignment: .leading, spacing: 2) { @@ -555,58 +538,31 @@ 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 let playlist = playlist, let songs = playlist.entry { List { Section { - Button(action: downloadPlaylist) { - HStack { - Image(systemName: "arrow.down.circle") - Text(isDownloading ? "Downloading..." : "Download All") - .font(.caption) - } - .foregroundColor(.pink) + Button(action: { + for song in songs { offlineStore.downloadFromServer(song: song) } + }) { + HStack { Image(systemName: "arrow.down.circle"); Text("Download All").font(.caption) }.foregroundColor(.pink) } - .disabled(isDownloading) } - ForEach(songs) { song in - WatchSongRow( - song: song, - isPlaying: audioPlayer.currentSong?.id == song.id, - isOffline: offlineStore.isSongAvailable(song.id) - ) { - audioPlayer.play(song: song, fromQueue: songs) - } + WatchServerSongRow(song: song, allSongs: songs) } } .navigationTitle(playlist.name) - } else if isLoading { - ProgressView() - } + } else if isLoading { ProgressView() } } .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 } + do { playlist = try await watchManager.getPlaylist(id: playlistId); isLoading = false } + catch { isLoading = false } } } } @@ -616,19 +572,15 @@ struct WatchPlaylistDetailView: View { 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 { + TextField("Search", text: $query).onSubmit { performSearch() } + if isSearching { ProgressView() } + else if let results = results { List { if let songs = results.song, !songs.isEmpty { Section("Songs") { @@ -679,26 +631,16 @@ struct WatchSongRow: View { Button(action: onTap) { HStack(spacing: 6) { if isPlaying { - Image(systemName: "waveform") - .font(.caption2).foregroundColor(.pink).frame(width: 14) + 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) + 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) + 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) + Text(song.durationFormatted).font(.caption2).foregroundColor(.gray) } } } @@ -714,6 +656,7 @@ struct WatchSettingsView: View { var body: some View { NavigationView { List { + // Server Section("Server") { ForEach(watchManager.servers) { server in VStack(alignment: .leading, spacing: 2) { @@ -735,25 +678,38 @@ struct WatchSettingsView: View { if watchManager.isSyncing { HStack { ProgressView().scaleEffect(0.6) - Text("Syncing library...").font(.caption2).foregroundColor(.gray) + Text("Syncing...").font(.caption2).foregroundColor(.gray) } } } - Section("Storage") { + // Cache Management + Section("Cache") { HStack { - Text("Offline Songs").font(.caption); Spacer() + Text("Downloaded 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("Storage Used").font(.caption) + Spacer() + Text(offlineStore.cacheSize).font(.caption).foregroundColor(.gray) } + if !offlineStore.songs.isEmpty { - Button("Remove All Downloads", role: .destructive) { offlineStore.removeAll() }.font(.caption) + Button("Clear All Downloads", role: .destructive) { + offlineStore.removeAll() + }.font(.caption) } + + Button("Clear Library Cache") { + let keys = ["wcache_artists", "wcache_genres", "wcache_all_albums", "wcache_playlists", "wcache_recent"] + for key in keys { UserDefaults.standard.removeObject(forKey: key) } + WKInterfaceDevice.current().play(.click) + }.font(.caption).foregroundColor(.orange) } + // Phone Section("Phone") { HStack { Circle().fill(watchManager.isPhoneReachable ? .green : .orange).frame(width: 6, height: 6)