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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user