mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
327
Yattee/Views/Player/MPVVideoView.swift
Normal file
327
Yattee/Views/Player/MPVVideoView.swift
Normal file
@@ -0,0 +1,327 @@
|
||||
//
|
||||
// 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 {
|
||||
// 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
|
||||
guard let video = playerState.currentVideo else { return }
|
||||
let currentTime = playerState.currentTime
|
||||
Task {
|
||||
await playerService.play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime)
|
||||
}
|
||||
}
|
||||
)
|
||||
#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()
|
||||
}
|
||||
)
|
||||
#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
|
||||
Reference in New Issue
Block a user