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>
283 lines
12 KiB
Swift
283 lines
12 KiB
Swift
//
|
|
// PlayerToolBar.swift
|
|
// Pods
|
|
//
|
|
// Created by kintan on 16/5/21.
|
|
//
|
|
//
|
|
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#else
|
|
import AppKit
|
|
#endif
|
|
import AVKit
|
|
|
|
public class PlayerToolBar: UIStackView {
|
|
public let srtButton = UIButton()
|
|
public let timeLabel = UILabel()
|
|
public let currentTimeLabel = UILabel()
|
|
public let totalTimeLabel = UILabel()
|
|
public let playButton = UIButton()
|
|
public let timeSlider = KSSlider()
|
|
public let playbackRateButton = UIButton()
|
|
public let videoSwitchButton = UIButton()
|
|
public let audioSwitchButton = UIButton()
|
|
public let definitionButton = UIButton()
|
|
public let pipButton = UIButton()
|
|
public var onFocusUpdate: ((_ cofusedItem: UIView) -> Void)?
|
|
public var timeType = TimeType.minOrHour {
|
|
didSet {
|
|
if timeType != oldValue {
|
|
let currentTimeText = currentTime.toString(for: timeType)
|
|
let totalTimeText = totalTime.toString(for: timeType)
|
|
currentTimeLabel.text = currentTimeText
|
|
totalTimeLabel.text = totalTimeText
|
|
timeLabel.text = "\(currentTimeText) / \(totalTimeText)"
|
|
}
|
|
}
|
|
}
|
|
|
|
public var currentTime: TimeInterval = 0 {
|
|
didSet {
|
|
guard !currentTime.isNaN else {
|
|
currentTime = 0
|
|
return
|
|
}
|
|
if currentTime != oldValue {
|
|
let text = currentTime.toString(for: timeType)
|
|
currentTimeLabel.text = text
|
|
timeLabel.text = "\(text) / \(totalTime.toString(for: timeType))"
|
|
if isLiveStream {
|
|
timeSlider.value = Float(todayInterval)
|
|
} else {
|
|
timeSlider.value = Float(currentTime)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
lazy var startDateTimeInteral: TimeInterval = {
|
|
let date = Date()
|
|
let calendar = Calendar.current
|
|
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
|
let startDate = calendar.date(from: components)
|
|
return startDate?.timeIntervalSince1970 ?? 0
|
|
}()
|
|
|
|
var todayInterval: TimeInterval {
|
|
Date().timeIntervalSince1970 - startDateTimeInteral
|
|
}
|
|
|
|
public var totalTime: TimeInterval = 0 {
|
|
didSet {
|
|
guard !totalTime.isNaN else {
|
|
totalTime = 0
|
|
return
|
|
}
|
|
if totalTime != oldValue {
|
|
let text = totalTime.toString(for: timeType)
|
|
totalTimeLabel.text = text
|
|
timeLabel.text = "\(currentTime.toString(for: timeType)) / \(text)"
|
|
timeSlider.maximumValue = Float(totalTime)
|
|
}
|
|
if isLiveStream {
|
|
timeSlider.maximumValue = Float(60 * 60 * 24)
|
|
}
|
|
}
|
|
}
|
|
|
|
public var isLiveStream: Bool {
|
|
totalTime == 0
|
|
}
|
|
|
|
public var isSeekable: Bool = true {
|
|
didSet {
|
|
timeSlider.isUserInteractionEnabled = isSeekable
|
|
}
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
initUI()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init(coder _: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func initUI() {
|
|
let focusColor = UIColor.white
|
|
let tintColor = UIColor.gray
|
|
distribution = .fill
|
|
currentTimeLabel.textColor = UIColor(rgb: 0x9B9B9B)
|
|
currentTimeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .regular)
|
|
currentTimeLabel.text = 0.toString(for: timeType)
|
|
totalTimeLabel.textColor = UIColor(rgb: 0x9B9B9B)
|
|
totalTimeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .regular)
|
|
totalTimeLabel.text = 0.toString(for: timeType)
|
|
|
|
timeLabel.textColor = UIColor(rgb: 0x9B9B9B)
|
|
timeLabel.textAlignment = .left
|
|
timeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .regular)
|
|
timeLabel.text = "\(0.toString(for: timeType)) / \(0.toString(for: timeType))"
|
|
timeSlider.minimumValue = 0
|
|
#if os(iOS)
|
|
if #available(macCatalyst 15.0, iOS 15.0, *) {
|
|
timeSlider.preferredBehavioralStyle = .pad
|
|
timeSlider.maximumTrackTintColor = focusColor.withAlphaComponent(0.2)
|
|
timeSlider.minimumTrackTintColor = focusColor
|
|
}
|
|
#endif
|
|
#if !targetEnvironment(macCatalyst)
|
|
timeSlider.maximumTrackTintColor = focusColor.withAlphaComponent(0.2)
|
|
timeSlider.minimumTrackTintColor = focusColor
|
|
#endif
|
|
playButton.tag = PlayerButtonType.play.rawValue
|
|
playButton.setTitleColor(focusColor, for: .focused)
|
|
playButton.setTitleColor(tintColor, for: .normal)
|
|
playbackRateButton.tag = PlayerButtonType.rate.rawValue
|
|
playbackRateButton.titleFont = .systemFont(ofSize: 14, weight: .medium)
|
|
playbackRateButton.setTitleColor(focusColor, for: .focused)
|
|
playbackRateButton.setTitleColor(tintColor, for: .normal)
|
|
definitionButton.tag = PlayerButtonType.definition.rawValue
|
|
definitionButton.titleFont = .systemFont(ofSize: 14, weight: .medium)
|
|
definitionButton.setTitleColor(focusColor, for: .focused)
|
|
definitionButton.setTitleColor(tintColor, for: .normal)
|
|
audioSwitchButton.tag = PlayerButtonType.audioSwitch.rawValue
|
|
audioSwitchButton.titleFont = .systemFont(ofSize: 14, weight: .medium)
|
|
audioSwitchButton.setTitleColor(focusColor, for: .focused)
|
|
audioSwitchButton.setTitleColor(tintColor, for: .normal)
|
|
videoSwitchButton.tag = PlayerButtonType.videoSwitch.rawValue
|
|
videoSwitchButton.titleFont = .systemFont(ofSize: 14, weight: .medium)
|
|
videoSwitchButton.setTitleColor(focusColor, for: .focused)
|
|
videoSwitchButton.setTitleColor(tintColor, for: .normal)
|
|
srtButton.tag = PlayerButtonType.srt.rawValue
|
|
srtButton.titleFont = .systemFont(ofSize: 14, weight: .medium)
|
|
srtButton.setTitleColor(focusColor, for: .focused)
|
|
srtButton.setTitleColor(tintColor, for: .normal)
|
|
pipButton.tag = PlayerButtonType.pictureInPicture.rawValue
|
|
pipButton.titleFont = .systemFont(ofSize: 14, weight: .medium)
|
|
pipButton.setTitleColor(focusColor, for: .focused)
|
|
pipButton.setTitleColor(tintColor, for: .normal)
|
|
if #available(macOS 11.0, *) {
|
|
pipButton.setImage(UIImage(systemName: "pip.enter"), for: .normal)
|
|
pipButton.setImage(UIImage(systemName: "pip.exit"), for: .selected)
|
|
playButton.setImage(UIImage(systemName: "play.fill"), for: .normal)
|
|
playButton.setImage(UIImage(systemName: "pause.fill"), for: .selected)
|
|
srtButton.setImage(UIImage(systemName: "captions.bubble"), for: .normal)
|
|
definitionButton.setImage(UIImage(systemName: "arrow.up.right.video"), for: .normal)
|
|
audioSwitchButton.setImage(UIImage(systemName: "waveform"), for: .normal)
|
|
videoSwitchButton.setImage(UIImage(systemName: "video.badge.ellipsis"), for: .normal)
|
|
playbackRateButton.setImage(UIImage(systemName: "speedometer"), for: .normal)
|
|
}
|
|
playButton.translatesAutoresizingMaskIntoConstraints = false
|
|
srtButton.translatesAutoresizingMaskIntoConstraints = false
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
if #available(tvOS 14.0, *) {
|
|
pipButton.isHidden = !AVPictureInPictureController.isPictureInPictureSupported()
|
|
}
|
|
#if os(tvOS)
|
|
srtButton.fillImage()
|
|
pipButton.fillImage()
|
|
playButton.fillImage()
|
|
definitionButton.fillImage()
|
|
audioSwitchButton.fillImage()
|
|
videoSwitchButton.fillImage()
|
|
playbackRateButton.fillImage()
|
|
playButton.tintColor = tintColor
|
|
playbackRateButton.tintColor = tintColor
|
|
definitionButton.tintColor = tintColor
|
|
audioSwitchButton.tintColor = tintColor
|
|
videoSwitchButton.tintColor = tintColor
|
|
srtButton.tintColor = tintColor
|
|
pipButton.tintColor = tintColor
|
|
timeSlider.tintColor = tintColor
|
|
NSLayoutConstraint.activate([
|
|
playButton.widthAnchor.constraint(equalTo: playButton.heightAnchor),
|
|
playbackRateButton.widthAnchor.constraint(equalTo: playbackRateButton.heightAnchor),
|
|
definitionButton.widthAnchor.constraint(equalTo: definitionButton.heightAnchor),
|
|
audioSwitchButton.widthAnchor.constraint(equalTo: audioSwitchButton.heightAnchor),
|
|
videoSwitchButton.widthAnchor.constraint(equalTo: videoSwitchButton.heightAnchor),
|
|
srtButton.widthAnchor.constraint(equalTo: srtButton.heightAnchor),
|
|
pipButton.widthAnchor.constraint(equalTo: pipButton.heightAnchor),
|
|
heightAnchor.constraint(equalToConstant: 40),
|
|
])
|
|
#else
|
|
timeSlider.tintColor = .white
|
|
playButton.tintColor = .white
|
|
playbackRateButton.tintColor = .white
|
|
definitionButton.tintColor = .white
|
|
audioSwitchButton.tintColor = .white
|
|
videoSwitchButton.tintColor = .white
|
|
srtButton.tintColor = .white
|
|
pipButton.tintColor = .white
|
|
NSLayoutConstraint.activate([
|
|
playButton.widthAnchor.constraint(equalToConstant: 30),
|
|
heightAnchor.constraint(equalToConstant: 49),
|
|
srtButton.widthAnchor.constraint(equalToConstant: 40),
|
|
])
|
|
#endif
|
|
}
|
|
|
|
override public func addArrangedSubview(_ view: UIView) {
|
|
super.addArrangedSubview(view)
|
|
view.isHidden = false
|
|
}
|
|
|
|
#if canImport(UIKit)
|
|
override open func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) {
|
|
super.didUpdateFocus(in: context, with: coordinator)
|
|
if let nextFocusedItem = context.nextFocusedItem {
|
|
if let nextFocusedButton = nextFocusedItem as? UIButton {
|
|
nextFocusedButton.tintColor = nextFocusedButton.titleColor(for: .focused)
|
|
}
|
|
if context.previouslyFocusedItem != nil,
|
|
let nextFocusedView = nextFocusedItem as? UIView
|
|
{
|
|
onFocusUpdate?(nextFocusedView)
|
|
}
|
|
}
|
|
if let previouslyFocusedItem = context.previouslyFocusedItem as? UIButton {
|
|
if previouslyFocusedItem.isSelected {
|
|
previouslyFocusedItem.tintColor = previouslyFocusedItem.titleColor(for: .selected)
|
|
} else if previouslyFocusedItem.isHighlighted {
|
|
previouslyFocusedItem.tintColor = previouslyFocusedItem.titleColor(for: .highlighted)
|
|
} else {
|
|
previouslyFocusedItem.tintColor = previouslyFocusedItem.titleColor(for: .normal)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
open func addTarget(_ target: AnyObject?, action: Selector) {
|
|
playButton.addTarget(target, action: action, for: .primaryActionTriggered)
|
|
playbackRateButton.addTarget(target, action: action, for: .primaryActionTriggered)
|
|
definitionButton.addTarget(target, action: action, for: .primaryActionTriggered)
|
|
audioSwitchButton.addTarget(target, action: action, for: .primaryActionTriggered)
|
|
videoSwitchButton.addTarget(target, action: action, for: .primaryActionTriggered)
|
|
srtButton.addTarget(target, action: action, for: .primaryActionTriggered)
|
|
pipButton.addTarget(target, action: action, for: .primaryActionTriggered)
|
|
}
|
|
|
|
public func reset() {
|
|
currentTime = 0
|
|
totalTime = 0
|
|
playButton.isSelected = false
|
|
timeSlider.value = 0.0
|
|
timeSlider.isPlayable = false
|
|
playbackRateButton.setTitle(NSLocalizedString("speed", comment: ""), for: .normal)
|
|
}
|
|
}
|
|
|
|
extension KSOptions {
|
|
static func image(named: String) -> UIImage? {
|
|
#if canImport(UIKit)
|
|
return UIImage(named: named, in: .module, compatibleWith: nil)
|
|
#else
|
|
return Bundle.module.image(forResource: named)
|
|
#endif
|
|
}
|
|
}
|