NavidromeApp/watchOS/Views/WatchLibraryView.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

611 lines
22 KiB
Swift

import SwiftUI
struct WatchLibraryView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@EnvironmentObject var audioPlayer: WatchAudioPlayer
@EnvironmentObject var offlineStore: WatchOfflineStore
var body: some View {
TabView {
// Now Playing
WatchNowPlayingView()
// Offline Library
WatchOfflineLibraryView()
// Browse Server
WatchBrowseView()
// Settings
WatchSettingsView()
}
.tabViewStyle(.verticalPage)
}
}
// 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("Send songs from your iPhone or download from Browse")
.font(.caption2)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
}
} else {
List {
// Play controls
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)
}
}
// Songs by album (swipe to delete)
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
) {
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)
}
}
}
}
}
// Storage info + delete all
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("Library")
.alert("Remove All Songs?", isPresented: $showDeleteAll) {
Button("Remove", role: .destructive) { offlineStore.removeAll() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This will delete \(offlineStore.songs.count) songs (\(offlineStore.formattedSize)) from your watch.")
}
}
}
}
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")
}
// Server info
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 on Watch
struct WatchRecentAlbumsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var albums: [Album] = []
@State private var isLoading = true
var body: some View {
Group {
if 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 {
do {
albums = try await watchManager.getAlbumList(type: "newest", size: 30)
isLoading = false
} catch {
isLoading = false
}
}
}
}
// MARK: - Album Detail on Watch
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 isLoading {
ProgressView()
} else if let album = album {
List {
// Album header
VStack(spacing: 4) {
Text(album.name)
.font(.caption)
.fontWeight(.bold)
.multilineTextAlignment(.center)
Text(album.artist ?? "")
.font(.caption2)
.foregroundColor(.pink)
}
.frame(maxWidth: .infinity)
.listRowBackground(Color.clear)
// Play / Download buttons
Button(action: { playAlbum(shuffle: false) }) {
Label("Play", systemImage: "play.fill")
.foregroundColor(.pink)
}
Button(action: { playAlbum(shuffle: true) }) {
Label("Shuffle", systemImage: "shuffle")
.foregroundColor(.pink)
}
Button(action: downloadAll) {
HStack {
if isDownloading {
ProgressView().scaleEffect(0.6)
}
Label(
isDownloading ? "Downloading..." : "Download for Offline",
systemImage: "arrow.down.circle"
)
}
.foregroundColor(.blue)
}
.disabled(isDownloading)
// Songs
ForEach(album.song ?? []) { song in
WatchSongRow(
song: song,
isPlaying: audioPlayer.currentSong?.id == song.id,
isOffline: offlineStore.isSongAvailable(song.id)
) {
let songs = album.song ?? []
let idx = songs.firstIndex(where: { $0.id == song.id }) ?? 0
audioPlayer.play(song: song, fromQueue: songs, at: idx)
}
}
}
}
}
.task {
do {
album = try await watchManager.getAlbum(id: albumId)
isLoading = false
} catch {
isLoading = false
}
}
}
private func playAlbum(shuffle: Bool) {
guard let songs = album?.song, let first = songs.first else { return }
if shuffle { audioPlayer.shuffleEnabled = true }
audioPlayer.play(song: first, fromQueue: songs)
}
private func downloadAll() {
guard let album = album else { return }
isDownloading = true
Task {
await offlineStore.downloadAlbum(album)
await MainActor.run { isDownloading = false }
}
}
}
// MARK: - Playlists on Watch
struct WatchPlaylistsListView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@State private var playlists: [Playlist] = []
@State private var isLoading = true
var body: some View {
Group {
if isLoading {
ProgressView()
} else if playlists.isEmpty {
Text("No Playlists")
.font(.caption)
.foregroundColor(.gray)
} 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 {
do {
playlists = try await watchManager.getPlaylists()
isLoading = false
} catch { isLoading = false }
}
}
}
// MARK: - Playlist Detail on Watch
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 isLoading {
ProgressView()
} else if let playlist = playlist {
List {
Button(action: {
let songs = playlist.entry ?? []
guard let first = songs.first else { return }
audioPlayer.play(song: first, fromQueue: songs)
}) {
Label("Play All", systemImage: "play.fill")
.foregroundColor(.pink)
}
Button(action: downloadPlaylist) {
HStack {
if isDownloading { ProgressView().scaleEffect(0.6) }
Label(isDownloading ? "Downloading..." : "Download All", systemImage: "arrow.down.circle")
}
.foregroundColor(.blue)
}
.disabled(isDownloading)
ForEach(playlist.entry ?? []) { song in
WatchSongRow(
song: song,
isPlaying: audioPlayer.currentSong?.id == song.id,
isOffline: offlineStore.isSongAvailable(song.id)
) {
let songs = playlist.entry ?? []
let idx = songs.firstIndex(where: { $0.id == song.id }) ?? 0
audioPlayer.play(song: song, fromQueue: songs, at: idx)
}
}
}
.navigationTitle(playlist.name)
}
}
.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 on Watch
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: - Watch 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)
}
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()
}
}
}