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:
@@ -1,9 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
actor MLBServerAPI {
|
||||
static let defaultBaseURL = "https://ballgame.treytartt.com"
|
||||
|
||||
let baseURL: String
|
||||
|
||||
init(baseURL: String = "http://10.3.3.11:5714") {
|
||||
init(baseURL: String = defaultBaseURL) {
|
||||
self.baseURL = baseURL
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ private func gamesViewModelDebugURLDescription(_ url: URL) -> String {
|
||||
}
|
||||
|
||||
private struct RemoteVideoFeedEntry: Decodable {
|
||||
let videoFile: String
|
||||
let videoFile: String?
|
||||
let hlsUrl: String?
|
||||
let genderValue: String?
|
||||
}
|
||||
|
||||
@@ -46,7 +47,7 @@ final class GamesViewModel {
|
||||
var multiViewLayoutMode: MultiViewLayoutMode = .balanced
|
||||
var audioFocusStreamID: String?
|
||||
|
||||
var serverBaseURL: String = "http://10.3.3.11:5714"
|
||||
var serverBaseURL: String = MLBServerAPI.defaultBaseURL
|
||||
var defaultResolution: String = "best"
|
||||
|
||||
@ObservationIgnored
|
||||
@@ -172,7 +173,8 @@ final class GamesViewModel {
|
||||
overrideHeaders: activeStreams[streamIdx].overrideHeaders,
|
||||
player: activeStreams[streamIdx].player,
|
||||
isPlaying: activeStreams[streamIdx].isPlaying,
|
||||
isMuted: activeStreams[streamIdx].isMuted
|
||||
isMuted: activeStreams[streamIdx].isMuted,
|
||||
forceMuteAudio: activeStreams[streamIdx].forceMuteAudio
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -358,8 +360,6 @@ final class GamesViewModel {
|
||||
func addStream(broadcast: Broadcast, game: Game) {
|
||||
guard activeStreams.count < 4 else { return }
|
||||
guard !activeStreams.contains(where: { $0.id == broadcast.id }) else { return }
|
||||
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
|
||||
|
||||
let stream = ActiveStream(
|
||||
id: broadcast.id,
|
||||
game: game,
|
||||
@@ -368,7 +368,7 @@ final class GamesViewModel {
|
||||
streamURLString: broadcast.streamURL
|
||||
)
|
||||
activeStreams.append(stream)
|
||||
if shouldCaptureAudio {
|
||||
if shouldCaptureAudio(for: stream) {
|
||||
audioFocusStreamID = stream.id
|
||||
}
|
||||
syncAudioFocus()
|
||||
@@ -376,8 +376,6 @@ final class GamesViewModel {
|
||||
|
||||
func addStreamByTeam(teamCode: String, game: Game) {
|
||||
guard activeStreams.count < 4 else { return }
|
||||
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
|
||||
|
||||
let config = StreamConfig(team: teamCode, resolution: defaultResolution, date: selectedDate)
|
||||
let stream = ActiveStream(
|
||||
id: "\(teamCode)-\(game.id)",
|
||||
@@ -386,7 +384,7 @@ final class GamesViewModel {
|
||||
config: config
|
||||
)
|
||||
activeStreams.append(stream)
|
||||
if shouldCaptureAudio {
|
||||
if shouldCaptureAudio(for: stream) {
|
||||
audioFocusStreamID = stream.id
|
||||
}
|
||||
syncAudioFocus()
|
||||
@@ -397,21 +395,22 @@ final class GamesViewModel {
|
||||
label: String,
|
||||
game: Game,
|
||||
url: URL,
|
||||
headers: [String: String] = [:]
|
||||
headers: [String: String] = [:],
|
||||
forceMuteAudio: Bool = false
|
||||
) {
|
||||
guard activeStreams.count < 4 else { return }
|
||||
guard !activeStreams.contains(where: { $0.id == id }) else { return }
|
||||
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
|
||||
|
||||
let stream = ActiveStream(
|
||||
id: id,
|
||||
game: game,
|
||||
label: label,
|
||||
overrideURL: url,
|
||||
overrideHeaders: headers.isEmpty ? nil : headers
|
||||
overrideHeaders: headers.isEmpty ? nil : headers,
|
||||
forceMuteAudio: forceMuteAudio
|
||||
)
|
||||
activeStreams.append(stream)
|
||||
if shouldCaptureAudio {
|
||||
if shouldCaptureAudio(for: stream) {
|
||||
audioFocusStreamID = stream.id
|
||||
}
|
||||
syncAudioFocus()
|
||||
@@ -422,7 +421,8 @@ final class GamesViewModel {
|
||||
label: String,
|
||||
game: Game,
|
||||
feedURL: URL,
|
||||
headers: [String: String] = [:]
|
||||
headers: [String: String] = [:],
|
||||
forceMuteAudio: Bool = false
|
||||
) async -> Bool {
|
||||
guard let resolvedURL = await resolveAuthenticatedVideoFeedURL(feedURL: feedURL, headers: headers) else {
|
||||
return false
|
||||
@@ -433,7 +433,8 @@ final class GamesViewModel {
|
||||
label: label,
|
||||
game: game,
|
||||
url: resolvedURL,
|
||||
headers: headers
|
||||
headers: headers,
|
||||
forceMuteAudio: forceMuteAudio
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -464,7 +465,7 @@ final class GamesViewModel {
|
||||
audioFocusStreamID = nil
|
||||
} else if removedWasAudioFocus {
|
||||
let replacementIndex = min(index, activeStreams.count - 1)
|
||||
audioFocusStreamID = activeStreams[replacementIndex].id
|
||||
audioFocusStreamID = preferredAudioFocusStreamID(preferredIndex: replacementIndex)
|
||||
}
|
||||
syncAudioFocus()
|
||||
}
|
||||
@@ -496,8 +497,13 @@ final class GamesViewModel {
|
||||
}
|
||||
|
||||
func setAudioFocus(streamID: String?) {
|
||||
if let streamID, activeStreams.contains(where: { $0.id == streamID }) {
|
||||
if let streamID,
|
||||
let stream = activeStreams.first(where: { $0.id == streamID }),
|
||||
!stream.forceMuteAudio {
|
||||
audioFocusStreamID = streamID
|
||||
} else if streamID != nil {
|
||||
syncAudioFocus()
|
||||
return
|
||||
} else {
|
||||
audioFocusStreamID = nil
|
||||
}
|
||||
@@ -512,7 +518,7 @@ final class GamesViewModel {
|
||||
guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return }
|
||||
activeStreams[index].player = player
|
||||
activeStreams[index].isPlaying = true
|
||||
let shouldMute = audioFocusStreamID != streamID
|
||||
let shouldMute = shouldMuteAudio(for: activeStreams[index])
|
||||
activeStreams[index].isMuted = shouldMute
|
||||
player.isMuted = shouldMute
|
||||
}
|
||||
@@ -528,13 +534,42 @@ final class GamesViewModel {
|
||||
}
|
||||
|
||||
private func syncAudioFocus() {
|
||||
if let audioFocusStreamID,
|
||||
!activeStreams.contains(where: { $0.id == audioFocusStreamID && !$0.forceMuteAudio }) {
|
||||
self.audioFocusStreamID = preferredAudioFocusStreamID()
|
||||
}
|
||||
|
||||
for index in activeStreams.indices {
|
||||
let shouldMute = activeStreams[index].id != audioFocusStreamID
|
||||
let shouldMute = shouldMuteAudio(for: activeStreams[index])
|
||||
activeStreams[index].isMuted = shouldMute
|
||||
activeStreams[index].player?.isMuted = shouldMute
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldCaptureAudio(for stream: ActiveStream) -> Bool {
|
||||
!stream.forceMuteAudio && audioFocusStreamID == nil
|
||||
}
|
||||
|
||||
private func shouldMuteAudio(for stream: ActiveStream) -> Bool {
|
||||
stream.forceMuteAudio || audioFocusStreamID != stream.id
|
||||
}
|
||||
|
||||
private func preferredAudioFocusStreamID(preferredIndex: Int? = nil) -> String? {
|
||||
let eligibleIndices = activeStreams.indices.filter { !activeStreams[$0].forceMuteAudio }
|
||||
guard !eligibleIndices.isEmpty else { return nil }
|
||||
|
||||
if let preferredIndex {
|
||||
if let forwardIndex = eligibleIndices.first(where: { $0 >= preferredIndex }) {
|
||||
return activeStreams[forwardIndex].id
|
||||
}
|
||||
if let fallbackIndex = eligibleIndices.last {
|
||||
return activeStreams[fallbackIndex].id
|
||||
}
|
||||
}
|
||||
|
||||
return activeStreams[eligibleIndices[0]].id
|
||||
}
|
||||
|
||||
func buildStreamURL(for config: StreamConfig) async -> URL {
|
||||
let startedAt = Date()
|
||||
logGamesViewModel("buildStreamURL start mediaId=\(config.mediaId) resolution=\(config.resolution)")
|
||||
@@ -623,8 +658,9 @@ final class GamesViewModel {
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let entries = try decoder.decode([RemoteVideoFeedEntry].self, from: data)
|
||||
|
||||
let urls = entries.compactMap { entry in
|
||||
URL(string: entry.videoFile, relativeTo: feedURL)?.absoluteURL
|
||||
let urls: [URL] = entries.compactMap { entry -> URL? in
|
||||
guard let path = entry.hlsUrl ?? entry.videoFile else { return nil }
|
||||
return URL(string: path, relativeTo: feedURL)?.absoluteURL
|
||||
}
|
||||
|
||||
guard !urls.isEmpty else {
|
||||
@@ -763,7 +799,6 @@ final class GamesViewModel {
|
||||
func addMLBNetwork() async {
|
||||
guard activeStreams.count < 4 else { return }
|
||||
guard !activeStreams.contains(where: { $0.id == "MLBN" }) else { return }
|
||||
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
|
||||
|
||||
let dummyGame = Game(
|
||||
id: "MLBN",
|
||||
@@ -778,7 +813,7 @@ final class GamesViewModel {
|
||||
id: "MLBN", game: dummyGame, label: "MLB Network", overrideURL: url
|
||||
)
|
||||
activeStreams.append(stream)
|
||||
if shouldCaptureAudio {
|
||||
if shouldCaptureAudio(for: stream) {
|
||||
audioFocusStreamID = stream.id
|
||||
}
|
||||
syncAudioFocus()
|
||||
@@ -818,4 +853,5 @@ struct ActiveStream: Identifiable, @unchecked Sendable {
|
||||
var player: AVPlayer?
|
||||
var isPlaying = false
|
||||
var isMuted = false
|
||||
var forceMuteAudio = false
|
||||
}
|
||||
|
||||
39
mlbTVOS/Views/Components/PlatformUI.swift
Normal file
39
mlbTVOS/Views/Components/PlatformUI.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlatformPressButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.92 : 1.0)
|
||||
.animation(.easeOut(duration: 0.16), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func platformCardStyle() -> some View {
|
||||
#if os(tvOS)
|
||||
self.buttonStyle(.card)
|
||||
#else
|
||||
self.buttonStyle(PlatformPressButtonStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func platformFocusSection() -> some View {
|
||||
#if os(tvOS)
|
||||
self.focusSection()
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func platformFocusable(_ enabled: Bool = true) -> some View {
|
||||
#if os(tvOS)
|
||||
self.focusable(enabled)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,14 @@ private func logDashboard(_ message: String) {
|
||||
enum SpecialPlaybackChannelConfig {
|
||||
static let werkoutNSFWStreamID = "WKNSFW"
|
||||
static let werkoutNSFWTitle = "Werkout NSFW"
|
||||
static let werkoutNSFWSubtitle = "Authenticated private HLS feed"
|
||||
static let werkoutNSFWFeedURLString = "https://dev.werkout.fitness/videos/nsfw_videos/"
|
||||
static let werkoutNSFWAuthToken = "15d7565cde9e8c904ae934f8235f68f6a24b4a03"
|
||||
static let werkoutNSFWSubtitle = "Authenticated OF media feed"
|
||||
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout&type=video"
|
||||
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc1MjQ2OTI2fQ.rDvZzLQ70MM9drHwA8hRxmqcTTgBGBHXxv1Cc55HSqc"
|
||||
static let werkoutNSFWTeamCode = "WK"
|
||||
|
||||
static var werkoutNSFWHeaders: [String: String] {
|
||||
[
|
||||
"authorization": "Token \(werkoutNSFWAuthToken)",
|
||||
"Cookie": werkoutNSFWCookie,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,15 +50,48 @@ enum SpecialPlaybackChannelConfig {
|
||||
|
||||
struct DashboardView: View {
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var selectedGame: Game?
|
||||
@State private var fullScreenBroadcast: BroadcastSelection?
|
||||
@State private var pendingFullScreenBroadcast: BroadcastSelection?
|
||||
@State private var showMLBNetworkSheet = false
|
||||
@State private var showWerkoutNSFWSheet = false
|
||||
|
||||
private var horizontalPadding: CGFloat {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact ? 20 : 32
|
||||
#else
|
||||
60
|
||||
#endif
|
||||
}
|
||||
|
||||
private var verticalPadding: CGFloat {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact ? 24 : 32
|
||||
#else
|
||||
40
|
||||
#endif
|
||||
}
|
||||
|
||||
private var contentSpacing: CGFloat {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact ? 32 : 42
|
||||
#else
|
||||
50
|
||||
#endif
|
||||
}
|
||||
|
||||
private var shelfCardWidth: CGFloat {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact ? 300 : 360
|
||||
#else
|
||||
400
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 50) {
|
||||
VStack(alignment: .leading, spacing: contentSpacing) {
|
||||
headerSection
|
||||
|
||||
if viewModel.isLoading {
|
||||
@@ -111,8 +144,8 @@ struct DashboardView: View {
|
||||
multiViewStatus
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 60)
|
||||
.padding(.vertical, 40)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, verticalPadding)
|
||||
}
|
||||
.onAppear {
|
||||
logDashboard("DashboardView appeared")
|
||||
@@ -210,7 +243,8 @@ struct DashboardView: View {
|
||||
}
|
||||
return SingleStreamPlaybackSource(
|
||||
url: url,
|
||||
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
|
||||
forceMuteAudio: true
|
||||
)
|
||||
}
|
||||
let stream = ActiveStream(
|
||||
@@ -240,7 +274,8 @@ struct DashboardView: View {
|
||||
}
|
||||
return SingleStreamPlaybackSource(
|
||||
url: nextURL,
|
||||
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
|
||||
forceMuteAudio: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -303,10 +338,10 @@ struct DashboardView: View {
|
||||
GameCardView(game: game) {
|
||||
selectedGame = game
|
||||
}
|
||||
.frame(width: 400)
|
||||
.frame(width: shelfCardWidth)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
@@ -392,12 +427,19 @@ struct DashboardView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var featuredChannelsSection: some View {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
mlbNetworkCard
|
||||
.frame(maxWidth: .infinity)
|
||||
ViewThatFits {
|
||||
HStack(alignment: .top, spacing: 24) {
|
||||
mlbNetworkCard
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
nsfwVideosCard
|
||||
.frame(maxWidth: .infinity)
|
||||
nsfwVideosCard
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
mlbNetworkCard
|
||||
nsfwVideosCard
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +478,7 @@ struct DashboardView: View {
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -478,7 +520,7 @@ struct DashboardView: View {
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
// MARK: - Multi-View Status
|
||||
@@ -528,6 +570,7 @@ struct BroadcastSelection: Identifiable {
|
||||
struct WerkoutNSFWSheet: View {
|
||||
var onWatchFullScreen: () -> Void
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isResolvingMultiViewSource = false
|
||||
@State private var multiViewErrorMessage: String?
|
||||
@@ -540,17 +583,41 @@ struct WerkoutNSFWSheet: View {
|
||||
added || viewModel.activeStreams.count < 4
|
||||
}
|
||||
|
||||
private var usesStackedLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var outerHorizontalPadding: CGFloat {
|
||||
usesStackedLayout ? 20 : 94
|
||||
}
|
||||
|
||||
private var outerVerticalPadding: CGFloat {
|
||||
usesStackedLayout ? 24 : 70
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
sheetBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
HStack(alignment: .top, spacing: 32) {
|
||||
overviewColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
ViewThatFits {
|
||||
HStack(alignment: .top, spacing: 32) {
|
||||
overviewColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
actionColumn
|
||||
.frame(width: 360, alignment: .leading)
|
||||
actionColumn
|
||||
.frame(width: 360, alignment: .leading)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
overviewColumn
|
||||
actionColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(38)
|
||||
.background(
|
||||
@@ -561,8 +628,8 @@ struct WerkoutNSFWSheet: View {
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 94)
|
||||
.padding(.vertical, 70)
|
||||
.padding(.horizontal, outerHorizontalPadding)
|
||||
.padding(.vertical, outerVerticalPadding)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,7 +778,8 @@ struct WerkoutNSFWSheet: View {
|
||||
label: SpecialPlaybackChannelConfig.werkoutNSFWTitle,
|
||||
game: SpecialPlaybackChannelConfig.werkoutNSFWGame,
|
||||
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
|
||||
forceMuteAudio: true
|
||||
)
|
||||
isResolvingMultiViewSource = false
|
||||
if didAddStream {
|
||||
@@ -770,7 +838,7 @@ struct WerkoutNSFWSheet: View {
|
||||
.fill(fill)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,12 +26,20 @@ struct FeaturedGameCard: View {
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 28) {
|
||||
matchupColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
ViewThatFits {
|
||||
HStack(alignment: .top, spacing: 28) {
|
||||
matchupColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
sidePanel
|
||||
.frame(width: 760, alignment: .leading)
|
||||
sidePanel
|
||||
.frame(width: 760, alignment: .leading)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
matchupColumn
|
||||
sidePanel
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 34)
|
||||
.padding(.top, 30)
|
||||
@@ -60,7 +68,7 @@ struct FeaturedGameCard: View {
|
||||
)
|
||||
.shadow(color: shadowColor, radius: 24, y: 10)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -62,7 +62,7 @@ struct GameCardView: View {
|
||||
y: 8
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -76,7 +76,7 @@ struct GameCenterView: View {
|
||||
.background(.white.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
.disabled(game.gamePk == nil)
|
||||
}
|
||||
.padding(22)
|
||||
|
||||
@@ -4,6 +4,7 @@ struct StreamOptionsSheet: View {
|
||||
let game: Game
|
||||
var onWatch: ((BroadcastSelection) -> Void)?
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
|
||||
@@ -17,6 +18,22 @@ struct StreamOptionsSheet: View {
|
||||
viewModel.activeStreams.count < 4
|
||||
}
|
||||
|
||||
private var usesStackedLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var horizontalPadding: CGFloat {
|
||||
usesStackedLayout ? 20 : 56
|
||||
}
|
||||
|
||||
private var verticalPadding: CGFloat {
|
||||
usesStackedLayout ? 24 : 42
|
||||
}
|
||||
|
||||
private var awayPitcherName: String? {
|
||||
guard let pitchers = game.pitchers else { return nil }
|
||||
let parts = pitchers.components(separatedBy: " vs ")
|
||||
@@ -32,12 +49,20 @@ struct StreamOptionsSheet: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
HStack(alignment: .top, spacing: 28) {
|
||||
matchupColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
ViewThatFits {
|
||||
HStack(alignment: .top, spacing: 28) {
|
||||
matchupColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
actionRail
|
||||
.frame(width: 520, alignment: .leading)
|
||||
actionRail
|
||||
.frame(width: 520, alignment: .leading)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
matchupColumn
|
||||
actionRail
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
if !canAddMoreStreams {
|
||||
@@ -53,8 +78,8 @@ struct StreamOptionsSheet: View {
|
||||
.background(panelBackground)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 56)
|
||||
.padding(.vertical, 42)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, verticalPadding)
|
||||
}
|
||||
.background(sheetBackground.ignoresSafeArea())
|
||||
}
|
||||
@@ -470,7 +495,7 @@ struct StreamOptionsSheet: View {
|
||||
.fill(fill)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,28 @@ import SwiftUI
|
||||
|
||||
struct LeagueCenterView: View {
|
||||
@Environment(GamesViewModel.self) private var gamesViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var viewModel = LeagueCenterViewModel()
|
||||
@State private var selectedGame: Game?
|
||||
|
||||
private let rosterColumns = [
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
]
|
||||
private var rosterColumns: [GridItem] {
|
||||
let columnCount: Int
|
||||
#if os(iOS)
|
||||
columnCount = horizontalSizeClass == .compact ? 1 : 2
|
||||
#else
|
||||
columnCount = 3
|
||||
#endif
|
||||
|
||||
return Array(repeating: GridItem(.flexible(), spacing: 14), count: columnCount)
|
||||
}
|
||||
|
||||
private var horizontalPadding: CGFloat {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact ? 20 : 32
|
||||
#else
|
||||
56
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -40,7 +54,7 @@ struct LeagueCenterView: View {
|
||||
messagePanel(playerErrorMessage, tint: .orange)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 56)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
.background(screenBackground.ignoresSafeArea())
|
||||
@@ -158,7 +172,7 @@ struct LeagueCenterView: View {
|
||||
.padding(22)
|
||||
.background(sectionPanel)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
.disabled(linkedGame == nil)
|
||||
}
|
||||
|
||||
@@ -218,7 +232,7 @@ struct LeagueCenterView: View {
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.focusSection()
|
||||
.platformFocusSection()
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
@@ -306,12 +320,12 @@ struct LeagueCenterView: View {
|
||||
.stroke(.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.focusSection()
|
||||
.platformFocusSection()
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
@@ -356,7 +370,7 @@ struct LeagueCenterView: View {
|
||||
.padding(24)
|
||||
.background(sectionPanel)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.focusable(true)
|
||||
.platformFocusable()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,7 +405,7 @@ struct LeagueCenterView: View {
|
||||
.padding(16)
|
||||
.background(sectionPanel)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -482,7 +496,7 @@ struct LeagueCenterView: View {
|
||||
.padding(24)
|
||||
.background(sectionPanel)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.focusable(true)
|
||||
.platformFocusable()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,7 +594,7 @@ struct LeagueCenterView: View {
|
||||
.background(.white.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
private func loadingPanel(title: String) -> some View {
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct MLBNetworkSheet: View {
|
||||
var onWatchFullScreen: () -> Void
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var added: Bool {
|
||||
@@ -13,17 +14,41 @@ struct MLBNetworkSheet: View {
|
||||
added || viewModel.activeStreams.count < 4
|
||||
}
|
||||
|
||||
private var usesStackedLayout: Bool {
|
||||
#if os(iOS)
|
||||
horizontalSizeClass == .compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
private var outerHorizontalPadding: CGFloat {
|
||||
usesStackedLayout ? 20 : 94
|
||||
}
|
||||
|
||||
private var outerVerticalPadding: CGFloat {
|
||||
usesStackedLayout ? 24 : 70
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
sheetBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
HStack(alignment: .top, spacing: 32) {
|
||||
networkOverview
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
ViewThatFits {
|
||||
HStack(alignment: .top, spacing: 32) {
|
||||
networkOverview
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
actionColumn
|
||||
.frame(width: 360, alignment: .leading)
|
||||
actionColumn
|
||||
.frame(width: 360, alignment: .leading)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
networkOverview
|
||||
actionColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(38)
|
||||
.background(
|
||||
@@ -34,8 +59,8 @@ struct MLBNetworkSheet: View {
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 94)
|
||||
.padding(.vertical, 70)
|
||||
.padding(.horizontal, outerHorizontalPadding)
|
||||
.padding(.vertical, outerVerticalPadding)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +234,7 @@ struct MLBNetworkSheet: View {
|
||||
.fill(fill)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
.disabled(disabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,6 +76,7 @@ private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource)
|
||||
}
|
||||
|
||||
struct SingleStreamPlaybackScreen: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
|
||||
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
|
||||
let tickerGames: [Game]
|
||||
@@ -90,6 +91,18 @@ struct SingleStreamPlaybackScreen: View {
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
.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
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay")
|
||||
@@ -103,10 +116,12 @@ struct SingleStreamPlaybackScreen: View {
|
||||
struct SingleStreamPlaybackSource: Sendable {
|
||||
let url: URL
|
||||
let httpHeaders: [String: String]
|
||||
let forceMuteAudio: Bool
|
||||
|
||||
init(url: URL, httpHeaders: [String: String] = [:]) {
|
||||
init(url: URL, httpHeaders: [String: String] = [:], forceMuteAudio: Bool = false) {
|
||||
self.url = url
|
||||
self.httpHeaders = httpHeaders
|
||||
self.forceMuteAudio = forceMuteAudio
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +300,9 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
let controller = AVPlayerViewController()
|
||||
controller.allowsPictureInPicturePlayback = true
|
||||
controller.showsPlaybackControls = true
|
||||
#if os(iOS)
|
||||
controller.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
#endif
|
||||
logSingleStream("AVPlayerViewController configured without contentOverlayView ticker")
|
||||
|
||||
Task { @MainActor in
|
||||
@@ -311,6 +329,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
let playerItem = makeSingleStreamPlayerItem(from: source)
|
||||
let player = AVPlayer(playerItem: playerItem)
|
||||
player.automaticallyWaitsToMinimizeStalling = false
|
||||
player.isMuted = source.forceMuteAudio
|
||||
logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false")
|
||||
context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource)
|
||||
controller.player = player
|
||||
@@ -433,6 +452,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
let nextItem = makeSingleStreamPlayerItem(from: nextSource)
|
||||
player.replaceCurrentItem(with: nextItem)
|
||||
player.automaticallyWaitsToMinimizeStalling = false
|
||||
player.isMuted = nextSource.forceMuteAudio
|
||||
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
|
||||
logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))")
|
||||
player.playImmediately(atRate: 1.0)
|
||||
|
||||
Reference in New Issue
Block a user