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>
595 lines
15 KiB
Swift
595 lines
15 KiB
Swift
//
|
|
// 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
|