mirror of
https://github.com/yattee/yattee.git
synced 2026-07-19 22:02:10 +00:00
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.
338 lines
12 KiB
Swift
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
|