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
611 lines
22 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|