Changes
3 changed files (+268/-5)
-
-
@@ -14,6 +14,9 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 import Foundation import MediaPlayer import OSLog import RoonKit import SwiftUI
-
@@ -23,6 +26,27 @@ final class ZoneDataModel {@ObservationIgnored private let browsing: BrowsingDataModel? @ObservationIgnored private let imageURLBuilder: ImageURLBuilderDataModel? // Now playing feature is not very suited for apps that work as a frontend for // network audio. iOS/iPadOS aggressively suspends non foreground apps, that // closes network connection, hence playback status no longer receives updates // and playback controls would be ignored or errored out. As Roon client requires // an alive connection whole time, "wake the app on incoming packet" technique // won't work (OS will shutdown the connection, I guess.) Missing feature is better // than fundamentally broken feature. #if os(macOS) @ObservationIgnored private let commandCenter = MPRemoteCommandCenter.shared() @ObservationIgnored private var playHandler: Any? = nil @ObservationIgnored private var pauseHandler: Any? = nil @ObservationIgnored private var prevHandler: Any? = nil @ObservationIgnored private var nextHandler: Any? = nil @ObservationIgnored private var seekHandler: Any? = nil #endif let conn: Communicatable public var zone: TransportService.Zone? = nil {
-
@@ -34,6 +58,8 @@if let browsing = browsing { browsing.zone = zone } updateNowPlaying() } }
-
@@ -75,9 +101,163 @@ }public var seek: Float = 0.0 init(conn: Communicatable, browsing: BrowsingDataModel? = nil) { init( conn: Communicatable, browsing: BrowsingDataModel? = nil, imageURLBuilder: ImageURLBuilderDataModel? = nil ) { self.conn = conn self.browsing = browsing self.imageURLBuilder = imageURLBuilder #if os(macOS) // MPRemoteCommandCenter does not support async functions. As there seems no way // to safely wait for async task, we simply returns success even if a request // failed. self.playHandler = commandCenter.playCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } Task { do { let res = try await self.conn.request( .init(control: .init(zoneID: zone.id, control: .play)) ) _ = try TransportService.ControlResponse.init(res) } catch { Logger().warning("Failed to send play request: \(error)") } } return .success } self.pauseHandler = commandCenter.pauseCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } Task { do { let res = try await self.conn.request( .init(control: .init(zoneID: zone.id, control: .pause)) ) _ = try TransportService.ControlResponse.init(res) } catch { Logger().warning("Failed to send pause request: \(error)") } } return .success } self.prevHandler = commandCenter.previousTrackCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } Task { do { let res = try await self.conn.request( .init(control: .init(zoneID: zone.id, control: .previous)) ) _ = try TransportService.ControlResponse.init(res) } catch { Logger().warning("Failed to send prev track request: \(error)") } } return .success } self.nextHandler = commandCenter.nextTrackCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } Task { do { let res = try await self.conn.request( .init(control: .init(zoneID: zone.id, control: .next)) ) _ = try TransportService.ControlResponse.init(res) } catch { Logger().warning("Failed to send next track request: \(error)") } } return .success } self.seekHandler = commandCenter.changePlaybackPositionCommand.addTarget { [unowned self] event in guard let zone = self.zone else { return .deviceNotFound } guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } Task { do { let res = try await self.conn.request( .init( seek: .init( zoneID: zone.id, seconds: Int64(event.positionTime), mode: .absolute ) ) ) _ = try TransportService.SeekResponse.init(res) } catch { Logger().warning("Failed to send seek request: \(error)") } } return .success } commandCenter.bookmarkCommand.isEnabled = false commandCenter.changePlaybackRateCommand.isEnabled = false commandCenter.changeRepeatModeCommand.isEnabled = false commandCenter.changeShuffleModeCommand.isEnabled = false commandCenter.disableLanguageOptionCommand.isEnabled = false commandCenter.dislikeCommand.isEnabled = false commandCenter.enableLanguageOptionCommand.isEnabled = false commandCenter.likeCommand.isEnabled = false commandCenter.ratingCommand.isEnabled = false #endif } deinit { #if os(macOS) if let playHandler = playHandler { commandCenter.playCommand.removeTarget(playHandler) } if let pauseHandler = pauseHandler { commandCenter.pauseCommand.removeTarget(pauseHandler) } if let prevHandler = prevHandler { commandCenter.previousTrackCommand.removeTarget(prevHandler) } if let nextHandler = nextHandler { commandCenter.nextTrackCommand.removeTarget(nextHandler) } if let seekHandler = seekHandler { commandCenter.changePlaybackPositionCommand.removeTarget(seekHandler) } #endif } func watchChanges() async throws {
-
@@ -92,6 +272,8 @@ for zone in zones {seeks[zone.id] = zone.nowPlaying?.seekPosition } updateNowPlaying() for try await message in await conn.messages.compactMap({ TransportService.ZoneChangeEvent($0) }) {
-
@@ -110,7 +292,82 @@for seekChange in message.seekChanges { seeks[seekChange.zoneID] = seekChange.seekPosition } updateNowPlaying() } } private func updateNowPlaying() { #if os(macOS) let center = MPNowPlayingInfoCenter.default() var nowPlayingInfo = [String: Any]() switch self.zone?.state { case .playing: center.playbackState = .playing case .paused: center.playbackState = .paused default: center.playbackState = .stopped } commandCenter.pauseCommand.isEnabled = zone?.isPauseAllowed ?? false commandCenter.playCommand.isEnabled = zone?.isPauseAllowed ?? false commandCenter.previousTrackCommand.isEnabled = zone?.isPreviousAllowed ?? false commandCenter.nextTrackCommand.isEnabled = zone?.isNextAllowed ?? false commandCenter.changePlaybackPositionCommand.isEnabled = zone?.isSeekAllowed ?? false defer { center.nowPlayingInfo = nowPlayingInfo } guard let zone = self.zone, let nowPlaying = zone.nowPlaying else { return } if let secondLine = nowPlaying.doubleLine.line2 { nowPlayingInfo[MPMediaItemPropertyArtist] = secondLine } if let artwork = nowPlaying.imageKey, let imageURLBuilder = self.imageURLBuilder { let image = MPMediaItemArtwork( boundsSize: .init(width: 256, height: 256), requestHandler: { size in let url = imageURLBuilder.build( req: ImageService.GetRequest.init( key: artwork, format: .png, scale: .fit, width: UInt(size.width), height: UInt(size.height) ) ) guard let url = url, let data = try? Data(contentsOf: url), let image = NSImage(data: data) else { return NSImage(size: size) } return image } ) nowPlayingInfo[MPMediaItemPropertyArtwork] = image } nowPlayingInfo[MPMediaItemPropertyMediaType] = MPMediaType.music.rawValue if let duration = nowPlaying.length { nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration } nowPlayingInfo[MPMediaItemPropertyTitle] = nowPlaying.doubleLine.line1 if let seek = seeks[zone.id] ?? nowPlaying.seekPosition { nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = seek } #endif } private func putZone(zone: TransportService.Zone) {
-
-
-
@@ -21,6 +21,8 @@struct ConnectedScreen: View { private let logger = Logger() @Environment(ImageURLBuilderDataModel.self) var builder @State private var model: ZoneDataModel @State private var browsing: BrowsingDataModel
-
@@ -35,11 +37,15 @@ BrowseService.Hierarchy.settings,BrowseService.Hierarchy.internetRadio, ] init(conn: Communicatable & Connectable) { init(conn: Communicatable & Connectable, host: String, port: UInt16) { let browsing = BrowsingDataModel(conn: conn) self.browsing = browsing model = ZoneDataModel(conn: conn, browsing: browsing) model = ZoneDataModel( conn: conn, browsing: browsing, imageURLBuilder: ImageURLBuilderDataModel(host: host, port: port) ) } private let actionQueue = DispatchQueue(
-
@@ -460,5 +466,5 @@ }} #Preview { ConnectedScreen(conn: MockServer()) ConnectedScreen(conn: MockServer(), host: "localhost", port: 8080) }
-
-
-
@@ -43,7 +43,7 @@ Loading(onCancel: {onCancel() }) case .connected(let conn): ConnectedScreen(conn: conn) ConnectedScreen(conn: conn, host: self.conn.host, port: self.conn.port) .environment( ImageURLBuilderDataModel(host: self.conn.host, port: self.conn.port) )
-