-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
-
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
-
36
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
-
47
-
48
-
49
-
50
-
51
-
52
-
53
-
54
-
55
-
56
-
57
-
58
-
59
-
60
-
61
-
62
-
63
-
64
-
65
-
66
-
67
-
68
-
69
-
70
-
71
-
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
-
81
-
82
-
83
-
84
-
85
-
86
-
87
-
88
-
89
-
90
-
91
-
92
-
93
-
94
-
95
-
96
-
97
-
98
-
99
-
100
-
101
-
102
-
103
-
104
-
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
-
114
-
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
-
126
-
127
-
128
-
129
-
130
-
131
-
132
-
133
-
134
-
135
-
136
-
137
-
138
-
139
-
140
-
141
-
142
-
143
-
144
-
145
// 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 PlaybackBar: View {
@Environment(ZoneDataModel.self) var model: ZoneDataModel
@State private var isPerformingAction = false
private var zone: TransportService.Zone? {
model.zone
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 8) {
if let imageKey = zone?.nowPlaying?.imageKey {
Artwork(imageKey: imageKey, width: 96, height: 96)
.frame(width: 48, height: 48)
}
VStack(alignment: .leading, spacing: 2) {
let line1 = zone?.nowPlaying?.doubleLine.line1 ?? " "
let line2 = zone?.nowPlaying?.doubleLine.line2 ?? " "
Text(line1)
.font(.headline)
.lineLimit(1)
Text(line2)
.font(.subheadline)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack {
HStack {
Button {
Task {
await performAction(.previous)
}
} label: {
Label("Previous", systemImage: "backward.end.fill")
}
.font(.title3)
.disabled(isPerformingAction)
.disabled(!(zone?.isPreviousAllowed ?? false))
if zone?.state == .playing {
Button {
Task {
await performAction(.pause)
}
} label: {
Label("Pause", systemImage: "pause.fill")
}
.font(.title)
.disabled(isPerformingAction)
.disabled(!(zone?.isPauseAllowed ?? false))
} else {
Button {
Task {
await performAction(.play)
}
} label: {
Label("Play", systemImage: "play.fill")
}
.font(.title)
.disabled(isPerformingAction)
.disabled(!(zone?.isPlayAllowed ?? false))
}
Button {
Task {
await performAction(.next)
}
} label: {
Label("Next", systemImage: "forward.end.fill")
}
.font(.title3)
.disabled(isPerformingAction)
.disabled(!(zone?.isNextAllowed ?? false))
}
.labelStyle(.iconOnly)
.buttonStyle(.borderless)
Spacer()
if model.zones.count > 0 && zone != nil {
@Bindable var model = model
Picker("Zone", selection: $model.zone) {
ForEach(
model.zones.values.sorted(by: {
$0.id < $1.id
})
) { (zone: TransportService.Zone) in
Text(zone.displayName).tag(zone)
}
}
.scaledToFit()
}
}
}
.padding([.horizontal], 12)
.padding([.vertical], 10)
}
private func performAction(_ action: TransportService.ControlAction) async {
guard let zone = zone else {
return
}
isPerformingAction = true
do {
let msg = try await model.conn.request(
try Moo(control: .init(zoneID: zone.id, control: action)),
timeout: .seconds(5)
)
_ = try TransportService.ControlResponse(msg)
} catch {
Logger().warning("Failed to send control message: \(error)")
}
isPerformingAction = false
}
}