import SwiftUI import WatchKit // MARK: - Main Tab View struct WatchLibraryView: View { @EnvironmentObject var watchManager: WatchSessionManager @EnvironmentObject var audioPlayer: WatchAudioPlayer @EnvironmentObject var offlineStore: WatchOfflineStore var body: some View { TabView { WatchNowPlayingView() WatchMyMusicView() WatchSettingsView() } .tabViewStyle(.verticalPage) .task { await watchManager.syncLibrary() } } } // MARK: - My Music struct WatchMyMusicView: View { @EnvironmentObject var watchManager: WatchSessionManager var body: some View { NavigationView { List { NavigationLink(destination: WatchOnMyWristView()) { Label("On My Wrist", systemImage: "applewatch") } 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: - 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) } } .swipeActions(edge: .trailing) { Button(role: .destructive) { for s in albumSongs { offlineStore.removeSong(s.id) } } label: { Label("Remove", 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) } } .swipeActions(edge: .trailing) { Button(role: .destructive) { offlineStore.removeSong(offline.id) } label: { Label("Remove", systemImage: "trash") } } } } // 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 { let cached = watchManager.cachedArtists() 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 } } } } struct WatchArtistDetailView: View { @EnvironmentObject var watchManager: WatchSessionManager 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) if let year = album.year { Text("\(year)").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 { if let fresh = try await watchManager.getArtist(id: artistId) { watchManager.cacheEncode(fresh, key: "artist_\(artistId)") await MainActor.run { artist = fresh; isLoading = false } } } catch { isLoading = false } } } } // 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 { List(albums) { album in NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) { HStack(spacing: 8) { WatchCoverArt(coverArtId: album.coverArt, size: 36) .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) } } } .swipeActions(edge: .trailing) { Button(action: { downloadAlbum(album) }) { Label("Download", systemImage: "arrow.down.circle") } .tint(.blue) } } } } .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 } } } 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) } } .swipeActions(edge: .trailing) { if offlineStore.isSongAvailable(song.id) { Button(role: .destructive) { offlineStore.removeSong(song.id) } label: { Label("Remove", systemImage: "trash") } } else { Button(action: { offlineStore.downloadFromServer(song: song) }) { Label("Download", systemImage: "arrow.down.circle") } .tint(.blue) } } .swipeActions(edge: .leading) { Button(action: { audioPlayer.playNext(song) }) { Label("Next", systemImage: "text.line.first.and.arrowtriangle.forward") } .tint(.orange) } } } /// 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 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: - Playlists struct WatchPlaylistsListView: View { @EnvironmentObject var watchManager: WatchSessionManager @State private var playlists: [Playlist] = [] @State private var isLoading = true var body: some View { Group { if playlists.isEmpty && isLoading { ProgressView() } else { List(playlists) { playlist in NavigationLink(destination: WatchPlaylistDetailView(playlistId: playlist.id)) { VStack(alignment: .leading, spacing: 2) { Text(playlist.name).font(.caption).lineLimit(1) Text("\(playlist.songCount ?? 0) songs").font(.caption2).foregroundColor(.gray) } } } } } .navigationTitle("Playlists") .task { let cached = watchManager.cachedPlaylists() if !cached.isEmpty { playlists = cached; isLoading = false } do { let fresh = try await watchManager.getPlaylists() watchManager.cacheEncode(fresh, key: "playlists") await MainActor.run { playlists = fresh; isLoading = false } } catch { isLoading = false } } } } 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 var body: some View { Group { if let playlist = playlist, let songs = playlist.entry { List { Section { Button(action: { for song in songs { offlineStore.downloadFromServer(song: song) } }) { HStack { Image(systemName: "arrow.down.circle"); Text("Download All").font(.caption) }.foregroundColor(.pink) } } ForEach(songs) { song in WatchServerSongRow(song: song, allSongs: songs) } } .navigationTitle(playlist.name) } else if isLoading { ProgressView() } } .task { do { playlist = try await watchManager.getPlaylist(id: playlistId); isLoading = false } catch { isLoading = false } } } } // MARK: - Search struct WatchSearchView: View { @EnvironmentObject var watchManager: WatchSessionManager @EnvironmentObject var audioPlayer: WatchAudioPlayer @State private var query = "" @State private var results: SearchResult3? @State private var isSearching = false var body: some View { VStack { TextField("Search", text: $query).onSubmit { performSearch() } if isSearching { ProgressView() } else if let results = results { List { if let songs = results.song, !songs.isEmpty { Section("Songs") { ForEach(songs.prefix(10)) { song in WatchSongRow(song: song, isPlaying: audioPlayer.currentSong?.id == song.id) { audioPlayer.play(song: song, fromQueue: songs) } } } } if let albums = results.album, !albums.isEmpty { Section("Albums") { ForEach(albums.prefix(5)) { album in NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) { VStack(alignment: .leading) { Text(album.name).font(.caption).lineLimit(1) Text(album.artist ?? "").font(.caption2).foregroundColor(.gray) } } } } } } } } .navigationTitle("Search") } private func performSearch() { guard !query.isEmpty else { return } isSearching = true Task { do { results = try await watchManager.search(query: query); isSearching = false } catch { isSearching = false } } } } // MARK: - Reusable Song Row struct WatchSongRow: View { let song: Song let isPlaying: Bool var isOffline: Bool = false let onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: 6) { if isPlaying { Image(systemName: "waveform").font(.caption2).foregroundColor(.pink).frame(width: 14) } else if isOffline { Image(systemName: "checkmark.circle.fill").font(.caption2).foregroundColor(.green).frame(width: 14) } VStack(alignment: .leading, spacing: 1) { Text(song.title).font(.caption).foregroundColor(isPlaying ? .pink : .white).lineLimit(1) Text(song.artist ?? "").font(.caption2).foregroundColor(.gray).lineLimit(1) } Spacer() Text(song.durationFormatted).font(.caption2).foregroundColor(.gray) } } } } // MARK: - Settings struct WatchSettingsView: View { @EnvironmentObject var watchManager: WatchSessionManager @EnvironmentObject var offlineStore: WatchOfflineStore @State private var showAddServer = false var body: some View { NavigationView { List { // Server Section("Server") { ForEach(watchManager.servers) { server in VStack(alignment: .leading, spacing: 2) { HStack { Text(server.name).font(.caption) if watchManager.activeServer?.id == server.id { Image(systemName: "checkmark").font(.caption2).foregroundColor(.pink) } } Text(server.url).font(.caption2).foregroundColor(.gray).lineLimit(1) } .contentShape(Rectangle()) .onTapGesture { watchManager.setActive(server) } } Button("Add Server") { showAddServer = true }.font(.caption).foregroundColor(.pink) Button("Sync from iPhone") { watchManager.requestServersFromPhone() }.font(.caption) if watchManager.isSyncing { HStack { ProgressView().scaleEffect(0.6) Text("Syncing...").font(.caption2).foregroundColor(.gray) } } } // Cache Management Section("Cache") { HStack { Text("Downloaded Songs").font(.caption) Spacer() Text("\(offlineStore.songs.count)").font(.caption).foregroundColor(.gray) } HStack { Text("Storage Used").font(.caption) Spacer() Text(offlineStore.cacheSize).font(.caption).foregroundColor(.gray) } if !offlineStore.songs.isEmpty { 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) Text(watchManager.isPhoneReachable ? "Connected" : "Not Reachable").font(.caption).foregroundColor(.gray) } } } .navigationTitle("Settings") } .sheet(isPresented: $showAddServer) { WatchAddServerView() } } } // MARK: - Watch Cover Art (lightweight async image for watchOS) struct WatchCoverArt: View { let coverArtId: String? let size: CGFloat @State private var image: UIImage? var body: some View { Group { if let img = image { Image(uiImage: img) .resizable() .scaledToFill() } else { ZStack { RoundedRectangle(cornerRadius: 4) .fill(Color(white: 0.2)) Image(systemName: "music.note") .font(.system(size: max(8, size * 0.3))) .foregroundColor(.gray) } } } .frame(width: size, height: size) .cornerRadius(4) .task { guard let id = coverArtId, let url = WatchSessionManager.shared.coverArtURL(id: id, size: Int(size) * 2) else { return } do { let (data, _) = try await URLSession.shared.data(from: url) if let img = UIImage(data: data) { await MainActor.run { image = img } } } catch { } } } }