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:
2026-01-21 22:12:08 -06:00
commit 872354b834
283 changed files with 338296 additions and 0 deletions

View File

@@ -0,0 +1,594 @@
//
// AppKitExtend.swift
// KSPlayer
//
// Created by kintan on 2018/3/9.
//
/// 'NSWindow' is unavailable in Mac Catalyst
#if !canImport(UIKit)
import AppKit
import CoreMedia
import IOKit.pwr_mgt
public typealias UIApplicationDelegate = NSApplicationDelegate
public typealias UIApplication = NSApplication
public typealias UIWindow = NSWindow
public typealias UIViewController = NSViewController
public typealias UIColor = NSColor
public typealias UIStackView = NSStackView
public typealias UIPanGestureRecognizer = NSPanGestureRecognizer
public typealias UIGestureRecognizer = NSGestureRecognizer
public typealias UIGestureRecognizerDelegate = NSGestureRecognizerDelegate
public typealias UIViewContentMode = ContentMode
public typealias UIFont = NSFont
public typealias UIFontDescriptor = NSFontDescriptor
public typealias UIControl = NSControl
public typealias UITextField = NSTextField
public typealias UIImageView = NSImageView
public typealias UITapGestureRecognizer = NSClickGestureRecognizer
public typealias UXSlider = NSSlider
public typealias UITableView = NSTableView
public typealias UITableViewDelegate = NSTableViewDelegate
public typealias UITableViewDataSource = NSTableViewDataSource
public typealias UITouch = NSTouch
public typealias UIEvent = NSEvent
public typealias UIButton = KSButton
public extension UIFontDescriptor.SymbolicTraits {
static var traitItalic = italic
static var traitBold = bold
}
extension NSScreen {
var scale: CGFloat {
backingScaleFactor
}
}
public extension NSClickGestureRecognizer {
var numberOfTapsRequired: Int {
get {
numberOfClicksRequired
}
set {
numberOfClicksRequired = newValue
}
}
func require(toFail otherGestureRecognizer: NSClickGestureRecognizer) {
buttonMask = otherGestureRecognizer.buttonMask << 1
}
}
public extension NSView {
@objc internal var contentMode: UIViewContentMode {
get {
if let contentsGravity = backingLayer?.contentsGravity {
switch contentsGravity {
case .resize:
return .scaleToFill
case .resizeAspect:
return .scaleAspectFit
case .resizeAspectFill:
return .scaleAspectFill
default:
return .scaleAspectFit
}
} else {
return .scaleAspectFit
}
}
set {
switch newValue {
case .scaleToFill:
backingLayer?.contentsGravity = .resize
case .scaleAspectFit:
backingLayer?.contentsGravity = .resizeAspect
case .scaleAspectFill:
backingLayer?.contentsGravity = .resizeAspectFill
case .center:
backingLayer?.contentsGravity = .center
default:
break
}
}
}
var center: CGPoint {
CGPoint(x: frame.midX, y: frame.midY)
}
var alpha: CGFloat {
get {
alphaValue
}
set {
alphaValue = newValue
}
}
var clipsToBounds: Bool {
get {
if let layer {
return layer.masksToBounds
} else {
return false
}
}
set {
backingLayer?.masksToBounds = newValue
}
}
class func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void, completion: ((Bool) -> Void)? = nil) {
CATransaction.begin()
CATransaction.setAnimationDuration(duration)
CATransaction.setCompletionBlock {
completion?(true)
}
animations()
CATransaction.commit()
}
class func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void) {
animate(withDuration: duration, animations: animations, completion: nil)
}
func layoutIfNeeded() {
layer?.layoutIfNeeded()
}
func centerRotate(byDegrees: Double) {
layer?.position = center
layer?.anchorPoint = CGPoint(x: 0.5, y: 0.5)
layer?.setAffineTransform(CGAffineTransform(rotationAngle: CGFloat(Double.pi * byDegrees / 180.0)))
}
}
public extension NSImage {
convenience init(cgImage: CGImage) {
self.init(cgImage: cgImage, size: NSSize.zero)
}
@available(macOS 11.0, *)
convenience init?(systemName: String) {
self.init(systemSymbolName: systemName, accessibilityDescription: nil)
}
}
extension NSButton {
var titleFont: UIFont? {
get {
font
}
set {
font = newValue
}
}
var tintColor: UIColor? {
get {
contentTintColor
}
set {
contentTintColor = newValue
}
}
var backgroundColor: UIColor? {
get {
if let layer, let cgColor = layer.backgroundColor {
return UIColor(cgColor: cgColor)
} else {
return nil
}
}
set {
backingLayer?.backgroundColor = newValue?.cgColor
}
}
}
extension NSImageView {
var backgroundColor: UIColor? {
get {
if let layer, let cgColor = layer.backgroundColor {
return UIColor(cgColor: cgColor)
} else {
return nil
}
}
set {
backingLayer?.backgroundColor = newValue?.cgColor
}
}
}
public extension NSControl {
var textAlignment: NSTextAlignment {
get {
alignment
}
set {
alignment = newValue
}
}
var text: String {
get {
stringValue
}
set {
stringValue = newValue
}
}
var attributedText: NSAttributedString? {
get {
attributedStringValue
}
set {
attributedStringValue = newValue ?? NSAttributedString()
}
}
var numberOfLines: Int {
get {
usesSingleLineMode ? 1 : 0
}
set {
usesSingleLineMode = newValue == 1
}
}
}
public extension NSTextContainer {
var numberOfLines: Int {
get {
maximumNumberOfLines
}
set {
maximumNumberOfLines = newValue
}
}
}
public extension NSResponder {
var next: NSResponder? {
nextResponder
}
}
public extension NSSlider {
var minimumTrackTintColor: UIColor? {
get {
trackFillColor
}
set {
trackFillColor = newValue
}
}
var maximumTrackTintColor: UIColor? {
get {
nil
}
set {}
}
}
public extension NSStackView {
var axis: NSUserInterfaceLayoutOrientation {
get {
orientation
}
set {
orientation = newValue
}
}
}
public extension NSGestureRecognizer {
func addTarget(_ target: AnyObject, action: Selector) {
self.target = target
self.action = action
}
}
public extension UIApplication {
private static var assertionID = IOPMAssertionID()
static var isIdleTimerDisabled = false {
didSet {
if isIdleTimerDisabled != oldValue {
if isIdleTimerDisabled {
_ = IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
"KSPlayer is playing video" as CFString,
&assertionID)
} else {
_ = IOPMAssertionRelease(assertionID)
}
}
}
}
var isIdleTimerDisabled: Bool {
get {
UIApplication.isIdleTimerDisabled
}
set {
UIApplication.isIdleTimerDisabled = newValue
}
}
}
// @available(*, unavailable, renamed: "UIView.ContentMode")
@objc public enum ContentMode: Int {
case scaleToFill
case scaleAspectFit // contents scaled to fit with fixed aspect. remainder is transparent
case scaleAspectFill // contents scaled to fill with fixed aspect. some portion of content may be clipped.
case redraw // redraw on bounds change (calls -setNeedsDisplay)
case center // contents remain same size. positioned adjusted.
case top
case bottom
case left
case right
case topLeft
case topRight
case bottomLeft
case bottomRight
}
public extension UIControl {
struct State: OptionSet {
public var rawValue: UInt
public init(rawValue: UInt) { self.rawValue = rawValue }
public static var normal = State(rawValue: 1 << 0)
public static var highlighted = State(rawValue: 1 << 1)
public static var disabled = State(rawValue: 1 << 2)
public static var selected = State(rawValue: 1 << 3)
public static var focused = State(rawValue: 1 << 4)
public static var application = State(rawValue: 1 << 5)
public static var reserved = State(rawValue: 1 << 6)
}
}
extension UIControl.State: Hashable {}
public class UILabel: NSTextField {
override init(frame frameRect: CGRect) {
super.init(frame: frameRect)
alignment = .left
isBordered = false
isEditable = false
isSelectable = false
isBezeled = false
drawsBackground = false
focusRingType = .none
textColor = NSColor.white
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
public class KSButton: NSButton {
private var images = [UIControl.State: UIImage]()
private var titles = [UIControl.State: String]()
private var titleColors = [State: UIColor]()
private var targetActions = [ControlEvents: (AnyObject?, Selector)]()
override public init(frame frameRect: CGRect) {
super.init(frame: frameRect)
isBordered = false
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public var isSelected: Bool = false {
didSet {
update(state: isSelected ? .selected : .normal)
}
}
override public var isEnabled: Bool {
didSet {
update(state: isEnabled ? .normal : .disabled)
}
}
open func setImage(_ image: UIImage?, for state: UIControl.State) {
images[state] = image
if state == .normal, isEnabled, !isSelected {
self.image = image
}
}
open func setTitle(_ title: String, for state: UIControl.State) {
titles[state] = title
if state == .normal, isEnabled, !isSelected {
self.title = title
}
}
open func setTitleColor(_ titleColor: UIColor?, for state: UIControl.State) {
titleColors[state] = titleColor
if state == .normal, isEnabled, !isSelected {
// self.titleColor = titleColor
}
}
private func update(state: UIControl.State) {
if let stateImage = images[state] {
image = stateImage
}
if let stateTitle = titles[state] {
title = stateTitle
}
}
open func addTarget(_ target: AnyObject?, action: Selector, for controlEvents: ControlEvents) {
targetActions[controlEvents] = (target, action)
}
open func removeTarget(_: AnyObject?, action _: Selector?, for controlEvents: ControlEvents) {
targetActions.removeValue(forKey: controlEvents)
}
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 public func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
if let (target, action) = targetActions[.touchUpInside] ?? targetActions[.primaryActionTriggered] {
_ = target?.perform(action, with: self)
}
}
override public func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
if let (target, action) = targetActions[.mouseExited] {
_ = target?.perform(action, with: self)
}
}
override public func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
if let (target, action) = targetActions[.mouseExited] {
_ = target?.perform(action, with: self)
}
}
open func sendActions(for controlEvents: ControlEvents) {
if let (target, action) = targetActions[controlEvents] {
_ = target?.perform(action, with: self)
}
}
}
public class KSSlider: NSSlider {
weak var delegate: KSSliderDelegate?
public var trackHeigt = CGFloat(2)
public var isPlayable = false
public var isUserInteractionEnabled: Bool = true
var tintColor: UIColor?
public convenience init() {
self.init(frame: .zero)
}
override public init(frame frameRect: CGRect) {
super.init(frame: frameRect)
target = self
action = #selector(progressSliderTouchEnded(_:))
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func progressSliderTouchEnded(_ sender: KSSlider) {
if isUserInteractionEnabled {
delegate?.slider(value: Double(sender.floatValue), event: .touchUpInside)
}
}
open func setThumbImage(_: UIImage?, for _: State) {}
@IBInspectable var maximumValue: Float {
get {
Float(maxValue)
}
set {
maxValue = Double(newValue)
}
}
@IBInspectable var minimumValue: Float {
get {
Float(minValue)
}
set {
minValue = Double(newValue)
}
}
@IBInspectable var value: Float {
get {
floatValue
}
set {
floatValue = newValue
}
}
}
extension UIView {
func image() -> UIImage? {
guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else {
return nil
}
cacheDisplay(in: bounds, to: rep)
let image = NSImage(size: bounds.size)
image.addRepresentation(rep)
return image
}
}
// todo
open class UIAlertController: UIViewController {
public enum Style: Int {
case actionSheet
case alert
}
public convenience init(title _: String?, message _: String?, preferredStyle _: UIAlertController.Style) {
self.init()
}
var preferredAction: UIAlertAction?
open func addAction(_: UIAlertAction) {}
}
open class UIAlertAction: NSObject {
public enum Style: Int {
case `default`
case cancel
case destructive
}
public let title: String?
public let style: UIAlertAction.Style
public private(set) var isEnabled: Bool = false
public init(title: String?, style: UIAlertAction.Style, handler _: ((UIAlertAction) -> Void)? = nil) {
self.title = title
self.style = style
}
}
public extension UIViewController {
func present(_: UIViewController, animated _: Bool, completion _: (() -> Void)? = nil) {}
}
#endif

View File

@@ -0,0 +1,282 @@
//
// 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
}
}

View File

@@ -0,0 +1,220 @@
//
// 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
}
}

View File

@@ -0,0 +1,210 @@
//
// File.swift
// KSPlayer
//
// Created by kintan on 2018/3/9.
//
#if canImport(UIKit)
import UIKit
public class KSSlider: UXSlider {
private var tapGesture: UITapGestureRecognizer!
private var panGesture: UIPanGestureRecognizer!
weak var delegate: KSSliderDelegate?
public var trackHeigt = CGFloat(2)
public var isPlayable = false
override public init(frame: CGRect) {
super.init(frame: frame)
tapGesture = UITapGestureRecognizer(target: self, action: #selector(actionTapGesture(sender:)))
panGesture = UIPanGestureRecognizer(target: self, action: #selector(actionPanGesture(sender:)))
addGestureRecognizer(tapGesture)
addGestureRecognizer(panGesture)
addTarget(self, action: #selector(progressSliderTouchBegan(_:)), for: .touchDown)
addTarget(self, action: #selector(progressSliderValueChanged(_:)), for: .valueChanged)
addTarget(self, action: #selector(progressSliderTouchEnded(_:)), for: [.touchUpInside, .touchCancel, .touchUpOutside, .primaryActionTriggered])
}
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override open func trackRect(forBounds bounds: CGRect) -> CGRect {
var customBounds = super.trackRect(forBounds: bounds)
customBounds.origin.y -= trackHeigt / 2
customBounds.size.height = trackHeigt
return customBounds
}
override open func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect {
let rect = super.thumbRect(forBounds: bounds, trackRect: rect, value: value)
return rect.insetBy(dx: -20, dy: -20)
}
// MARK: - handle UI slider actions
@objc private func progressSliderTouchBegan(_ sender: KSSlider) {
guard isPlayable else { return }
tapGesture.isEnabled = false
panGesture.isEnabled = false
value = value
delegate?.slider(value: Double(sender.value), event: .touchDown)
}
@objc private func progressSliderValueChanged(_ sender: KSSlider) {
guard isPlayable else { return }
delegate?.slider(value: Double(sender.value), event: .valueChanged)
}
@objc private func progressSliderTouchEnded(_ sender: KSSlider) {
guard isPlayable else { return }
tapGesture.isEnabled = true
panGesture.isEnabled = true
delegate?.slider(value: Double(sender.value), event: .touchUpInside)
}
@objc private func actionTapGesture(sender: UITapGestureRecognizer) {
// guard isPlayable else {
// return
// }
let touchPoint = sender.location(in: self)
let value = (maximumValue - minimumValue) * Float(touchPoint.x / frame.size.width)
self.value = value
delegate?.slider(value: Double(value), event: .valueChanged)
delegate?.slider(value: Double(value), event: .touchUpInside)
}
@objc private func actionPanGesture(sender: UIPanGestureRecognizer) {
// guard isPlayable else {
// return
// }
let touchPoint = sender.location(in: self)
let value = (maximumValue - minimumValue) * Float(touchPoint.x / frame.size.width)
self.value = value
if sender.state == .began {
delegate?.slider(value: Double(value), event: .touchDown)
} else if sender.state == .ended {
delegate?.slider(value: Double(value), event: .touchUpInside)
} else {
delegate?.slider(value: Double(value), event: .valueChanged)
}
}
}
#if os(tvOS)
public class UXSlider: UIProgressView {
@IBInspectable public var value: Float {
get {
progress * maximumValue
}
set {
progress = newValue / maximumValue
}
}
@IBInspectable public var maximumValue: Float = 1 {
didSet {
refresh()
}
}
@IBInspectable public var minimumValue: Float = 0 {
didSet {
refresh()
}
}
open var minimumTrackTintColor: UIColor? {
get {
progressTintColor
}
set {
progressTintColor = newValue
}
}
open var maximumTrackTintColor: UIColor? {
get {
trackTintColor
}
set {
trackTintColor = newValue
}
}
open func setThumbImage(_: UIImage?, for _: UIControl.State) {}
open func addTarget(_: Any?, action _: Selector, for _: UIControl.Event) {}
override public init(frame: CGRect) {
super.init(frame: frame)
setup()
}
public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
// MARK: - private functions
private func setup() {
refresh()
}
private func refresh() {}
open func trackRect(forBounds bounds: CGRect) -> CGRect {
bounds
}
open func thumbRect(forBounds bounds: CGRect, trackRect _: CGRect, value _: Float) -> CGRect {
bounds
}
}
#else
public typealias UXSlider = UISlider
#endif
public typealias UIViewContentMode = UIView.ContentMode
extension UIButton {
func fillImage() {
contentMode = .scaleAspectFill
contentHorizontalAlignment = .fill
contentVerticalAlignment = .fill
}
var titleFont: UIFont? {
get {
titleLabel?.font
}
set {
titleLabel?.font = newValue
}
}
var title: String? {
get {
titleLabel?.text
}
set {
titleLabel?.text = newValue
}
}
}
extension UIView {
func image() -> UIImage? {
UIGraphicsBeginImageContextWithOptions(bounds.size, isOpaque, 0.0)
defer { UIGraphicsEndImageContext() }
if let context = UIGraphicsGetCurrentContext() {
layer.render(in: context)
let image = UIGraphicsGetImageFromCurrentImageContext()
return image
}
return nil
}
public func centerRotate(byDegrees: Double) {
transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi * byDegrees / 180.0))
}
}
#endif

View File

@@ -0,0 +1,48 @@
//
// File.swift
// KSPlayer
//
// Created by kintan on 2018/3/9.
//
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
extension UIView {
var backingLayer: CALayer? {
#if !canImport(UIKit)
wantsLayer = true
#endif
return layer
}
var cornerRadius: CGFloat {
get {
backingLayer?.cornerRadius ?? 0
}
set {
backingLayer?.cornerRadius = newValue
}
}
}
@objc public enum ControlEvents: Int {
case touchDown
case touchUpInside
case touchCancel
case valueChanged
case primaryActionTriggered
case mouseEntered
case mouseExited
}
protocol KSSliderDelegate: AnyObject {
/**
call when slider action trigged
- parameter value: progress
- parameter event: action
*/
func slider(value: Double, event: ControlEvents)
}

View File

@@ -0,0 +1,850 @@
//
// Utility.swift
// KSPlayer
//
// Created by kintan on 2018/3/9.
//
import AVFoundation
import CryptoKit
import SwiftUI
#if canImport(UIKit)
import UIKit
#else
import AppKit
#endif
#if canImport(MobileCoreServices)
import MobileCoreServices.UTType
#endif
open class LayerContainerView: UIView {
#if canImport(UIKit)
override open class var layerClass: AnyClass {
CAGradientLayer.self
}
#else
override public init(frame: CGRect) {
super.init(frame: frame)
layer = CAGradientLayer()
}
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
#endif
public var gradientLayer: CAGradientLayer {
// swiftlint:disable force_cast
layer as! CAGradientLayer
// swiftlint:enable force_cast
}
}
class GIFCreator {
private let destination: CGImageDestination
private let frameProperties: CFDictionary
private(set) var firstImage: UIImage?
init(savePath: URL, imagesCount: Int) {
try? FileManager.default.removeItem(at: savePath)
frameProperties = [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: 0.25]] as CFDictionary
destination = CGImageDestinationCreateWithURL(savePath as CFURL, kUTTypeGIF, imagesCount, nil)!
let fileProperties = [kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFLoopCount: 0]]
CGImageDestinationSetProperties(destination, fileProperties as CFDictionary)
}
func add(image: CGImage) {
if firstImage == nil {
firstImage = UIImage(cgImage: image)
}
CGImageDestinationAddImage(destination, image, frameProperties)
}
func finalize() -> Bool {
let result = CGImageDestinationFinalize(destination)
return result
}
}
public extension String {
static func systemClockTime(second: Bool = false) -> String {
let date = Date()
let calendar = Calendar.current
let component = calendar.dateComponents([.hour, .minute, .second], from: date)
if second {
return String(format: "%02i:%02i:%02i", component.hour!, component.minute!, component.second!)
} else {
return String(format: "%02i:%02i", component.hour!, component.minute!)
}
}
///
/// - Parameter fromStr: srt 00:02:52,184 ass 0:30:11.56 vtt 00:00.430
/// - Returns:
func parseDuration() -> TimeInterval {
let scanner = Scanner(string: self)
var hour: Double = 0
if split(separator: ":").count > 2 {
hour = scanner.scanDouble() ?? 0.0
_ = scanner.scanString(":")
}
let min = scanner.scanDouble() ?? 0.0
_ = scanner.scanString(":")
let sec = scanner.scanDouble() ?? 0.0
if scanner.scanString(",") == nil {
_ = scanner.scanString(".")
}
let millisecond = scanner.scanDouble() ?? 0.0
return (hour * 3600.0) + (min * 60.0) + sec + (millisecond / 1000.0)
}
func md5() -> String {
Data(utf8).md5()
}
}
public extension UIColor {
convenience init?(assColor: String) {
var colorString = assColor
// &H &
if colorString.hasPrefix("&H") {
colorString = String(colorString.dropFirst(2))
}
if colorString.hasSuffix("&") {
colorString = String(colorString.dropLast())
}
if let hex = Scanner(string: colorString).scanInt(representation: .hexadecimal) {
self.init(abgr: hex)
} else {
return nil
}
}
convenience init(abgr hex: Int) {
let alpha = 1 - (CGFloat(hex >> 24 & 0xFF) / 255)
let blue = CGFloat((hex >> 16) & 0xFF)
let green = CGFloat((hex >> 8) & 0xFF)
let red = CGFloat(hex & 0xFF)
self.init(red: red / 255.0, green: green / 255.0, blue: blue / 255.0, alpha: alpha)
}
convenience init(rgb hex: Int, alpha: CGFloat = 1) {
let red = CGFloat((hex >> 16) & 0xFF)
let green = CGFloat((hex >> 8) & 0xFF)
let blue = CGFloat(hex & 0xFF)
self.init(red: red / 255.0, green: green / 255.0, blue: blue / 255.0, alpha: alpha)
}
func createImage(size: CGSize = CGSize(width: 1, height: 1)) -> UIImage {
#if canImport(UIKit)
let rect = CGRect(origin: .zero, size: size)
UIGraphicsBeginImageContext(rect.size)
let context = UIGraphicsGetCurrentContext()
context?.setFillColor(cgColor)
context?.fill(rect)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return image!
#else
let image = NSImage(size: size)
image.lockFocus()
drawSwatch(in: CGRect(origin: .zero, size: size))
image.unlockFocus()
return image
#endif
}
}
extension AVAsset {
public func generateGIF(beginTime: TimeInterval, endTime: TimeInterval, interval: Double = 0.2, savePath: URL, progress: @escaping (Double) -> Void, completion: @escaping (Error?) -> Void) {
let count = Int(ceil((endTime - beginTime) / interval))
let timesM = (0 ..< count).map { NSValue(time: CMTime(seconds: beginTime + Double($0) * interval)) }
let imageGenerator = createImageGenerator()
let gifCreator = GIFCreator(savePath: savePath, imagesCount: count)
var i = 0
imageGenerator.generateCGImagesAsynchronously(forTimes: timesM) { _, imageRef, _, result, error in
switch result {
case .succeeded:
guard let imageRef else { return }
i += 1
gifCreator.add(image: imageRef)
progress(Double(i) / Double(count))
guard i == count else { return }
if gifCreator.finalize() {
completion(nil)
} else {
let error = NSError(domain: AVFoundationErrorDomain, code: -1, userInfo: [NSLocalizedDescriptionKey: "Generate Gif Failed!"])
completion(error)
}
case .failed:
if let error {
completion(error)
}
case .cancelled:
break
@unknown default:
break
}
}
}
private func createComposition(beginTime: TimeInterval, endTime: TimeInterval) async throws -> AVMutableComposition {
let compositionM = AVMutableComposition()
let audioTrackM = compositionM.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
let videoTrackM = compositionM.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
let cutRange = CMTimeRange(start: beginTime, end: endTime)
#if os(xrOS)
if let assetAudioTrack = try await loadTracks(withMediaType: .audio).first {
try audioTrackM?.insertTimeRange(cutRange, of: assetAudioTrack, at: .zero)
}
if let assetVideoTrack = try await loadTracks(withMediaType: .video).first {
try videoTrackM?.insertTimeRange(cutRange, of: assetVideoTrack, at: .zero)
}
#else
if let assetAudioTrack = tracks(withMediaType: .audio).first {
try audioTrackM?.insertTimeRange(cutRange, of: assetAudioTrack, at: .zero)
}
if let assetVideoTrack = tracks(withMediaType: .video).first {
try videoTrackM?.insertTimeRange(cutRange, of: assetVideoTrack, at: .zero)
}
#endif
return compositionM
}
func createExportSession(beginTime: TimeInterval, endTime: TimeInterval) async throws -> AVAssetExportSession? {
let compositionM = try await createComposition(beginTime: beginTime, endTime: endTime)
guard let exportSession = AVAssetExportSession(asset: compositionM, presetName: "") else {
return nil
}
exportSession.shouldOptimizeForNetworkUse = true
exportSession.outputFileType = .mp4
return exportSession
}
func exportMp4(beginTime: TimeInterval, endTime: TimeInterval, outputURL: URL, progress: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) throws {
try FileManager.default.removeItem(at: outputURL)
Task {
guard let exportSession = try await createExportSession(beginTime: beginTime, endTime: endTime) else { return }
exportSession.outputURL = outputURL
await exportSession.export()
switch exportSession.status {
case .exporting:
progress(Double(exportSession.progress))
case .completed:
progress(1)
completion(.success(outputURL))
exportSession.cancelExport()
case .failed:
if let error = exportSession.error {
completion(.failure(error))
}
exportSession.cancelExport()
case .cancelled:
exportSession.cancelExport()
case .unknown, .waiting:
break
@unknown default:
break
}
}
}
func exportMp4(beginTime: TimeInterval, endTime: TimeInterval, progress: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) throws {
guard var exportURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
exportURL = exportURL.appendingPathExtension("Export.mp4")
try exportMp4(beginTime: beginTime, endTime: endTime, outputURL: exportURL, progress: progress, completion: completion)
}
}
extension UIImageView {
func image(url: URL?) {
guard let url else { return }
DispatchQueue.global().async { [weak self] in
guard let self else { return }
let data = try? Data(contentsOf: url)
let image = data.flatMap { UIImage(data: $0) }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.image = image
}
}
}
}
#if canImport(UIKit)
extension AVPlayer.HDRMode {
var dynamicRange: DynamicRange {
if contains(.dolbyVision) {
return .dolbyVision
} else if contains(.hlg) {
return .hlg
} else if contains(.hdr10) {
return .hdr10
} else {
return .sdr
}
}
}
#endif
public extension FourCharCode {
var string: String {
let cString: [CChar] = [
CChar(self >> 24 & 0xFF),
CChar(self >> 16 & 0xFF),
CChar(self >> 8 & 0xFF),
CChar(self & 0xFF),
0,
]
return String(cString: cString)
}
}
extension CMTime {
init(seconds: TimeInterval) {
self.init(seconds: seconds, preferredTimescale: Int32(USEC_PER_SEC))
}
}
extension CMTimeRange {
init(start: TimeInterval, end: TimeInterval) {
self.init(start: CMTime(seconds: start), end: CMTime(seconds: end))
}
}
extension CGPoint {
var reverse: CGPoint {
CGPoint(x: y, y: x)
}
}
extension CGSize {
var reverse: CGSize {
CGSize(width: height, height: width)
}
var toPoint: CGPoint {
CGPoint(x: width, y: height)
}
var isHorizonal: Bool {
width > height
}
}
func * (left: CGSize, right: CGFloat) -> CGSize {
CGSize(width: left.width * right, height: left.height * right)
}
func * (left: CGPoint, right: CGFloat) -> CGPoint {
CGPoint(x: left.x * right, y: left.y * right)
}
func * (left: CGRect, right: CGFloat) -> CGRect {
CGRect(origin: left.origin * right, size: left.size * right)
}
func - (left: CGSize, right: CGSize) -> CGSize {
CGSize(width: left.width - right.width, height: left.height - right.height)
}
@inline(__always)
@preconcurrency
// @MainActor
public func runOnMainThread(block: @escaping () -> Void) {
if Thread.isMainThread {
block()
} else {
Task {
await MainActor.run(body: block)
}
}
}
public extension URL {
var isMovie: Bool {
if let typeID = try? resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier as CFString? {
return UTTypeConformsTo(typeID, kUTTypeMovie)
}
return false
}
var isAudio: Bool {
if let typeID = try? resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier as CFString? {
return UTTypeConformsTo(typeID, kUTTypeAudio)
}
return false
}
var isSubtitle: Bool {
["ass", "srt", "ssa", "vtt"].contains(pathExtension.lowercased())
}
var isPlaylist: Bool {
["cue", "m3u", "pls"].contains(pathExtension.lowercased())
}
func parsePlaylist() async throws -> [(String, URL, [String: String])] {
let data = try await data()
var entrys = data.parsePlaylist()
for i in 0 ..< entrys.count {
var entry = entrys[i]
if entry.1.path.hasPrefix("./") {
entry.1 = deletingLastPathComponent().appendingPathComponent(entry.1.path).standardized
entrys[i] = entry
}
}
return entrys
}
func data(userAgent: String? = nil) async throws -> Data {
if isFileURL {
return try Data(contentsOf: self)
} else {
var request = URLRequest(url: self)
if let userAgent {
request.addValue(userAgent, forHTTPHeaderField: "User-Agent")
}
let (data, _) = try await URLSession.shared.data(for: request)
return data
}
}
func download(userAgent: String? = nil, completion: @escaping ((String, URL) -> Void)) {
var request = URLRequest(url: self)
if let userAgent {
request.addValue(userAgent, forHTTPHeaderField: "User-Agent")
}
let task = URLSession.shared.downloadTask(with: request) { url, response, _ in
guard let url, let response else {
return
}
//
completion(response.suggestedFilename ?? url.lastPathComponent, url)
}
task.resume()
}
}
public extension Data {
func parsePlaylist() -> [(String, URL, [String: String])] {
guard let string = String(data: self, encoding: .utf8) else {
return []
}
let scanner = Scanner(string: string)
var entrys = [(String, URL, [String: String])]()
guard let symbol = scanner.scanUpToCharacters(from: .newlines), symbol.contains("#EXTM3U") else {
return []
}
while !scanner.isAtEnd {
if let entry = scanner.parseM3U() {
entrys.append(entry)
}
}
return entrys
}
func md5() -> String {
let digestData = Insecure.MD5.hash(data: self)
return String(digestData.map { String(format: "%02hhx", $0) }.joined().prefix(32))
}
}
extension Scanner {
/*
#EXTINF:-1 tvg-id="ExampleTV.ua" tvg-logo="https://image.com" group-title="test test", Example TV (720p) [Not 24/7]
#EXTVLCOPT:http-referrer=http://example.com/
#EXTVLCOPT:http-user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64)
http://example.com/stream.m3u8
*/
func parseM3U() -> (String, URL, [String: String])? {
if scanString("#EXTINF:") == nil {
_ = scanUpToCharacters(from: .newlines)
return nil
}
var extinf = [String: String]()
if let duration = scanDouble() {
extinf["duration"] = String(duration)
}
while scanString(",") == nil {
let key = scanUpToString("=")
_ = scanString("=\"")
let value = scanUpToString("\"")
_ = scanString("\"")
if let key, let value {
extinf[key] = value
}
}
let title = scanUpToCharacters(from: .newlines)
while scanString("#EXT") != nil {
if scanString("VLCOPT:") != nil {
let key = scanUpToString("=")
_ = scanString("=")
let value = scanUpToCharacters(from: .newlines)
if let key, let value {
extinf[key] = value
}
} else {
let key = scanUpToString(":")
_ = scanString(":")
let value = scanUpToCharacters(from: .newlines)
if let key, let value {
extinf[key] = value
}
}
}
let urlString = scanUpToCharacters(from: .newlines)
if let urlString, let url = URL(string: urlString) {
return (title ?? url.lastPathComponent, url, extinf)
}
return nil
}
}
extension HTTPURLResponse {
var filename: String? {
let httpFileName = "attachment; filename="
if var disposition = value(forHTTPHeaderField: "Content-Disposition"), disposition.hasPrefix(httpFileName) {
disposition.removeFirst(httpFileName.count)
return disposition
}
return nil
}
}
public extension Double {
var kmFormatted: String {
// return .formatted(.number.notation(.compactName))
if self >= 1_000_000 {
return String(format: "%.1fM", locale: Locale.current, self / 1_000_000)
// .replacingOccurrences(of: ".0", with: "")
} else if self >= 10000, self <= 999_999 {
return String(format: "%.1fK", locale: Locale.current, self / 1000)
// .replacingOccurrences(of: ".0", with: "")
} else {
return String(format: "%.0f", locale: Locale.current, self)
}
}
}
extension TextAlignment: RawRepresentable {
public typealias RawValue = String
public init?(rawValue: RawValue) {
if rawValue == "Leading" {
self = .leading
} else if rawValue == "Center" {
self = .center
} else if rawValue == "Trailing" {
self = .trailing
} else {
return nil
}
}
public var rawValue: RawValue {
switch self {
case .leading:
return "Leading"
case .center:
return "Center"
case .trailing:
return "Trailing"
}
}
}
extension TextAlignment: Identifiable {
public var id: Self { self }
}
extension HorizontalAlignment: Hashable, RawRepresentable {
public typealias RawValue = String
public init?(rawValue: RawValue) {
if rawValue == "Leading" {
self = .leading
} else if rawValue == "Center" {
self = .center
} else if rawValue == "Trailing" {
self = .trailing
} else {
return nil
}
}
public var rawValue: RawValue {
switch self {
case .leading:
return "Leading"
case .center:
return "Center"
case .trailing:
return "Trailing"
default:
return ""
}
}
}
extension HorizontalAlignment: Identifiable {
public var id: Self { self }
}
extension VerticalAlignment: Hashable, RawRepresentable {
public typealias RawValue = String
public init?(rawValue: RawValue) {
if rawValue == "Top" {
self = .top
} else if rawValue == "Center" {
self = .center
} else if rawValue == "Bottom" {
self = .bottom
} else {
return nil
}
}
public var rawValue: RawValue {
switch self {
case .top:
return "Top"
case .center:
return "Center"
case .bottom:
return "Bottom"
default:
return ""
}
}
}
extension VerticalAlignment: Identifiable {
public var id: Self { self }
}
extension Color: RawRepresentable {
public typealias RawValue = String
public init?(rawValue: RawValue) {
guard let data = Data(base64Encoded: rawValue) else {
self = .black
return
}
do {
let color = try NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: data) ?? .black
self = Color(color)
} catch {
self = .black
}
}
public var rawValue: RawValue {
do {
if #available(macOS 11.0, iOS 14, tvOS 14, *) {
let data = try NSKeyedArchiver.archivedData(withRootObject: UIColor(self), requiringSecureCoding: false) as Data
return data.base64EncodedString()
} else {
return ""
}
} catch {
return ""
}
}
}
extension Array: RawRepresentable where Element: Codable {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([Element].self, from: data)
else { return nil }
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "[]"
}
return result
}
}
extension Date: RawRepresentable {
public typealias RawValue = String
public init?(rawValue: RawValue) {
guard let data = rawValue.data(using: .utf8),
let date = try? JSONDecoder().decode(Date.self, from: data)
else {
return nil
}
self = date
}
public var rawValue: RawValue {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return ""
}
return result
}
}
extension CGImage {
static func combine(images: [(CGRect, CGImage)]) -> CGImage? {
if images.isEmpty {
return nil
}
if images.count == 1 {
return images[0].1
}
var width = 0
var height = 0
for (rect, _) in images {
width = max(width, Int(rect.maxX))
height = max(height, Int(rect.maxY))
}
let bitsPerComponent = 8
// RGBA(bytes) * bitsPerComponent *width
let bytesPerRow = 4 * 8 * bitsPerComponent * width
return autoreleasepool {
let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: bytesPerRow, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
guard let context else {
return nil
}
// context.clear(CGRect(origin: .zero, size: CGSize(width: width, height: height)))
for (rect, cgImage) in images {
context.draw(cgImage, in: CGRect(x: rect.origin.x, y: CGFloat(height) - rect.maxY, width: rect.width, height: rect.height))
}
let cgImage = context.makeImage()
return cgImage
}
}
func data(type: AVFileType, quality: CGFloat) -> Data? {
autoreleasepool {
guard let mutableData = CFDataCreateMutable(nil, 0),
let destination = CGImageDestinationCreateWithData(mutableData, type.rawValue as CFString, 1, nil)
else {
return nil
}
CGImageDestinationAddImage(destination, self, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return mutableData as Data
}
}
static func make(rgbData: UnsafePointer<UInt8>, linesize: Int, width: Int, height: Int, isAlpha: Bool = false) -> CGImage? {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo: CGBitmapInfo = isAlpha ? CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue) : CGBitmapInfo.byteOrderMask
guard let data = CFDataCreate(kCFAllocatorDefault, rgbData, linesize * height), let provider = CGDataProvider(data: data) else {
return nil
}
// swiftlint:disable line_length
return CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: isAlpha ? 32 : 24, bytesPerRow: linesize, space: colorSpace, bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)
// swiftlint:enable line_length
}
}
public extension AVFileType {
static let png = AVFileType(kUTTypePNG as String)
static let jpeg2000 = AVFileType(kUTTypeJPEG2000 as String)
}
extension URL: Identifiable {
public var id: Self { self }
}
extension String: Identifiable {
public var id: Self { self }
}
extension Float: Identifiable {
public var id: Self { self }
}
public enum Either<Left, Right> {
case left(Left), right(Right)
}
public extension Either {
init(_ left: Left, or _: Right.Type) { self = .left(left) }
init(_ left: Left) { self = .left(left) }
init(_ right: Right) { self = .right(right) }
}
/// Allows to "box" another value.
final class Box<T> {
let value: T
init(_ value: T) {
self.value = value
}
}
extension Array {
init(tuple: (Element, Element, Element, Element, Element, Element, Element, Element)) {
self.init([tuple.0, tuple.1, tuple.2, tuple.3, tuple.4, tuple.5, tuple.6, tuple.7])
}
init(tuple: (Element, Element, Element, Element)) {
self.init([tuple.0, tuple.1, tuple.2, tuple.3])
}
var tuple8: (Element, Element, Element, Element, Element, Element, Element, Element) {
(self[0], self[1], self[2], self[3], self[4], self[5], self[6], self[7])
}
var tuple4: (Element, Element, Element, Element) {
(self[0], self[1], self[2], self[3])
}
//
func mergeSortBottomUp(isOrderedBefore: (Element, Element) -> Bool) -> [Element] {
let n = count
var z = [self, self] // the two working arrays
var d = 0 // z[d] is used for reading, z[1 - d] for writing
var width = 1
while width < n {
var i = 0
while i < n {
var j = i
var l = i
var r = i + width
let lmax = Swift.min(l + width, n)
let rmax = Swift.min(r + width, n)
while l < lmax, r < rmax {
if isOrderedBefore(z[d][l], z[d][r]) {
z[1 - d][j] = z[d][l]
l += 1
} else {
z[1 - d][j] = z[d][r]
r += 1
}
j += 1
}
while l < lmax {
z[1 - d][j] = z[d][l]
j += 1
l += 1
}
while r < rmax {
z[1 - d][j] = z[d][r]
j += 1
r += 1
}
i += width * 2
}
width *= 2 // in each step, the subarray to merge becomes larger
d = 1 - d // swap active array
}
return z[d]
}
}