Watch overhaul other changes
This commit is contained in:
parent
e0b2575e45
commit
16097f5ff2
12 changed files with 970 additions and 553 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue