Files
yattee/Shared/Player/VideoPlayerView.swift
Arkadiusz Fal f4d4daccd0 Optimize SwiftUI performance throughout the app
This commit addresses multiple SwiftUI performance bottlenecks identified
through code analysis, focusing on view rendering efficiency, list
performance, and memory usage optimization.

Key improvements:

- HomeView: Optimize async task management using structured concurrency
  with async let to handle multiple Defaults updates in a single task

- VideoCell: Remove GeometryReader from VideoCellThumbnail to eliminate
  layout thrashing; change @ObservedObject to computed property for shared
  ThumbnailsModel

- ThumbnailView: Cache URL extension computation in init() instead of
  recalculating on every body evaluation

- FavoriteItemView: Replace filter().prefix() with early-exit loop and
  capacity reservation for significant performance gain on large lists

- ContentItemView: Optimize FetchRequest creation with direct predicate
  construction only for video items, empty predicate for others

- VideoPlayerView: Fix playerSize didSet trigger by moving
  updateSidebarQueue() calls to explicit onChange/onAppear handlers

- FeedView: Replace .unique() with Set-based deduplication for O(n)
  performance and reduced allocations

- VerticalCells: Remove expensive sorting on every redraw; items should
  be pre-sorted from source

These optimizations follow SwiftUI best practices by minimizing expensive
computations in view bodies, caching computed values, using efficient data
structures, and avoiding unnecessary redraws and layout passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:14:35 +01:00

513 lines
18 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 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? {
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: .bottomLeading) {
#if os(tvOS)
ZStack {
player.playerBackendView
if player.activeBackend == .mpv {
tvControls
}
}
.ignoresSafeArea()
#else
GeometryReader { geometry in
player.playerBackendView
.modifier(
VideoPlayerSizeModifier(
geometry: geometry,
aspectRatio: player.aspectRatio,
fullScreen: fullScreenPlayer,
detailsHiddenInFullScreen: detailsHiddenInFullScreen
)
)
.onHover { hovering in
hoveringPlayer = hovering
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 {
player.controls.resetTimer()
}
}
return $0
}
}
#endif
.background(Color.black)
if !detailsHiddenInFullScreen {
VideoDetails(
video: player.videoForDisplay,
fullScreen: $fullScreenDetails,
sidebarQueue: $sidebarQueue
)
.modifier(VideoDetailsPaddingModifier(
playerSize: player.playerSize,
fullScreen: fullScreenDetails
))
#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