767 lines
30 KiB
Swift
767 lines
30 KiB
Swift
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 { }
|
||
}
|
||
}
|
||
}
|