Files
simvision/KSPlayer-main/Sources/KSPlayer/Video/MacVideoPlayerView.swift
Michael Simard 872354b834 Initial commit: SimVision tvOS streaming app
Features:
- VOD library with movie grouping and version detection
- TV show library with season/episode organization
- TMDB integration for trending shows and recently aired episodes
- Recent releases section with TMDB release date sorting
- Watch history tracking with continue watching
- Playlist caching (12-hour TTL) for offline support
- M3U playlist parsing with XStream API support
- Authentication with credential storage

Technical:
- SwiftUI for tvOS
- Actor-based services for thread safety
- Persistent caching for playlists, TMDB data, and watch history
- KSPlayer integration for video playback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:12:08 -06:00

210 lines
7.6 KiB
Swift

//
// MacVideoPlayerView.swift
// Pods
//
// Created by kintan on 2018/10/31.
//
#if !canImport(UIKit)
import AppKit
import AVFoundation
public extension NSPasteboard.PasteboardType {
static let nsURL = NSPasteboard.PasteboardType("NSURL")
static let nsFilenames = NSPasteboard.PasteboardType("NSFilenamesPboardType")
}
public extension NSDraggingInfo {
@MainActor
func getUrl() -> URL? {
guard let types = draggingPasteboard.types else { return nil }
if types.contains(.nsFilenames) {
guard let paths = draggingPasteboard.propertyList(forType: .nsFilenames) as? [String] else { return nil }
let urls = paths.map { URL(fileURLWithPath: $0) }
return urls.first
}
return nil
}
}
open class MacVideoPlayerView: VideoPlayerView {
override open func customizeUIComponents() {
super.customizeUIComponents()
registerForDraggedTypes([.nsFilenames, .nsURL, .string])
}
override open func player(layer: KSPlayerLayer, state: KSPlayerState) {
super.player(layer: layer, state: state)
if state == .readyToPlay {
let naturalSize = layer.player.naturalSize
window?.aspectRatio = naturalSize
}
}
}
extension MacVideoPlayerView {
override open func updateTrackingAreas() {
for trackingArea in trackingAreas {
removeTrackingArea(trackingArea)
}
let trackingArea = NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow], owner: self, userInfo: nil)
addTrackingArea(trackingArea)
}
override open func mouseEntered(with _: NSEvent) {
isMaskShow = true
}
override open func mouseMoved(with _: NSEvent) {
isMaskShow = true
}
override open func mouseExited(with _: NSEvent) {
isMaskShow = false
}
override open func scrollWheel(with event: NSEvent) {
if event.phase.contains(.began) {
if event.scrollingDeltaX != 0 {
scrollDirection = .horizontal
tmpPanValue = toolBar.timeSlider.value
} else if event.scrollingDeltaY != 0 {
scrollDirection = .vertical
tmpPanValue = 1
}
} else if event.phase.contains(.changed) {
let delta = scrollDirection == .horizontal ? event.scrollingDeltaX : event.scrollingDeltaY
if scrollDirection == .horizontal {
tmpPanValue += Float(delta / 10000) * Float(totalTime)
showSeekToView(second: Double(tmpPanValue), isAdd: delta > 0)
} else {
if KSOptions.enableVolumeGestures {
tmpPanValue -= Float(delta / 1000)
tmpPanValue = max(min(tmpPanValue, 1), 0)
}
}
} else if event.phase.contains(.ended) {
if scrollDirection == .horizontal {
slider(value: Double(tmpPanValue), event: .touchUpInside)
hideSeekToView()
} else {
if KSOptions.enableVolumeGestures {
playerLayer?.player.playbackVolume = tmpPanValue
}
}
}
}
override open var acceptsFirstResponder: Bool {
true
}
override open func keyDown(with event: NSEvent) {
if let specialKey = event.specialKey {
if specialKey == .rightArrow {
slider(value: Double(toolBar.timeSlider.value) + 0.01 * totalTime, event: .touchUpInside)
} else if specialKey == .leftArrow {
slider(value: Double(toolBar.timeSlider.value) - 0.01 * totalTime, event: .touchUpInside)
}
} else if let character = event.characters?.first {
if character == " " {
onButtonPressed(toolBar.playButton)
}
}
}
override open func draggingEntered(_: NSDraggingInfo) -> NSDragOperation {
.copy
}
override open func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
if let url = sender.getUrl() {
if url.isMovie || url.isAudio {
set(resource: KSPlayerResource(url: url, options: KSOptions()))
return true
} else {
srtControl.selectedSubtitleInfo = URLSubtitleInfo(url: url)
return true
}
}
return false
}
}
class UIActivityIndicatorView: UIView {
private let loadingView = NSView()
private let progressLabel = UILabel()
public var progress: Double = 0 {
didSet {
print("new progress: \(progress)")
progressLabel.stringValue = "\(Int(progress * 100))%"
}
}
override init(frame frameRect: CGRect) {
super.init(frame: frameRect)
wantsLayer = true
backingLayer?.backgroundColor = UIColor(white: 0, alpha: 0.2).cgColor
setupLoadingView()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupLoadingView() {
loadingView.wantsLayer = true
addSubview(loadingView)
let imageView = NSImageView()
imageView.image = KSOptions.image(named: "loading")
loadingView.addSubview(imageView)
imageView.imageScaling = .scaleAxesIndependently
imageView.translatesAutoresizingMaskIntoConstraints = false
loadingView.translatesAutoresizingMaskIntoConstraints = false
translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
widthAnchor.constraint(equalToConstant: 110),
heightAnchor.constraint(equalToConstant: 110),
loadingView.centerXAnchor.constraint(equalTo: centerXAnchor),
loadingView.centerYAnchor.constraint(equalTo: centerYAnchor),
loadingView.widthAnchor.constraint(equalTo: widthAnchor),
loadingView.heightAnchor.constraint(equalTo: heightAnchor),
imageView.bottomAnchor.constraint(equalTo: loadingView.bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: loadingView.leadingAnchor),
imageView.heightAnchor.constraint(equalTo: widthAnchor),
imageView.widthAnchor.constraint(equalTo: heightAnchor),
])
progressLabel.alignment = .center
progressLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
addSubview(progressLabel)
progressLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
progressLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
progressLabel.topAnchor.constraint(equalTo: loadingView.bottomAnchor, constant: 20),
progressLabel.widthAnchor.constraint(equalToConstant: 100),
progressLabel.heightAnchor.constraint(equalToConstant: 22),
])
startAnimating()
}
}
extension UIActivityIndicatorView: LoadingIndector {
func startAnimating() {
loadingView.backingLayer?.position = CGPoint(x: loadingView.layer!.frame.midX, y: loadingView.layer!.frame.midY)
loadingView.backingLayer?.anchorPoint = CGPoint(x: 0.5, y: 0.5)
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation.z")
rotationAnimation.duration = 1.0
rotationAnimation.repeatCount = MAXFLOAT
rotationAnimation.fromValue = 0.0
rotationAnimation.toValue = Float.pi * -2
loadingView.backingLayer?.add(rotationAnimation, forKey: "loading")
}
func stopAnimating() {
loadingView.backingLayer?.removeAnimation(forKey: "loading")
}
}
#endif