Changes
2 changed files (+320/-22)
-
-
@@ -436,6 +436,92 @@ }} } }, "PlaybackBar.OutputPanel.Close" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Close" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "閉じる" } } } }, "PlaybackBar.OutputPanel.Label" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Sound Outputs" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "出力一覧" } } } }, "PlaybackBar.OutputPanel.NoVolumeControl" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "This output does not support volume control." } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "この出力機器は Roon 上からの音量制御ができません。" } } } }, "PlaybackBar.OutputPanel.StepVolumeLabel" : { "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Volume Control" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "音量" } } } }, "PlaybackBar.OutputPanel.VolumeSlider" : { "comment" : "A label for volume slider.", "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Volume slider" } }, "ja" : { "stringUnit" : { "state" : "translated", "value" : "音量調整" } } } }, "PlaybackBar.Pause" : { "comment" : "A label for a pause button.", "localizations" : {
-
-
-
@@ -23,6 +23,7 @@ @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? { model.zone
-
@@ -180,33 +181,90 @@ } else {Spacer() } if model.zones.count > 0 && zone != nil { Button { isZonePickerVisible = true } label: { Label { Text( "PlaybackBar.ZonePicker.Label", comment: "A label for zone picker." ) } icon: { Image(systemName: "square.2.layers.3d.top.filled") HStack { if model.zones.count > 0 && zone != nil { Button { isZonePickerVisible = true } label: { Label { Text( "PlaybackBar.ZonePicker.Label", comment: "A label for zone picker." ) } icon: { Image(systemName: "square.2.layers.3d.top.filled") } .labelStyle(.iconOnly) } .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("PlaybackBar.ZonePicker.Title") } } } .scaledToFit() } .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) 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" ) ) } } } } header: { Text("PlaybackBar.ZonePicker.Title") } } #endif } .scaledToFit() } } }
-
@@ -236,6 +294,141 @@ 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 } } // MARK: - Previews private actor MockServer: Communicatable {
-
@@ -331,6 +524,18 @@ "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": {
-
@@ -378,6 +583,13 @@ case "\(TransportService.id)/seek":let res = try msg.json(type: TransportService.SeekRequest.self) position = res.seconds return Moo(verb: "COMPLETE", service: "Success") 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:
-