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:
594
KSPlayer-main/Sources/KSPlayer/Core/AppKitExtend.swift
Normal file
594
KSPlayer-main/Sources/KSPlayer/Core/AppKitExtend.swift
Normal 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
|
||||
282
KSPlayer-main/Sources/KSPlayer/Core/PlayerToolBar.swift
Normal file
282
KSPlayer-main/Sources/KSPlayer/Core/PlayerToolBar.swift
Normal 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
|
||||
}
|
||||
}
|
||||
220
KSPlayer-main/Sources/KSPlayer/Core/PlayerView.swift
Normal file
220
KSPlayer-main/Sources/KSPlayer/Core/PlayerView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
210
KSPlayer-main/Sources/KSPlayer/Core/UIKitExtend.swift
Normal file
210
KSPlayer-main/Sources/KSPlayer/Core/UIKitExtend.swift
Normal 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
|
||||
48
KSPlayer-main/Sources/KSPlayer/Core/UXKit.swift
Normal file
48
KSPlayer-main/Sources/KSPlayer/Core/UXKit.swift
Normal 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)
|
||||
}
|
||||
850
KSPlayer-main/Sources/KSPlayer/Core/Utility.swift
Normal file
850
KSPlayer-main/Sources/KSPlayer/Core/Utility.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user