feat(ios): iPhone channel drill-down, fix chat compose box, unify chat+activity

- Channels tab is now a drill-down on iPhone: ChannelBrowserView lists top-level
  channels; ChannelDetailView shows the people in a channel, its sub-channels, and
  an explicit Join button (with password prompt). iPad split view unchanged.
- Extract self-contained UserRow (context menu + sheets) from UserListView so admin
  actions are reused in the drill-down.
- Fix off-screen chat compose box: pin VoiceControlsView via per-tab
  .safeAreaInset(edge: .bottom) instead of a floating overlay, so it reserves layout
  space above the tab bar (keeping the compose box visible, cooperating with keyboard
  avoidance) without covering the tab bar buttons.
- Collapse Activity into Chat like macOS/Windows: ChatView renders a merged,
  time-sorted timeline of messages + activity (activity rows in gray); remove the
  Activity tab and ActivityLogView.
- Label the RPSystemBroadcastPickerView inner UIButton for VoiceOver
  ("Share/Stop sharing screen audio") instead of relying on an outer SwiftUI label.
This commit is contained in:
2026-06-21 03:02:58 +02:00
parent c5ad080692
commit b07362e525
10 changed files with 419 additions and 187 deletions

View File

@@ -21,7 +21,9 @@
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000023 /* ChannelTreeView.swift */; };
BBBB0000000000000000003D /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000024 /* UserListView.swift */; };
BBBB0000000000000000003E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000025 /* ChatView.swift */; };
BBBB0000000000000000003F /* ActivityLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000026 /* ActivityLogView.swift */; };
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000060 /* ChannelBrowserView.swift */; };
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000061 /* ChannelDetailView.swift */; };
BBBB00000000000000000072 /* UserRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000062 /* UserRow.swift */; };
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000027 /* VoiceControlsView.swift */; };
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000028 /* PerUserTuningView.swift */; };
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00000000000000000029 /* ChannelEditView.swift */; };
@@ -75,7 +77,9 @@
BBBB00000000000000000023 /* ChannelTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTreeView.swift; sourceTree = "<group>"; };
BBBB00000000000000000024 /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = "<group>"; };
BBBB00000000000000000025 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
BBBB00000000000000000026 /* ActivityLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogView.swift; sourceTree = "<group>"; };
BBBB00000000000000000060 /* ChannelBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBrowserView.swift; sourceTree = "<group>"; };
BBBB00000000000000000061 /* ChannelDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelDetailView.swift; sourceTree = "<group>"; };
BBBB00000000000000000062 /* UserRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRow.swift; sourceTree = "<group>"; };
BBBB00000000000000000027 /* VoiceControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceControlsView.swift; sourceTree = "<group>"; };
BBBB00000000000000000028 /* PerUserTuningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerUserTuningView.swift; sourceTree = "<group>"; };
BBBB00000000000000000029 /* ChannelEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelEditView.swift; sourceTree = "<group>"; };
@@ -176,7 +180,9 @@
BBBB00000000000000000023 /* ChannelTreeView.swift */,
BBBB00000000000000000024 /* UserListView.swift */,
BBBB00000000000000000025 /* ChatView.swift */,
BBBB00000000000000000026 /* ActivityLogView.swift */,
BBBB00000000000000000060 /* ChannelBrowserView.swift */,
BBBB00000000000000000061 /* ChannelDetailView.swift */,
BBBB00000000000000000062 /* UserRow.swift */,
BBBB00000000000000000027 /* VoiceControlsView.swift */,
BBBB00000000000000000028 /* PerUserTuningView.swift */,
BBBB00000000000000000029 /* ChannelEditView.swift */,
@@ -302,7 +308,9 @@
BBBB0000000000000000003C /* ChannelTreeView.swift in Sources */,
BBBB0000000000000000003D /* UserListView.swift in Sources */,
BBBB0000000000000000003E /* ChatView.swift in Sources */,
BBBB0000000000000000003F /* ActivityLogView.swift in Sources */,
BBBB00000000000000000070 /* ChannelBrowserView.swift in Sources */,
BBBB00000000000000000071 /* ChannelDetailView.swift in Sources */,
BBBB00000000000000000072 /* UserRow.swift in Sources */,
BBBB00000000000000000040 /* VoiceControlsView.swift in Sources */,
BBBB00000000000000000041 /* PerUserTuningView.swift in Sources */,
BBBB00000000000000000042 /* ChannelEditView.swift in Sources */,

View File

@@ -1,49 +0,0 @@
import SwiftUI
struct ActivityLogView: View {
@Bindable var session: SessionState
private static let timeFormatter: DateFormatter = {
let fmt = DateFormatter()
fmt.dateStyle = .none
fmt.timeStyle = .medium
return fmt
}()
var body: some View {
ScrollViewReader { proxy in
List(session.activityLog) { entry in
HStack(alignment: .top, spacing: 8) {
Text(Self.timeFormatter.string(from: entry.timestamp))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
.frame(width: 64, alignment: .leading)
Text(entry.text)
.font(.caption)
}
.id(entry.id)
.listRowSeparator(.hidden)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(Self.timeFormatter.string(from: entry.timestamp)): \(entry.text)")
}
.listStyle(.plain)
.onChange(of: session.activityLog.count) { _, _ in
if let last = session.activityLog.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
.navigationTitle("Activity")
.navigationBarTitleDisplayMode(.inline)
.overlay {
if session.activityLog.isEmpty {
ContentUnavailableView(
"No Activity",
systemImage: "bell.slash",
description: Text("Events will appear here as they happen.")
)
}
}
}
}

View File

@@ -0,0 +1,91 @@
import SwiftUI
import VoiceCatCore
/// iPhone channels tab: a drill-down browser. The root list shows only top-level channels;
/// tapping (or VoiceOver-activating) a channel pushes `ChannelDetailView`, which shows the
/// people in it and any sub-channels. Joining is an explicit action inside the detail view.
struct ChannelBrowserView: View {
@Bindable var session: SessionState
@State private var showCreateChannel = false
var body: some View {
NavigationStack {
List {
ForEach(rootChannels) { ch in
NavigationLink {
ChannelDetailView(channel: ch, session: session)
} label: {
ChannelRow(channel: ch, session: session)
}
.swipeActions(edge: .trailing) {
if session.permissions.isAdmin {
Button(role: .destructive) {
session.deleteChannel(ch.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
.navigationTitle("Channels")
.toolbar {
if session.permissions.canCreateTempChannel || session.permissions.isAdmin {
ToolbarItem(placement: .primaryAction) {
Button {
showCreateChannel = true
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Create channel")
}
}
}
.sheet(isPresented: $showCreateChannel) {
ChannelEditView(channelId: nil, session: session)
}
}
}
private var rootChannels: [Channel] {
session.channels
.filter { $0.parentId == 0 }
.sorted { $0.name < $1.name }
}
}
/// Shared channel row used by the iPhone browser and detail views. Mirrors the look of the
/// iPad `ChannelTreeView` row but keyed on a `Channel` rather than a tree `ChannelNode`.
struct ChannelRow: View {
let channel: Channel
let session: SessionState
var body: some View {
let isCurrent = session.currentChannelId == channel.id
let usersHere = session.users.filter { $0.channelId == channel.id }
HStack(spacing: 8) {
Image(systemName: channel.passwordProtected ? "lock.fill" : "number")
.foregroundStyle(isCurrent ? .blue : .secondary)
.imageScale(.small)
VStack(alignment: .leading, spacing: 1) {
Text(channel.name)
.fontWeight(isCurrent ? .semibold : .regular)
if !channel.topic.isEmpty {
Text(channel.topic)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
if !usersHere.isEmpty {
Text("\(usersHere.count)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(channel.name)\(isCurrent ? ", current" : "")\(channel.passwordProtected ? ", password protected" : "")\(!usersHere.isEmpty ? ", \(usersHere.count) users" : "")")
}
}

View File

@@ -0,0 +1,88 @@
import SwiftUI
import VoiceCatCore
/// The drilled-into view for a single channel: a Join control, the people currently in the
/// channel, and any sub-channels (each drilling deeper via a nested `ChannelDetailView`).
struct ChannelDetailView: View {
let channel: Channel
@Bindable var session: SessionState
@State private var showPasswordPrompt = false
@State private var password = ""
private var isCurrent: Bool { session.currentChannelId == channel.id }
private var people: [User] { session.users.filter { $0.channelId == channel.id } }
private var subchannels: [Channel] {
session.channels.filter { $0.parentId == channel.id }.sorted { $0.name < $1.name }
}
var body: some View {
List {
Section {
if isCurrent {
Label("You're here", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.accessibilityLabel("You are in this channel")
} else {
Button {
join()
} label: {
Label("Join Channel", systemImage: "arrow.right.circle.fill")
}
.accessibilityLabel("Join \(channel.name)")
}
}
Section("People") {
if people.isEmpty {
Text("No one here yet.")
.foregroundStyle(.secondary)
} else {
ForEach(people) { user in
UserRow(user: user, session: session)
}
}
}
if !subchannels.isEmpty {
Section("Channels") {
ForEach(subchannels) { sub in
NavigationLink {
ChannelDetailView(channel: sub, session: session)
} label: {
ChannelRow(channel: sub, session: session)
}
.swipeActions(edge: .trailing) {
if session.permissions.isAdmin {
Button(role: .destructive) {
session.deleteChannel(sub.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
}
}
.navigationTitle(channel.name)
.navigationBarTitleDisplayMode(.inline)
.alert("Channel Password", isPresented: $showPasswordPrompt) {
SecureField("Password", text: $password)
.accessibilityLabel("Channel password")
Button("Join") {
session.joinChannel(channel.id, password: password)
password = ""
}
Button("Cancel", role: .cancel) { password = "" }
}
}
private func join() {
if channel.passwordProtected {
showPasswordPrompt = true
} else {
session.joinChannel(channel.id)
}
}
}

View File

@@ -1,27 +1,61 @@
import SwiftUI
import VoiceCatCore
/// One row of the combined chat/activity timeline. Mirrors the macOS/Windows clients, which
/// collapse chat messages and activity events into a single scrolling log (chat in normal
/// text, activity events in gray).
private enum TimelineItem: Identifiable {
case message(ChatMessage)
case activity(ActivityEntry)
var id: UUID {
switch self {
case .message(let m): return m.id
case .activity(let a): return a.id
}
}
var timestamp: Date {
switch self {
case .message(let m): return m.timestamp
case .activity(let a): return a.timestamp
}
}
}
struct ChatView: View {
@Bindable var session: SessionState
@State private var composeText = ""
@State private var scope: VoiceCatTextScope = .channel
@State private var privateTargetId: UInt32 = 0
private var timeline: [TimelineItem] {
let merged = session.messages.map(TimelineItem.message)
+ session.activityLog.map(TimelineItem.activity)
return merged.sorted { $0.timestamp < $1.timestamp }
}
var body: some View {
VStack(spacing: 0) {
// Message list
// Combined chat + activity timeline
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(session.messages) { msg in
ChatBubble(message: msg)
.id(msg.id)
ForEach(timeline) { item in
switch item {
case .message(let msg):
ChatBubble(message: msg)
.id(item.id)
case .activity(let entry):
ActivityRow(entry: entry)
.id(item.id)
}
}
}
.padding()
}
.onChange(of: session.messages.count) { _, _ in
if let last = session.messages.last {
.onChange(of: session.messages.count + session.activityLog.count) { _, _ in
if let last = timeline.last {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
@@ -92,3 +126,34 @@ private struct ChatBubble: View {
.accessibilityLabel("\(message.senderName) at \(timeString): \(message.text)")
}
}
/// A compact, gray activity row interleaved into the chat timeline (joins/leaves, talk state,
/// streams, server mute, etc.). Matches the "activity = gray" convention of the macOS/Windows
/// unified logs.
private struct ActivityRow: View {
let entry: ActivityEntry
private static let timeFormatter: DateFormatter = {
let fmt = DateFormatter()
fmt.dateStyle = .none
fmt.timeStyle = .short
return fmt
}()
private var timeString: String { Self.timeFormatter.string(from: entry.timestamp) }
var body: some View {
HStack(alignment: .top, spacing: 6) {
Text(timeString)
.font(.caption2)
.foregroundStyle(.tertiary)
.monospacedDigit()
Text(entry.text)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(timeString): \(entry.text)")
}
}

View File

@@ -24,26 +24,33 @@ private struct iPhoneMainView: View {
var body: some View {
TabView {
ChannelTreeView(session: session)
ChannelBrowserView(session: session)
.voiceControlsBar(session)
.tabItem {
Label("Channels", systemImage: "list.bullet.indent")
}
ChatView(session: session)
.voiceControlsBar(session)
.tabItem {
Label("Chat", systemImage: "message")
}
ActivityLogView(session: session)
.tabItem {
Label("Activity", systemImage: "bell")
}
SettingsView(session: session)
.voiceControlsBar(session)
.tabItem {
Label("Settings", systemImage: "gear")
}
}
.overlay(alignment: .bottom) {
}
}
private extension View {
/// Pin the shared voice controls just above the tab bar, *inside each tab's content area*.
/// Applying this per-tab (rather than to the TabView itself) reserves layout space above
/// the tab bar keeping ChatView's compose box visible and cooperating with keyboard
/// avoidance without the bar covering the tab bar's buttons.
func voiceControlsBar(_ session: SessionState) -> some View {
safeAreaInset(edge: .bottom) {
VoiceControlsView(session: session)
.padding(.bottom, 56) // above tab bar
}
}
}

View File

@@ -3,11 +3,6 @@ import VoiceCatCore
struct UserListView: View {
@Bindable var session: SessionState
@State private var selectedUser: User?
@State private var showPerUserTuning = false
@State private var showBanUser = false
@State private var showMoveUser = false
@State private var showPermissions = false
var body: some View {
let channelUsers = session.currentChannelId == 0
@@ -15,10 +10,7 @@ struct UserListView: View {
: session.users.filter { $0.channelId == session.currentChannelId }
List(channelUsers) { user in
UserRowView(user: user, isSelf: user.id == session.selfUserId)
.contextMenu {
userContextMenu(user: user)
}
UserRow(user: user, session: session)
}
.listStyle(.plain)
.overlay {
@@ -30,115 +22,5 @@ struct UserListView: View {
)
}
}
.sheet(item: $selectedUser) { user in
if showPerUserTuning {
PerUserTuningView(user: user, session: session)
} else if showBanUser {
BanUserView(user: user, session: session)
} else if showMoveUser {
MoveUserView(user: user, session: session)
} else if showPermissions {
PermissionsView(user: user, session: session)
}
}
}
@ViewBuilder
private func userContextMenu(user: User) -> some View {
if user.id != session.selfUserId {
Button {
selectedUser = user
showPerUserTuning = true
showBanUser = false; showMoveUser = false; showPermissions = false
} label: {
Label("Volume / NR", systemImage: "speaker.wave.2")
}
if session.permissions.canKick || session.permissions.isAdmin {
Divider()
Button {
session.kickUser(user.id, reason: "")
} label: {
Label("Kick", systemImage: "person.fill.xmark")
}
if session.permissions.canBan || session.permissions.isAdmin {
Button(role: .destructive) {
selectedUser = user
showBanUser = true
showPerUserTuning = false; showMoveUser = false; showPermissions = false
} label: {
Label("Ban…", systemImage: "nosign")
}
}
}
if session.permissions.canMoveUsers || session.permissions.isAdmin {
Button {
selectedUser = user
showMoveUser = true
showPerUserTuning = false; showBanUser = false; showPermissions = false
} label: {
Label("Move to channel…", systemImage: "arrow.right.circle")
}
}
if session.permissions.isAdmin {
Divider()
let muted = user.serverMuted
Button {
session.setServerMute(user.id, muted: !muted, deafened: user.serverDeafened)
} label: {
Label(muted ? "Unmute" : "Server Mute", systemImage: muted ? "mic" : "mic.slash")
}
Button {
selectedUser = user
showPermissions = true
showPerUserTuning = false; showBanUser = false; showMoveUser = false
} label: {
Label("Permissions…", systemImage: "lock.shield")
}
}
}
}
}
private struct UserRowView: View {
let user: User
let isSelf: Bool
var body: some View {
HStack(spacing: 10) {
Image(systemName: user.selfMicMuted || user.serverMuted ? "mic.slash.fill" : "mic.fill")
.foregroundStyle(user.selfMicMuted || user.serverMuted ? .red : .green)
.imageScale(.small)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(user.nickname)
.fontWeight(isSelf ? .semibold : .regular)
if isSelf {
Text("(you)")
.font(.caption2)
.foregroundStyle(.secondary)
}
if user.isGuest {
Text("guest")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if user.serverMuted || user.serverDeafened {
Text(user.serverDeafened ? "server deafened" : "server muted")
.font(.caption2)
.foregroundStyle(.orange)
}
}
Spacer()
if user.selfDeafened {
Image(systemName: "headphones.slash")
.imageScale(.small)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(user.nickname)\(isSelf ? ", you" : "")\(user.isGuest ? ", guest" : "")\(user.selfMicMuted ? ", muted" : "")\(user.serverMuted ? ", server muted" : "")")
}
}

View File

@@ -0,0 +1,121 @@
import SwiftUI
import VoiceCatCore
/// A single user row with its admin context menu and the sheets those actions present.
/// Self-contained (owns its own sheet state) so it can be reused both by the iPad
/// `UserListView` middle column and by the iPhone `ChannelDetailView` drill-down.
struct UserRow: View {
let user: User
@Bindable var session: SessionState
@State private var activeSheet: ActiveSheet?
private enum ActiveSheet: Identifiable {
case tuning, ban, move, permissions
var id: Int { hashValue }
}
var body: some View {
UserRowView(user: user, isSelf: user.id == session.selfUserId)
.contextMenu { contextMenu }
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .tuning: PerUserTuningView(user: user, session: session)
case .ban: BanUserView(user: user, session: session)
case .move: MoveUserView(user: user, session: session)
case .permissions: PermissionsView(user: user, session: session)
}
}
}
@ViewBuilder
private var contextMenu: some View {
if user.id != session.selfUserId {
Button {
activeSheet = .tuning
} label: {
Label("Volume / NR", systemImage: "speaker.wave.2")
}
if session.permissions.canKick || session.permissions.isAdmin {
Divider()
Button {
session.kickUser(user.id, reason: "")
} label: {
Label("Kick", systemImage: "person.fill.xmark")
}
if session.permissions.canBan || session.permissions.isAdmin {
Button(role: .destructive) {
activeSheet = .ban
} label: {
Label("Ban…", systemImage: "nosign")
}
}
}
if session.permissions.canMoveUsers || session.permissions.isAdmin {
Button {
activeSheet = .move
} label: {
Label("Move to channel…", systemImage: "arrow.right.circle")
}
}
if session.permissions.isAdmin {
Divider()
let muted = user.serverMuted
Button {
session.setServerMute(user.id, muted: !muted, deafened: user.serverDeafened)
} label: {
Label(muted ? "Unmute" : "Server Mute", systemImage: muted ? "mic" : "mic.slash")
}
Button {
activeSheet = .permissions
} label: {
Label("Permissions…", systemImage: "lock.shield")
}
}
}
}
}
private struct UserRowView: View {
let user: User
let isSelf: Bool
var body: some View {
HStack(spacing: 10) {
Image(systemName: user.selfMicMuted || user.serverMuted ? "mic.slash.fill" : "mic.fill")
.foregroundStyle(user.selfMicMuted || user.serverMuted ? .red : .green)
.imageScale(.small)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(user.nickname)
.fontWeight(isSelf ? .semibold : .regular)
if isSelf {
Text("(you)")
.font(.caption2)
.foregroundStyle(.secondary)
}
if user.isGuest {
Text("guest")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if user.serverMuted || user.serverDeafened {
Text(user.serverDeafened ? "server deafened" : "server muted")
.font(.caption2)
.foregroundStyle(.orange)
}
}
Spacer()
if user.selfDeafened {
Image(systemName: "headphones.slash")
.imageScale(.small)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(user.nickname)\(isSelf ? ", you" : "")\(user.isGuest ? ", guest" : "")\(user.selfMicMuted ? ", muted" : "")\(user.serverMuted ? ", server muted" : "")")
}
}

View File

@@ -64,8 +64,6 @@ struct VoiceControlsView: View {
// SCREEN_AUDIO stream. Tinted while sharing.
BroadcastPickerButton(isSharing: session.voiceState.screenSharing)
.frame(width: 32, height: 32)
.accessibilityLabel(session.voiceState.screenSharing
? "Stop sharing screen audio" : "Share screen audio")
// Disconnect
Button(role: .destructive) {
@@ -134,6 +132,13 @@ private struct BroadcastPickerButton: UIViewRepresentable {
func updateUIView(_ uiView: RPSystemBroadcastPickerView, context: Context) {
uiView.tintColor = isSharing ? .systemGreen : .label
// RPSystemBroadcastPickerView owns an inner UIButton that VoiceOver focuses; its default
// label is the picker's image name ("module icon"). Label the inner button directly
// a SwiftUI .accessibilityLabel on the representable doesn't reach it.
let label = isSharing ? "Stop sharing screen audio" : "Share screen audio"
for case let button as UIButton in uiView.subviews {
button.accessibilityLabel = label
}
}
}