diff --git a/Shared/Storage/WatchConnectivityManager.swift b/Shared/Storage/WatchConnectivityManager.swift
index a790459..364ea80 100644
--- a/Shared/Storage/WatchConnectivityManager.swift
+++ b/Shared/Storage/WatchConnectivityManager.swift
@@ -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"])
diff --git a/iOS/App/NavidromePlayerApp.swift b/iOS/App/NavidromePlayerApp.swift
index aa1370e..4981714 100644
--- a/iOS/App/NavidromePlayerApp.swift
+++ b/iOS/App/NavidromePlayerApp.swift
@@ -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()
}
}
}
diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist
index 0cbaf63..9f973cd 100644
--- a/iOS/Resources/Info.plist
+++ b/iOS/Resources/Info.plist
@@ -26,6 +26,12 @@
audio
fetch
+ processing
+
+ BGTaskSchedulerPermittedIdentifiers
+
+ com.navidromeplayer.smartdj.refresh
+ com.navidromeplayer.library.sync
UILaunchStoryboardName
LaunchScreen
diff --git a/iOS/Views/Common/MainTabView.swift b/iOS/Views/Common/MainTabView.swift
index da34ca3..dcff141 100644
--- a/iOS/Views/Common/MainTabView.swift
+++ b/iOS/Views/Common/MainTabView.swift
@@ -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
diff --git a/iOS/Views/Library/AlbumDetailView.swift b/iOS/Views/Library/AlbumDetailView.swift
index 7ac5e4b..d551361 100644
--- a/iOS/Views/Library/AlbumDetailView.swift
+++ b/iOS/Views/Library/AlbumDetailView.swift
@@ -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()
diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift
index b9d13fd..5527262 100644
--- a/iOS/Views/Library/MyMusicView.swift
+++ b/iOS/Views/Library/MyMusicView.swift
@@ -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 = []
@@ -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
+ }
}
}
diff --git a/iOS/Views/Library/PlaylistsView.swift b/iOS/Views/Library/PlaylistsView.swift
index e8e6dc8..477cd4b 100644
--- a/iOS/Views/Library/PlaylistsView.swift
+++ b/iOS/Views/Library/PlaylistsView.swift
@@ -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")
+ }
+ }
}
}
diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift
index 113acdd..d4f6c3a 100644
--- a/iOS/Views/NowPlaying/NowPlayingView.swift
+++ b/iOS/Views/NowPlaying/NowPlayingView.swift
@@ -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
)
}
diff --git a/watchOS/App/WatchOfflineStore.swift b/watchOS/App/WatchOfflineStore.swift
index 8486950..8ea436d 100644
--- a/watchOS/App/WatchOfflineStore.swift
+++ b/watchOS/App/WatchOfflineStore.swift
@@ -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.0–1.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)
+ }
}
diff --git a/watchOS/App/WatchSessionManager.swift b/watchOS/App/WatchSessionManager.swift
index e7d33d2..7962459 100644
--- a/watchOS/App/WatchSessionManager.swift
+++ b/watchOS/App/WatchSessionManager.swift
@@ -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)
}
}
diff --git a/watchOS/Audio/WatchAudioPlayer.swift b/watchOS/Audio/WatchAudioPlayer.swift
index b06aae9..0444f57 100644
--- a/watchOS/Audio/WatchAudioPlayer.swift
+++ b/watchOS/Audio/WatchAudioPlayer.swift
@@ -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() }
}
diff --git a/watchOS/Views/WatchLibraryView.swift b/watchOS/Views/WatchLibraryView.swift
index 408bfef..cab5484 100644
--- a/watchOS/Views/WatchLibraryView.swift
+++ b/watchOS/Views/WatchLibraryView.swift
@@ -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 (A–Z list with art, expandable songs)
struct WatchAlbumsView: View {
@EnvironmentObject var watchManager: WatchSessionManager
+ @EnvironmentObject var offlineStore: WatchOfflineStore
@State private var albums: [Album] = []
@State private var isLoading = true
var body: some View {
Group {
- if albums.isEmpty && isLoading {
- ProgressView()
- } else {
+ 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)