Files
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

221 lines
6.1 KiB
Swift

//
// PlayerView.swift
// VoiceNote
//
// Created by kintan on 2018/8/16.
//
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
import AVFoundation
public enum PlayerButtonType: Int {
case play = 101
case pause
case back
case srt
case landscape
case replay
case lock
case rate
case definition
case pictureInPicture
case audioSwitch
case videoSwitch
}
public protocol PlayerControllerDelegate: AnyObject {
func playerController(state: KSPlayerState)
func playerController(currentTime: TimeInterval, totalTime: TimeInterval)
func playerController(finish error: Error?)
func playerController(maskShow: Bool)
func playerController(action: PlayerButtonType)
// `bufferedCount: 0` indicates first time loading
func playerController(bufferedCount: Int, consumeTime: TimeInterval)
func playerController(seek: TimeInterval)
}
open class PlayerView: UIView, KSPlayerLayerDelegate, KSSliderDelegate {
public typealias ControllerDelegate = PlayerControllerDelegate
public var playerLayer: KSPlayerLayer? {
didSet {
playerLayer?.delegate = self
}
}
public weak var delegate: ControllerDelegate?
public let toolBar = PlayerToolBar()
public let srtControl = SubtitleModel()
// Listen to play time change
public var playTimeDidChange: ((TimeInterval, TimeInterval) -> Void)?
public var backBlock: (() -> Void)?
public convenience init() {
#if os(macOS)
self.init(frame: .zero)
#else
self.init(frame: CGRect(origin: .zero, size: KSOptions.sceneSize))
#endif
}
override public init(frame: CGRect) {
super.init(frame: frame)
toolBar.timeSlider.delegate = self
toolBar.addTarget(self, action: #selector(onButtonPressed(_:)))
}
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func onButtonPressed(_ button: UIButton) {
guard let type = PlayerButtonType(rawValue: button.tag) else { return }
#if os(macOS)
if let menu = button.menu,
let item = button.menu?.items.first(where: { $0.state == .on })
{
menu.popUp(positioning: item,
at: button.frame.origin,
in: self)
} else {
onButtonPressed(type: type, button: button)
}
#elseif os(tvOS)
onButtonPressed(type: type, button: button)
#else
if #available(iOS 14.0, *), button.menu != nil {
return
}
onButtonPressed(type: type, button: button)
#endif
}
open func onButtonPressed(type: PlayerButtonType, button: UIButton) {
var type = type
if type == .play, button.isSelected {
type = .pause
}
switch type {
case .back:
backBlock?()
case .play, .replay:
play()
case .pause:
pause()
default:
break
}
delegate?.playerController(action: type)
}
#if canImport(UIKit)
override open func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let presse = presses.first else {
return
}
switch presse.type {
case .playPause:
if let playerLayer, playerLayer.state.isPlaying {
pause()
} else {
play()
}
default: super.pressesBegan(presses, with: event)
}
}
#endif
open func play() {
becomeFirstResponder()
playerLayer?.play()
toolBar.playButton.isSelected = true
}
open func pause() {
playerLayer?.pause()
}
open func seek(time: TimeInterval, completion: @escaping ((Bool) -> Void)) {
playerLayer?.seek(time: time, autoPlay: KSOptions.isSeekedAutoPlay, completion: completion)
}
open func resetPlayer() {
pause()
totalTime = 0.0
}
open func set(url: URL, options: KSOptions) {
srtControl.url = url
toolBar.currentTime = 0
totalTime = 0
playerLayer = KSPlayerLayer(url: url, options: options)
}
// MARK: - KSSliderDelegate
open func slider(value: Double, event: ControlEvents) {
if event == .valueChanged {
toolBar.currentTime = value
} else if event == .touchUpInside {
seek(time: value) { [weak self] _ in
self?.delegate?.playerController(seek: value)
}
}
}
// MARK: - KSPlayerLayerDelegate
open func player(layer: KSPlayerLayer, state: KSPlayerState) {
delegate?.playerController(state: state)
if state == .readyToPlay {
totalTime = layer.player.duration
toolBar.isSeekable = layer.player.seekable
toolBar.playButton.isSelected = true
} else if state == .playedToTheEnd || state == .paused || state == .error {
toolBar.playButton.isSelected = false
}
}
open func player(layer _: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {
delegate?.playerController(currentTime: currentTime, totalTime: totalTime)
playTimeDidChange?(currentTime, totalTime)
toolBar.currentTime = currentTime
self.totalTime = totalTime
}
open func player(layer _: KSPlayerLayer, finish error: Error?) {
delegate?.playerController(finish: error)
}
open func player(layer _: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) {
delegate?.playerController(bufferedCount: bufferedCount, consumeTime: consumeTime)
}
}
public extension PlayerView {
var totalTime: TimeInterval {
get {
toolBar.totalTime
}
set {
toolBar.totalTime = newValue
}
}
}
extension UIView {
var viewController: UIViewController? {
var next = next
while next != nil {
if let viewController = next as? UIViewController {
return viewController
}
next = next?.next
}
return nil
}
}