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:
@@ -623,7 +623,8 @@ struct WerkoutNSFWSheet: View {
|
||||
sheetBackground
|
||||
.ignoresSafeArea()
|
||||
|
||||
ViewThatFits {
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(alignment: .top, spacing: 32) {
|
||||
overviewColumn
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -638,7 +639,8 @@ struct WerkoutNSFWSheet: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(38)
|
||||
}
|
||||
.padding(usesStackedLayout ? 24 : 38)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
||||
.fill(.black.opacity(0.46))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user