Watch overhaul other changes

This commit is contained in:
Dallas Groot 2026-04-03 19:20:55 -07:00
parent e0b2575e45
commit 16097f5ff2
12 changed files with 970 additions and 553 deletions

View file

@ -465,6 +465,28 @@ extension WatchConnectivityManager: WCSessionDelegate {
// MARK: - Receive messages
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let type = message["type"] as? String else { return }
#if os(iOS)
if type == "watchDownloadStatus" {
// Watch completed or removed a download update our tracking
if let songId = message["songId"] as? String,
let downloaded = message["downloaded"] as? Bool {
DispatchQueue.main.async {
if downloaded {
self.watchSongIds.insert(songId)
} else {
self.watchSongIds.remove(songId)
}
self.objectWillChange.send()
}
wcLog("Watch download status: \(songId)\(downloaded ? "downloaded" : "removed")")
}
}
#endif
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
guard let type = message["type"] as? String else {
replyHandler(["error": "unknown message type"])

View file

@ -1,8 +1,24 @@
import SwiftUI
import BackgroundTasks
// MARK: - App Delegate (Background Upload Session)
// MARK: - App Delegate (Background Sessions + BGTask Registration)
class AppDelegate: NSObject, UIApplicationDelegate {
static let smartDJTaskId = "com.navidromeplayer.smartdj.refresh"
static let syncTaskId = "com.navidromeplayer.library.sync"
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
// Register background tasks
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.smartDJTaskId, using: nil) { task in
self.handleSmartDJRefresh(task: task as! BGAppRefreshTask)
}
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.syncTaskId, using: nil) { task in
self.handleLibrarySync(task: task as! BGProcessingTask)
}
return true
}
func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
@ -13,6 +29,57 @@ class AppDelegate: NSObject, UIApplicationDelegate {
DebugLogger.shared.log("Background session woke app: \(identifier)", category: "Upload")
}
}
// MARK: - Smart DJ Background Refresh
private func handleSmartDJRefresh(task: BGAppRefreshTask) {
scheduleSmartDJRefresh() // Schedule next
let refreshTask = Task {
do {
// Pre-analyze upcoming queue tracks via Companion API
if CompanionSettings.shared.isEnabled {
let queue = AudioPlayer.shared.queue
let idx = AudioPlayer.shared.queueIndex
let upcoming = Array(queue.dropFirst(idx + 1).prefix(5))
for song in upcoming {
guard let path = song.path else { continue }
_ = try? await CompanionAPIService().fetchProfile(relativePath: path)
}
}
// Also do a delta sync
SyncEngine.shared.syncIfNeeded()
OptimisticActionQueue.shared.flush()
task.setTaskCompleted(success: true)
}
}
task.expirationHandler = { refreshTask.cancel() }
}
private func handleLibrarySync(task: BGProcessingTask) {
let syncTask = Task {
SyncEngine.shared.syncIfNeeded()
task.setTaskCompleted(success: true)
}
task.expirationHandler = { syncTask.cancel() }
}
static func scheduleSmartDJRefresh() {
let request = BGAppRefreshTaskRequest(identifier: smartDJTaskId)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
try? BGTaskScheduler.shared.submit(request)
}
static func scheduleLibrarySync() {
let request = BGProcessingTaskRequest(identifier: syncTaskId)
request.requiresNetworkConnectivity = true
request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // 1 hour
try? BGTaskScheduler.shared.submit(request)
}
}
@main
@ -74,6 +141,10 @@ struct RootView: View {
if CompanionSettings.shared.isEnabled {
CompanionPushClient.shared.connect()
}
// Schedule background tasks
AppDelegate.scheduleSmartDJRefresh()
AppDelegate.scheduleLibrarySync()
}
}
}

View file

@ -26,6 +26,12 @@
<array>
<string>audio</string>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.navidromeplayer.smartdj.refresh</string>
<string>com.navidromeplayer.library.sync</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>

View file

@ -11,6 +11,12 @@ struct MainTabView: View {
@State private var navigateToArtistId: String?
@State var showNowPlaying = false
// Dynamic Island morph
@Namespace private var playerNamespace
@State private var isDynamicIsland = false
@State private var dragOffset: CGFloat = 0
@State private var isDragging = false
// Debug panel (docked)
@State private var debugPanelHeight: CGFloat = 250
@State private var debugDragOffset: CGFloat = 0
@ -62,21 +68,20 @@ struct MainTabView: View {
}
.tag(3)
VisualizerSettingsView()
.tabItem {
Image(systemName: "waveform")
Text("Visualizer")
}
.tag(4)
SettingsView()
.tabItem {
Image(systemName: "gear")
Text("Settings")
}
.tag(5)
.tag(4)
}
.tint(accentPink)
.safeAreaInset(edge: .bottom) {
// Reserve space for MiniPlayerBar so content never gets obscured
if audioPlayer.currentSong != nil && !showNowPlaying {
Color.clear.frame(height: 80)
}
}
// Debug panel (docked) only when enabled and NOT in PiP mode
if debugLogger.isEnabled && !debugPipMode {
@ -88,10 +93,59 @@ struct MainTabView: View {
if audioPlayer.currentSong != nil {
let effectiveDebugHeight = max(debugPanelHeight - debugDragOffset, 120)
let showDocked = debugLogger.isEnabled && !debugPipMode
MiniPlayerBar(showNowPlaying: $showNowPlaying)
if isDynamicIsland {
// Dynamic Island layout at top
DynamicIslandView(
namespace: playerNamespace,
showNowPlaying: $showNowPlaying,
isDynamicIsland: $isDynamicIsland
)
.zIndex(3)
} else {
// Standard MiniPlayerBar at bottom with drag-to-morph
MiniPlayerBar(
showNowPlaying: $showNowPlaying,
namespace: playerNamespace
)
.padding(.bottom, showDocked ? 49 + effectiveDebugHeight : 49)
.animation(.easeInOut(duration: 0.25), value: showDocked)
.offset(y: dragOffset)
.gesture(
DragGesture(minimumDistance: 10)
.onChanged { value in
// Only allow upward drag
let translation = min(value.translation.height, 0)
dragOffset = translation
isDragging = true
}
.onEnded { value in
isDragging = false
let screenH = UIScreen.main.bounds.height
let velocity = value.predictedEndTranslation.height
let dragPct = abs(value.translation.height) / screenH
if dragPct > 0.35 || velocity < -800 {
// Morph to Dynamic Island
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
isDynamicIsland = true
dragOffset = 0
}
} else if value.translation.height < -20 {
// Small upward swipe open NowPlaying
dragOffset = 0
withAnimation(.easeInOut(duration: 0.35)) {
showNowPlaying = true
}
} else {
withAnimation(.spring(response: 0.3)) {
dragOffset = 0
}
}
}
)
.zIndex(3)
}
}
}
@ -433,6 +487,7 @@ struct MiniPlayerBar: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@StateObject private var colorExtractor = AlbumColorExtractor.shared
@Binding var showNowPlaying: Bool
var namespace: Namespace.ID
@State private var isScrubbing = false
@State private var scrubPosition: Double = 0
@State private var playbackTime: TimeInterval = 0
@ -523,6 +578,7 @@ struct MiniPlayerBar: View {
.frame(width: 44, height: 44)
.cornerRadius(6)
.shadow(radius: 3)
.matchedGeometryEffect(id: "albumArt", in: namespace)
VStack(alignment: .leading, spacing: 1) {
Text(audioPlayer.currentSong?.title ?? "")
@ -586,6 +642,104 @@ struct MiniPlayerBar: View {
}
}
// MARK: - Dynamic Island Layout
struct DynamicIslandView: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@StateObject private var colorExtractor = AlbumColorExtractor.shared
var namespace: Namespace.ID
@Binding var showNowPlaying: Bool
@Binding var isDynamicIsland: Bool
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
VStack {
HStack(spacing: 0) {
// Leading: compact album art pill
Group {
if let song = audioPlayer.currentSong {
AsyncCoverArt(coverArtId: song.coverArt, size: 48)
} else {
Color.gray.opacity(0.3)
}
}
.frame(width: 28, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.matchedGeometryEffect(id: "albumArt", in: namespace)
.padding(.leading, 8)
// Song info (compact)
VStack(alignment: .leading, spacing: 0) {
Text(audioPlayer.currentSong?.title ?? "")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
Text(audioPlayer.currentSong?.artist ?? "")
.font(.system(size: 9))
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
}
.padding(.leading, 6)
.frame(maxWidth: 100, alignment: .leading)
// Center spacer (hardware cutout zone)
Spacer()
// Trailing: compact visualizer
if VisualizerSettings.shared.enabled {
CompactVisualizerView(
isPlaying: audioPlayer.isPlaying,
accentColor: colorExtractor.isLoaded ? colorExtractor.primaryColor : accentPink,
height: 28
)
.frame(width: 60, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
// Play/pause when no visualizer
Button(action: { audioPlayer.togglePlayPause() }) {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 14))
.foregroundColor(.white)
}
.frame(width: 36, height: 28)
}
Spacer().frame(width: 8)
}
.frame(height: 40)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(.black)
.shadow(color: .black.opacity(0.5), radius: 10, y: 2)
)
.padding(.horizontal, 40)
.padding(.top, 10)
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.35)) {
showNowPlaying = true
isDynamicIsland = false
}
}
.gesture(
DragGesture(minimumDistance: 10)
.onEnded { value in
if value.translation.height > 40 {
// Drag down return to mini player
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) {
isDynamicIsland = false
}
}
}
)
Spacer()
}
.ignoresSafeArea(edges: .top)
}
}
// MARK: - Keyboard Dismiss
/// Dismiss keyboard from anywhere

View file

@ -334,14 +334,6 @@ struct AlbumDetailView: View {
}) {
Label("Remove Download", systemImage: "trash")
}
if WatchConnectivityManager.shared.isWatchAvailable {
Button(action: {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}) {
Label("Send to Watch", systemImage: "applewatch.and.arrow.forward")
}
}
} else {
Button(action: {
if let server = serverManager.activeServer {
@ -352,6 +344,17 @@ struct AlbumDetailView: View {
}
}
// Send to Watch always visible
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
Button(action: {
if !isOnWatch { _ = WatchConnectivityManager.shared.sendSongToWatch(song) }
}) {
Label(isOnWatch ? "On Watch ✓" : "Send to Watch",
systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward")
}
.disabled(isOnWatch)
// Favourite
Button(action: {
Task {
if song.starred != nil {
@ -361,7 +364,7 @@ struct AlbumDetailView: View {
}
}
}) {
Label(song.starred != nil ? "Unstar" : "Star", systemImage: song.starred != nil ? "heart.slash" : "heart")
Label(song.starred != nil ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart")
}
Divider()

View file

@ -23,12 +23,6 @@ struct MyMusicView: View {
@State private var newPlaylistName = ""
@State private var searchText = ""
// Pagination for songs
@State private var songOffset = 0
@State private var hasMoreSongs = true
@State private var albumOffset = 0
@State private var hasMoreAlbums = true
// Album selection mode for batch editing
@State private var isSelectingAlbums = false
@State private var selectedAlbumIds: Set<String> = []
@ -42,6 +36,15 @@ struct MyMusicView: View {
@State private var artistCoverTargetId: String?
@ObservedObject private var artistCoverStore = ArtistCoverStore.shared
// Song info / edit
@State private var getInfoSong: Song?
@State private var trackEditorSong: Song?
// Playlist rename
@State private var showRenamePlaylist = false
@State private var renamePlaylistId: String?
@State private var renamePlaylistName = ""
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
enum SortMode: String, CaseIterable {
@ -162,6 +165,18 @@ struct MyMusicView: View {
}
Button("Cancel", role: .cancel) { newPlaylistName = "" }
}
.alert("Rename Playlist", isPresented: $showRenamePlaylist) {
TextField("Name", text: $renamePlaylistName)
Button("Save") {
let newName = renamePlaylistName
guard let pid = renamePlaylistId else { return }
Task {
try? await serverManager.client.updatePlaylist(id: pid, name: newName)
await loadData()
}
}
Button("Cancel", role: .cancel) { }
}
.task { await loadData() }
.refreshable { await loadData() }
.sheet(isPresented: $showBatchAlbumEditor) {
@ -175,6 +190,12 @@ struct MyMusicView: View {
MultiAlbumEditorSheet(albumIds: [albumId])
}
}
.sheet(item: $getInfoSong) { song in
SongInfoSheet(song: song)
}
.sheet(item: $trackEditorSong) { song in
TrackEditorView(song: song)
}
.navigationDestination(isPresented: Binding(
get: { navigateToPlaylistId != nil },
set: { if !$0 { navigateToPlaylistId = nil } }
@ -374,6 +395,14 @@ struct MyMusicView: View {
.padding(.vertical, 10)
}
.contextMenu {
Button(action: {
renamePlaylistId = playlist.id
renamePlaylistName = playlist.name
showRenamePlaylist = true
}) {
Label("Rename Playlist", systemImage: "pencil")
}
Button(role: .destructive, action: {
Task {
try? await serverManager.client.deletePlaylist(id: playlist.id)
@ -678,16 +707,6 @@ struct MyMusicView: View {
}
.padding(16)
if hasMoreAlbums && searchText.isEmpty {
Button(action: { Task { await loadMoreAlbums() } }) {
Text("Load More")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
}
if filtered.isEmpty && !isLoading {
emptyState("No albums found")
}
@ -731,16 +750,6 @@ struct MyMusicView: View {
.padding(.leading, 72)
}
if hasMoreSongs && searchText.isEmpty {
Button(action: { Task { await loadMoreSongs() } }) {
Text("Load More")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
}
if filtered.isEmpty && !isLoading {
emptyState("No songs found")
}
@ -779,7 +788,7 @@ struct MyMusicView: View {
if isOnWatch {
Image(systemName: "applewatch")
.font(.system(size: 9))
.foregroundColor(.blue.opacity(0.7))
.foregroundColor(.green)
}
if isDownloaded {
Image(systemName: "arrow.down.circle.fill")
@ -805,12 +814,42 @@ struct MyMusicView: View {
Button(action: { audioPlayer.playLater(song) }) {
Label("Play Later", systemImage: "text.line.last.and.arrowtriangle.forward")
}
Divider()
Button(action: { audioPlayer.playInstantMix(basedOn: song) }) {
Label("Instant Mix", systemImage: "wand.and.stars")
}
Divider()
if offlineManager.isSongDownloaded(song.id) {
// Favourite toggle
Button(action: {
Task {
if song.starred != nil {
try? await serverManager.client.unstar(id: song.id)
await MainActor.run {
favouriteSongs.removeAll { $0.id == song.id }
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
}
} else {
try? await serverManager.client.star(id: song.id)
await MainActor.run {
if !favouriteSongs.contains(where: { $0.id == song.id }) {
favouriteSongs.append(song)
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
}
}
}
}
}) {
Label(song.starred != nil ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart")
}
Divider()
// Download
if isDownloaded {
Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) {
Label("Remove Download", systemImage: "trash")
}
@ -823,6 +862,29 @@ struct MyMusicView: View {
Label("Download", systemImage: "arrow.down.circle")
}
}
// Send to Watch (always visible)
Button(action: {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}) {
Label(
isOnWatch ? "On Watch ✓" : "Send to Watch",
systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward"
)
}
.disabled(isOnWatch)
Divider()
Button(action: { getInfoSong = song }) {
Label("Get Info", systemImage: "info.circle")
}
if CompanionSettings.shared.isEnabled {
Button(action: { trackEditorSong = song }) {
Label("Edit Tags", systemImage: "tag")
}
}
}
}
@ -993,15 +1055,16 @@ struct MyMusicView: View {
async let albumsReq = client.getAlbumList2(type: "newest", size: 30)
async let playlistsReq = client.getPlaylists()
async let artistsReq = client.getArtists()
async let allAlbumsReq = client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: 0)
async let genresReq = client.getGenres()
async let songsReq = client.search3(query: "", songCount: 100, songOffset: 0)
async let starredReq = client.getStarred2()
let (albums, playlistList, artistList, albumsFull, genreList, searchResult, starred) = try await (
albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq, starredReq
let (albums, playlistList, artistList, genreList, starred) = try await (
albumsReq, playlistsReq, artistsReq, genresReq, starredReq
)
// Fetch ALL albums (paginated)
let albumsFull = try await fetchAllAlbums(client: client)
cache.cacheAlbums(albums)
cache.cachePlaylists(playlistList)
cache.cacheArtists(artistList)
@ -1016,12 +1079,8 @@ struct MyMusicView: View {
self.playlists = playlistList
self.artists = artistList
self.allAlbums = albumsFull
self.albumOffset = 100
self.hasMoreAlbums = albumsFull.count >= 100
self.genres = genreList
self.allSongs = searchResult?.song ?? []
self.songOffset = 100
self.hasMoreSongs = (searchResult?.song?.count ?? 0) >= 100
self.allSongs = [] // Songs loaded on-demand via search
if let starredSongs = starred?.song {
self.favouriteSongs = starredSongs
}
@ -1033,27 +1092,27 @@ struct MyMusicView: View {
}
}
private func loadMoreAlbums() async {
do {
let more = try await serverManager.client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: albumOffset)
await MainActor.run {
allAlbums.append(contentsOf: more)
albumOffset += 100
hasMoreAlbums = more.count >= 100
}
} catch { }
}
private func loadMoreSongs() async {
do {
let result = try await serverManager.client.search3(query: "", songCount: 100, songOffset: songOffset)
let moreSongs = result?.song ?? []
await MainActor.run {
allSongs.append(contentsOf: moreSongs)
songOffset += 100
hasMoreSongs = moreSongs.count >= 100
}
} catch { }
/// Paginate all albums from the server
private func fetchAllAlbums(client: SubsonicClient) async throws -> [Album] {
var all: [Album] = []
var offset = 0
let pageSize = 500
while true {
let page = try await client.getAlbumList2(type: "alphabeticalByName", size: pageSize, offset: offset)
all.append(contentsOf: page)
if page.count < pageSize { break }
offset += pageSize
}
// Sort: alpha first, then numerals, then special characters
return all.sorted { a, b in
let aFirst = a.name.first ?? Character("\0")
let bFirst = b.name.first ?? Character("\0")
let aIsLetter = aFirst.isLetter
let bIsLetter = bFirst.isLetter
if aIsLetter && !bIsLetter { return true }
if !aIsLetter && bIsLetter { return false }
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
}
}
}

View file

@ -126,8 +126,8 @@ struct PlaylistDetailView: View {
@State private var showPlaylistPicker = false
@State private var playlistPickerSongId: String?
@State private var availablePlaylists: [Playlist] = []
@State private var showGetInfo = false
@State private var getInfoSong: Song?
@State private var trackEditorSong: Song?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
@ -152,8 +152,11 @@ struct PlaylistDetailView: View {
.sheet(isPresented: $showPlaylistPicker) {
AddToPlaylistSheet(songId: playlistPickerSongId, playlists: availablePlaylists)
}
.sheet(isPresented: $showGetInfo) {
if let song = getInfoSong { SongInfoSheet(song: song) }
.sheet(item: $getInfoSong) { song in
SongInfoSheet(song: song)
}
.sheet(item: $trackEditorSong) { song in
TrackEditorView(song: song)
}
.task {
do {
@ -372,6 +375,8 @@ struct PlaylistDetailView: View {
@ViewBuilder
private func songContextMenu(song: Song, index: Int) -> some View {
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
Button(action: { audioPlayer.playNow(song) }) {
Label("Play Now", systemImage: "play.fill")
}
@ -395,6 +400,23 @@ struct PlaylistDetailView: View {
Label("Add to Playlist...", systemImage: "text.badge.plus")
}
Divider()
// Favourite
Button(action: {
Task {
if song.starred != nil {
try? await serverManager.client.unstar(id: song.id)
} else {
try? await serverManager.client.star(id: song.id)
}
}
}) {
Label(song.starred != nil ? "Unfavourite" : "Favourite", systemImage: song.starred != nil ? "heart.slash.fill" : "heart")
}
Divider()
// Remove from playlist (only in playlist context)
Button(role: .destructive, action: {
Task {
try? await serverManager.client.updatePlaylist(id: playlistId, songIndexesToRemove: [index])
@ -403,18 +425,13 @@ struct PlaylistDetailView: View {
}) {
Label("Remove from Playlist", systemImage: "minus.circle")
}
Divider()
if offlineManager.isSongDownloaded(song.id) {
Button(role: .destructive, action: { offlineManager.removeSong(song.id) }) {
Label("Remove Download", systemImage: "trash")
}
if WatchConnectivityManager.shared.isWatchAvailable {
Button(action: {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}) {
Label("Send to Watch", systemImage: "applewatch.and.arrow.forward")
}
}
} else {
Button(action: {
if let server = serverManager.activeServer {
@ -424,8 +441,26 @@ struct PlaylistDetailView: View {
Label("Download", systemImage: "arrow.down.circle")
}
}
Button(action: { getInfoSong = song; showGetInfo = true }) {
// Send to Watch always visible
Button(action: {
if !isOnWatch { _ = WatchConnectivityManager.shared.sendSongToWatch(song) }
}) {
Label(isOnWatch ? "On Watch ✓" : "Send to Watch",
systemImage: isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward")
}
.disabled(isOnWatch)
Divider()
Button(action: { getInfoSong = song }) {
Label("Get Info", systemImage: "info.circle")
}
if CompanionSettings.shared.isEnabled {
Button(action: { trackEditorSong = song }) {
Label("Edit Tags", systemImage: "tag")
}
}
}
}

View file

@ -177,6 +177,7 @@ struct NowPlayingView: View {
@State private var isDraggingSlider = false
@State private var dragPosition: Double = 0
@State private var showQueue = false
@State private var showVisualizerSettings = false
@State private var showPlaylistPicker = false
@State private var showGetInfo = false
@State private var showTrackEditor = false
@ -301,6 +302,9 @@ struct NowPlayingView: View {
TrackEditorView(song: song)
}
}
.sheet(isPresented: $showVisualizerSettings) {
VisualizerSettingsView()
}
.sheet(isPresented: $showShazamResult) {
ShazamResultSheet(
title: shazamResult ?? "",
@ -486,6 +490,25 @@ struct NowPlayingView: View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
// Visualizer Settings (centered, full width)
Button(action: {
showEllipsisMenu = false
showVisualizerSettings = true
}) {
HStack(spacing: 8) {
Image(systemName: "waveform")
.font(.system(size: 18))
.foregroundColor(accentPink)
Text("Visualizer Settings")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
}
.frame(maxWidth: .infinity, minHeight: 44)
.background(Color.white.opacity(0.08))
.cornerRadius(12)
}
.padding(.horizontal, 16)
if !isRadio {
// Row 1: Instant Mix | Add to Playlist
gridRow(
@ -520,9 +543,10 @@ struct NowPlayingView: View {
})
)
// Row 3: Download/Remove | Send to Watch
// Row 3: Download/Remove | Send to Watch (always visible)
if let song = audioPlayer.currentSong {
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
gridRow(
left: isDownloaded
? ("Remove Download", "trash", { showEllipsisMenu = false; offlineManager.removeSong(song.id) })
@ -530,12 +554,35 @@ struct NowPlayingView: View {
showEllipsisMenu = false
if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) }
}),
right: WatchConnectivityManager.shared.isWatchAvailable
? ("Send to Watch", "applewatch.and.arrow.forward", {
right: (
isOnWatch ? "On Watch ✓" : "Send to Watch",
isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward",
{
showEllipsisMenu = false
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
})
: nil
if !isOnWatch {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}
}
)
)
// Row 3b: Favourite toggle
gridRow(
left: (
song.starred != nil ? "Unfavourite" : "Favourite",
song.starred != nil ? "heart.slash.fill" : "heart",
{
showEllipsisMenu = false
Task {
if song.starred != nil {
try? await serverManager.client.unstar(id: song.id)
} else {
try? await serverManager.client.star(id: song.id)
}
}
}
),
right: nil
)
}

View file

@ -1,15 +1,19 @@
import Foundation
import Combine
import WatchKit
import WatchConnectivity
/// Manages offline songs stored directly on the Apple Watch
class WatchOfflineStore: ObservableObject {
/// Manages offline songs stored directly on the Apple Watch.
/// Downloads use URLSession background configuration for reliability.
/// Progress tracked per-song; haptic feedback on completion.
class WatchOfflineStore: NSObject, ObservableObject, URLSessionDownloadDelegate {
static let shared = WatchOfflineStore()
@Published var songs: [WatchOfflineSong] = []
@Published var totalSize: Int64 = 0
@Published var isDownloading = false
@Published var downloadProgress: [String: Double] = [:]
@Published var downloadProgress: [String: Double] = [:] // songId 0.01.0
@Published var downloadComplete: [String: Bool] = [:] // songId true when done
struct WatchOfflineSong: Codable, Identifiable {
let id: String
@ -21,8 +25,17 @@ class WatchOfflineStore: ObservableObject {
private let catalogKey = "watch_offline_songs"
private let fileManager = FileManager.default
private var pendingSongs: [String: Song] = [:] // taskId Song
private var taskToSongId: [Int: String] = [] // URLSession task ID songId
private var musicDirectory: URL {
private lazy var backgroundSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "com.navidromeplayer.watch.download")
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
var musicDirectory: URL {
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let dir = docs.appendingPathComponent("OfflineMusic", isDirectory: true)
if !fileManager.fileExists(atPath: dir.path) {
@ -31,7 +44,8 @@ class WatchOfflineStore: ObservableObject {
return dir
}
private init() {
override init() {
super.init()
loadCatalog()
}
@ -39,23 +53,8 @@ class WatchOfflineStore: ObservableObject {
func addSong(_ song: Song, localPath: String) {
guard !songs.contains(where: { $0.id == song.id }) else { return }
let fileSize: Int64
if let attrs = try? fileManager.attributesOfItem(atPath: localPath),
let size = attrs[.size] as? Int64 {
fileSize = size
} else {
fileSize = 0
}
let offlineSong = WatchOfflineSong(
id: song.id,
song: song,
localPath: localPath,
fileSize: fileSize,
dateAdded: Date()
)
let fileSize: Int64 = (try? fileManager.attributesOfItem(atPath: localPath)[.size] as? Int64) ?? 0
let offlineSong = WatchOfflineSong(id: song.id, song: song, localPath: localPath, fileSize: fileSize, dateAdded: Date())
songs.append(offlineSong)
totalSize += fileSize
saveCatalog()
@ -64,18 +63,19 @@ class WatchOfflineStore: ObservableObject {
func removeSong(_ songId: String) {
guard let idx = songs.firstIndex(where: { $0.id == songId }) else { return }
let song = songs[idx]
// Reconstruct path from filename
let filename = (song.localPath as NSString).lastPathComponent
let url = musicDirectory.appendingPathComponent(filename)
try? fileManager.removeItem(at: url)
totalSize -= song.fileSize
songs.remove(at: idx)
saveCatalog()
notifyiOS(songId: songId, downloaded: false)
}
func removeAll() {
for song in songs {
try? fileManager.removeItem(atPath: song.localPath)
let filename = (song.localPath as NSString).lastPathComponent
try? fileManager.removeItem(at: musicDirectory.appendingPathComponent(filename))
}
songs.removeAll()
totalSize = 0
@ -84,62 +84,116 @@ class WatchOfflineStore: ObservableObject {
func localURL(for songId: String) -> URL? {
guard let song = songs.first(where: { $0.id == songId }) else { return nil }
// Reconstruct path from filename to handle sandbox UUID changes
let filename = (song.localPath as NSString).lastPathComponent
let url = musicDirectory.appendingPathComponent(filename)
return fileManager.fileExists(atPath: url.path) ? url : nil
}
func isSongAvailable(_ songId: String) -> Bool {
return localURL(for: songId) != nil
}
func isSongAvailable(_ songId: String) -> Bool { localURL(for: songId) != nil }
// MARK: - Download directly from server (WiFi)
// MARK: - Direct Server Download (Background URLSession)
func downloadFromServer(song: Song) async {
func downloadFromServer(song: Song) {
guard !songs.contains(where: { $0.id == song.id }) else { return }
guard downloadProgress[song.id] == nil else { return } // already downloading
await MainActor.run {
isDownloading = true
downloadProgress[song.id] = 0
guard let url = WatchSessionManager.shared.streamURL(songId: song.id, maxBitRate: 128) else {
print("[Watch] No stream URL for \(song.id)")
return
}
do {
// WatchSessionManager.downloadSong already transcodes at 192kbps MP3
let (data, _) = try await WatchSessionManager.shared.downloadSong(id: song.id)
// Always mp3 since watch downloads are transcoded
let filename = "\(song.id).mp3"
let localURL = musicDirectory.appendingPathComponent(filename)
try data.write(to: localURL)
await MainActor.run {
addSong(song, localPath: localURL.path)
downloadProgress.removeValue(forKey: song.id)
isDownloading = downloadProgress.isEmpty ? false : true
}
} catch {
await MainActor.run {
downloadProgress.removeValue(forKey: song.id)
isDownloading = downloadProgress.isEmpty ? false : true
}
print("Watch download failed: \(error)")
DispatchQueue.main.async {
self.downloadProgress[song.id] = 0.0
self.downloadComplete[song.id] = false
}
pendingSongs[song.id] = song
let task = backgroundSession.downloadTask(with: url)
taskToSongId[task.taskIdentifier] = song.id
task.resume()
print("[Watch] Download started: \(song.title)")
}
func downloadAlbum(_ album: AlbumWithSongs) async {
func downloadAlbum(_ album: AlbumWithSongs) {
guard let albumSongs = album.song else { return }
for song in albumSongs {
await downloadFromServer(song: song)
downloadFromServer(song: song)
}
}
// MARK: - Update catalog from phone sync
// MARK: - URLSession Download Delegate
func updateCatalog(_ phoneSongs: [Song]) {
// This updates the expected catalog; actual files arrive via file transfer
// We can show which songs are expected vs available
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
guard let songId = taskToSongId[downloadTask.taskIdentifier] else { return }
let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0
DispatchQueue.main.async {
self.downloadProgress[songId] = progress
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
guard let songId = taskToSongId[downloadTask.taskIdentifier],
let song = pendingSongs[songId] else { return }
let filename = "\(songId).mp3"
let destURL = musicDirectory.appendingPathComponent(filename)
do {
if fileManager.fileExists(atPath: destURL.path) {
try fileManager.removeItem(at: destURL)
}
try fileManager.moveItem(at: location, to: destURL)
DispatchQueue.main.async {
self.addSong(song, localPath: destURL.path)
self.downloadProgress.removeValue(forKey: songId)
self.downloadComplete[songId] = true
self.pendingSongs.removeValue(forKey: songId)
self.taskToSongId.removeValue(forKey: downloadTask.taskIdentifier)
// Haptic feedback
WKInterfaceDevice.current().play(.success)
// Notify iOS
self.notifyiOS(songId: songId, downloaded: true)
// Clear completion indicator after 2s
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
self.downloadComplete.removeValue(forKey: songId)
}
}
print("[Watch] Download complete: \(song.title)")
} catch {
print("[Watch] Save failed: \(error)")
DispatchQueue.main.async {
self.downloadProgress.removeValue(forKey: songId)
self.pendingSongs.removeValue(forKey: songId)
}
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error, let songId = taskToSongId[task.taskIdentifier] else { return }
print("[Watch] Download error for \(songId): \(error.localizedDescription)")
DispatchQueue.main.async {
self.downloadProgress.removeValue(forKey: songId)
self.pendingSongs.removeValue(forKey: songId)
self.taskToSongId.removeValue(forKey: task.taskIdentifier)
}
}
// MARK: - iOS Notification
private func notifyiOS(songId: String, downloaded: Bool) {
guard WCSession.default.isReachable else { return }
WCSession.default.sendMessage([
"type": "watchDownloadStatus",
"songId": songId,
"downloaded": downloaded
], replyHandler: nil, errorHandler: nil)
}
// MARK: - Grouped Access
@ -148,10 +202,6 @@ class WatchOfflineStore: ObservableObject {
Dictionary(grouping: songs) { $0.song.album ?? "Unknown Album" }
}
var songsByArtist: [String: [WatchOfflineSong]] {
Dictionary(grouping: songs) { $0.song.artist ?? "Unknown Artist" }
}
var formattedSize: String {
ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file)
}
@ -167,7 +217,6 @@ class WatchOfflineStore: ObservableObject {
private func loadCatalog() {
if let data = UserDefaults.standard.data(forKey: catalogKey),
let decoded = try? JSONDecoder().decode([WatchOfflineSong].self, from: data) {
// Reconstruct paths from filenames to handle sandbox UUID changes
songs = decoded.filter { song in
let filename = (song.localPath as NSString).lastPathComponent
let url = musicDirectory.appendingPathComponent(filename)
@ -176,4 +225,11 @@ class WatchOfflineStore: ObservableObject {
totalSize = songs.reduce(0) { $0 + $1.fileSize }
}
}
// MARK: - Cache Management
var cacheSize: String {
let size = songs.reduce(Int64(0)) { $0 + $1.fileSize }
return ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
}
}

View file

@ -275,9 +275,8 @@ class WatchSessionManager: NSObject, ObservableObject {
print("[Watch] Downloading from server: \(song.title)")
Task {
await WatchOfflineStore.shared.downloadFromServer(song: song)
print("[Watch] Download complete: \(song.title)")
DispatchQueue.main.async {
WatchOfflineStore.shared.downloadFromServer(song: song)
}
}

View file

@ -404,6 +404,15 @@ class WatchAudioPlayer: NSObject, ObservableObject {
switch repeatMode { case .off: repeatMode = .all; case .all: repeatMode = .one; case .one: repeatMode = .off }
}
func playNext(_ song: Song) {
let insertAt = min(queueIndex + 1, queue.count)
queue.insert(song, at: insertAt)
}
func playLater(_ song: Song) {
queue.append(song)
}
@objc private func playerDidFinish() {
if repeatMode == .one { seek(to: 0); player?.play() } else { next() }
}

View file

@ -1,4 +1,5 @@
import SwiftUI
import WatchKit
// MARK: - Main Tab View
@ -11,19 +12,14 @@ struct WatchLibraryView: View {
TabView {
WatchNowPlayingView()
WatchMyMusicView()
WatchOfflineLibraryView()
WatchBrowseView()
WatchSettingsView()
}
.tabViewStyle(.verticalPage)
.task {
// Sync library in background on launch
await watchManager.syncLibrary()
}
.task { await watchManager.syncLibrary() }
}
}
// MARK: - My Music (Artists, Albums, Genres, Songs)
// MARK: - My Music
struct WatchMyMusicView: View {
@EnvironmentObject var watchManager: WatchSessionManager
@ -31,6 +27,9 @@ struct WatchMyMusicView: View {
var body: some View {
NavigationView {
List {
NavigationLink(destination: WatchOnMyWristView()) {
Label("On My Wrist", systemImage: "applewatch")
}
NavigationLink(destination: WatchArtistsView()) {
Label("Artists", systemImage: "music.mic")
}
@ -52,6 +51,127 @@ struct WatchMyMusicView: View {
}
}
// 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 {
@ -61,18 +181,15 @@ struct WatchArtistsView: View {
var body: some View {
Group {
if artistIndexes.isEmpty && isLoading {
ProgressView()
} else {
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)
Text(artist.name).font(.caption).lineLimit(1)
}
}
}
@ -83,35 +200,21 @@ struct WatchArtistsView: View {
}
.navigationTitle("Artists")
.task {
// Cache first
let cached = watchManager.cachedArtists()
if !cached.isEmpty {
artistIndexes = cached
isLoading = false
}
// Refresh
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
}
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
@ -123,41 +226,23 @@ struct WatchArtistDetailView: View {
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)
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)
}
} 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
}
if let cached = watchManager.cachedArtistDetail(id: artistId) { artist = cached; isLoading = false }
do {
let fresh = try await watchManager.getArtist(id: artistId)
if let fresh {
if let fresh = try await watchManager.getArtist(id: artistId) {
watchManager.cacheEncode(fresh, key: "artist_\(artistId)")
await MainActor.run { artist = fresh; isLoading = false }
}
@ -166,28 +251,32 @@ struct WatchArtistDetailView: View {
}
}
// MARK: - Albums (All)
// 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 {
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)
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")
}
}
}
@ -196,10 +285,7 @@ struct WatchAlbumsView: View {
.navigationTitle("Albums")
.task {
let cached = watchManager.cachedAlbums()
if !cached.isEmpty {
albums = cached
isLoading = false
}
if !cached.isEmpty { albums = cached; isLoading = false }
do {
let fresh = try await watchManager.getAllAlbums()
watchManager.cacheEncode(fresh, key: "all_albums")
@ -207,6 +293,148 @@ struct WatchAlbumsView: View {
} 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
@ -218,19 +446,14 @@ struct WatchGenresView: View {
var body: some View {
Group {
if genres.isEmpty && isLoading {
ProgressView()
} else {
if genres.isEmpty && isLoading { ProgressView() }
else {
List(genres) { genre in
NavigationLink(destination: WatchGenreDetailView(genreName: genre.value)) {
HStack {
Text(genre.value)
.font(.caption)
.lineLimit(1)
Text(genre.value).font(.caption).lineLimit(1)
Spacer()
Text("\(genre.albumCount ?? 0)")
.font(.caption2)
.foregroundColor(.gray)
Text("\(genre.albumCount ?? 0)").font(.caption2).foregroundColor(.gray)
}
}
}
@ -239,10 +462,7 @@ struct WatchGenresView: View {
.navigationTitle("Genres")
.task {
let cached = watchManager.cachedGenres()
if !cached.isEmpty {
genres = cached
isLoading = false
}
if !cached.isEmpty { genres = cached; isLoading = false }
do {
let fresh = try await watchManager.getGenres()
watchManager.cacheEncode(fresh, key: "genres")
@ -255,15 +475,13 @@ struct WatchGenresView: View {
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 {
if albums.isEmpty && isLoading { ProgressView() }
else {
List(albums) { album in
NavigationLink(destination: WatchAlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 2) {
@ -276,242 +494,8 @@ struct WatchGenreDetailView: View {
}
.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 }
do { albums = try await watchManager.getAlbumsByGenre(genreName); isLoading = false }
catch { isLoading = false }
}
}
}
@ -525,9 +509,8 @@ struct WatchPlaylistsListView: View {
var body: some View {
Group {
if playlists.isEmpty && isLoading {
ProgressView()
} else {
if playlists.isEmpty && isLoading { ProgressView() }
else {
List(playlists) { playlist in
NavigationLink(destination: WatchPlaylistDetailView(playlistId: playlist.id)) {
VStack(alignment: .leading, spacing: 2) {
@ -555,58 +538,31 @@ 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)
Button(action: {
for song in songs { offlineStore.downloadFromServer(song: song) }
}) {
HStack { Image(systemName: "arrow.down.circle"); Text("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)
}
WatchServerSongRow(song: song, allSongs: songs)
}
}
.navigationTitle(playlist.name)
} else if isLoading {
ProgressView()
}
} 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 }
do { playlist = try await watchManager.getPlaylist(id: playlistId); isLoading = false }
catch { isLoading = false }
}
}
}
@ -616,19 +572,15 @@ struct WatchPlaylistDetailView: View {
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 {
TextField("Search", text: $query).onSubmit { performSearch() }
if isSearching { ProgressView() }
else if let results = results {
List {
if let songs = results.song, !songs.isEmpty {
Section("Songs") {
@ -679,26 +631,16 @@ struct WatchSongRow: View {
Button(action: onTap) {
HStack(spacing: 6) {
if isPlaying {
Image(systemName: "waveform")
.font(.caption2).foregroundColor(.pink).frame(width: 14)
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)
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)
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)
Text(song.durationFormatted).font(.caption2).foregroundColor(.gray)
}
}
}
@ -714,6 +656,7 @@ struct WatchSettingsView: View {
var body: some View {
NavigationView {
List {
// Server
Section("Server") {
ForEach(watchManager.servers) { server in
VStack(alignment: .leading, spacing: 2) {
@ -735,25 +678,38 @@ struct WatchSettingsView: View {
if watchManager.isSyncing {
HStack {
ProgressView().scaleEffect(0.6)
Text("Syncing library...").font(.caption2).foregroundColor(.gray)
Text("Syncing...").font(.caption2).foregroundColor(.gray)
}
}
}
Section("Storage") {
// Cache Management
Section("Cache") {
HStack {
Text("Offline Songs").font(.caption); Spacer()
Text("Downloaded 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)
Text("Storage Used").font(.caption)
Spacer()
Text(offlineStore.cacheSize).font(.caption).foregroundColor(.gray)
}
if !offlineStore.songs.isEmpty {
Button("Remove All Downloads", role: .destructive) { offlineStore.removeAll() }.font(.caption)
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)