Fix NSFW sheet scroll on iOS/iPad, clean up audio pin

- 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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-16 19:30:25 -05:00
parent 08ad702f9d
commit da033cf12c
3 changed files with 122 additions and 28 deletions

View File

@@ -623,22 +623,24 @@ struct WerkoutNSFWSheet: View {
sheetBackground sheetBackground
.ignoresSafeArea() .ignoresSafeArea()
ViewThatFits { ScrollView(.vertical, showsIndicators: false) {
HStack(alignment: .top, spacing: 32) { ViewThatFits(in: .horizontal) {
overviewColumn HStack(alignment: .top, spacing: 32) {
.frame(maxWidth: .infinity, alignment: .leading) overviewColumn
.frame(maxWidth: .infinity, alignment: .leading)
actionColumn actionColumn
.frame(width: 360, alignment: .leading) .frame(width: 360, alignment: .leading)
} }
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
overviewColumn overviewColumn
actionColumn actionColumn
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
}
} }
} }
.padding(38) .padding(usesStackedLayout ? 24 : 38)
.background( .background(
RoundedRectangle(cornerRadius: 34, style: .continuous) RoundedRectangle(cornerRadius: 34, style: .continuous)
.fill(.black.opacity(0.46)) .fill(.black.opacity(0.46))

View File

@@ -630,7 +630,10 @@ private struct MultiStreamTile: View {
) )
let item = makePlayerItem(url: url, headers: headers) 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 { private func makePlayerItem(url: URL, headers: [String: String]) -> AVPlayerItem {
@@ -644,6 +647,8 @@ private struct MultiStreamTile: View {
let asset = AVURLAsset(url: url, options: assetOptions) let asset = AVURLAsset(url: url, options: assetOptions)
item = AVPlayerItem(asset: asset) item = AVPlayerItem(asset: asset)
} }
item.allowedAudioSpatializationFormats = []
logMultiView("startStream configured player item id=\(stream.id) allowedAudioSpatializationFormats=[]")
pinAudioSelection(on: item) pinAudioSelection(on: item)
return 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. /// Pin the HLS audio rendition so ABR can't swap channel layouts mid-stream.
private func pinAudioSelection(on item: AVPlayerItem) { private func pinAudioSelection(on item: AVPlayerItem) {
let streamID = stream.id let streamID = stream.id
let asset = item.asset Task { @MainActor in
Task { @MainActor [weak item] in await enforcePinnedMultiStreamAudioSelection(on: item, streamID: streamID)
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)")
} }
} }
@@ -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 { private struct MultiStreamPlayerLayerView: UIViewRepresentable {
let player: AVPlayer let player: AVPlayer
let streamID: String let streamID: String
@@ -1548,6 +1587,7 @@ final class AudioDiagnostics {
) { [weak self, weak item] _ in ) { [weak self, weak item] _ in
guard let self, let item else { return } guard let self, let item else { return }
Task { @MainActor in Task { @MainActor in
await enforcePinnedMultiStreamAudioSelection(on: item, streamID: self.tag)
let asset = item.asset let asset = item.asset
guard let group = try? await asset.loadMediaSelectionGroup(for: .audible), guard let group = try? await asset.loadMediaSelectionGroup(for: .audible),
let selected = item.currentMediaSelection.selectedMediaOption(in: group) else { let selected = item.currentMediaSelection.selectedMediaOption(in: group) else {

View File

@@ -71,22 +71,58 @@ private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource)
) )
} }
item.preferredForwardBufferDuration = 8 item.preferredForwardBufferDuration = 8
item.allowedAudioSpatializationFormats = []
logSingleStream("Configured player item preferredForwardBufferDuration=8 allowedAudioSpatializationFormats=[]")
pinSingleStreamAudioSelection(on: item) pinSingleStreamAudioSelection(on: item)
return item return item
} }
/// Pin the HLS audio rendition so ABR can't swap channel layouts mid-stream. /// Pin the HLS audio rendition so ABR can't swap channel layouts mid-stream.
private func pinSingleStreamAudioSelection(on item: AVPlayerItem) { private func pinSingleStreamAudioSelection(on item: AVPlayerItem) {
let asset = item.asset Task { @MainActor in
Task { @MainActor [weak item] in await enforcePinnedSingleStreamAudioSelection(on: item)
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)")
} }
} }
@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 { struct SingleStreamPlaybackScreen: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
@@ -565,9 +601,12 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
let playerItem = makeSingleStreamPlayerItem(from: source) let playerItem = makeSingleStreamPlayerItem(from: source)
let player = AVPlayer(playerItem: playerItem) let player = AVPlayer(playerItem: playerItem)
player.appliesMediaSelectionCriteriaAutomatically = false
player.automaticallyWaitsToMinimizeStalling = true player.automaticallyWaitsToMinimizeStalling = true
player.isMuted = source.forceMuteAudio 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) context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource)
controller.player = player controller.player = player
if context.coordinator.audioDiagnostics == nil { 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) { func scheduleStartupRecovery(for player: AVPlayer) {