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>
387 lines
12 KiB
Swift
387 lines
12 KiB
Swift
//
|
||
// KSSubtitle.swift
|
||
// Pods
|
||
//
|
||
// Created by kintan on 2017/4/2.
|
||
//
|
||
//
|
||
|
||
import CoreFoundation
|
||
import CoreGraphics
|
||
import Foundation
|
||
import SwiftUI
|
||
|
||
public class SubtitlePart: CustomStringConvertible, Identifiable {
|
||
public var start: TimeInterval
|
||
public var end: TimeInterval
|
||
public var origin: CGPoint = .zero
|
||
public let text: NSAttributedString?
|
||
public var image: UIImage?
|
||
public var textPosition: TextPosition?
|
||
public var description: String {
|
||
"Subtile Group ==========\nstart: \(start)\nend:\(end)\ntext:\(String(describing: text))"
|
||
}
|
||
|
||
public convenience init(_ start: TimeInterval, _ end: TimeInterval, _ string: String) {
|
||
var text = string
|
||
text = text.trimmingCharacters(in: .whitespaces)
|
||
text = text.replacingOccurrences(of: "\r", with: "")
|
||
self.init(start, end, attributedString: NSAttributedString(string: text))
|
||
}
|
||
|
||
public init(_ start: TimeInterval, _ end: TimeInterval, attributedString: NSAttributedString?) {
|
||
self.start = start
|
||
self.end = end
|
||
text = attributedString
|
||
}
|
||
}
|
||
|
||
public struct TextPosition {
|
||
public var verticalAlign: VerticalAlignment = .bottom
|
||
public var horizontalAlign: HorizontalAlignment = .center
|
||
public var leftMargin: CGFloat = 0
|
||
public var rightMargin: CGFloat = 0
|
||
public var verticalMargin: CGFloat = 10
|
||
public var edgeInsets: EdgeInsets {
|
||
var edgeInsets = EdgeInsets()
|
||
if verticalAlign == .bottom {
|
||
edgeInsets.bottom = verticalMargin
|
||
} else if verticalAlign == .top {
|
||
edgeInsets.top = verticalMargin
|
||
}
|
||
if horizontalAlign == .leading {
|
||
edgeInsets.leading = leftMargin
|
||
}
|
||
if horizontalAlign == .trailing {
|
||
edgeInsets.trailing = rightMargin
|
||
}
|
||
return edgeInsets
|
||
}
|
||
|
||
public mutating func ass(alignment: String?) {
|
||
switch alignment {
|
||
case "1":
|
||
verticalAlign = .bottom
|
||
horizontalAlign = .leading
|
||
case "2":
|
||
verticalAlign = .bottom
|
||
horizontalAlign = .center
|
||
case "3":
|
||
verticalAlign = .bottom
|
||
horizontalAlign = .trailing
|
||
case "4":
|
||
verticalAlign = .center
|
||
horizontalAlign = .leading
|
||
case "5":
|
||
verticalAlign = .center
|
||
horizontalAlign = .center
|
||
case "6":
|
||
verticalAlign = .center
|
||
horizontalAlign = .trailing
|
||
case "7":
|
||
verticalAlign = .top
|
||
horizontalAlign = .leading
|
||
case "8":
|
||
verticalAlign = .top
|
||
horizontalAlign = .center
|
||
case "9":
|
||
verticalAlign = .top
|
||
horizontalAlign = .trailing
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
extension SubtitlePart: Comparable {
|
||
public static func == (left: SubtitlePart, right: SubtitlePart) -> Bool {
|
||
if left.start == right.start, left.end == right.end {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
|
||
public static func < (left: SubtitlePart, right: SubtitlePart) -> Bool {
|
||
if left.start < right.start {
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
extension SubtitlePart: NumericComparable {
|
||
public typealias Compare = TimeInterval
|
||
public static func == (left: SubtitlePart, right: TimeInterval) -> Bool {
|
||
left.start <= right && left.end >= right
|
||
}
|
||
|
||
public static func < (left: SubtitlePart, right: TimeInterval) -> Bool {
|
||
left.end < right
|
||
}
|
||
}
|
||
|
||
public protocol KSSubtitleProtocol {
|
||
func search(for time: TimeInterval) -> [SubtitlePart]
|
||
}
|
||
|
||
public protocol SubtitleInfo: KSSubtitleProtocol, AnyObject, Hashable, Identifiable {
|
||
var subtitleID: String { get }
|
||
var name: String { get }
|
||
var delay: TimeInterval { get set }
|
||
// var userInfo: NSMutableDictionary? { get set }
|
||
// var subtitleDataSouce: SubtitleDataSouce? { get set }
|
||
// var comment: String? { get }
|
||
var isEnabled: Bool { get set }
|
||
}
|
||
|
||
public extension SubtitleInfo {
|
||
var id: String { subtitleID }
|
||
func hash(into hasher: inout Hasher) {
|
||
hasher.combine(subtitleID)
|
||
}
|
||
|
||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||
lhs.subtitleID == rhs.subtitleID
|
||
}
|
||
}
|
||
|
||
public class KSSubtitle {
|
||
public var parts: [SubtitlePart] = []
|
||
public init() {}
|
||
}
|
||
|
||
extension KSSubtitle: KSSubtitleProtocol {
|
||
/// Search for target group for time
|
||
public func search(for time: TimeInterval) -> [SubtitlePart] {
|
||
var result = [SubtitlePart]()
|
||
for part in parts {
|
||
if part == time {
|
||
result.append(part)
|
||
} else if part.start > time {
|
||
break
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
}
|
||
|
||
public extension KSSubtitle {
|
||
func parse(url: URL, userAgent: String? = nil, encoding: String.Encoding? = nil) async throws {
|
||
let data = try await url.data(userAgent: userAgent)
|
||
try parse(data: data, encoding: encoding)
|
||
}
|
||
|
||
func parse(data: Data, encoding: String.Encoding? = nil) throws {
|
||
var string: String?
|
||
let encodes = [encoding ?? String.Encoding.utf8,
|
||
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.big5.rawValue))),
|
||
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))),
|
||
String.Encoding.unicode]
|
||
for encode in encodes {
|
||
string = String(data: data, encoding: encode)
|
||
if string != nil {
|
||
break
|
||
}
|
||
}
|
||
guard let subtitle = string else {
|
||
throw NSError(errorCode: .subtitleUnEncoding)
|
||
}
|
||
let scanner = Scanner(string: subtitle)
|
||
_ = scanner.scanCharacters(from: .controlCharacters)
|
||
let parse = KSOptions.subtitleParses.first { $0.canParse(scanner: scanner) }
|
||
if let parse {
|
||
parts = parse.parse(scanner: scanner)
|
||
if parts.count == 0 {
|
||
throw NSError(errorCode: .subtitleUnParse)
|
||
}
|
||
} else {
|
||
throw NSError(errorCode: .subtitleFormatUnSupport)
|
||
}
|
||
}
|
||
|
||
// public static func == (lhs: KSURLSubtitle, rhs: KSURLSubtitle) -> Bool {
|
||
// lhs.url == rhs.url
|
||
// }
|
||
}
|
||
|
||
public protocol NumericComparable {
|
||
associatedtype Compare
|
||
static func < (lhs: Self, rhs: Compare) -> Bool
|
||
static func == (lhs: Self, rhs: Compare) -> Bool
|
||
}
|
||
|
||
extension Collection where Element: NumericComparable {
|
||
func binarySearch(key: Element.Compare) -> Self.Index? {
|
||
var lowerBound = startIndex
|
||
var upperBound = endIndex
|
||
while lowerBound < upperBound {
|
||
let midIndex = index(lowerBound, offsetBy: distance(from: lowerBound, to: upperBound) / 2)
|
||
if self[midIndex] == key {
|
||
return midIndex
|
||
} else if self[midIndex] < key {
|
||
lowerBound = index(lowerBound, offsetBy: 1)
|
||
} else {
|
||
upperBound = midIndex
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
open class SubtitleModel: ObservableObject {
|
||
public enum Size {
|
||
case smaller
|
||
case standard
|
||
case large
|
||
public var rawValue: CGFloat {
|
||
switch self {
|
||
case .smaller:
|
||
#if os(tvOS) || os(xrOS)
|
||
return 48
|
||
#elseif os(macOS) || os(xrOS)
|
||
return 20
|
||
#else
|
||
if UI_USER_INTERFACE_IDIOM() == .phone {
|
||
return 12
|
||
} else {
|
||
return 20
|
||
}
|
||
#endif
|
||
case .standard:
|
||
#if os(tvOS) || os(xrOS)
|
||
return 58
|
||
#elseif os(macOS) || os(xrOS)
|
||
return 26
|
||
#else
|
||
if UI_USER_INTERFACE_IDIOM() == .phone {
|
||
return 16
|
||
} else {
|
||
return 26
|
||
}
|
||
#endif
|
||
case .large:
|
||
#if os(tvOS) || os(xrOS)
|
||
return 68
|
||
#elseif os(macOS) || os(xrOS)
|
||
return 32
|
||
#else
|
||
if UI_USER_INTERFACE_IDIOM() == .phone {
|
||
return 20
|
||
} else {
|
||
return 32
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
|
||
public static var textColor: Color = .white
|
||
public static var textBackgroundColor: Color = .clear
|
||
public static var textFont: UIFont {
|
||
textBold ? .boldSystemFont(ofSize: textFontSize) : .systemFont(ofSize: textFontSize)
|
||
}
|
||
|
||
public static var textFontSize = SubtitleModel.Size.standard.rawValue
|
||
public static var textBold = false
|
||
public static var textItalic = false
|
||
public static var textPosition = TextPosition()
|
||
public static var audioRecognizes = [any AudioRecognize]()
|
||
private var subtitleDataSouces: [SubtitleDataSouce] = KSOptions.subtitleDataSouces
|
||
@Published
|
||
public private(set) var subtitleInfos = [any SubtitleInfo]()
|
||
@Published
|
||
public private(set) var parts = [SubtitlePart]()
|
||
public var subtitleDelay = 0.0 // s
|
||
public var url: URL? {
|
||
didSet {
|
||
subtitleInfos.removeAll()
|
||
searchSubtitle(query: nil, languages: [])
|
||
if url != nil {
|
||
subtitleInfos.append(contentsOf: SubtitleModel.audioRecognizes)
|
||
}
|
||
for datasouce in subtitleDataSouces {
|
||
addSubtitle(dataSouce: datasouce)
|
||
}
|
||
// 要用async,不能在更新UI的时候,修改Publishe变量
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.parts = []
|
||
self?.selectedSubtitleInfo = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
@Published
|
||
public var selectedSubtitleInfo: (any SubtitleInfo)? {
|
||
didSet {
|
||
oldValue?.isEnabled = false
|
||
selectedSubtitleInfo?.isEnabled = true
|
||
if let url, let info = selectedSubtitleInfo as? URLSubtitleInfo, !info.downloadURL.isFileURL, let cache = subtitleDataSouces.first(where: { $0 is CacheSubtitleDataSouce }) as? CacheSubtitleDataSouce {
|
||
cache.addCache(fileURL: url, downloadURL: info.downloadURL)
|
||
}
|
||
}
|
||
}
|
||
|
||
public init() {}
|
||
|
||
public func addSubtitle(info: any SubtitleInfo) {
|
||
if subtitleInfos.first(where: { $0.subtitleID == info.subtitleID }) == nil {
|
||
subtitleInfos.append(info)
|
||
}
|
||
}
|
||
|
||
public func subtitle(currentTime: TimeInterval) -> Bool {
|
||
var newParts = [SubtitlePart]()
|
||
if let subtile = selectedSubtitleInfo {
|
||
let currentTime = currentTime - subtile.delay - subtitleDelay
|
||
newParts = subtile.search(for: currentTime)
|
||
if newParts.isEmpty {
|
||
newParts = parts.filter { part in
|
||
part == currentTime
|
||
}
|
||
}
|
||
}
|
||
// swiftUI不会判断是否相等。所以需要这边判断下。
|
||
if newParts != parts {
|
||
for part in newParts {
|
||
if let text = part.text as? NSMutableAttributedString {
|
||
text.addAttributes([.font: SubtitleModel.textFont],
|
||
range: NSRange(location: 0, length: text.length))
|
||
}
|
||
}
|
||
parts = newParts
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
|
||
public func searchSubtitle(query: String?, languages: [String]) {
|
||
for dataSouce in subtitleDataSouces {
|
||
if let dataSouce = dataSouce as? SearchSubtitleDataSouce {
|
||
subtitleInfos.removeAll { info in
|
||
dataSouce.infos.contains {
|
||
$0 === info
|
||
}
|
||
}
|
||
Task { @MainActor in
|
||
try? await dataSouce.searchSubtitle(query: query, languages: languages)
|
||
subtitleInfos.append(contentsOf: dataSouce.infos)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
public func addSubtitle(dataSouce: SubtitleDataSouce) {
|
||
if let dataSouce = dataSouce as? FileURLSubtitleDataSouce {
|
||
Task { @MainActor in
|
||
try? await dataSouce.searchSubtitle(fileURL: url)
|
||
subtitleInfos.append(contentsOf: dataSouce.infos)
|
||
}
|
||
} else {
|
||
subtitleInfos.append(contentsOf: dataSouce.infos)
|
||
}
|
||
}
|
||
}
|