NavidromeApp/watchOS/Views/WatchLibraryView.swift
2026-04-03 19:20:55 -07:00

726 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
.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 {
@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 (AZ 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) {
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")
}
}
}
}
}
.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)
}
}
.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
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()
}
}
}