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>
This commit is contained in:
336
KSPlayer-main/Sources/KSPlayer/AVPlayer/KSVideoPlayer.swift
Normal file
336
KSPlayer-main/Sources/KSPlayer/AVPlayer/KSVideoPlayer.swift
Normal file
@@ -0,0 +1,336 @@
|
||||
//
|
||||
// KSVideoPlayer.swift
|
||||
// KSPlayer
|
||||
//
|
||||
// Created by kintan on 2023/2/11.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
|
||||
public typealias UIViewRepresentable = NSViewRepresentable
|
||||
#endif
|
||||
|
||||
public struct KSVideoPlayer {
|
||||
public private(set) var coordinator: Coordinator
|
||||
public let url: URL
|
||||
public let options: KSOptions
|
||||
public init(coordinator: Coordinator, url: URL, options: KSOptions) {
|
||||
self.coordinator = coordinator
|
||||
self.url = url
|
||||
self.options = options
|
||||
}
|
||||
}
|
||||
|
||||
extension KSVideoPlayer: UIViewRepresentable {
|
||||
public func makeCoordinator() -> Coordinator {
|
||||
coordinator
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
public typealias UIViewType = UIView
|
||||
public func makeUIView(context: Context) -> UIViewType {
|
||||
context.coordinator.makeView(url: url, options: options)
|
||||
}
|
||||
|
||||
public func updateUIView(_ view: UIViewType, context: Context) {
|
||||
updateView(view, context: context)
|
||||
}
|
||||
|
||||
// iOS tvOS真机先调用onDisappear在调用dismantleUIView,但是模拟器就反过来了。
|
||||
public static func dismantleUIView(_: UIViewType, coordinator: Coordinator) {
|
||||
coordinator.resetPlayer()
|
||||
}
|
||||
#else
|
||||
public typealias NSViewType = UIView
|
||||
public func makeNSView(context: Context) -> NSViewType {
|
||||
context.coordinator.makeView(url: url, options: options)
|
||||
}
|
||||
|
||||
public func updateNSView(_ view: NSViewType, context: Context) {
|
||||
updateView(view, context: context)
|
||||
}
|
||||
|
||||
// macOS先调用onDisappear在调用dismantleNSView
|
||||
public static func dismantleNSView(_ view: NSViewType, coordinator: Coordinator) {
|
||||
coordinator.resetPlayer()
|
||||
view.window?.aspectRatio = CGSize(width: 16, height: 9)
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
private func updateView(_: UIView, context: Context) {
|
||||
if context.coordinator.playerLayer?.url != url {
|
||||
_ = context.coordinator.makeView(url: url, options: options)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class Coordinator: ObservableObject {
|
||||
public var state: KSPlayerState {
|
||||
playerLayer?.state ?? .initialized
|
||||
}
|
||||
|
||||
@Published
|
||||
public var isMuted: Bool = false {
|
||||
didSet {
|
||||
playerLayer?.player.isMuted = isMuted
|
||||
}
|
||||
}
|
||||
|
||||
@Published
|
||||
public var playbackVolume: Float = 1.0 {
|
||||
didSet {
|
||||
playerLayer?.player.playbackVolume = playbackVolume
|
||||
}
|
||||
}
|
||||
|
||||
@Published
|
||||
public var isScaleAspectFill = false {
|
||||
didSet {
|
||||
playerLayer?.player.contentMode = isScaleAspectFill ? .scaleAspectFill : .scaleAspectFit
|
||||
}
|
||||
}
|
||||
|
||||
@Published
|
||||
public var playbackRate: Float = 1.0 {
|
||||
didSet {
|
||||
playerLayer?.player.playbackRate = playbackRate
|
||||
}
|
||||
}
|
||||
|
||||
@Published
|
||||
@MainActor
|
||||
public var isMaskShow = true {
|
||||
didSet {
|
||||
if isMaskShow != oldValue {
|
||||
mask(show: isMaskShow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var subtitleModel = SubtitleModel()
|
||||
public var timemodel = ControllerTimeModel()
|
||||
// 在SplitView模式下,第二次进入会先调用makeUIView。然后在调用之前的dismantleUIView.所以如果进入的是同一个View的话,就会导致playerLayer被清空了。最准确的方式是在onDisappear清空playerLayer
|
||||
public var playerLayer: KSPlayerLayer? {
|
||||
didSet {
|
||||
oldValue?.delegate = nil
|
||||
oldValue?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private var delayHide: DispatchWorkItem?
|
||||
public var onPlay: ((TimeInterval, TimeInterval) -> Void)?
|
||||
public var onFinish: ((KSPlayerLayer, Error?) -> Void)?
|
||||
public var onStateChanged: ((KSPlayerLayer, KSPlayerState) -> Void)?
|
||||
public var onBufferChanged: ((Int, TimeInterval) -> Void)?
|
||||
#if canImport(UIKit)
|
||||
fileprivate var onSwipe: ((UISwipeGestureRecognizer.Direction) -> Void)?
|
||||
@objc fileprivate func swipeGestureAction(_ recognizer: UISwipeGestureRecognizer) {
|
||||
onSwipe?(recognizer.direction)
|
||||
}
|
||||
#endif
|
||||
|
||||
public init() {}
|
||||
|
||||
public func makeView(url: URL, options: KSOptions) -> UIView {
|
||||
defer {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.subtitleModel.url = url
|
||||
}
|
||||
}
|
||||
if let playerLayer {
|
||||
if playerLayer.url == url {
|
||||
return playerLayer.player.view ?? UIView()
|
||||
}
|
||||
playerLayer.delegate = nil
|
||||
playerLayer.set(url: url, options: options)
|
||||
playerLayer.delegate = self
|
||||
return playerLayer.player.view ?? UIView()
|
||||
} else {
|
||||
let playerLayer = KSPlayerLayer(url: url, options: options, delegate: self)
|
||||
self.playerLayer = playerLayer
|
||||
return playerLayer.player.view ?? UIView()
|
||||
}
|
||||
}
|
||||
|
||||
public func resetPlayer() {
|
||||
onStateChanged = nil
|
||||
onPlay = nil
|
||||
onFinish = nil
|
||||
onBufferChanged = nil
|
||||
#if canImport(UIKit)
|
||||
onSwipe = nil
|
||||
#endif
|
||||
playerLayer = nil
|
||||
delayHide?.cancel()
|
||||
delayHide = nil
|
||||
subtitleModel.selectedSubtitleInfo?.isEnabled = false
|
||||
}
|
||||
|
||||
public func skip(interval: Int) {
|
||||
if let playerLayer {
|
||||
seek(time: playerLayer.player.currentPlaybackTime + TimeInterval(interval))
|
||||
}
|
||||
}
|
||||
|
||||
public func seek(time: TimeInterval) {
|
||||
playerLayer?.seek(time: TimeInterval(time))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func mask(show: Bool, autoHide: Bool = true) {
|
||||
isMaskShow = show
|
||||
if show {
|
||||
delayHide?.cancel()
|
||||
// 播放的时候才自动隐藏
|
||||
guard state == .bufferFinished else { return }
|
||||
if autoHide {
|
||||
delayHide = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.state == .bufferFinished {
|
||||
self.isMaskShow = false
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + KSOptions.animateDelayTimeInterval,
|
||||
execute: delayHide!)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
show ? NSCursor.unhide() : NSCursor.setHiddenUntilMouseMoves(true)
|
||||
if let window = playerLayer?.player.view?.window {
|
||||
if !window.styleMask.contains(.fullScreen) {
|
||||
window.standardWindowButton(.closeButton)?.superview?.superview?.isHidden = !show
|
||||
// window.standardWindowButton(.zoomButton)?.isHidden = !show
|
||||
// window.standardWindowButton(.closeButton)?.isHidden = !show
|
||||
// window.standardWindowButton(.miniaturizeButton)?.isHidden = !show
|
||||
// window.titleVisibility = show ? .visible : .hidden
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension KSVideoPlayer.Coordinator: KSPlayerLayerDelegate {
|
||||
public func player(layer: KSPlayerLayer, state: KSPlayerState) {
|
||||
onStateChanged?(layer, state)
|
||||
if state == .readyToPlay {
|
||||
playbackRate = layer.player.playbackRate
|
||||
if let subtitleDataSouce = layer.player.subtitleDataSouce {
|
||||
// 要延后增加内嵌字幕。因为有些内嵌字幕是放在视频流的。所以会比readyToPlay回调晚。
|
||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.subtitleModel.addSubtitle(dataSouce: subtitleDataSouce)
|
||||
if self.subtitleModel.selectedSubtitleInfo == nil, layer.options.autoSelectEmbedSubtitle {
|
||||
self.subtitleModel.selectedSubtitleInfo = subtitleDataSouce.infos.first { $0.isEnabled }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if state == .bufferFinished {
|
||||
isMaskShow = false
|
||||
} else {
|
||||
isMaskShow = true
|
||||
#if canImport(UIKit)
|
||||
if state == .preparing, let view = layer.player.view {
|
||||
let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(swipeGestureAction(_:)))
|
||||
swipeDown.direction = .down
|
||||
view.addGestureRecognizer(swipeDown)
|
||||
let swipeLeft = UISwipeGestureRecognizer(target: self, action: #selector(swipeGestureAction(_:)))
|
||||
swipeLeft.direction = .left
|
||||
view.addGestureRecognizer(swipeLeft)
|
||||
let swipeRight = UISwipeGestureRecognizer(target: self, action: #selector(swipeGestureAction(_:)))
|
||||
swipeRight.direction = .right
|
||||
view.addGestureRecognizer(swipeRight)
|
||||
let swipeUp = UISwipeGestureRecognizer(target: self, action: #selector(swipeGestureAction(_:)))
|
||||
swipeUp.direction = .up
|
||||
view.addGestureRecognizer(swipeUp)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public func player(layer _: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {
|
||||
onPlay?(currentTime, totalTime)
|
||||
if currentTime >= Double(Int.max) || currentTime <= Double(Int.min) || totalTime >= Double(Int.max) || totalTime <= Double(Int.min) {
|
||||
return
|
||||
}
|
||||
let current = Int(currentTime)
|
||||
let total = Int(max(0, totalTime))
|
||||
if timemodel.currentTime != current {
|
||||
timemodel.currentTime = current
|
||||
}
|
||||
if timemodel.totalTime != total {
|
||||
timemodel.totalTime = total
|
||||
}
|
||||
_ = subtitleModel.subtitle(currentTime: currentTime)
|
||||
}
|
||||
|
||||
public func player(layer: KSPlayerLayer, finish error: Error?) {
|
||||
onFinish?(layer, error)
|
||||
}
|
||||
|
||||
public func player(layer _: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) {
|
||||
onBufferChanged?(bufferedCount, consumeTime)
|
||||
}
|
||||
}
|
||||
|
||||
extension KSVideoPlayer: Equatable {
|
||||
public static func == (lhs: KSVideoPlayer, rhs: KSVideoPlayer) -> Bool {
|
||||
lhs.url == rhs.url
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public extension KSVideoPlayer {
|
||||
func onBufferChanged(_ handler: @escaping (Int, TimeInterval) -> Void) -> Self {
|
||||
coordinator.onBufferChanged = handler
|
||||
return self
|
||||
}
|
||||
|
||||
/// Playing to the end.
|
||||
func onFinish(_ handler: @escaping (KSPlayerLayer, Error?) -> Void) -> Self {
|
||||
coordinator.onFinish = handler
|
||||
return self
|
||||
}
|
||||
|
||||
func onPlay(_ handler: @escaping (TimeInterval, TimeInterval) -> Void) -> Self {
|
||||
coordinator.onPlay = handler
|
||||
return self
|
||||
}
|
||||
|
||||
/// Playback status changes, such as from play to pause.
|
||||
func onStateChanged(_ handler: @escaping (KSPlayerLayer, KSPlayerState) -> Void) -> Self {
|
||||
coordinator.onStateChanged = handler
|
||||
return self
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
func onSwipe(_ handler: @escaping (UISwipeGestureRecognizer.Direction) -> Void) -> Self {
|
||||
coordinator.onSwipe = handler
|
||||
return self
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension View {
|
||||
func then(_ body: (inout Self) -> Void) -> Self {
|
||||
var result = self
|
||||
body(&result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// 这是一个频繁变化的model。View要少用这个
|
||||
public class ControllerTimeModel: ObservableObject {
|
||||
// 改成int才不会频繁更新
|
||||
@Published
|
||||
public var currentTime = 0
|
||||
@Published
|
||||
public var totalTime = 1
|
||||
}
|
||||
Reference in New Issue
Block a user