Add iOS/iPad target with platform-adaptive UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-30 21:30:28 -05:00
parent 127125ae1b
commit fda809fd2f
21 changed files with 851 additions and 129 deletions

View File

@@ -180,7 +180,7 @@ struct MultiStreamView: View {
destructive: false
)
}
.buttonStyle(.card)
.platformCardStyle()
Button {
viewModel.clearAllStreams()
@@ -192,7 +192,7 @@ struct MultiStreamView: View {
destructive: true
)
}
.buttonStyle(.card)
.platformCardStyle()
}
}
}
@@ -233,7 +233,9 @@ struct MultiStreamView: View {
private struct MultiViewCanvas: View {
@Environment(GamesViewModel.self) private var viewModel
#if os(tvOS)
@FocusState private var focusedStreamID: String?
#endif
let contentInsets: CGFloat
let gap: CGFloat
@@ -250,35 +252,23 @@ private struct MultiViewCanvas: View {
inset: contentInsets,
gap: gap
)
#if os(tvOS)
let focusEntries = Array(viewModel.activeStreams.enumerated()).compactMap { index, stream -> MultiViewFocusEntry? in
guard index < frames.count else { return nil }
return MultiViewFocusEntry(streamID: stream.id, frame: frames[index])
}
#endif
ZStack(alignment: .topLeading) {
ForEach(Array(viewModel.activeStreams.enumerated()), id: \.element.id) { index, stream in
if index < frames.count {
let frame = frames[index]
MultiStreamTile(
stream: stream,
position: index + 1,
isPrimary: viewModel.isPrimaryStream(stream.id),
isAudioFocused: viewModel.audioFocusStreamID == stream.id,
isFocused: focusedStreamID == stream.id,
showsPrimaryBadge: viewModel.multiViewLayoutMode == .spotlight
&& viewModel.isPrimaryStream(stream.id)
&& viewModel.activeStreams.count > 1,
videoGravity: videoGravity,
cornerRadius: cornerRadius,
onSelect: { onSelect(stream) }
)
.frame(width: frame.width, height: frame.height)
.position(x: frame.midX, y: frame.midY)
.focused($focusedStreamID, equals: stream.id)
tileView(for: stream, frame: frame, position: index + 1)
}
}
}
.focusSection()
.platformFocusSection()
#if os(tvOS)
.onMoveCommand { direction in
let currentID = focusedStreamID ?? viewModel.audioFocusStreamID ?? viewModel.activeStreams.first?.id
if let nextID = nextMultiViewFocusID(
@@ -289,7 +279,9 @@ private struct MultiViewCanvas: View {
focusedStreamID = nextID
}
}
#endif
}
#if os(tvOS)
.onAppear {
if focusedStreamID == nil {
focusedStreamID = viewModel.audioFocusStreamID ?? viewModel.activeStreams.first?.id
@@ -307,6 +299,41 @@ private struct MultiViewCanvas: View {
viewModel.setAudioFocus(streamID: streamID)
}
}
#endif
}
@ViewBuilder
private func tileView(for stream: ActiveStream, frame: CGRect, position: Int) -> some View {
let tile = MultiStreamTile(
stream: stream,
position: position,
isPrimary: viewModel.isPrimaryStream(stream.id),
isAudioFocused: viewModel.audioFocusStreamID == stream.id,
isFocused: {
#if os(tvOS)
focusedStreamID == stream.id
#else
false
#endif
}(),
showsPrimaryBadge: viewModel.multiViewLayoutMode == .spotlight
&& viewModel.isPrimaryStream(stream.id)
&& viewModel.activeStreams.count > 1,
videoGravity: videoGravity,
cornerRadius: cornerRadius,
onSelect: { onSelect(stream) }
)
#if os(tvOS)
tile
.frame(width: frame.width, height: frame.height)
.position(x: frame.midX, y: frame.midY)
.focused($focusedStreamID, equals: stream.id)
#else
tile
.frame(width: frame.width, height: frame.height)
.position(x: frame.midX, y: frame.midY)
#endif
}
}
@@ -404,9 +431,8 @@ private struct MultiStreamTile: View {
radius: isFocused ? 26 : 20,
y: 10
)
.focusEffectDisabled()
.focusable(true)
.animation(.easeOut(duration: 0.18), value: isFocused)
.platformFocusable()
.onAppear {
logMultiView("tile appeared id=\(stream.id) label=\(stream.label)")
}
@@ -418,6 +444,8 @@ private struct MultiStreamTile: View {
qualityUpgradeTask = nil
playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
}
#if os(tvOS)
.focusEffectDisabled()
.onPlayPauseCommand {
if player?.rate == 0 {
player?.play()
@@ -425,6 +453,7 @@ private struct MultiStreamTile: View {
player?.pause()
}
}
#endif
.onTapGesture {
onSelect()
}
@@ -507,7 +536,7 @@ private struct MultiStreamTile: View {
)
if let player {
player.isMuted = viewModel.audioFocusStreamID != stream.id
player.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
playbackDiagnostics.attach(
to: player,
streamID: stream.id,
@@ -529,7 +558,7 @@ private struct MultiStreamTile: View {
}
if let existingPlayer = stream.player {
existingPlayer.isMuted = viewModel.audioFocusStreamID != stream.id
existingPlayer.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
self.player = existingPlayer
hasError = false
playbackDiagnostics.attach(
@@ -725,12 +754,10 @@ private struct MultiStreamTile: View {
.value
}
private func playbackEndedHandler(for player: AVPlayer) -> (() -> Void)? {
private func playbackEndedHandler(for player: AVPlayer) -> (@MainActor @Sendable () async -> Void)? {
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil }
return {
Task { @MainActor in
await playNextWerkoutClip(on: player)
}
await playNextWerkoutClip(on: player)
}
}
@@ -832,7 +859,7 @@ private final class MultiStreamPlaybackDiagnostics: ObservableObject {
to player: AVPlayer,
streamID: String,
label: String,
onPlaybackEnded: (() -> Void)? = nil
onPlaybackEnded: (@MainActor @Sendable () async -> Void)? = nil
) {
let playerIdentifier = ObjectIdentifier(player)
let itemIdentifier = player.currentItem.map { ObjectIdentifier($0) }
@@ -922,7 +949,10 @@ private final class MultiStreamPlaybackDiagnostics: ObservableObject {
queue: .main
) { _ in
logMultiView("playerItem didPlayToEnd id=\(streamID)")
onPlaybackEnded?()
guard let onPlaybackEnded else { return }
Task { @MainActor in
await onPlaybackEnded()
}
}
)
@@ -1002,7 +1032,7 @@ private struct MultiViewLayoutPicker: View {
.stroke(viewModel.multiViewLayoutMode == mode ? .blue.opacity(0.9) : .white.opacity(0.12), lineWidth: 1)
)
}
.buttonStyle(.card)
.platformCardStyle()
}
}
}
@@ -1027,6 +1057,10 @@ struct StreamControlSheet: View {
viewModel.audioFocusStreamID == streamID
}
private var forceMuteAudio: Bool {
stream?.forceMuteAudio == true
}
var body: some View {
ZStack {
LinearGradient(
@@ -1052,18 +1086,21 @@ struct StreamControlSheet: View {
HStack(spacing: 10) {
controlBadge(title: isPrimary ? "Primary Tile" : "Secondary Tile", tint: .blue)
controlBadge(title: isAudioFocused ? "Live Audio" : "Muted", tint: isAudioFocused ? .green : .white)
controlBadge(
title: forceMuteAudio ? "Video Only" : (isAudioFocused ? "Live Audio" : "Muted"),
tint: forceMuteAudio ? .orange : (isAudioFocused ? .green : .white)
)
}
}
VStack(spacing: 14) {
HStack(spacing: 14) {
actionCard(
title: isAudioFocused ? "Mute All" : "Listen Here",
subtitle: isAudioFocused ? "Silence the multiview mix." : "Route game audio to this tile.",
icon: isAudioFocused ? "speaker.slash.fill" : "speaker.wave.2.fill",
tint: isAudioFocused ? .white : .green,
disabled: false
title: forceMuteAudio ? "Audio Disabled" : (isAudioFocused ? "Mute All" : "Listen Here"),
subtitle: forceMuteAudio ? "This channel is always muted." : (isAudioFocused ? "Silence the multiview mix." : "Route game audio to this tile."),
icon: forceMuteAudio ? "speaker.slash.circle.fill" : (isAudioFocused ? "speaker.slash.fill" : "speaker.wave.2.fill"),
tint: forceMuteAudio ? .orange : (isAudioFocused ? .white : .green),
disabled: forceMuteAudio
) {
viewModel.toggleAudioFocus(streamID: streamID)
}
@@ -1181,7 +1218,7 @@ struct StreamControlSheet: View {
)
}
.disabled(disabled)
.buttonStyle(.card)
.platformCardStyle()
}
}
@@ -1210,9 +1247,23 @@ struct MultiStreamFullScreenView: View {
.padding(.horizontal, 18)
.padding(.bottom, 12)
}
.overlay(alignment: .topTrailing) {
#if os(iOS)
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(20)
}
#endif
}
#if os(tvOS)
.onExitCommand {
dismiss()
}
#endif
.onChange(of: viewModel.activeStreams.count) { _, count in
if count == 0 {
dismiss()
@@ -1308,6 +1359,7 @@ private struct MultiViewFocusEntry {
let frame: CGRect
}
#if os(tvOS)
private func nextMultiViewFocusID(
from currentID: String?,
direction: MoveCommandDirection,
@@ -1357,3 +1409,4 @@ private func nextMultiViewFocusID(
.entry
.streamID
}
#endif