Changes
2 changed files (+115/-23)
-
-
@@ -21,32 +21,52 @@ @Observablefinal class ZoneDataModel { let conn: Communicatable public var zone: TransportService.Zone? = nil private var _zones: [TransportService.Zone] = [] private(set) var zones: [TransportService.Zone] { get { _zones } public var zone: TransportService.Zone? = nil { didSet { if !suppressSeekUpdates, let zone = zone { seek = Float(seeks[zone.id].flatMap({ $0 }) ?? 0) } } } set { _zones = newValue private(set) var zones: [TransportService.Zone] = [] { didSet { if let zoneID = zone?.id { for zone in _zones { for zone in zones { if zone.id == zoneID { self.zone = zone return } } } for zone in _zones { for zone in zones { self.zone = zone return } self.zone = nil zone = nil } } /// Whenever this property is on, `ZoneDataModel` skips updating `seek` property. /// Use this automatic changes to `seek` property is not desirable, e.g. user dragging a seekbar. public var suppressSeekUpdates: Bool = false private var seeks: [TransportService.Zone.ID: UInt64?] = [:] { didSet { guard let zone = zone else { return } if !suppressSeekUpdates { seek = Float(seeks[zone.id].flatMap({ $0 }) ?? 0) } } } public var seek: Float = 0.0 init(conn: Communicatable) { self.conn = conn }
-
@@ -59,6 +79,10 @@ let body = try TransportService.SubscribeZoneChangesResponse(msg)zones = body.zones for zone in zones { seeks[zone.id] = zone.nowPlaying?.seekPosition } for try await message in await conn.messages.compactMap({ TransportService.ZoneChangeEvent($0) }) {
-
@@ -73,6 +97,10 @@for removedID in message.removedZoneIDs { removeZone(zoneID: removedID) } for seekChange in message.seekChanges { seeks[seekChange.zoneID] = seekChange.seekPosition } } }
-
@@ -82,11 +110,15 @@ zones[index] = zone} else { zones.append(zone) } seeks[zone.id] = zone.nowPlaying?.seekPosition } private func removeZone(zoneID: TransportService.Zone.ID) { if let index = zones.firstIndex(where: { z in z.id == zoneID }) { zones.remove(at: index) } seeks[zoneID] = nil } }
-
-
-
@@ -28,6 +28,8 @@ model.zone} var body: some View { @Bindable var model = model VStack(alignment: .leading, spacing: 14) { HStack(spacing: 8) { if let imageKey = zone?.nowPlaying?.imageKey {
-
@@ -50,7 +52,7 @@ }.frame(maxWidth: .infinity, alignment: .leading) } HStack { HStack(spacing: 20) { HStack { Button { Task {
-
@@ -101,10 +103,60 @@ }.labelStyle(.iconOnly) .buttonStyle(.borderless) Spacer() if let length = model.zone?.nowPlaying?.length, length > 0, let zone = zone { // Adding step parameter results in messy and broken-look UI on macOS. // The tick marks might work for less ticks (up to 10,) but it's completely // broken on this kind of usage; they are too dense to the point it renders // like visual glitch, and some ticks are skipped. I'd call it broken. // Fortunately, in this usecase, eliminating `step: 1.0` is acceptable because // we're casting to `Int64` anyway. So, even when SwiftUI uses steps less than // 1, fractional part will be ignored and end experience will be similar to // `step: 1.0`. Slider( value: $model.seek, in: 0.0...Float(length), onEditingChanged: { editing in if editing { model.suppressSeekUpdates = true } else { Task { do { let msg = try await model.conn.request( try Moo( seek: .init(zoneID: zone.id, seconds: Int64(model.seek)) ), timeout: .seconds(2) ) _ = try TransportService.SeekResponse(msg) } catch { Logger().warning("Failed to seek: \(error)") } // If we resume accepting seek change events before seek request, // there is a chance incoming seek change "resets" seekbar position // then seek request updates seek then an event after that moves // seekbar position. From user's perspective, this is "seekbar // jumps to previous position then jumps again to the dragged // position". That's not good UX. model.suppressSeekUpdates = false } } } ) { // Label for screen readers. Text("Playback position") } .labelsHidden() .disabled(!(zone.isSeekAllowed ?? false)) .frame(maxWidth: .infinity) } else { Spacer() } if model.zones.count > 0 && zone != nil { @Bindable var model = model Picker("Zone", selection: $model.zone) { ForEach(model.zones) { (zone: TransportService.Zone) in Text(zone.displayName).tag(zone)
-
@@ -143,27 +195,28 @@// MARK: - Previews private actor MockServer: Communicatable { var position: Int64 = 0 var length: Int64 = 120 var messages: AsyncStream<Moo> { AsyncStream { stream in Task { do { var i: Int = 0 while true { try Task.checkCancellation() try await Task.sleep(for: .seconds(1)) i += 1 if i > 60 { i = 0 position += 1 if position > length { position = 0 } let body = """ { "zones_seek_changed": [ { "zone_id": "foo", "seek_position": \(String(i, radix: 10)) "zone_id": "0-foo", "seek_position": \(String(position, radix: 10)) } ] }
-
@@ -200,6 +253,7 @@ }fileprivate enum MockRequestError: Error { case unsupportedMessage case invalidRequest } private func request(
-
@@ -236,8 +290,8 @@ }} ], "now_playing": { "seek_position": 89, "length": 120, "seek_position": \(String(position, radix: 10)), "length": \(String(length, radix: 10)), "one_line": { "line1": "A song" },
-
@@ -276,6 +330,12 @@ "Content-Length": String(body.utf8.count, radix: 10),], body: body.data(using: .utf8)! ) case "\(TransportService.id)/seek": let res = try msg.json(type: TransportService.SeekRequest.self) position = res.seconds return Moo(verb: "COMPLETE", service: "Success") default: throw MockRequestError.unsupportedMessage }
-