Files
yattee/Yattee/Views/Player/MPVVideoView.swift
Arkadiusz Fal 4ec9b936a6 Fix background playback setting behavior across platforms
Hide the toggle on macOS, where apps are never suspended and playback
always continues regardless of the setting. Pause playback on iOS when
backgrounding with the setting disabled — the audio background mode
previously kept mpv playing, making the off state a no-op.

Move the continue-playing policy into PlayerService and make the
backend's background render pause unconditional (PiP-gated only):
it is a GPU optimization that should not depend on the setting.
Also ungate the player sheet visibility handlers, which manage
foreground mini player rendering unrelated to background playback.
2026-07-18 16:28:19 +02:00

338 lines
12 KiB
Swift

//
// MPVVideoView.swift
// Yattee
//
// SwiftUI view that displays MPV video with custom controls overlay.
//
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
/// SwiftUI view that wraps MPV video rendering with custom controls.
struct MPVVideoView: View {
@Environment(\.appEnvironment) private var appEnvironment
let backend: MPVBackend
@Bindable var playerState: PlayerState
let playerService: PlayerService
/// Whether to show playback controls
var showsControls: Bool = true
/// Whether to apply aspect ratio (enable when this view controls its own size)
var appliesAspectRatio: Bool = false
/// Whether this view is in widescreen layout mode
var isWideScreenLayout: Bool = false
/// Whether to show the debug overlay (disable when parent handles it)
var showsDebugOverlay: Bool = true
/// Callback for toggling panel visibility (widescreen layout)
var onTogglePanel: (() -> Void)? = nil
/// Whether panel is currently visible (widescreen layout)
var isPanelVisible: Bool = true
/// Which side the panel is on (widescreen layout)
var panelSide: FloatingPanelSide = .right
/// Callback for closing the video
var onClose: (() -> Void)? = nil
/// Callback for toggling fullscreen (widescreen videos only)
var onToggleFullscreen: (() -> Void)? = nil
/// Whether currently in fullscreen mode
var isFullscreen: Bool = false
/// Whether current video is widescreen (aspect ratio > 1.0)
var isWidescreenVideo: Bool = false
#if os(iOS)
/// Callback for toggling orientation lock
var onToggleOrientationLock: (() -> Void)? = nil
/// Whether orientation is currently locked
var isOrientationLocked: Bool = false
/// Callback for toggling details visibility (fullscreen in portrait)
var onToggleDetailsVisibility: (() -> Void)? = nil
/// Callback for showing queue management sheet
var onShowQueue: (() -> Void)? = nil
#endif
/// Debug stats for overlay (updated periodically when visible)
@State private var debugStats: MPVDebugStats = MPVDebugStats()
/// Timer for updating debug stats
@State private var debugUpdateTimer: Timer?
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let height = geometry.size.height
ZStack {
// MPV render surface - always show (fullscreen is now within the same view hierarchy)
MPVRenderViewRepresentable(
backend: backend,
playerState: playerState
)
.frame(width: width, height: height)
.background(Color.black)
// Loading overlay - shown only if backend setup is not complete
// (should be rare since backends are pre-warmed at app launch)
if !backend.isSetupComplete {
Color.black
.overlay(
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
.tint(.white)
Text("Initializing player...")
.font(.caption)
.foregroundColor(.secondary)
}
)
.transition(.opacity)
.zIndex(100)
}
// Custom controls overlay (MPV has no native controls)
// Hide during PiP or when debug overlay is shown
if showsControls && playerState.pipState != .active && !playerState.showDebugOverlay {
controlsView
.frame(width: width, height: height)
}
// Debug overlay - show when enabled and not in widescreen (widescreen has its own)
if showsDebugOverlay && !isWideScreenLayout {
// Tap anywhere to dismiss
Color.clear
.contentShape(Rectangle())
.onTapGesture {
playerState.showDebugOverlay = false
}
.opacity(playerState.showDebugOverlay ? 1 : 0)
.allowsHitTesting(playerState.showDebugOverlay)
VStack {
HStack {
MPVDebugOverlay(
stats: debugStats,
isVisible: $playerState.showDebugOverlay,
isLandscape: false
)
Spacer()
}
Spacer()
}
.padding(12)
.allowsHitTesting(false)
.opacity(playerState.showDebugOverlay ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: playerState.showDebugOverlay)
}
}
.frame(width: width, height: height)
}
.modifier(ConditionalAspectRatio(
ratio: playerState.displayAspectRatio,
applies: appliesAspectRatio
))
.onAppear {
#if os(iOS) || os(macOS)
// Resume the MPV display link when the expanded video view appears.
// MiniPlayerView pauses rendering when its video preview hides during expand;
// PlayerService.playerSheetDidAppear skips resuming while PiP is active, so it
// can't be relied on to resume. On macOS this also recovers the sheet re-open
// black screen when the shared render view reappears without a window re-parent.
backend.resumeRendering()
#endif
// Start debug updates if overlay is already visible when view appears
if playerState.showDebugOverlay {
startDebugUpdates()
}
}
.onChange(of: playerState.showDebugOverlay) { _, isVisible in
if isVisible {
startDebugUpdates()
} else {
stopDebugUpdates()
}
}
.onDisappear {
stopDebugUpdates()
}
}
private func startDebugUpdates() {
// Stop any existing timer first
stopDebugUpdates()
// Update immediately
debugStats = backend.getDebugStats()
// Then update every second
debugUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor in
self.debugStats = self.backend.getDebugStats()
}
}
}
private func stopDebugUpdates() {
debugUpdateTimer?.invalidate()
debugUpdateTimer = nil
}
// MARK: - Controls View
@ViewBuilder
private var controlsView: some View {
#if os(iOS)
PlayerControlsView(
playerState: playerState,
onPlayPause: {
playerService.togglePlayPause()
},
onSeek: { time in
await playerService.seek(to: time)
},
onSeekForward: { seconds in
playerService.seekForward(by: seconds)
},
onSeekBackward: { seconds in
playerService.seekBackward(by: seconds)
},
onToggleFullscreen: onToggleFullscreen,
isFullscreen: isFullscreen,
isWidescreenVideo: isWidescreenVideo,
onClose: onClose,
onTogglePiP: {
backend.togglePiP()
},
onToggleDebug: {
withAnimation(.easeInOut(duration: 0.2)) {
playerState.showDebugOverlay.toggle()
}
},
isWideScreenLayout: isWideScreenLayout,
onTogglePanel: onTogglePanel,
isPanelVisible: isPanelVisible,
panelSide: panelSide,
onToggleOrientationLock: onToggleOrientationLock,
isOrientationLocked: isOrientationLocked,
onToggleDetailsVisibility: onToggleDetailsVisibility,
onPlayNext: {
await playerService.playNext()
},
onPlayPrevious: {
await playerService.playPrevious()
},
onShowQueue: onShowQueue,
onVolumeChanged: { [weak appEnvironment] volume in
playerService.currentBackend?.volume = volume
appEnvironment?.settingsManager.playerVolume = volume
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
},
onMuteToggled: { [weak appEnvironment] in
let newMuted = !playerState.isMuted
playerService.currentBackend?.isMuted = newMuted
playerState.isMuted = newMuted
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
},
currentVideo: playerState.currentVideo,
availableCaptions: playerService.availableCaptions,
currentCaption: playerService.currentCaption,
availableStreams: playerService.availableStreams,
currentStream: playerState.currentStream,
currentAudioStream: playerState.currentAudioStream,
onRateChanged: { rate in
playerState.rate = rate
playerService.currentBackend?.rate = Float(rate.rawValue)
},
onCaptionSelected: { caption in
playerService.loadCaption(caption)
},
onStreamSelected: { stream, audioStream in
Task {
await playerService.selectStreamManually(stream, audioStream: audioStream)
}
}
)
#elseif os(macOS)
MacOSPlayerControlsView(
playerState: playerState,
onPlayPause: {
playerService.togglePlayPause()
},
onSeek: { time in
await playerService.seek(to: time)
},
onSeekForward: { seconds in
playerService.seekForward(by: seconds)
},
onSeekBackward: { seconds in
playerService.seekBackward(by: seconds)
},
onToggleFullscreen: onToggleFullscreen,
isFullscreen: isFullscreen,
onClose: onClose,
onPlayNext: {
await playerService.playNext()
},
onVolumeChanged: { [weak appEnvironment] volume in
playerService.currentBackend?.volume = volume
appEnvironment?.settingsManager.playerVolume = volume
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
},
onMuteToggled: { [weak appEnvironment] in
let newMuted = !playerState.isMuted
playerService.currentBackend?.isMuted = newMuted
playerState.isMuted = newMuted
appEnvironment?.remoteControlCoordinator.broadcastStateUpdate()
},
onRateChanged: { rate in
playerState.rate = rate
playerService.currentBackend?.rate = Float(rate.rawValue)
}
)
#else
// tvOS uses its own player controls (TVPlayerControlsView)
EmptyView()
#endif
}
}
/// Conditionally applies aspect ratio modifier
struct ConditionalAspectRatio: ViewModifier {
let ratio: Double
let applies: Bool
func body(content: Content) -> some View {
if applies {
content
.aspectRatio(ratio, contentMode: .fit)
} else {
content
}
}
}
// MARK: - Preview
#Preview {
// Preview requires mock objects
ZStack {
Color.black
Text("MPV Video View")
.foregroundStyle(.white)
}
.aspectRatio(16/9, contentMode: .fit)
}
#endif