mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
386 lines
14 KiB
Swift
386 lines
14 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
final class NuvioPlayerWindow {
|
|
let state = NuvioPlayerState()
|
|
var mpvView: NuvioMPVView!
|
|
private var containerView: PlayerContainerView?
|
|
private var stateTimer: Timer?
|
|
private var hideTimer: Timer?
|
|
private var hostingView: NSHostingView<NuvioControlsView>!
|
|
private var keyMonitor: Any?
|
|
private var mouseMonitor: Any?
|
|
private var gestureDismissWork: DispatchWorkItem?
|
|
private let mouseWakeThreshold: CGFloat = 8
|
|
private var lastControlMousePoint: NSPoint?
|
|
|
|
func show() {
|
|
DispatchQueue.main.async { [self] in
|
|
guard let window = NSApp.keyWindow ?? NSApp.mainWindow,
|
|
let parentView = window.contentView else { return }
|
|
|
|
window.acceptsMouseMovedEvents = true
|
|
|
|
let containerView = PlayerContainerView(frame: parentView.bounds)
|
|
containerView.translatesAutoresizingMaskIntoConstraints = false
|
|
containerView.playerWindow = self
|
|
self.containerView = containerView
|
|
parentView.addSubview(containerView)
|
|
NSLayoutConstraint.activate([
|
|
containerView.leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
|
|
containerView.trailingAnchor.constraint(equalTo: parentView.trailingAnchor),
|
|
containerView.topAnchor.constraint(equalTo: parentView.topAnchor),
|
|
containerView.bottomAnchor.constraint(equalTo: parentView.bottomAnchor),
|
|
])
|
|
|
|
mpvView = NuvioMPVView(frame: containerView.bounds)
|
|
mpvView.translatesAutoresizingMaskIntoConstraints = false
|
|
containerView.addSubview(mpvView)
|
|
NSLayoutConstraint.activate([
|
|
mpvView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
mpvView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
mpvView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
|
mpvView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
|
])
|
|
mpvView.setup()
|
|
mpvView.onStateChanged = { [weak self] in
|
|
self?.syncStateFromMPV()
|
|
}
|
|
|
|
let controlsView = NuvioControlsView(
|
|
state: state,
|
|
onPlay: { [weak self] in self?.mpvView.playPlayback() },
|
|
onPause: { [weak self] in self?.mpvView.pausePlayback() },
|
|
onSeekBack: { [weak self] in self?.mpvView.seekByMs(-10000) },
|
|
onSeekForward: { [weak self] in self?.mpvView.seekByMs(10000) },
|
|
onSeekTo: { [weak self] ms in self?.mpvView.seekToMs(ms) },
|
|
onCycleResize: { [weak self] in self?.cycleResizeMode() },
|
|
onCycleSpeed: { [weak self] in self?.cycleSpeed() },
|
|
onSelectAudioTrack: { [weak self] trackId in self?.mpvView.selectAudio(trackId) },
|
|
onSelectSubtitleTrack: { [weak self] trackId in self?.mpvView.selectSubtitle(trackId) },
|
|
onClose: { [weak self] in self?.close() },
|
|
onSkip: { [weak self] in
|
|
guard let self else { return }
|
|
self.mpvView.seekToMs(self.state.skipEndTimeMs)
|
|
self.state.skipButtonType = nil
|
|
},
|
|
onNextEpisode: { [weak self] in
|
|
self?.state.nextEpisodePressed = true
|
|
},
|
|
onApplySubtitleStyle: { [weak self] color, outline, fontSize, subPos in
|
|
self?.mpvView.applySubtitleStyle(textColor: color, outlineSize: outline, fontSize: fontSize, subPos: subPos)
|
|
},
|
|
onAddSubtitleUrl: { [weak self] url in
|
|
self?.mpvView.addSubtitleUrl(url)
|
|
},
|
|
onRemoveExternalAndSelect: { [weak self] trackId in
|
|
self?.mpvView.removeExternalSubtitlesAndSelect(trackId)
|
|
},
|
|
onFetchAddonSubtitles: { [weak self] in
|
|
self?.state.addonSubtitlesFetchRequested = true
|
|
},
|
|
onSubmitIntro: { [weak self] segmentType, startSec, endSec in
|
|
self?.state.submitIntroSegmentType = segmentType
|
|
self?.state.submitIntroStartSec = startSec
|
|
self?.state.submitIntroEndSec = endSec
|
|
self?.state.submitIntroRequested = true
|
|
}
|
|
)
|
|
hostingView = NSHostingView(rootView: controlsView)
|
|
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
|
hostingView.layer?.backgroundColor = .clear
|
|
containerView.addSubview(hostingView)
|
|
NSLayoutConstraint.activate([
|
|
hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
|
hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
|
hostingView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
|
hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
|
])
|
|
|
|
let area = NSTrackingArea(
|
|
rect: containerView.bounds,
|
|
options: [.mouseMoved, .activeAlways, .inVisibleRect, .mouseEnteredAndExited],
|
|
owner: containerView,
|
|
userInfo: nil
|
|
)
|
|
containerView.addTrackingArea(area)
|
|
|
|
stateTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
|
|
self?.syncStateFromMPV()
|
|
}
|
|
|
|
scheduleHideControls()
|
|
|
|
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
guard let self else { return event }
|
|
return self.handleKeyDown(event) ? nil : event
|
|
}
|
|
|
|
mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged]) { [weak self] event in
|
|
guard let self else { return event }
|
|
if self.isMouseEventInsidePlayer(event) {
|
|
self.handleMouseMoved(event)
|
|
} else if self.state.cursorHidden {
|
|
NSCursor.unhide()
|
|
self.state.cursorHidden = false
|
|
self.lastControlMousePoint = nil
|
|
}
|
|
return event
|
|
}
|
|
}
|
|
}
|
|
|
|
func close() {
|
|
DispatchQueue.main.async { [self] in
|
|
if let monitor = keyMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
keyMonitor = nil
|
|
}
|
|
if let monitor = mouseMonitor {
|
|
NSEvent.removeMonitor(monitor)
|
|
mouseMonitor = nil
|
|
}
|
|
gestureDismissWork?.cancel()
|
|
gestureDismissWork = nil
|
|
stateTimer?.invalidate()
|
|
stateTimer = nil
|
|
hideTimer?.invalidate()
|
|
hideTimer = nil
|
|
mpvView?.destroyPlayer()
|
|
containerView?.removeFromSuperview()
|
|
containerView = nil
|
|
lastControlMousePoint = nil
|
|
state.isClosed = true
|
|
NSCursor.unhide()
|
|
}
|
|
}
|
|
|
|
func handleMouseMoved(_ event: NSEvent? = nil) {
|
|
if state.controlsLocked {
|
|
return
|
|
}
|
|
guard let point = currentMousePointInPlayer(event), hasMeaningfulMouseMovement(to: point) else { return }
|
|
showControls()
|
|
scheduleHideControls()
|
|
}
|
|
|
|
func handleMouseExited() {
|
|
lastControlMousePoint = nil
|
|
if state.cursorHidden {
|
|
NSCursor.unhide()
|
|
state.cursorHidden = false
|
|
}
|
|
}
|
|
|
|
func handleMouseClicked() {
|
|
if state.controlsLocked {
|
|
state.lockedOverlayVisible = true
|
|
return
|
|
}
|
|
if state.controlsVisible {
|
|
hideControlsNow()
|
|
} else {
|
|
showControls()
|
|
scheduleHideControls()
|
|
}
|
|
}
|
|
|
|
func handleKeyDown(_ event: NSEvent) -> Bool {
|
|
if state.controlsLocked {
|
|
if event.keyCode == 53 {
|
|
close()
|
|
return true
|
|
}
|
|
state.lockedOverlayVisible = true
|
|
return true
|
|
}
|
|
switch event.keyCode {
|
|
case 53:
|
|
close()
|
|
return true
|
|
case 49:
|
|
if state.isPlaying { mpvView.pausePlayback() } else { mpvView.playPlayback() }
|
|
return true
|
|
case 123:
|
|
mpvView.seekByMs(-10000)
|
|
showGestureFeedback(GestureFeedbackState(message: "-10s", icon: .seekBackward))
|
|
return true
|
|
case 124:
|
|
mpvView.seekByMs(10000)
|
|
showGestureFeedback(GestureFeedbackState(message: "+10s", icon: .seekForward))
|
|
return true
|
|
case 125:
|
|
let vol = mpvView.adjustVolume(by: -5)
|
|
let muted = vol <= 0
|
|
showGestureFeedback(GestureFeedbackState(
|
|
message: muted ? "Muted" : "Volume \(Int(vol))%",
|
|
icon: muted ? .volumeMuted : .volume,
|
|
isDanger: muted
|
|
))
|
|
return true
|
|
case 126:
|
|
let vol = mpvView.adjustVolume(by: 5)
|
|
showGestureFeedback(GestureFeedbackState(message: "Volume \(Int(vol))%", icon: .volume))
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func showGestureFeedback(_ feedback: GestureFeedbackState) {
|
|
state.gestureFeedback = feedback
|
|
gestureDismissWork?.cancel()
|
|
let work = DispatchWorkItem { [weak self] in
|
|
self?.state.gestureFeedback = nil
|
|
}
|
|
gestureDismissWork = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9, execute: work)
|
|
}
|
|
|
|
private func syncStateFromMPV() {
|
|
guard mpvView != nil, mpvView.mpv != nil else { return }
|
|
mpvView.refreshPlaybackState()
|
|
mpvView.refreshTracks()
|
|
state.isLoading = mpvView.isPlayerLoading
|
|
state.isPlaying = mpvView.isPlayerPlaying
|
|
state.isEnded = mpvView.isPlayerEnded
|
|
state.positionMs = mpvView.positionMs
|
|
state.durationMs = mpvView.durationMs
|
|
state.bufferedMs = mpvView.bufferedMs
|
|
state.currentSpeed = mpvView.currentSpeed
|
|
state.audioTracks = mpvView.audioTracks
|
|
state.subtitleTracks = mpvView.subtitleTracks
|
|
state.errorMessage = mpvView.currentErrorMessage
|
|
if !mpvView.isPlayerLoading && !state.initialLoadCompleted {
|
|
state.initialLoadCompleted = true
|
|
}
|
|
}
|
|
|
|
private func cycleResizeMode() {
|
|
let next = (state.resizeMode + 1) % 3
|
|
state.resizeMode = next
|
|
mpvView.setResize(next)
|
|
}
|
|
|
|
private func cycleSpeed() {
|
|
let speeds: [Float] = [1.0, 1.25, 1.5, 2.0]
|
|
let current = state.currentSpeed
|
|
var nextSpeed: Float = 1.0
|
|
for s in speeds {
|
|
if s > current + 0.01 {
|
|
nextSpeed = s
|
|
break
|
|
}
|
|
}
|
|
state.currentSpeed = nextSpeed
|
|
mpvView.setSpeed(nextSpeed)
|
|
}
|
|
|
|
private func showControls() {
|
|
if state.controlsLocked {
|
|
state.lockedOverlayVisible = true
|
|
return
|
|
}
|
|
state.controlsVisible = true
|
|
if state.cursorHidden {
|
|
NSCursor.unhide()
|
|
state.cursorHidden = false
|
|
}
|
|
}
|
|
|
|
private func hideControlsNow() {
|
|
hideTimer?.invalidate()
|
|
hideTimer = nil
|
|
if state.controlsLocked {
|
|
return
|
|
}
|
|
state.controlsVisible = false
|
|
lastControlMousePoint = currentMousePointInPlayer()
|
|
if !state.cursorHidden && isCurrentMouseInsidePlayer() {
|
|
NSCursor.hide()
|
|
state.cursorHidden = true
|
|
}
|
|
}
|
|
|
|
private func scheduleHideControls() {
|
|
hideTimer?.invalidate()
|
|
if state.controlsLocked {
|
|
return
|
|
}
|
|
hideTimer = Timer.scheduledTimer(withTimeInterval: 3.5, repeats: false) { [weak self] _ in
|
|
guard let self, self.state.isPlaying else { return }
|
|
if self.state.showSubtitlePanel || self.state.showAudioPanel || self.state.showSourcesPanel || self.state.showEpisodesPanel || self.state.showSubmitIntroPanel { return }
|
|
self.state.controlsVisible = false
|
|
self.lastControlMousePoint = self.currentMousePointInPlayer()
|
|
if !self.state.cursorHidden && self.isCurrentMouseInsidePlayer() {
|
|
NSCursor.hide()
|
|
self.state.cursorHidden = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isMouseEventInsidePlayer(_ event: NSEvent) -> Bool {
|
|
currentMousePointInPlayer(event) != nil
|
|
}
|
|
|
|
private func isCurrentMouseInsidePlayer() -> Bool {
|
|
currentMousePointInPlayer() != nil
|
|
}
|
|
|
|
private func currentMousePointInPlayer(_ event: NSEvent? = nil) -> NSPoint? {
|
|
guard let containerView else { return nil }
|
|
if let event {
|
|
guard event.window === containerView.window else { return nil }
|
|
let point = containerView.convert(event.locationInWindow, from: nil)
|
|
return containerView.bounds.contains(point) ? point : nil
|
|
}
|
|
guard let window = containerView.window else { return nil }
|
|
let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
|
|
let point = containerView.convert(windowPoint, from: nil)
|
|
return containerView.bounds.contains(point) ? point : nil
|
|
}
|
|
|
|
private func hasMeaningfulMouseMovement(to point: NSPoint) -> Bool {
|
|
guard let lastPoint = lastControlMousePoint else {
|
|
lastControlMousePoint = point
|
|
return state.controlsVisible
|
|
}
|
|
let dx = point.x - lastPoint.x
|
|
let dy = point.y - lastPoint.y
|
|
if dx * dx + dy * dy < mouseWakeThreshold * mouseWakeThreshold {
|
|
return false
|
|
}
|
|
lastControlMousePoint = point
|
|
return true
|
|
}
|
|
}
|
|
|
|
final class PlayerContainerView: NSView {
|
|
weak var playerWindow: NuvioPlayerWindow?
|
|
|
|
override var acceptsFirstResponder: Bool { true }
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
playerWindow?.mpvView?.openGLContext?.update()
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
}
|
|
|
|
override func mouseMoved(with event: NSEvent) {
|
|
playerWindow?.handleMouseMoved(event)
|
|
}
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
playerWindow?.handleMouseExited()
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
if event.clickCount == 2 {
|
|
if let w = window {
|
|
w.toggleFullScreen(nil)
|
|
}
|
|
}
|
|
}
|
|
}
|