oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1@_spi(Fixtures) import CallsKit
2import SwiftUI
3
4/// What the controls sheet was closed in order to open.
5enum MoreAction { case devices, chat, report }
6
7/// The full-screen call: the picture under the status bar and the home
8/// indicator, both of which go with the controls. A tap shows the
9/// controls and another hides them; they go on their own 3 s after the
10/// last touch (see ControlsVisibility for why that long, and what keeps
11/// them up). The bar is a controls menu on the left and End call on the
12/// right; everything else is in the menu.
13extension PhoneCall {
14 /// The round buttons' size, and the bar's height with its margin.
15 static let button: CGFloat = 56
16 static let barHeight: CGFloat = 56 + 16
17 static let tileBackdrop = Color(white: 0.13)
18
19 var fullScreen: some View {
20 GeometryReader { geometry in
21 let safe = geometry.safeAreaInsets
22 let size = CGSize(
23 width: geometry.size.width + safe.leading + safe.trailing,
24 height: geometry.size.height + safe.top + safe.bottom)
25 ZStack {
26 Color.black
27 stage(in: size, safe: safe)
28 .contentShape(Rectangle())
29 .onTapGesture { chrome.tapped() }
30 .accessibilityAddTraits(.allowsDirectInteraction)
31 if !grid, selfFloats { selfView(in: size, safe: safe) }
32 topOverlay(safe: safe)
33 // Gone rather than transparent while hidden, so nothing invisible
34 // can be tapped or found; the fade is the transition.
35 if chrome.visible { bar(safe: safe).transition(.opacity) }
36 // Space on a hardware keyboard, as the classic bar's Mute has it;
37 // the full-screen Mute is in the menu, so this stands in for it.
38 Button("Mute or unmute microphone") { call.mute() }
39 .keyboardShortcut(.space, modifiers: [])
40 .opacity(0).frame(width: 0, height: 0).accessibilityHidden(true)
41 }
42 .frame(width: size.width, height: size.height)
43 .ignoresSafeArea()
44 }
45 .toolbar(.hidden, for: .navigationBar)
46 .statusBarHidden(!chrome.visible)
47 .persistentSystemOverlays(chrome.visible ? .automatic : .hidden)
48 .animation(fade, value: chrome.visible)
49 .onAppear { updateHolds() }
50 .onChange(of: showingMore) { updateHolds() }
51 .onChange(of: showingDevices) { updateHolds() }
52 .onChange(of: showingChat) { updateHolds() }
53 .onChange(of: voiceOver) { updateHolds() }
54 .sheet(
55 isPresented: $showingMore,
56 onDismiss: {
57 switch afterMore {
58 case .devices?: showingDevices = true
59 case .chat?:
60 readChatCount = call.receivedChatCount
61 showingChat = true
62 case .report?: call.reportProblem()
63 case nil: break
64 }
65 afterMore = nil
66 }
67 ) {
68 MoreControls(
69 call: call, arrangement: arrangement, title: itemTitle, unread: unread,
70 open: { action in
71 afterMore = action
72 showingMore = false
73 },
74 close: { showingMore = false }
75 )
76 .presentationDetents([.medium, .large])
77 }
78 }
79
80 private var fade: Animation {
81 ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion)
82 }
83
84 private func updateHolds() {
85 chrome.set(.menuOpen, showingMore || showingDevices || showingChat)
86 chrome.set(.voiceOver, voiceOver)
87 }
88
89 // MARK: Stage
90
91 @ViewBuilder private func stage(in size: CGSize, safe: EdgeInsets) -> some View {
92 Group {
93 if let fixtureBackdrop {
94 fixtureBackdrop.view.accessibilityHidden(true).accessibilityIdentifier("callStage")
95 } else if grid {
96 grid(in: size, safe: safe).accessibilityIdentifier("callStage")
97 } else {
98 speakerStage(in: size, safe: safe)
99 }
100 }
101 .coordinateSpace(name: arrangeSpace)
102 .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
103 }
104
105 /// Speaker view: the stage fills the whole screen, edge to edge, and
106 /// everyone else is a filmstrip floating over its bottom that scrolls
107 /// sideways, in this viewer's order (2026-10-05: until then the stage
108 /// stopped above the filmstrip, a black band behind it). The filmstrip
109 /// sits just above the home indicator and rises clear of the bar while
110 /// the controls show, as the self view does; the stage's name rides just
111 /// above it (StageLabel). In a call of two there is no filmstrip: the
112 /// other person fills the screen and you float.
113 private func speakerStage(in size: CGSize, safe: EdgeInsets) -> some View {
114 let strip = selfFloats ? [] : filmstrip
115 let thumb = Self.filmstripTile(in: size)
116 let band = strip.isEmpty ? 0 : thumb.height + 2 * Self.filmstripPadding
117 let raised = chrome.visible ? Self.barHeight : 0
118 let lift = EdgeInsets(
119 top: 0, leading: safe.leading,
120 bottom: safe.bottom + StageLabel.lift(bar: raised, band: band),
121 trailing: 0)
122 return ZStack(alignment: .bottom) {
123 Group {
124 if let focused {
125 phoneTile(focused, cornerRadius: 0, compact: true, labelInsets: lift, onStage: true)
126 } else {
127 ContentUnavailableView(
128 "Waiting for others", systemImage: "person.2",
129 description: Text("Others will appear here when they join.")
130 )
131 .foregroundStyle(.white.opacity(0.85))
132 .environment(\.colorScheme, .dark)
133 .frame(maxWidth: .infinity, maxHeight: .infinity)
134 }
135 }
136 // The stage, and not the filmstrip, is what the UI tests tap to
137 // bring the controls back.
138 .accessibilityIdentifier("callStage")
139 .frame(maxWidth: .infinity, maxHeight: .infinity)
140 // The pin, over the stage's top corner below the title; outside
141 // the stage's own accessibility identifier, so it keeps its own.
142 if let focused, arrangement.pinned == focused {
143 PinBadge(title: itemTitle(focused)) { arrangement.pin(nil) }
144 .padding(.top, max(safe.top, 12) + 60).padding(.trailing, safe.trailing + 12)
145 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
146 }
147 if !strip.isEmpty {
148 ScrollView(.horizontal) {
149 HStack(spacing: 6) {
150 ForEach(strip, id: \.self) { item in
151 phoneTile(item, cornerRadius: 10, compact: true, backdrop: Self.tileBackdrop, iconOnly: true)
152 .frame(width: thumb.width, height: thumb.height)
153 .modifier(arrangeable(item, tapToPin: true))
154 }
155 }
156 .padding(.horizontal, 10).padding(.vertical, Self.filmstripPadding)
157 .frame(minWidth: size.width - safe.leading - safe.trailing)
158 }
159 .scrollIndicators(.never)
160 .accessibilityElement(children: .contain)
161 .accessibilityLabel("Filmstrip")
162 .accessibilityIdentifier("filmstrip")
163 .padding(.leading, safe.leading).padding(.trailing, safe.trailing)
164 .padding(.bottom, safe.bottom + raised)
165 .animation(ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion), value: chrome.visible)
166 }
167 }
168 }
169
170 static let filmstripPadding: CGFloat = 6
171
172 /// A filmstrip tile: upright 3:4 in portrait, as a phone's camera sees
173 /// a face, 4:3 in landscape; about an eighth of the screen's height.
174 static func filmstripTile(in size: CGSize) -> CGSize {
175 if size.height >= size.width {
176 let height = min(132, max(96, (size.height / 7).rounded()))
177 return CGSize(width: (height * 3 / 4).rounded(), height: height)
178 }
179 let height = min(96, max(64, (size.height / 5).rounded()))
180 return CGSize(width: (height * 4 / 3).rounded(), height: height)
181 }
182
183 /// Every tile as large as fits inside the safe area, scrolling only
184 /// once they would be too small to see.
185 private func grid(in size: CGSize, safe: EdgeInsets) -> some View {
186 let items = gridItems
187 let count = items.count
188 let area = CGSize(
189 width: size.width - safe.leading - safe.trailing - 8,
190 height: size.height - safe.top - safe.bottom - 8)
191 let aspect: CGFloat = area.width > area.height ? 4 / 3 : 3 / 4
192 let columns = GridFit.columns(count: count, in: area, aspect: aspect)
193 let fitted = GridFit.tile(count: count, columns: columns, in: area, aspect: aspect)
194 let tile = fitted.width >= 110 ? fitted : CGSize(width: 110, height: 110 / aspect)
195 let across = fitted.width >= 110 ? columns : max(1, Int((area.width + 4) / (tile.width + 4)))
196 return ScrollView {
197 LazyVGrid(
198 columns: Array(repeating: GridItem(.fixed(tile.width), spacing: 4), count: across),
199 spacing: 4
200 ) {
201 ForEach(items, id: \.self) { item in
202 phoneTile(item, cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop)
203 .frame(width: tile.width, height: tile.height)
204 .modifier(arrangeable(item, tapToPin: false))
205 }
206 }
207 .padding(4)
208 .frame(minHeight: area.height + 8)
209 }
210 .scrollIndicators(.never)
211 .padding(safe)
212 }
213
214 // MARK: Self view
215
216 /// You, small and floating: dragged anywhere, settling in the nearest
217 /// corner, above the bar while it shows. Muted is said on it always,
218 /// since the Mute button is a tap away inside the menu.
219 private func selfView(in size: CGSize, safe: EdgeInsets) -> some View {
220 let corner = PiPCorner(rawValue: pipCorner) ?? .bottomTrailing
221 let pip = PiPLayout.size(in: size, touch: true)
222 let insets = EdgeInsets(
223 top: safe.top, leading: safe.leading,
224 bottom: safe.bottom + (chrome.visible ? Self.barHeight : 0), trailing: safe.trailing)
225 let frame = PiPLayout.frame(corner: corner, size: pip, in: size, insets: insets)
226 return PhoneVideoTile(
227 track: call.localTrack, label: "You", muted: call.muted, mirrored: true,
228 cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 14,
229 compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio
230 )
231 .frame(width: pip.width, height: pip.height)
232 .shadow(color: .black.opacity(0.35), radius: 8, y: 3)
233 .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height)
234 .onTapGesture { chrome.tapped() }
235 .gesture(
236 DragGesture()
237 .onChanged { pipDrag = $0.translation }
238 .onEnded { drag in
239 let dropped = CGPoint(
240 x: frame.midX + drag.translation.width, y: frame.midY + drag.translation.height)
241 let headed = CGPoint(
242 x: frame.midX + drag.predictedEndTranslation.width,
243 y: frame.midY + drag.predictedEndTranslation.height)
244 withAnimation(reduceMotion ? nil : .spring(duration: 0.3)) {
245 pipCorner = PiPLayout.corner(droppedAt: dropped, predicted: headed, in: size).rawValue
246 pipDrag = .zero
247 }
248 }
249 )
250 .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: frame)
251 .accessibilityIdentifier("selfView")
252 .accessibilityActions {
253 ForEach(PiPCorner.allCases, id: \.self) { place in
254 Button(moveTitle(place)) { pipCorner = place.rawValue }
255 }
256 }
257 }
258
259 private func moveTitle(_ corner: PiPCorner) -> String {
260 switch corner {
261 case .topLeading: "Move self view to top left"
262 case .topTrailing: "Move self view to top right"
263 case .bottomLeading: "Move self view to bottom left"
264 case .bottomTrailing: "Move self view to bottom right"
265 }
266 }
267
268 // MARK: Title and notices
269
270 private var notices: [CallNotice] {
271 CallNotices.notices(
272 phase: call.noticePhase, status: call.status, sharingScreen: call.sharingScreen,
273 screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery)
274 }
275
276 private func topOverlay(safe: EdgeInsets) -> some View {
277 VStack(spacing: 8) {
278 // No pill (2026-10-02, Paul approved): how the call is going is the
279 // notices' to say. A title someone chose is a small centred line at
280 // the top, inside the safe area, fading with the controls; a title
281 // Calls wrote is not shown (CallTitle).
282 if let shown = CallTitle.shown(title: call.title, named: call.named) {
283 Text(shown)
284 .font(.footnote.weight(.semibold)).foregroundStyle(.white)
285 .shadow(color: .black.opacity(0.5), radius: 2, y: 1)
286 .lineLimit(1).truncationMode(.tail)
287 .frame(maxWidth: .infinity)
288 .opacity(chrome.visible ? 1 : 0)
289 .allowsHitTesting(false)
290 .accessibilityHidden(!chrome.visible)
291 .accessibilityAddTraits(.isHeader)
292 .accessibilityIdentifier("callTitle")
293 }
294
295 ForEach(notices.filter { $0.important && !dismissedNotices.contains($0.id) }) { notice in
296 noticeView(notice)
297 }
298 ForEach(call.state?.waiting ?? []) { person in
299 HStack(spacing: 10) {
300 Text("\(person.name) is waiting").lineLimit(2)
301 Button("Let in") { call.admit(person.peer) }.buttonStyle(.borderedProminent)
302 }
303 .modifier(PhoneNoticeSurface())
304 }
305 // The relay warning goes with the controls; it lived in the pill.
306 ForEach(notices.filter { !$0.important }) { notice in
307 noticeView(notice)
308 .opacity(chrome.visible ? 1 : 0)
309 .allowsHitTesting(chrome.visible)
310 .accessibilityHidden(!chrome.visible)
311 }
312 }
313 .padding(.top, max(safe.top, 12) + 4)
314 .padding(.leading, safe.leading + 12).padding(.trailing, safe.trailing + 12)
315 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
316 }
317
318 @ViewBuilder private func noticeView(_ notice: CallNotice) -> some View {
319 HStack(spacing: 10) {
320 switch notice.kind {
321 case .sharing:
322 Label(notice.text, systemImage: "rectangle.on.rectangle")
323 .foregroundStyle(CallOverlay.alertText).bold()
324 case .screenRecovering:
325 Label(notice.text, systemImage: "arrow.clockwise")
326 case .connecting, .reconnecting, .waitingToJoin:
327 ProgressView().tint(.white)
328 Text(notice.text)
329 case .full:
330 Text(notice.text)
331 Button("Try joining again") { call.retryJoin() }
332 case .ended:
333 Label(notice.text, systemImage: "phone.down")
334 case .problem, .floor:
335 Label(notice.text, systemImage: "exclamationmark.triangle.fill")
336 }
337 if let action = notice.action {
338 // The same stop as the ⋯ sheet's, which ends the broadcast.
339 NoticeActionButton(action) { call.perform(action) }
340 }
341 if notice.dismissible {
342 Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) }
343 .labelStyle(.iconOnly)
344 }
345 }
346 .modifier(PhoneNoticeSurface())
347 }
348
349 // MARK: Bar
350
351 private func bar(safe: EdgeInsets) -> some View {
352 HStack(spacing: 14) {
353 Button {
354 showingMore = true
355 } label: {
356 Label {
357 Text("More controls")
358 } icon: {
359 CallGlyph("ellipsis", rotation: .degrees(90))
360 }
361 .labelStyle(.iconOnly)
362 }
363 .buttonStyle(RoundCallButtonStyle(.normal))
364 .overlay(alignment: .topTrailing) {
365 if unread > 0 {
366 Text("\(unread)").font(.caption.bold()).foregroundStyle(.white)
367 .padding(5).background(CallOverlay.badgeFill, in: Capsule()).accessibilityHidden(true)
368 }
369 }
370 .accessibilityLabel(unread > 0 ? "More controls, \(unread) new chat messages" : "More controls")
371 .accessibilityIdentifier("moreControls")
372 if micBesideMenu {
373 Button(
374 call.muted ? "Unmute microphone" : "Mute microphone",
375 glyph: call.muted ? "mic.slash.fill" : "mic.fill"
376 ) { call.mute() }
377 .labelStyle(.iconOnly)
378 .buttonStyle(RoundCallButtonStyle(call.muted ? .off : .normal))
379 .accessibilityIdentifier("mute")
380 }
381 Spacer(minLength: 0)
382 Button("End call", glyph: "phone.down.fill", role: .destructive, action: leave)
383 .labelStyle(.iconOnly)
384 .buttonStyle(RoundCallButtonStyle(.leave))
385 .accessibilityIdentifier("leave")
386 }
387 .padding(.leading, safe.leading + 20).padding(.trailing, safe.trailing + 20)
388 .padding(.bottom, max(safe.bottom, 16) + 4)
389 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
390 .background(alignment: .bottom) {
391 // Enough shade under the buttons to read them over a white wall.
392 LinearGradient(colors: [.clear, .black.opacity(0.35)], startPoint: .top, endPoint: .bottom)
393 .frame(height: Self.barHeight + safe.bottom + 40)
394 .allowsHitTesting(false)
395 }
396 }
397}
398
399/// A round call button over the picture: a borderless glass circle with a
400/// white symbol, or filled for End call's red and a muted microphone's
401/// amber (see CallOverlay).
402struct RoundCallButtonStyle: ButtonStyle {
403 let tone: CallOverlayButtonStyle.Tone
404 init(_ tone: CallOverlayButtonStyle.Tone) { self.tone = tone }
405 func makeBody(configuration: Configuration) -> some View {
406 configuration.label.font(.title2.weight(.semibold))
407 .modifier(CallGlyphSizing(.round))
408 .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white)
409 .frame(width: PhoneCall.button, height: PhoneCall.button)
410 .background {
411 switch tone {
412 case .normal: Color.clear.callOverlaySurface(Circle())
413 case .on: Circle().fill(CallOverlay.onFill)
414 case .off: Circle().fill(CallOverlay.offFill)
415 case .leave: Circle().fill(CallOverlay.leaveFill)
416 }
417 }
418 .opacity(configuration.isPressed ? 0.7 : 1)
419 .contentShape(Circle())
420 }
421}
422
423/// A notice over the picture: the overlay's borderless glass, white text,
424/// and dark-appearance controls whatever the system's.
425struct PhoneNoticeSurface: ViewModifier {
426 func body(content: Content) -> some View {
427 content.font(.callout).foregroundStyle(.white)
428 .environment(\.colorScheme, .dark)
429 .padding(.horizontal, 14).padding(.vertical, 8)
430 // No Liquid Glass rim: see NoticeSurface on the Mac.
431 .callOverlaySurface(RoundedRectangle(cornerRadius: 16, style: .continuous), rim: false)
432 .shadow(color: .black.opacity(0.25), radius: 8, y: 2)
433 }
434}
435
436/// The controls menu: everything but End call, each a labelled button.
437/// A sheet rather than a pop-up menu, so it can hold the system's screen
438/// broadcast picker and a toggle, and show at a glance what is on.
439struct MoreControls: View {
440 @ObservedObject var call: Call
441 @ObservedObject var arrangement: CallArrangement
442 let title: (StageItem) -> String
443 let unread: Int
444 let open: (MoreAction) -> Void
445 let close: () -> Void
446 @StateObject private var micModes = MicrophoneModes()
447
448 private let columns = [GridItem(.adaptive(minimum: 96), spacing: 12)]
449
450 var body: some View {
451 NavigationStack {
452 ScrollView {
453 VStack(alignment: .leading, spacing: 20) {
454 LazyVGrid(columns: columns, spacing: 16) {
455 tile(call.muted ? "Unmute" : "Mute") {
456 Button(
457 call.muted ? "Unmute microphone" : "Mute microphone",
458 glyph: call.muted ? "mic.slash.fill" : "mic.fill"
459 ) { call.mute() }
460 .buttonStyle(
461 CallActionStyle(
462 color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent,
463 emphasized: call.muted))
464 .accessibilityIdentifier("mute")
465 }
466 tile(call.cameraOff ? "Camera on" : "Camera off") {
467 Button(
468 call.cameraOff ? "Turn camera on" : "Turn camera off",
469 glyph: call.cameraOff ? "video.slash.fill" : "video.fill"
470 ) { call.toggleCamera() }
471 .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff))
472 .accessibilityIdentifier("camera")
473 }
474 tile("Flip") {
475 Button("Flip camera", glyph: "arrow.triangle.2.circlepath.camera") {
476 if let next = CameraFlip.next(from: call.currentCamera, in: call.cameras) {
477 Task { await call.useCamera(next.id) }
478 }
479 }
480 .buttonStyle(CallActionStyle(color: .blue))
481 .disabled(CameraFlip.next(from: call.currentCamera, in: call.cameras) == nil)
482 .accessibilityIdentifier("flipCamera")
483 }
484 tile(call.sharingScreen ? "Stop sharing" : "Share screen") {
485 if call.sharingScreen {
486 Button("Stop sharing your screen", glyph: "rectangle.on.rectangle.slash") {
487 call.stopSharingScreen()
488 }
489 .buttonStyle(CallActionStyle(color: .orange, emphasized: true))
490 .accessibilityIdentifier("shareScreen")
491 } else {
492 BroadcastButton().frame(width: 44, height: 44)
493 .disabled(call.state?.phase != "admitted")
494 .accessibilityIdentifier("shareScreen")
495 }
496 }
497 tile("Devices") {
498 Button("Devices", glyph: "slider.horizontal.3") { open(.devices) }
499 .buttonStyle(CallActionStyle(color: .teal))
500 .accessibilityIdentifier("devices")
501 }
502 tile("Mic Mode") {
503 Button("Mic Mode", glyph: "waveform") { micModes.showPicker() }
504 .buttonStyle(CallActionStyle(color: .teal))
505 .accessibilityIdentifier("micModeQuick")
506 .accessibilityHint(micModes.summary)
507 }
508 tile(arrangement.layout.title) {
509 Menu {
510 LayoutChoices(call: call, arrangement: arrangement, title: title)
511 } label: {
512 Label {
513 Text("Video layout")
514 } icon: {
515 CallGlyph(arrangement.layout.symbol)
516 }
517 }
518 .buttonStyle(CallActionStyle(color: .gray))
519 .accessibilityIdentifier("videoLayout")
520 }
521 if call.standing == "member" {
522 tile("Share link") {
523 MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo))
524 }
525 }
526 tile(unread > 0 ? "Chat (\(unread))" : "Chat") {
527 Button(unread == 0 ? "Chat" : "Chat (\(unread) new)", glyph: "bubble.left.and.bubble.right.fill") {
528 open(.chat)
529 }
530 .buttonStyle(CallActionStyle(color: .purple, emphasized: unread > 0))
531 .accessibilityIdentifier("openChat")
532 }
533 }
534 // Spec §15.26: this call's diagnostics, sent rather than copied.
535 ReportProblemButton { open(.report) }
536 .disabled(!call.canReportProblem)
537 VStack(alignment: .leading, spacing: 4) {
538 Toggle("Reduce background noise", isOn: $call.reduceNoise)
539 .accessibilityIdentifier("noiseReduction")
540 Text("Takes out typing, wind and room noise before others hear you.")
541 .font(.footnote).foregroundStyle(.secondary)
542 }
543 }
544 .padding(20)
545 }
546 .navigationTitle("Call controls").navigationBarTitleDisplayMode(.inline)
547 .toolbar {
548 ToolbarItem(placement: .confirmationAction) { Button("Done", action: close) }
549 }
550 .onAppear { micModes.refresh() }
551 }
552 }
553
554 /// A button with its caption under it. The caption is for the eye; the
555 /// button carries its own full label for VoiceOver.
556 private func tile(_ caption: String, @ViewBuilder _ content: () -> some View) -> some View {
557 VStack(spacing: 6) {
558 content()
559 Text(caption).font(.caption).foregroundStyle(.secondary).lineLimit(1)
560 .minimumScaleFactor(0.8).accessibilityHidden(true)
561 }
562 .frame(maxWidth: .infinity)
563 }
564}
565