Changes
3 changed files (+429/-313)
-
-
@@ -436,88 +436,6 @@ }} } }, "PlaybackBar.OutputPanel.Close" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Close" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } } } }, "PlaybackBar.OutputPanel.Label" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Sound Outputs" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "出力一覧" } } } }, "PlaybackBar.OutputPanel.NoVolumeControl" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "This output does not support volume control." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "この出力機器は Roon 上からの音量制御ができません。" } } } }, "PlaybackBar.OutputPanel.StepVolumeLabel" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Volume Control" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "音量" } } } }, "PlaybackBar.OutputPanel.VolumeSlider" : { "comment" : "A label for volume slider.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Volume slider" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "音量調整" } } } }, "PlaybackBar.Pause" : { "comment" : "A label for a pause button.", "localizations" : {
-
@@ -586,35 +504,36 @@ }} } }, "PlaybackBar.ZonePicker.Label" : { "comment" : "A label for zone picker.", "PlaybackBar.ZonePanel.Close" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Zone" "state" : "new", "value" : "Done" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ゾーン" "value" : "閉じる" } } } }, "PlaybackBar.ZonePicker.Title" : { "PlaybackBar.ZonePanel.Label" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Zones" "state" : "new", "value" : "Zone" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ゾーン一覧" "value" : "ゾーン" } } }
-
@@ -862,6 +781,75 @@ "ja" : {"stringUnit" : { "state" : "translated", "value" : "バージョン" } } } }, "ZoneOptionsPanel.NoVolumeControl" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "This output does not support volume control." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "この出力機器は Roon 上からの音量制御ができません。" } } } }, "ZoneOptionsPanel.StepVolumeLabel" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Volume Control" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "音量調整" } } } }, "ZoneOptionsPanel.VolumeSlider" : { "comment" : "A label for volume slider.", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Volume" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "音量" } } } }, "ZoneOptionsPanel.ZonePicker" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Zone" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "ゾーン" } } }
-
-
-
@@ -22,7 +22,6 @@ struct PlaybackBar: View {@Environment(ZoneDataModel.self) var model: ZoneDataModel @State private var isPerformingAction = false @State private var isZonePickerVisible = false @State private var isOutputPanelVisible = false private var zone: TransportService.Zone? {
-
@@ -205,97 +204,39 @@ } else {Spacer() } HStack { if model.zones.count > 0 && zone != nil { Button { isZonePickerVisible = true } label: { Label { Text( String( localized: "PlaybackBar.ZonePicker.Label", defaultValue: "Zone", comment: "A label for zone picker." ) ) } icon: { Image(systemName: "square.2.layers.3d.top.filled") } .labelStyle(.iconOnly) } .help(Text("PlaybackBar.ZonePicker.Label")) .popover(isPresented: $isZonePickerVisible) { List(selection: $model.zone) { Section { ForEach(model.zones) { (zone: TransportService.Zone) in Text(zone.displayName).tag(zone) } } header: { Text( String( localized: "PlaybackBar.ZonePicker.Title", defaultValue: "Zones" ) ) } } } .scaledToFit() Button { isOutputPanelVisible = true } label: { Label { Text( String( localized: "PlaybackBar.ZonePanel.Label", defaultValue: "Zone" ) ) } icon: { Image(systemName: "hifispeaker") } if let zone = zone { Button { isOutputPanelVisible = true } label: { Label { Text( String( localized: "PlaybackBar.OutputPanel.Label", defaultValue: "Sound Outputs" ) ) } icon: { Image(systemName: "hifispeaker") } .labelStyle(.iconOnly) } .help(Text("PlaybackBar.OutputPanel.Label")) // iPadOS has a long standing but that it can't determine // `popover` size based on its content, resulting in rendering // at minimum size. On the other hand, macOS has a same problem // but for `sheet`... so we have to use `popover` on macOS and // `sheet` on others. #if os(macOS) .popover(isPresented: $isOutputPanelVisible) { List { ForEach(zone.outputs) { output in OutputRow(output: output, model: model) } } .listStyle(.sidebar) } #else .sheet(isPresented: $isOutputPanelVisible) { VStack { List { ForEach(zone.outputs) { output in OutputRow(output: output, model: model) } Button { isOutputPanelVisible = false } label: { Text( String( localized: "PlaybackBar.OutputPanel.Close", defaultValue: "Close" ) .labelStyle(.iconOnly) } .help(Text("PlaybackBar.ZonePanel.Label")) .sheet(isPresented: $isOutputPanelVisible) { NavigationStack { ZoneOptionsPanel(model: model) .toolbar { ToolbarItem(placement: .confirmationAction) { Button { isOutputPanelVisible = false } label: { Text( String( localized: "PlaybackBar.ZonePanel.Close", defaultValue: "Done" ) } ) } } } #endif } } }
-
@@ -323,141 +264,6 @@ Logger().warning("Failed to send control message: \(error)")} isPerformingAction = false } } struct OutputRow: View { private let logger = Logger() private let output: TransportService.Output private let model: ZoneDataModel @State private var volume: Float64 var body: some View { Section { switch output.volume?.type { case .some("incremental"): Stepper( label: { Text( String( localized: "PlaybackBar.OutputPanel.StepVolumeLabel", defaultValue: "Volume Control" ) ) }, onIncrement: { Task { do { let res = try await model.conn.request( .init( changeVolume: .init( outputID: output.id, value: 1.0, mode: .relative ) ) ) _ = try TransportService.ChangeVolumeResponse(res) } catch { logger.warning("Failed to increment volume: \(error)") } } }, onDecrement: { Task { do { let res = try await model.conn.request( .init( changeVolume: .init( outputID: output.id, value: -1.0, mode: .relative ) ) ) _ = try TransportService.ChangeVolumeResponse(res) } catch { logger.warning("Failed to decrement volume: \(error)") } } } ) case .none: Text( String( localized: "PlaybackBar.OutputPanel.NoVolumeControl", defaultValue: "This output does not support volume control." ) ) case .some(let type): let min = output.volume?.min ?? 0 let max = output.volume?.max ?? 100 let format = { (value: Float64) in switch type { case "db": String(format: "%1.fdB", value) default: String(format: "%.f%%", ((value - min) / max) * 100) } } VStack { Slider( value: $volume, in: .init(uncheckedBounds: (min, max)), ) { Text( String( localized: "PlaybackBar.OutputPanel.VolumeSlider", defaultValue: "Volume slider", comment: "A label for volume slider." ) ) } minimumValueLabel: { Text(format(min)) } maximumValueLabel: { Text(format(max)) } onEditingChanged: { editing in // Issue API request only when user stopped editing (e.g., lift a finger) guard editing == false else { return } Task { do { let res = try await model.conn.request( .init( changeVolume: .init( outputID: output.id, value: volume, mode: .absolute ) ) ) _ = try TransportService.ChangeVolumeResponse(res) } catch { logger.warning("Failed to change volume: \(error)") } } } .labelsHidden() Text(format(volume)) } } } header: { Text(output.displayName) } } init(output: TransportService.Output, model: ZoneDataModel) { self.model = model self.output = output self.volume = output.volume?.value ?? 0 } }
-
-
-
@@ -0,0 +1,322 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 import OSLog import RoonKit import SwiftUI struct ZoneOptionsPanel: View { private var model: ZoneDataModel init(model: ZoneDataModel) { self.model = model } var body: some View { @Bindable var model = model Form { Picker(selection: $model.zone) { ForEach(model.zones) { (zone: TransportService.Zone) in Text(zone.displayName).tag(zone) } } label: { Text( String( localized: "ZoneOptionsPanel.ZonePicker", defaultValue: "Zone" ) ) } if let zone = model.zone { ForEach(zone.outputs) { output in Section { OutputRow(output: output, model: model) } header: { Text(output.displayName) } } } } .formStyle(.grouped) } } private struct OutputRow: View { private let logger = Logger() private let output: TransportService.Output private let model: ZoneDataModel @State private var volume: Float64 var body: some View { switch output.volume?.type { case .some("incremental"): Stepper( label: { Text( String( localized: "ZoneOptionsPanel.StepVolumeLabel", defaultValue: "Volume Control" ) ) }, onIncrement: { Task { do { let res = try await model.conn.request( .init( changeVolume: .init( outputID: output.id, value: 1.0, mode: .relative ) ) ) _ = try TransportService.ChangeVolumeResponse(res) } catch { logger.warning("Failed to increment volume: \(error)") } } }, onDecrement: { Task { do { let res = try await model.conn.request( .init( changeVolume: .init( outputID: output.id, value: -1.0, mode: .relative ) ) ) _ = try TransportService.ChangeVolumeResponse(res) } catch { logger.warning("Failed to decrement volume: \(error)") } } } ) case .none: Text( String( localized: "ZoneOptionsPanel.NoVolumeControl", defaultValue: "This output does not support volume control." ) ) case .some(let type): let min = output.volume?.min ?? 0 let max = output.volume?.max ?? 100 let format = { (value: Float64) in switch type { case "db": String(format: "%1.fdB", value) default: String(format: "%.f%%", ((value - min) / max) * 100) } } VStack { Slider( value: $volume, in: .init(uncheckedBounds: (min, max)), ) { Text( String( localized: "ZoneOptionsPanel.VolumeSlider", defaultValue: "Volume", comment: "A label for volume slider." ) ) } minimumValueLabel: { Text(format(min)) } maximumValueLabel: { Text(format(max)) } onEditingChanged: { editing in // Issue API request only when user stopped editing (e.g., lift a finger) guard editing == false else { return } Task { do { let res = try await model.conn.request( .init( changeVolume: .init( outputID: output.id, value: volume, mode: .absolute ) ) ) _ = try TransportService.ChangeVolumeResponse(res) } catch { logger.warning("Failed to change volume: \(error)") } } } Text(format(volume)) } } } init(output: TransportService.Output, model: ZoneDataModel) { self.model = model self.output = output self.volume = output.volume?.value ?? 0 } } // MARK: - Previews private actor MockServer: Communicatable { var messages: AsyncStream<Moo> { AsyncStream { stream in } } func request(_ msg: consuming Moo) async throws -> Moo { try await request(msg: msg, timeout: nil) } func request(_ msg: consuming Moo, timeout: ContinuousClock.Instant.Duration) async throws -> Moo { try await request(msg: msg, timeout: timeout) } fileprivate enum MockRequestError: Error { case unsupportedMessage case invalidRequest } private func request( msg: Moo, timeout: ContinuousClock.Instant.Duration? = nil ) async throws -> Moo { switch msg.service { case "\(TransportService.id)/subscribe_zones": let body = """ { "zones": [ { "zone_id": "0-foo", "display_name": "Foo", "outputs": [ { "output_id": "foo-out1", "display_name": "Foo Output #1", "volume": { "type": "number", "min": 0, "max": 100, "step": 1, "value": 50, "is_muted": false } }, { "output_id": "foo-out2", "display_name": "Foo Output #2", "volume": { "type": "incremental" } }, { "output_id": "foo-out3", "display_name": "Foo Output #3", "volume": { "type": "db", "min": 0, "max": 85, "step": 0.1, "value": 21, "is_muted": false } } ], "now_playing": { "seek_position": 0, "length": 10, "one_line": { "line1": "A song" }, "two_line": { "line1": "A song", "line2": "That's it" }, "three_line": { "line1": "A song", "line2": "Really, that's it" } }, "state": "playing", "is_previous_allowed": true, "is_next_allowed": false, "is_pause_allowed": true, "is_play_allowed": true, "is_seek_allowed": true }, { "zone_id": "1-bar", "display_name": "Bar", "outputs": [], "state": "stopped" } ] } """ return Moo( verb: "COMPLETE", service: "Subscribed", headers: [ "Content-Type": "application/json", "Content-Length": String(body.utf8.count, radix: 10), ], body: body.data(using: .utf8)! ) case "\(TransportService.id)/change_volume": let res = try msg.json(type: TransportService.ChangeVolumeRequest.self) print( "Got change_volume request for \(res.outputID), with \(res.value) in \(res.mode)" ) return Moo(verb: "COMPLETE", service: "Success") default: throw MockRequestError.unsupportedMessage } } func send(_ msg: consuming Moo) async throws { // No-op } } #Preview { let model = ZoneDataModel(conn: MockServer()) Task { try? await model.watchChanges() } return ZoneOptionsPanel(model: model) }
-