diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 739f8d0..7114447 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -1382,6 +1382,11 @@ class AudioPlayer: NSObject, ObservableObject { } } + func setVolume(_ vol: Float) { + volume = vol + player?.volume = vol + } + // MARK: - Queue Management func playNext(_ song: Song) { @@ -1502,6 +1507,14 @@ class AudioPlayer: NSObject, ObservableObject { info[MPMediaItemPropertyArtwork] = artwork } MPNowPlayingInfoCenter.default().nowPlayingInfo = info + + // Push state to Apple Watch — fires on song change, play/pause, and every 5s + WatchConnectivityManager.shared.sendNowPlayingToWatch( + song: currentSong, + isPlaying: isPlaying, + currentTime: currentTime, + duration: duration + ) #endif } @@ -2116,6 +2129,10 @@ class AudioPlayer: NSObject, ObservableObject { #if os(iOS) offlineVisBuffer = VisFrameBuffer.empty WidgetBridge.shared.clear() + // Tell watch playback stopped + WatchConnectivityManager.shared.sendNowPlayingToWatch( + song: nil, isPlaying: false, currentTime: 0, duration: 0 + ) #endif if isRadioStream { isRadioStream = false diff --git a/Shared/Storage/WatchConnectivityManager.swift b/Shared/Storage/WatchConnectivityManager.swift index 698972d..ad5aadf 100644 --- a/Shared/Storage/WatchConnectivityManager.swift +++ b/Shared/Storage/WatchConnectivityManager.swift @@ -394,11 +394,15 @@ class WatchConnectivityManager: NSObject, ObservableObject { func sendNowPlayingToWatch(song: Song?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) { guard let session = session, session.isReachable else { return } + let player = AudioPlayer.shared var info: [String: Any] = [ "type": "nowPlaying", "isPlaying": isPlaying, "currentTime": currentTime, - "duration": duration + "duration": duration, + "shuffleEnabled": player.shuffleEnabled, + "repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2, + "volume": Double(player.volume) ] if let song = song { @@ -543,6 +547,39 @@ extension WatchConnectivityManager: WCSessionDelegate { replyHandler(["success": true]) } + case "seekCommand": + if let time = message["time"] as? TimeInterval { + DispatchQueue.main.async { + AudioPlayer.shared.seek(to: time) + replyHandler(["success": true]) + } + } else { + replyHandler(["error": "missing time"]) + } + + case "shuffleCommand": + DispatchQueue.main.async { + AudioPlayer.shared.toggleShuffle() + replyHandler(["shuffleEnabled": AudioPlayer.shared.shuffleEnabled]) + } + + case "repeatCommand": + DispatchQueue.main.async { + AudioPlayer.shared.cycleRepeat() + let mode = AudioPlayer.shared.repeatMode + replyHandler(["repeatMode": mode == .off ? 0 : mode == .all ? 1 : 2]) + } + + case "volumeCommand": + if let vol = message["volume"] as? Double { + DispatchQueue.main.async { + AudioPlayer.shared.setVolume(Float(vol)) + replyHandler(["success": true]) + } + } else { + replyHandler(["error": "missing volume"]) + } + case "requestDownload": if let songId = message["songId"] as? String { Task { @@ -561,6 +598,26 @@ extension WatchConnectivityManager: WCSessionDelegate { } } + case "requestNowPlaying": + DispatchQueue.main.async { + let player = AudioPlayer.shared + var reply: [String: Any] = [ + "isPlaying": player.isPlaying, + "currentTime": player.currentTime, + "duration": player.duration, + "shuffleEnabled": player.shuffleEnabled, + "repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2, + "volume": Double(player.volume) + ] + if let song = player.currentSong { + reply["title"] = song.title + reply["artist"] = song.artist ?? "" + reply["album"] = song.album ?? "" + reply["coverArtId"] = song.coverArt ?? "" + } + replyHandler(reply) + } + default: replyHandler(["error": "unhandled type: \(type)"]) } diff --git a/watchOS/App/WatchSessionManager.swift b/watchOS/App/WatchSessionManager.swift index bb34104..5cfa7fc 100644 --- a/watchOS/App/WatchSessionManager.swift +++ b/watchOS/App/WatchSessionManager.swift @@ -23,11 +23,30 @@ class WatchSessionManager: NSObject, ObservableObject { var currentTime: TimeInterval var duration: TimeInterval var coverArtId: String? + var shuffleEnabled: Bool = false + var repeatMode: Int = 0 // 0=off, 1=all, 2=one + var volume: Float = 1.0 } private var session: WCSession? private let client = SubsonicClient() + /// Parse PhoneNowPlaying from a dictionary (reused by message handler + reply handler) + static func parseNowPlaying(from dict: [String: Any]) -> PhoneNowPlaying { + PhoneNowPlaying( + title: dict["title"] as? String ?? "", + artist: dict["artist"] as? String ?? "", + album: dict["album"] as? String ?? "", + isPlaying: dict["isPlaying"] as? Bool ?? false, + currentTime: dict["currentTime"] as? TimeInterval ?? 0, + duration: dict["duration"] as? TimeInterval ?? 0, + coverArtId: dict["coverArtId"] as? String, + shuffleEnabled: dict["shuffleEnabled"] as? Bool ?? false, + repeatMode: dict["repeatMode"] as? Int ?? 0, + volume: (dict["volume"] as? Double).map { Float($0) } ?? 1.0 + ) + } + private override init() { super.init() if WCSession.isSupported() { @@ -80,19 +99,70 @@ class WatchSessionManager: NSObject, ObservableObject { } func sendPlayCommand() { - session?.sendMessage(["type": "playCommand"], replyHandler: nil, errorHandler: nil) + session?.sendMessage(["type": "playCommand"], replyHandler: { [weak self] reply in + if let isPlaying = reply["isPlaying"] as? Bool { + DispatchQueue.main.async { + guard let self else { return } + self.phoneNowPlaying?.isPlaying = isPlaying + } + } + }, errorHandler: nil) } func sendNextCommand() { - session?.sendMessage(["type": "nextCommand"], replyHandler: nil, errorHandler: nil) + session?.sendMessage(["type": "nextCommand"], replyHandler: { _ in }, errorHandler: nil) } func sendPreviousCommand() { - session?.sendMessage(["type": "previousCommand"], replyHandler: nil, errorHandler: nil) + session?.sendMessage(["type": "previousCommand"], replyHandler: { _ in }, errorHandler: nil) + } + + func sendSeekCommand(to time: TimeInterval) { + session?.sendMessage(["type": "seekCommand", "time": time], replyHandler: { _ in }, errorHandler: nil) + } + + func sendShuffleCommand() { + session?.sendMessage(["type": "shuffleCommand"], replyHandler: { [weak self] reply in + if let enabled = reply["shuffleEnabled"] as? Bool { + DispatchQueue.main.async { + guard let self else { return } + self.phoneNowPlaying?.shuffleEnabled = enabled + } + } + }, errorHandler: nil) + } + + func sendRepeatCommand() { + session?.sendMessage(["type": "repeatCommand"], replyHandler: { [weak self] reply in + if let mode = reply["repeatMode"] as? Int { + DispatchQueue.main.async { + guard let self else { return } + self.phoneNowPlaying?.repeatMode = mode + } + } + }, errorHandler: nil) + } + + func sendVolumeCommand(_ volume: Float) { + session?.sendMessage(["type": "volumeCommand", "volume": volume], replyHandler: { _ in }, errorHandler: nil) + } + + /// Ask iPhone for current playback state (used on watch app launch) + func requestNowPlaying() { + guard let session = session, session.isReachable else { return } + session.sendMessage(["type": "requestNowPlaying"], replyHandler: { reply in + guard let title = reply["title"] as? String, !title.isEmpty else { + DispatchQueue.main.async { self.phoneNowPlaying = nil } + return + } + DispatchQueue.main.async { + self.phoneNowPlaying = Self.parseNowPlaying(from: reply) + } + }, errorHandler: { _ in }) } func requestDownload(songId: String) { - session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: nil, errorHandler: nil) + session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: { _ in }, errorHandler: nil) } // MARK: - Direct API Access (watch can also talk to server directly) @@ -394,15 +464,12 @@ extension WatchSessionManager: WCSessionDelegate { if type == "nowPlaying" { DispatchQueue.main.async { - self.phoneNowPlaying = PhoneNowPlaying( - title: message["title"] as? String ?? "", - artist: message["artist"] as? String ?? "", - album: message["album"] as? String ?? "", - isPlaying: message["isPlaying"] as? Bool ?? false, - currentTime: message["currentTime"] as? TimeInterval ?? 0, - duration: message["duration"] as? TimeInterval ?? 0, - coverArtId: message["coverArtId"] as? String - ) + // If no title, iPhone stopped playing — clear remote state + guard let title = message["title"] as? String, !title.isEmpty else { + self.phoneNowPlaying = nil + return + } + self.phoneNowPlaying = Self.parseNowPlaying(from: message) } } else if type == "tagsUpdated" { handleTagsUpdated(message) diff --git a/watchOS/Views/WatchNowPlayingView.swift b/watchOS/Views/WatchNowPlayingView.swift index 353a435..7b8a699 100644 --- a/watchOS/Views/WatchNowPlayingView.swift +++ b/watchOS/Views/WatchNowPlayingView.swift @@ -6,15 +6,30 @@ struct WatchNowPlayingView: View { @State private var crownVolume: Double = 0.8 @State private var showRouteInfo = false + @State private var volumeDebounceTask: Task? @FocusState private var isCrownFocused: Bool var body: some View { - if let song = audioPlayer.currentSong { - localNowPlaying(song) - } else if let phone = watchManager.phoneNowPlaying { - remoteNowPlaying(phone) - } else { - emptyState + Group { + if let song = audioPlayer.currentSong { + localNowPlaying(song) + } else if let phone = watchManager.phoneNowPlaying { + remoteNowPlaying(phone) + } else { + emptyState + } + } + .onAppear { + // If nothing is playing locally, ask iPhone for its current state + if audioPlayer.currentSong == nil { + watchManager.requestNowPlaying() + } + } + .onChange(of: watchManager.isPhoneReachable) { _, reachable in + // iPhone reconnected — refresh state + if reachable && audioPlayer.currentSong == nil { + watchManager.requestNowPlaying() + } } } @@ -39,18 +54,24 @@ struct WatchNowPlayingView: View { } .padding(.top, 2) - // Progress bar + // Progress bar (tap to seek) GeometryReader { geo in ZStack(alignment: .leading) { Capsule() .fill(Color.white.opacity(0.2)) - .frame(height: 3) + .frame(height: 4) Capsule() .fill(Color.pink) - .frame(width: geo.size.width * audioPlayer.progressPercent, height: 3) + .frame(width: geo.size.width * audioPlayer.progressPercent, height: 4) + } + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .onTapGesture { location in + let pct = max(0, min(location.x / geo.size.width, 1)) + audioPlayer.seek(to: pct * audioPlayer.duration) } } - .frame(height: 3) + .frame(height: 20) .padding(.horizontal, 8) // Time labels @@ -115,24 +136,30 @@ struct WatchNowPlayingView: View { } .buttonStyle(.plain) - // Volume - HStack(spacing: 2) { - Image(systemName: "speaker.fill") - .font(.system(size: 7)) - .foregroundColor(.gray) - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(Color.white.opacity(0.15)).frame(height: 2) - Capsule().fill(Color.white.opacity(0.6)) - .frame(width: geo.size.width * CGFloat(audioPlayer.volume), height: 2) - } - } - .frame(height: 8) - Image(systemName: "speaker.wave.2.fill") - .font(.system(size: 7)) + Spacer() + + // Volume buttons + Button(action: { + let newVol = max(0, audioPlayer.volume - 0.1) + audioPlayer.setVolume(newVol) + crownVolume = Double(newVol) + }) { + Image(systemName: "speaker.minus.fill") + .font(.system(size: 12)) .foregroundColor(.gray) } - .frame(width: 60) + .buttonStyle(.plain) + + Button(action: { + let newVol = min(1, audioPlayer.volume + 0.1) + audioPlayer.setVolume(newVol) + crownVolume = Double(newVol) + }) { + Image(systemName: "speaker.plus.fill") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + .buttonStyle(.plain) } } .padding(.horizontal, 4) @@ -281,59 +308,199 @@ struct WatchNowPlayingView: View { // MARK: - Remote Control (iPhone is playing) private func remoteNowPlaying(_ nowPlaying: WatchSessionManager.PhoneNowPlaying) -> some View { - VStack(spacing: 8) { + VStack(spacing: 4) { + // "Playing on iPhone" badge HStack(spacing: 4) { Image(systemName: "iphone") - .font(.system(size: 10)) + .font(.system(size: 9)) .foregroundColor(.blue) - Text("Playing on iPhone") - .font(.system(size: 10)) + Text("iPhone") + .font(.system(size: 9, weight: .medium)) .foregroundColor(.blue) } + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Capsule().fill(Color.blue.opacity(0.15))) - VStack(spacing: 2) { - Text(nowPlaying.title) - .font(.system(size: 14, weight: .semibold)) - .lineLimit(1) + // Cover art + song info + HStack(spacing: 10) { + if let coverArtId = nowPlaying.coverArtId, + let url = watchManager.coverArtURL(id: coverArtId, size: 80) { + AsyncImage(url: url) { image in + image.resizable().aspectRatio(contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(0.1)) + .overlay( + Image(systemName: "music.note") + .font(.system(size: 14)) + .foregroundColor(.gray) + ) + } + .frame(width: 40, height: 40) + .cornerRadius(6) + } else { + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(0.1)) + .frame(width: 40, height: 40) + .overlay( + Image(systemName: "music.note") + .font(.system(size: 14)) + .foregroundColor(.gray) + ) + } - Text(nowPlaying.artist) - .font(.system(size: 11)) - .foregroundColor(.pink) - .lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + Text(nowPlaying.title) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + Text(nowPlaying.artist) + .font(.system(size: 11)) + .foregroundColor(.pink) + .lineLimit(1) + Text(nowPlaying.album) + .font(.system(size: 10)) + .foregroundColor(.gray) + .lineLimit(1) + } + + Spacer(minLength: 0) } + .padding(.horizontal, 4) + // Progress bar (tap to seek) GeometryReader { geo in let pct = nowPlaying.duration > 0 ? nowPlaying.currentTime / nowPlaying.duration : 0 ZStack(alignment: .leading) { - Capsule().fill(Color.white.opacity(0.2)).frame(height: 3) + Capsule().fill(Color.white.opacity(0.2)).frame(height: 4) Capsule().fill(Color.pink) - .frame(width: geo.size.width * pct, height: 3) + .frame(width: geo.size.width * pct, height: 4) + } + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .onTapGesture { location in + let seekPct = max(0, min(location.x / geo.size.width, 1)) + let seekTime = nowPlaying.duration * seekPct + watchManager.sendSeekCommand(to: seekTime) } } - .frame(height: 3) + .frame(height: 20) .padding(.horizontal, 8) - HStack(spacing: 20) { + // Time labels + HStack { + Text(formatTime(nowPlaying.currentTime)) + .font(.system(size: 9)) + .foregroundColor(.gray) + Spacer() + Text("-\(formatTime(max(0, nowPlaying.duration - nowPlaying.currentTime)))") + .font(.system(size: 9)) + .foregroundColor(.gray) + } + .padding(.horizontal, 8) + + // Transport controls + HStack(spacing: 0) { + Spacer() Button(action: { watchManager.sendPreviousCommand() }) { Image(systemName: "backward.fill") .font(.system(size: 16)) + .foregroundColor(.white) } .buttonStyle(.plain) + .frame(width: 40, height: 36) + + Spacer() Button(action: { watchManager.sendPlayCommand() }) { Image(systemName: nowPlaying.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 24)) + .foregroundColor(.white) } .buttonStyle(.plain) + .frame(width: 50, height: 44) + + Spacer() Button(action: { watchManager.sendNextCommand() }) { Image(systemName: "forward.fill") .font(.system(size: 16)) + .foregroundColor(.white) + } + .buttonStyle(.plain) + .frame(width: 40, height: 36) + + Spacer() + } + + // Bottom row: shuffle, repeat, volume + HStack(spacing: 14) { + Button(action: { watchManager.sendShuffleCommand() }) { + Image(systemName: "shuffle") + .font(.system(size: 11)) + .foregroundColor(nowPlaying.shuffleEnabled ? .pink : .gray) + } + .buttonStyle(.plain) + + Button(action: { watchManager.sendRepeatCommand() }) { + Image(systemName: nowPlaying.repeatMode == 2 ? "repeat.1" : "repeat") + .font(.system(size: 11)) + .foregroundColor(nowPlaying.repeatMode != 0 ? .pink : .gray) + } + .buttonStyle(.plain) + + Spacer() + + // Volume buttons + Button(action: { + crownVolume = max(0, crownVolume - 0.1) + watchManager.sendVolumeCommand(Float(crownVolume)) + }) { + Image(systemName: "speaker.minus.fill") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + .buttonStyle(.plain) + + Button(action: { + crownVolume = min(1, crownVolume + 0.1) + watchManager.sendVolumeCommand(Float(crownVolume)) + }) { + Image(systemName: "speaker.plus.fill") + .font(.system(size: 12)) + .foregroundColor(.gray) } .buttonStyle(.plain) } } - .padding() + .padding(.horizontal, 4) + .focused($isCrownFocused) + .digitalCrownRotation( + $crownVolume, + from: 0, through: 1, by: 0.05, + sensitivity: .medium, + isContinuous: false, + isHapticFeedbackEnabled: true + ) + .onChange(of: crownVolume) { _, newValue in + // Debounce: Crown fires rapidly — only send after 150ms of no changes + volumeDebounceTask?.cancel() + volumeDebounceTask = Task { + try? await Task.sleep(for: .milliseconds(150)) + guard !Task.isCancelled else { return } + watchManager.sendVolumeCommand(Float(newValue)) + } + } + .onAppear { + crownVolume = Double(nowPlaying.volume) + isCrownFocused = true + } + } + + private func formatTime(_ seconds: TimeInterval) -> String { + let m = Int(seconds) / 60 + let s = Int(seconds) % 60 + return String(format: "%d:%02d", m, s) } // MARK: - Empty State @@ -347,7 +514,19 @@ struct WatchNowPlayingView: View { .font(.caption) .foregroundColor(.gray) - // Show connect button if no Bluetooth + if watchManager.isPhoneReachable { + Text("Play music on your iPhone to control it here") + .font(.system(size: 10)) + .foregroundColor(.gray.opacity(0.7)) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } else { + Text("iPhone not connected") + .font(.system(size: 10)) + .foregroundColor(.orange.opacity(0.8)) + } + + // Show connect button if no Bluetooth (for local watch playback) if !audioPlayer.isBluetoothConnected { Button(action: { Task { await audioPlayer.activateSession() }