From da033cf12cee0fada852605aff90c5fee5a6473b Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 16 Apr 2026 19:30:25 -0500 Subject: [PATCH] Fix NSFW sheet scroll on iOS/iPad, clean up audio pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WerkoutNSFWSheet: wrap content in ScrollView + ViewThatFits(in: .horizontal) so iPad's narrow sheet width falls back to VStack and content scrolls. - Tighten padding on compact layouts (38→24). - Revert AAC-preference in pinAudioSelection (stream is all AAC, no Dolby). Co-Authored-By: Claude Opus 4.6 (1M context) --- mlbTVOS/Views/DashboardView.swift | 26 +++++---- mlbTVOS/Views/MultiStreamView.swift | 56 +++++++++++++++--- mlbTVOS/Views/SingleStreamPlayerView.swift | 68 +++++++++++++++++++--- 3 files changed, 122 insertions(+), 28 deletions(-) diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index 0b8b1b8..801c18d 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -623,22 +623,24 @@ struct WerkoutNSFWSheet: View { sheetBackground .ignoresSafeArea() - ViewThatFits { - HStack(alignment: .top, spacing: 32) { - overviewColumn - .frame(maxWidth: .infinity, alignment: .leading) + ScrollView(.vertical, showsIndicators: false) { + ViewThatFits(in: .horizontal) { + 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) + VStack(alignment: .leading, spacing: 24) { + overviewColumn + actionColumn + .frame(maxWidth: .infinity, alignment: .leading) + } } } - .padding(38) + .padding(usesStackedLayout ? 24 : 38) .background( RoundedRectangle(cornerRadius: 34, style: .continuous) .fill(.black.opacity(0.46)) diff --git a/mlbTVOS/Views/MultiStreamView.swift b/mlbTVOS/Views/MultiStreamView.swift index 2f35f38..30c05d0 100644 --- a/mlbTVOS/Views/MultiStreamView.swift +++ b/mlbTVOS/Views/MultiStreamView.swift @@ -630,7 +630,10 @@ private struct MultiStreamTile: View { ) let item = makePlayerItem(url: url, headers: headers) - return AVPlayer(playerItem: item) + let player = AVPlayer(playerItem: item) + player.appliesMediaSelectionCriteriaAutomatically = false + logMultiView("startStream configured AVPlayer id=\(stream.id) appliesMediaSelectionCriteriaAutomatically=false") + return player } private func makePlayerItem(url: URL, headers: [String: String]) -> AVPlayerItem { @@ -644,6 +647,8 @@ private struct MultiStreamTile: View { let asset = AVURLAsset(url: url, options: assetOptions) item = AVPlayerItem(asset: asset) } + item.allowedAudioSpatializationFormats = [] + logMultiView("startStream configured player item id=\(stream.id) allowedAudioSpatializationFormats=[]") pinAudioSelection(on: item) return item } @@ -651,13 +656,8 @@ private struct MultiStreamTile: View { /// Pin the HLS audio rendition so ABR can't swap channel layouts mid-stream. private func pinAudioSelection(on item: AVPlayerItem) { let streamID = stream.id - let asset = item.asset - Task { @MainActor [weak item] in - guard let group = try? await asset.loadMediaSelectionGroup(for: .audible), - let option = group.defaultOption ?? group.options.first, - let item else { return } - item.select(option, in: group) - logMultiView("pinAudioSelection id=\(streamID) selected=\(option.displayName) options=\(group.options.count)") + Task { @MainActor in + await enforcePinnedMultiStreamAudioSelection(on: item, streamID: streamID) } } @@ -818,6 +818,45 @@ private struct MultiStreamTile: View { } } +@MainActor +private func enforcePinnedMultiStreamAudioSelection(on item: AVPlayerItem, streamID: String) async { + let asset = item.asset + guard let group = try? await asset.loadMediaSelectionGroup(for: .audible), + let option = preferredMultiStreamAudioOption(in: group) else { return } + let current = item.currentMediaSelection.selectedMediaOption(in: group) + if current != option { + item.select(option, in: group) + } + logMultiView( + "pinAudioSelection id=\(streamID) selected=\(option.displayName) current=\(current?.displayName ?? "nil") options=\(group.options.count)" + ) +} + +private func preferredMultiStreamAudioOption(in group: AVMediaSelectionGroup) -> AVMediaSelectionOption? { + let defaultOption = group.defaultOption + return group.options.max { lhs, rhs in + multiStreamAudioPreferenceScore(for: lhs, defaultOption: defaultOption) < multiStreamAudioPreferenceScore(for: rhs, defaultOption: defaultOption) + } ?? defaultOption ?? group.options.first +} + +private func multiStreamAudioPreferenceScore(for option: AVMediaSelectionOption, defaultOption: AVMediaSelectionOption?) -> Int { + let name = option.displayName.lowercased() + var score = 0 + + if option == defaultOption { score += 40 } + if name.contains("stereo") || name.contains("2.0") || name.contains("main") { score += 30 } + if name.contains("english") || name.contains("eng") { score += 20 } + if name.contains("surround") || name.contains("5.1") || name.contains("atmos") { score -= 30 } + if name.contains("spanish") || name.contains("sap") || name.contains("descriptive") || name.contains("alternate") { + score -= 25 + } + if option.hasMediaCharacteristic(.describesVideoForAccessibility) { + score -= 40 + } + + return score +} + private struct MultiStreamPlayerLayerView: UIViewRepresentable { let player: AVPlayer let streamID: String @@ -1548,6 +1587,7 @@ final class AudioDiagnostics { ) { [weak self, weak item] _ in guard let self, let item else { return } Task { @MainActor in + await enforcePinnedMultiStreamAudioSelection(on: item, streamID: self.tag) let asset = item.asset guard let group = try? await asset.loadMediaSelectionGroup(for: .audible), let selected = item.currentMediaSelection.selectedMediaOption(in: group) else { diff --git a/mlbTVOS/Views/SingleStreamPlayerView.swift b/mlbTVOS/Views/SingleStreamPlayerView.swift index e33712d..5ddcd59 100644 --- a/mlbTVOS/Views/SingleStreamPlayerView.swift +++ b/mlbTVOS/Views/SingleStreamPlayerView.swift @@ -71,22 +71,58 @@ private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) ) } item.preferredForwardBufferDuration = 8 + item.allowedAudioSpatializationFormats = [] + logSingleStream("Configured player item preferredForwardBufferDuration=8 allowedAudioSpatializationFormats=[]") pinSingleStreamAudioSelection(on: item) return item } /// Pin the HLS audio rendition so ABR can't swap channel layouts mid-stream. private func pinSingleStreamAudioSelection(on item: AVPlayerItem) { - let asset = item.asset - Task { @MainActor [weak item] in - guard let group = try? await asset.loadMediaSelectionGroup(for: .audible), - let option = group.defaultOption ?? group.options.first, - let item else { return } - item.select(option, in: group) - logSingleStream("pinAudioSelection selected=\(option.displayName) options=\(group.options.count)") + Task { @MainActor in + await enforcePinnedSingleStreamAudioSelection(on: item) } } +@MainActor +private func enforcePinnedSingleStreamAudioSelection(on item: AVPlayerItem) async { + let asset = item.asset + guard let group = try? await asset.loadMediaSelectionGroup(for: .audible), + let option = preferredSingleStreamAudioOption(in: group) else { return } + let current = item.currentMediaSelection.selectedMediaOption(in: group) + if current != option { + item.select(option, in: group) + } + logSingleStream( + "pinAudioSelection selected=\(option.displayName) current=\(current?.displayName ?? "nil") options=\(group.options.count)" + ) +} + +private func preferredSingleStreamAudioOption(in group: AVMediaSelectionGroup) -> AVMediaSelectionOption? { + let defaultOption = group.defaultOption + return group.options.max { lhs, rhs in + audioPreferenceScore(for: lhs, defaultOption: defaultOption) < audioPreferenceScore(for: rhs, defaultOption: defaultOption) + } ?? defaultOption ?? group.options.first +} + +private func audioPreferenceScore(for option: AVMediaSelectionOption, defaultOption: AVMediaSelectionOption?) -> Int { + let name = option.displayName.lowercased() + var score = 0 + + if option == defaultOption { score += 40 } + if name.contains("stereo") || name.contains("2.0") || name.contains("main") { score += 30 } + if name.contains("english") || name.contains("eng") { score += 20 } + if name.contains("surround") || name.contains("5.1") || name.contains("atmos") { score -= 30 } + if name.contains("spanish") || name.contains("sap") || name.contains("descriptive") || name.contains("alternate") { + score -= 25 + } + if option.hasMediaCharacteristic(.describesVideoForAccessibility) { + score -= 40 + } + + return score +} + struct SingleStreamPlaybackScreen: View { @Environment(\.dismiss) private var dismiss let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? @@ -565,9 +601,12 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { let playerItem = makeSingleStreamPlayerItem(from: source) let player = AVPlayer(playerItem: playerItem) + player.appliesMediaSelectionCriteriaAutomatically = false player.automaticallyWaitsToMinimizeStalling = true player.isMuted = source.forceMuteAudio - logSingleStream("Configured player for quality ramp preferredForwardBufferDuration=8 automaticallyWaitsToMinimizeStalling=true") + logSingleStream( + "Configured player for quality ramp preferredForwardBufferDuration=8 automaticallyWaitsToMinimizeStalling=true appliesMediaSelectionCriteriaAutomatically=false" + ) context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource) controller.player = player if context.coordinator.audioDiagnostics == nil { @@ -812,6 +851,19 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { } } ) + + notificationTokens.append( + NotificationCenter.default.addObserver( + forName: AVPlayerItem.mediaSelectionDidChangeNotification, + object: item, + queue: .main + ) { _ in + logSingleStream("Notification mediaSelectionDidChange") + Task { @MainActor in + await enforcePinnedSingleStreamAudioSelection(on: item) + } + } + ) } func scheduleStartupRecovery(for player: AVPlayer) {