Files
yattee/Shared/Player/VideoPlayerView.swift
Arkadiusz Fal d5f9a24efa Fix player controls clipping in resizable iPad windows
When playing video fullscreen in a resizable window on iPad, the player
height was being forced to UIScreen.main.bounds.size.height, which is
the full screen size. In resizable windows, this caused the player
container to extend beyond the visible window bounds, clipping controls
at the bottom.

Now on iPad, the player uses natural geometry provided by its container
which respects actual window bounds, while iPhone continues using
screen-based calculation for proper fullscreen behavior.
2025-11-14 23:26:20 +01:00

526 lines
19 KiB
Swift

import AVKit
#if os(iOS)
import CoreMotion
#endif
import Defaults
import Repeat
import Siesta
import SwiftUI
struct VideoPlayerView: View {
#if os(iOS)
static let hiddenOffset = UIScreen.main.bounds.height
static let defaultSidebarQueueValue = UIScreen.main.bounds.width > 900 && Defaults[.playerSidebar] == .whenFits
#else
static let defaultSidebarQueueValue = Defaults[.playerSidebar] != .never
#endif
#if os(macOS)
static let hiddenOffset = 0.0
#endif
static let defaultAspectRatio = Constants.aspectRatio16x9
static var defaultMinimumHeightLeft: Double {
#if os(macOS)
335
#else
140
#endif
}
@State private var playerSize: CGSize = .zero
@State private var hoveringPlayer = false
@State private var sidebarQueue = defaultSidebarQueueValue
@Environment(\.colorScheme) private var colorScheme
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
private var orientationModel = OrientationModel.shared
#elseif os(macOS)
var hoverThrottle = Throttle(interval: 0.5)
var mouseLocation: CGPoint { NSEvent.mouseLocation }
#endif
#if !os(tvOS)
@GestureState var dragGestureState = false
@GestureState var dragGestureOffset = CGSize.zero
// swiftlint:disable private_swiftui_state
@State var isHorizontalDrag = false
@State var isVerticalDrag = false
@State var viewDragOffset = Self.hiddenOffset
@State var detailViewDragOffset: Double = 0
// swiftlint:enable private_swiftui_state
#endif
// swiftlint:disable private_swiftui_state
@State var disableToggleGesture = false
@State var fullScreenDetails = false
// swiftlint:enable private_swiftui_state
@ObservedObject var player = PlayerModel.shared // swiftlint:disable:this swiftui_state_private
#if os(macOS)
@ObservedObject private var navigation = NavigationModel.shared
#endif
@Default(.horizontalPlayerGestureEnabled) var horizontalPlayerGestureEnabled
@Default(.fullscreenPlayerGestureEnabled) var fullscreenPlayerGestureEnabled
@Default(.seekGestureSpeed) var seekGestureSpeed
@Default(.seekGestureSensitivity) var seekGestureSensitivity
@Default(.playerSidebar) var playerSidebar
@Default(.gestureBackwardSeekDuration) private var gestureBackwardSeekDuration
@Default(.gestureForwardSeekDuration) private var gestureForwardSeekDuration
@Default(.avPlayerUsesSystemControls) var avPlayerUsesSystemControls
@ObservedObject var controlsOverlayModel = ControlOverlaysModel.shared // swiftlint:disable:this swiftui_state_private
var shouldShowCustomControls: Bool {
player.activeBackend == .mpv || !avPlayerUsesSystemControls || player.musicMode
}
var body: some View {
ZStack(alignment: overlayAlignment) {
videoPlayer
.zIndex(-1)
#if os(iOS)
.gesture(controlsOverlayModel.presenting ? videoPlayerCloseControlsOverlayGesture : nil)
#endif
overlay
}
.onAppear {
if player.musicMode {
player.backend.startControlsUpdates()
}
updateSidebarQueue()
}
.onChange(of: playerSidebar) { _ in
updateSidebarQueue()
}
}
var videoPlayer: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
content
.onAppear {
playerSize = geometry.size
updateSidebarQueue()
}
}
.ignoresSafeArea(.all, edges: .bottom)
#if os(iOS)
.frame(height: playerHeight.isNil ? nil : Double(playerHeight!))
#endif
.onChange(of: geometry.size) { newSize in
self.playerSize = newSize
updateSidebarQueue()
}
#if os(iOS)
.onChange(of: player.presentingPlayer) { newValue in
if newValue {
viewDragOffset = 0
}
}
.onAppear {
#if os(macOS)
if player.videoForDisplay.isNil {
player.hide()
}
#endif
viewDragOffset = 0
}
.onAnimationCompleted(for: viewDragOffset) {
guard !dragGestureState else { return }
if viewDragOffset == 0 {
player.onPresentPlayer.forEach { $0() }
player.onPresentPlayer = []
} else if viewDragOffset == Self.hiddenOffset {
player.hide(animate: false)
}
}
#endif
}
#if os(iOS)
.onChange(of: dragGestureState) { newValue in
guard !newValue else { return }
onPlayerDragGestureEnded()
}
.offset(y: playerOffset)
.animation(dragGestureState ? .interactiveSpring(response: 0.05) : .easeOut(duration: 0.2), value: playerOffset)
.backport
.persistentSystemOverlays(!fullScreenPlayer)
#endif
#if os(macOS)
.frame(minWidth: playerSidebar != .never ? 1100 : 650, minHeight: 700)
#endif
}
func updateSidebarQueue() {
#if os(iOS)
sidebarQueue = playerSize.width > 900 && playerSidebar == .whenFits
#elseif os(macOS)
sidebarQueue = playerSidebar != .never
#endif
}
var overlay: some View {
VStack {
if controlsOverlayModel.presenting {
HStack {
HStack {
ControlsOverlay()
#if os(tvOS)
.onExitCommand {
withAnimation(PlayerControls.animation) {
player.controls.hideOverlays()
}
}
.onPlayPauseCommand {
player.togglePlay()
}
#endif
.padding()
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
}
#if !os(tvOS)
.frame(maxWidth: fullScreenPlayer ? .infinity : player.playerSize.width)
#endif
#if !os(tvOS)
if !fullScreenPlayer, sidebarQueue {
Spacer()
}
#endif
}
#if os(tvOS)
.clipShape(RoundedRectangle(cornerRadius: 10))
#endif
.zIndex(1)
.transition(.opacity)
}
}
}
var overlayWidth: Double {
guard playerSize.width.isFinite else { return 200 }
return [playerSize.width - 50, 250].min()!
}
var overlayAlignment: Alignment {
#if os(tvOS)
return .bottomTrailing
#else
return .top
#endif
}
#if os(iOS)
var videoPlayerCloseControlsOverlayGesture: some Gesture {
TapGesture().onEnded {
withAnimation(PlayerControls.animation) {
player.controls.hideOverlays()
}
}
}
var playerOffset: Double {
dragGestureState && !isHorizontalDrag ? dragGestureOffset.height : dragOffset
}
var dragOffset: Double {
if viewDragOffset.isZero || viewDragOffset == Self.hiddenOffset {
return viewDragOffset
}
return player.presentingPlayer ? 0 : Self.hiddenOffset
}
var playerHeight: Double? {
// On iPad, don't force a specific height in fullscreen mode
// This allows the player to properly fit within resizable windows
if UIDevice.current.userInterfaceIdiom == .pad {
return nil
}
// For iPhone, use screen bounds to ensure proper fullscreen sizing
let lockedPortrait = player.lockedOrientation?.contains(.portrait) ?? false
let isPortrait = OrientationTracker.shared.currentInterfaceOrientation.isPortrait || lockedPortrait
return fullScreenPlayer ? UIScreen.main.bounds.size.height - (isPortrait ? safeAreaModel.safeArea.top + safeAreaModel.safeArea.bottom : 0) : nil
}
#endif
var content: some View {
Group {
ZStack(alignment: .topLeading) {
#if os(tvOS)
ZStack {
player.playerBackendView
if player.activeBackend == .mpv {
tvControls
}
}
.ignoresSafeArea()
#else
GeometryReader { geometry in
VStack(spacing: 0) {
player.playerBackendView
.modifier(
VideoPlayerSizeModifier(
geometry: geometry,
aspectRatio: player.aspectRatio,
fullScreen: fullScreenPlayer,
detailsHiddenInFullScreen: detailsHiddenInFullScreen
)
)
.onHover { hovering in
hoveringPlayer = hovering
// Only show/hide custom controls if they should be used
if shouldShowCustomControls {
if hovering {
player.controls.show()
} else {
player.controls.hide()
}
}
}
.gesture(player.controls.presentingOverlays ? nil : playerDragGesture)
#if os(macOS)
.onAppear {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
hoverThrottle.execute {
if !player.currentItem.isNil, hoveringPlayer, shouldShowCustomControls {
player.controls.resetTimer()
}
}
return $0
}
}
#endif
.background(Color.black)
if !detailsHiddenInFullScreen {
VideoDetails(
video: player.videoForDisplay,
fullScreen: $fullScreenDetails,
sidebarQueue: $sidebarQueue
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
#if os(macOS)
// TODO: Check whether this is needed on macOS.
.onDisappear {
if player.presentingPlayer {
player.setNeedsDrawing(true)
}
}
#endif
.id(player.currentVideo?.cacheKey)
.transition(.opacity)
.offset(y: detailViewDragOffset)
.gesture(detailsDragGesture)
} else {
VStack {}
}
}
}
#endif
}
#if os(iOS)
.background(BackgroundBlackView().edgesIgnoringSafeArea(.all))
#endif
.background(((colorScheme == .dark || fullScreenPlayer) ? Color.black : Color.white).edgesIgnoringSafeArea(.all))
#if os(macOS)
.frame(minWidth: 650)
#endif
#if os(tvOS)
.onMoveCommand { direction in
if direction == .up {
player.controls.show()
} else if direction == .down, !controlsOverlayModel.presenting, !player.controls.presentingControls {
withAnimation(PlayerControls.animation) {
controlsOverlayModel.hide()
}
}
player.controls.resetTimer()
guard !player.controls.presentingControls else { return }
if direction == .left {
let interval = TimeInterval(gestureBackwardSeekDuration) ?? 10
player.backend.seek(relative: .secondsInDefaultTimescale(-interval), seekType: .userInteracted)
}
if direction == .right {
let interval = TimeInterval(gestureForwardSeekDuration) ?? 10
player.backend.seek(relative: .secondsInDefaultTimescale(interval), seekType: .userInteracted)
}
}
.onPlayPauseCommand {
player.togglePlay()
}
.onExitCommand {
if player.controls.presentingOverlays {
player.controls.hideOverlays()
}
if player.controls.presentingControls {
player.controls.hide()
} else {
player.hide()
}
}
#endif
if !detailsHiddenInFullScreen {
#if os(iOS)
if sidebarQueue {
List {
PlayerQueueView(sidebarQueue: true)
}
#if os(macOS)
.listStyle(.inset)
#elseif os(iOS)
.listStyle(.grouped)
.backport
.scrollContentBackground(false)
#else
.listStyle(.plain)
#endif
.frame(maxWidth: 350)
.background((colorScheme == .dark ? Color.black : Color.white).ignoresSafeArea())
.transition(.move(edge: .bottom))
}
#elseif os(macOS)
if Defaults[.playerSidebar] != .never {
List {
PlayerQueueView(sidebarQueue: true)
}
.frame(maxWidth: 450)
.background(colorScheme == .dark ? Color.black : Color.white)
}
#endif
} else {
VStack {}
}
}
.onChange(of: fullScreenPlayer) { newValue in
if !newValue { player.controls.hideOverlays() }
}
#if os(iOS)
.statusBar(hidden: fullScreenPlayer)
.backport
.toolbarBackground(colorScheme == .light ? .white : .black)
.backport
.toolbarBackgroundVisibility(true)
.backport
.toolbarColorScheme(colorScheme)
#endif
#if os(macOS)
.background(
EmptyView().sheet(isPresented: $navigation.presentingPlaybackSettings) {
PlaybackSettings()
}
)
#endif
}
var detailsHiddenInFullScreen: Bool {
guard fullScreenPlayer else { return false }
if player.activeBackend == .mpv {
return true
}
#if os(iOS)
return !avPlayerUsesSystemControls || verticalSizeClass == .compact
#else
return !avPlayerUsesSystemControls
#endif
}
var fullScreenPlayer: Bool {
#if os(iOS)
player.playingFullScreen || verticalSizeClass == .compact
#elseif os(macOS)
player.playingFullScreen
#elseif os(tvOS)
true
#endif
}
@ViewBuilder var playerPlaceholder: some View {
if player.currentItem.isNil {
ZStack(alignment: .topTrailing) {
HStack {
Spacer()
VStack {
Spacer()
VStack(spacing: 10) {
#if !os(tvOS)
Image(systemName: "ticket")
.font(.system(size: 120))
#endif
}
Spacer()
}
.foregroundColor(.gray)
Spacer()
}
#if os(iOS)
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0, blendDuration: 0)) {
viewDragOffset = Self.hiddenOffset
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 40))
}
.opacity(fullScreenPlayer ? 1 : 0)
.buttonStyle(.plain)
.padding(10)
.foregroundColor(.gray)
#endif
}
.background(colorScheme == .dark ? Color.black : .white)
.contentShape(Rectangle())
.frame(width: player.playerSize.width, height: player.playerSize.height)
}
}
#if os(tvOS)
var tvControls: some View {
TVControls()
}
#endif
}
struct VideoPlayerView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Color.red
VideoPlayerView()
}
}
}
#if os(iOS)
struct BackgroundBlackView: UIViewRepresentable {
func makeUIView(context _: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async {
view.superview?.superview?.backgroundColor = .black
view.superview?.superview?.layer.removeAllAnimations()
}
return view
}
func updateUIView(_: UIView, context _: Context) {}
}
#endif