NavidromeApp/watchOS/Views/WatchLibraryView.swift

770 lines
28 KiB
Swift

import SwiftUI
// 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()
WatchOfflineLibraryView()
WatchBrowseView()
WatchSettingsView()
}
.tabViewStyle(.verticalPage)
.task {
// Sync library in background on launch
await watchManager.syncLibrary()
}
}
}
// MARK: - My Music (Artists, Albums, Genres, Songs)
struct WatchMyMusicView: View {
@EnvironmentObject var watchManager: WatchSessionManager
var body: some View {
NavigationView {
List {
NavigationLink(destination: WatchArtistsView()) {
Label("Artists", systemImage: "music.mic")
}
NavigationLink(destination: WatchAlbumsView()) {
Label("Albums", systemImage: "square.stack")
}
NavigationLink(destination: WatchGenresView()) {
Label("Genres", systemImage: "guitars")
}
NavigationLink(destination: WatchPlaylistsListView()) {
Label("Playlists", systemImage: "list.bullet")
}
NavigationLink(destination: WatchSearchView()) {
Label("Search", systemImage: "magnifyingglass")
}
}
.navigationTitle("My Music")
}
}
}
// MARK: - Artists
struct WatchArtistsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var artistIndexes: [ArtistIndex] = []
@State private var isLoading = true
var body: some View {
Group {
if artistIndexes.isEmpty && isLoading {
ProgressView()
} else {
List {
ForEach(artistIndexes) { index in
if let artists = index.artist, !artists.isEmpty {
Section(index.name) {
ForEach(artists) { artist in
NavigationLink(destination: WatchArtistDetailView(artistId: artist.id, artistName: artist.name)) {
Text(artist.name)
.font(.caption)
.lineLimit(1)
}
}
}
}
}
}
}
}
.navigationTitle("Artists")
.task {
// Cache first
let cached = watchManager.cachedArtists()
if !cached.isEmpty {
artistIndexes = cached
isLoading = false
}
// Refresh
do {
let fresh = try await watchManager.getArtists()
watchManager.cacheEncode(fresh, key: "artists")
await MainActor.run {
artistIndexes = fresh
isLoading = false
}
} catch {
isLoading = false
}
}
}
}
struct WatchArtistDetailView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@EnvironmentObject var offlineStore: WatchOfflineStore
let artistId: String
let artistName: String
@State private var artist: ArtistWithAlbums?
@State private var isLoading = true
var body: some View {
Group {
if let artist = artist {
List {
if let albums = artist.album, !albums.isEmpty {
ForEach(albums) { album in
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.caption)
.lineLimit(1)
HStack(spacing: 4) {
if let year = album.year {
Text("\(year)")
.font(.caption2)
.foregroundColor(.gray)
}
Text("\(album.songCount ?? 0) songs")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
}
}
}
} else if isLoading {
ProgressView()
} else {
Text("Not available")
.font(.caption)
.foregroundColor(.gray)
}
}
.navigationTitle(artistName)
.task {
if let cached = watchManager.cachedArtistDetail(id: artistId) {
artist = cached
isLoading = false
}
do {
let fresh = try await watchManager.getArtist(id: artistId)
if let fresh {
watchManager.cacheEncode(fresh, key: "artist_\(artistId)")
await MainActor.run { artist = fresh; isLoading = false }
}
} catch { isLoading = false }
}
}
}
// MARK: - Albums (All)
struct WatchAlbumsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var albums: [Album] = []
@State private var isLoading = true
var body: some View {
Group {
if albums.isEmpty && isLoading {
ProgressView()
} else {
List(albums) { album in
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 2) {
Text(album.name)
.font(.caption)
.lineLimit(1)
Text(album.artist ?? "")
.font(.caption2)
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
}
}
.navigationTitle("Albums")
.task {
let cached = watchManager.cachedAlbums()
if !cached.isEmpty {
albums = cached
isLoading = false
}
do {
let fresh = try await watchManager.getAllAlbums()
watchManager.cacheEncode(fresh, key: "all_albums")
await MainActor.run { albums = fresh; isLoading = false }
} catch { isLoading = false }
}
}
}
// MARK: - Genres
struct WatchGenresView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var genres: [Genre] = []
@State private var isLoading = true
var body: some View {
Group {
if genres.isEmpty && isLoading {
ProgressView()
} else {
List(genres) { genre in
NavigationLink(destination: WatchGenreDetailView(genreName: genre.value)) {
HStack {
Text(genre.value)
.font(.caption)
.lineLimit(1)
Spacer()
Text("\(genre.albumCount ?? 0)")
.font(.caption2)
.foregroundColor(.gray)
}
}
}
}
}
.navigationTitle("Genres")
.task {
let cached = watchManager.cachedGenres()
if !cached.isEmpty {
genres = cached
isLoading = false
}
do {
let fresh = try await watchManager.getGenres()
watchManager.cacheEncode(fresh, key: "genres")
await MainActor.run { genres = fresh; isLoading = false }
} catch { isLoading = false }
}
}
}
struct WatchGenreDetailView: View {
@EnvironmentObject var watchManager: WatchSessionManager
let genreName: String
@State private var albums: [Album] = []
@State private var isLoading = true
var body: some View {
Group {
if albums.isEmpty && isLoading {
ProgressView()
} else {
List(albums) { album in
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 2) {
Text(album.name).font(.caption).lineLimit(1)
Text(album.artist ?? "").font(.caption2).foregroundColor(.gray).lineLimit(1)
}
}
}
}
}
.navigationTitle(genreName)
.task {
do {
albums = try await watchManager.getAlbumsByGenre(genreName)
isLoading = false
} catch { isLoading = false }
}
}
}
// MARK: - Offline Library
struct WatchOfflineLibraryView: View {
@EnvironmentObject var offlineStore: WatchOfflineStore
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@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 }
}
}
}
// 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
@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)
}
.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)
}
}
}
.navigationTitle(playlist.name)
} 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 }
}
}
}
// 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 {
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 library...").font(.caption2).foregroundColor(.gray)
}
}
}
Section("Storage") {
HStack {
Text("Offline Songs").font(.caption); Spacer()
Text("\(offlineStore.songs.count)").font(.caption).foregroundColor(.gray)
}
HStack {
Text("Used").font(.caption); Spacer()
Text(offlineStore.formattedSize).font(.caption).foregroundColor(.gray)
}
if !offlineStore.songs.isEmpty {
Button("Remove All Downloads", role: .destructive) { offlineStore.removeAll() }.font(.caption)
}
}
Section("Phone") {
HStack {
Circle().fill(watchManager.isPhoneReachable ? .green : .orange).frame(width: 6, height: 6)
Text(watchManager.isPhoneReachable ? "Connected" : "Not Reachable").font(.caption).foregroundColor(.gray)
}
}
}
.navigationTitle("Settings")
}
.sheet(isPresented: $showAddServer) {
WatchAddServerView()
}
}
}