Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View 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