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:
422
KSPlayer-main/Sources/KSPlayer/Subtitle/KSParseProtocol.swift
Normal file
422
KSPlayer-main/Sources/KSPlayer/Subtitle/KSParseProtocol.swift
Normal file
@@ -0,0 +1,422 @@
|
||||
//
|
||||
// KSParseProtocol.swift
|
||||
// KSPlayer-7de52535
|
||||
//
|
||||
// Created by kintan on 2018/8/7.
|
||||
//
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if !canImport(UIKit)
|
||||
import AppKit
|
||||
#else
|
||||
import UIKit
|
||||
#endif
|
||||
public protocol KSParseProtocol {
|
||||
func canParse(scanner: Scanner) -> Bool
|
||||
func parsePart(scanner: Scanner) -> SubtitlePart?
|
||||
}
|
||||
|
||||
public extension KSOptions {
|
||||
static var subtitleParses: [KSParseProtocol] = [AssParse(), VTTParse(), SrtParse()]
|
||||
}
|
||||
|
||||
public extension String {}
|
||||
|
||||
public extension KSParseProtocol {
|
||||
func parse(scanner: Scanner) -> [SubtitlePart] {
|
||||
var groups = [SubtitlePart]()
|
||||
|
||||
while !scanner.isAtEnd {
|
||||
if let group = parsePart(scanner: scanner) {
|
||||
groups.append(group)
|
||||
}
|
||||
}
|
||||
groups = groups.mergeSortBottomUp { $0 < $1 }
|
||||
return groups
|
||||
}
|
||||
}
|
||||
|
||||
public class AssParse: KSParseProtocol {
|
||||
private var styleMap = [String: ASSStyle]()
|
||||
private var eventKeys = ["Layer", "Start", "End", "Style", "Name", "MarginL", "MarginR", "MarginV", "Effect", "Text"]
|
||||
private var playResX = Float(0.0)
|
||||
private var playResY = Float(0.0)
|
||||
public func canParse(scanner: Scanner) -> Bool {
|
||||
guard scanner.scanString("[Script Info]") != nil else {
|
||||
return false
|
||||
}
|
||||
while scanner.scanString("Format:") == nil {
|
||||
if scanner.scanString("PlayResX:") != nil {
|
||||
playResX = scanner.scanFloat() ?? 0
|
||||
} else if scanner.scanString("PlayResY:") != nil {
|
||||
playResY = scanner.scanFloat() ?? 0
|
||||
} else {
|
||||
_ = scanner.scanUpToCharacters(from: .newlines)
|
||||
}
|
||||
}
|
||||
guard var keys = scanner.scanUpToCharacters(from: .newlines)?.components(separatedBy: ",") else {
|
||||
return false
|
||||
}
|
||||
keys = keys.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
while scanner.scanString("Style:") != nil {
|
||||
_ = scanner.scanString("Format: ")
|
||||
guard let values = scanner.scanUpToCharacters(from: .newlines)?.components(separatedBy: ",") else {
|
||||
continue
|
||||
}
|
||||
var dic = [String: String]()
|
||||
for i in 1 ..< keys.count {
|
||||
dic[keys[i]] = values[i]
|
||||
}
|
||||
styleMap[values[0]] = dic.parseASSStyle()
|
||||
}
|
||||
_ = scanner.scanString("[Events]")
|
||||
if scanner.scanString("Format: ") != nil {
|
||||
guard let keys = scanner.scanUpToCharacters(from: .newlines)?.components(separatedBy: ",") else {
|
||||
return false
|
||||
}
|
||||
eventKeys = keys.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Dialogue: 0,0:12:37.73,0:12:38.83,Aki Default,,0,0,0,,{\be8}原来如此
|
||||
// ffmpeg 软解的字幕
|
||||
// 875,,Default,NTP,0000,0000,0000,!Effect,- 你们两个别冲这么快\\N- 我会取消所有行程尽快赶过去
|
||||
public func parsePart(scanner: Scanner) -> SubtitlePart? {
|
||||
let isDialogue = scanner.scanString("Dialogue") != nil
|
||||
var dic = [String: String]()
|
||||
for i in 0 ..< eventKeys.count {
|
||||
if !isDialogue, i == 1 {
|
||||
continue
|
||||
}
|
||||
if i == eventKeys.count - 1 {
|
||||
dic[eventKeys[i]] = scanner.scanUpToCharacters(from: .newlines)
|
||||
} else {
|
||||
dic[eventKeys[i]] = scanner.scanUpToString(",")
|
||||
_ = scanner.scanString(",")
|
||||
}
|
||||
}
|
||||
let start: TimeInterval
|
||||
let end: TimeInterval
|
||||
if let startString = dic["Start"], let endString = dic["End"] {
|
||||
start = startString.parseDuration()
|
||||
end = endString.parseDuration()
|
||||
} else {
|
||||
if isDialogue {
|
||||
return nil
|
||||
} else {
|
||||
start = 0
|
||||
end = 0
|
||||
}
|
||||
}
|
||||
var attributes: [NSAttributedString.Key: Any]?
|
||||
var textPosition: TextPosition
|
||||
if let style = dic["Style"], let assStyle = styleMap[style] {
|
||||
attributes = assStyle.attrs
|
||||
textPosition = assStyle.textPosition
|
||||
if let marginL = dic["MarginL"].flatMap(Double.init), marginL != 0 {
|
||||
textPosition.leftMargin = CGFloat(marginL)
|
||||
}
|
||||
if let marginR = dic["MarginR"].flatMap(Double.init), marginR != 0 {
|
||||
textPosition.rightMargin = CGFloat(marginR)
|
||||
}
|
||||
if let marginV = dic["MarginV"].flatMap(Double.init), marginV != 0 {
|
||||
textPosition.verticalMargin = CGFloat(marginV)
|
||||
}
|
||||
} else {
|
||||
textPosition = TextPosition()
|
||||
}
|
||||
guard var text = dic["Text"] else {
|
||||
return nil
|
||||
}
|
||||
text = text.replacingOccurrences(of: "\\N", with: "\n")
|
||||
text = text.replacingOccurrences(of: "\\n", with: "\n")
|
||||
let part = SubtitlePart(start, end, attributedString: text.build(textPosition: &textPosition, attributed: attributes))
|
||||
part.textPosition = textPosition
|
||||
return part
|
||||
}
|
||||
}
|
||||
|
||||
public struct ASSStyle {
|
||||
let attrs: [NSAttributedString.Key: Any]
|
||||
let textPosition: TextPosition
|
||||
}
|
||||
|
||||
// swiftlint:disable cyclomatic_complexity
|
||||
extension String {
|
||||
func build(textPosition: inout TextPosition, attributed: [NSAttributedString.Key: Any]? = nil) -> NSAttributedString {
|
||||
let lineCodes = splitStyle()
|
||||
let attributedStr = NSMutableAttributedString()
|
||||
var attributed = attributed ?? [:]
|
||||
for lineCode in lineCodes {
|
||||
attributedStr.append(lineCode.0.parseStyle(attributes: &attributed, style: lineCode.1, textPosition: &textPosition))
|
||||
}
|
||||
return attributedStr
|
||||
}
|
||||
|
||||
func splitStyle() -> [(String, String?)] {
|
||||
let scanner = Scanner(string: self)
|
||||
scanner.charactersToBeSkipped = nil
|
||||
var result = [(String, String?)]()
|
||||
var sytle: String?
|
||||
while !scanner.isAtEnd {
|
||||
if scanner.scanString("{") != nil {
|
||||
sytle = scanner.scanUpToString("}")
|
||||
_ = scanner.scanString("}")
|
||||
} else if let text = scanner.scanUpToString("{") {
|
||||
result.append((text, sytle))
|
||||
} else if let text = scanner.scanUpToCharacters(from: .newlines) {
|
||||
result.append((text, sytle))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseStyle(attributes: inout [NSAttributedString.Key: Any], style: String?, textPosition: inout TextPosition) -> NSAttributedString {
|
||||
guard let style else {
|
||||
return NSAttributedString(string: self, attributes: attributes)
|
||||
}
|
||||
var fontName: String?
|
||||
var fontSize: Float?
|
||||
let subStyleArr = style.components(separatedBy: "\\")
|
||||
var shadow = attributes[.shadow] as? NSShadow
|
||||
for item in subStyleArr {
|
||||
let itemStr = item.replacingOccurrences(of: " ", with: "")
|
||||
let scanner = Scanner(string: itemStr)
|
||||
let char = scanner.scanCharacter()
|
||||
switch char {
|
||||
case "a":
|
||||
let char = scanner.scanCharacter()
|
||||
if char == "n" {
|
||||
textPosition.ass(alignment: scanner.scanUpToCharacters(from: .newlines))
|
||||
}
|
||||
case "b":
|
||||
attributes[.expansion] = scanner.scanFloat()
|
||||
case "c":
|
||||
attributes[.foregroundColor] = scanner.scanUpToCharacters(from: .newlines).flatMap(UIColor.init(assColor:))
|
||||
case "f":
|
||||
let char = scanner.scanCharacter()
|
||||
if char == "n" {
|
||||
fontName = scanner.scanUpToCharacters(from: .newlines)
|
||||
} else if char == "s" {
|
||||
fontSize = scanner.scanFloat()
|
||||
}
|
||||
case "i":
|
||||
attributes[.obliqueness] = scanner.scanFloat()
|
||||
case "s":
|
||||
if scanner.scanString("had") != nil {
|
||||
if let size = scanner.scanFloat() {
|
||||
shadow = shadow ?? NSShadow()
|
||||
shadow?.shadowOffset = CGSize(width: CGFloat(size), height: CGFloat(size))
|
||||
shadow?.shadowBlurRadius = CGFloat(size)
|
||||
}
|
||||
attributes[.shadow] = shadow
|
||||
} else {
|
||||
attributes[.strikethroughStyle] = scanner.scanInt()
|
||||
}
|
||||
case "u":
|
||||
attributes[.underlineStyle] = scanner.scanInt()
|
||||
case "1", "2", "3", "4":
|
||||
let twoChar = scanner.scanCharacter()
|
||||
if twoChar == "c" {
|
||||
let color = scanner.scanUpToCharacters(from: .newlines).flatMap(UIColor.init(assColor:))
|
||||
if char == "1" {
|
||||
attributes[.foregroundColor] = color
|
||||
} else if char == "2" {
|
||||
// 还不知道这个要设置到什么颜色上
|
||||
// attributes[.backgroundColor] = color
|
||||
} else if char == "3" {
|
||||
attributes[.strokeColor] = color
|
||||
} else if char == "4" {
|
||||
shadow = shadow ?? NSShadow()
|
||||
shadow?.shadowColor = color
|
||||
attributes[.shadow] = shadow
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
// Apply font attributes if available
|
||||
if let fontName, let fontSize {
|
||||
let font = UIFont(name: fontName, size: CGFloat(fontSize)) ?? UIFont.systemFont(ofSize: CGFloat(fontSize))
|
||||
attributes[.font] = font
|
||||
}
|
||||
return NSAttributedString(string: self, attributes: attributes)
|
||||
}
|
||||
}
|
||||
|
||||
public extension [String: String] {
|
||||
func parseASSStyle() -> ASSStyle {
|
||||
var attributes: [NSAttributedString.Key: Any] = [:]
|
||||
if let fontName = self["Fontname"], let fontSize = self["Fontsize"].flatMap(Double.init) {
|
||||
var font = UIFont(name: fontName, size: fontSize) ?? UIFont.systemFont(ofSize: fontSize)
|
||||
if let degrees = self["Angle"].flatMap(Double.init), degrees != 0 {
|
||||
let radians = CGFloat(degrees * .pi / 180.0)
|
||||
#if !canImport(UIKit)
|
||||
let matrix = AffineTransform(rotationByRadians: radians)
|
||||
#else
|
||||
let matrix = CGAffineTransform(rotationAngle: radians)
|
||||
#endif
|
||||
let fontDescriptor = UIFontDescriptor(name: fontName, matrix: matrix)
|
||||
font = UIFont(descriptor: fontDescriptor, size: fontSize) ?? font
|
||||
}
|
||||
attributes[.font] = font
|
||||
}
|
||||
// 创建字体样式
|
||||
if let assColor = self["PrimaryColour"] {
|
||||
attributes[.foregroundColor] = UIColor(assColor: assColor)
|
||||
}
|
||||
// 还不知道这个要设置到什么颜色上
|
||||
if let assColor = self["SecondaryColour"] {
|
||||
// attributes[.backgroundColor] = UIColor(assColor: assColor)
|
||||
}
|
||||
if self["Bold"] == "1" {
|
||||
attributes[.expansion] = 1
|
||||
}
|
||||
if self["Italic"] == "1" {
|
||||
attributes[.obliqueness] = 1
|
||||
}
|
||||
if self["Underline"] == "1" {
|
||||
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
|
||||
}
|
||||
if self["StrikeOut"] == "1" {
|
||||
attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
|
||||
}
|
||||
|
||||
// if let scaleX = self["ScaleX"].flatMap(Double.init), scaleX != 100 {
|
||||
// attributes[.expansion] = scaleX / 100.0
|
||||
// }
|
||||
// if let scaleY = self["ScaleY"].flatMap(Double.init), scaleY != 100 {
|
||||
// attributes[.baselineOffset] = scaleY - 100.0
|
||||
// }
|
||||
|
||||
// if let spacing = self["Spacing"].flatMap(Double.init) {
|
||||
// attributes[.kern] = CGFloat(spacing)
|
||||
// }
|
||||
|
||||
if self["BorderStyle"] == "1" {
|
||||
if let strokeWidth = self["Outline"].flatMap(Double.init), strokeWidth > 0 {
|
||||
attributes[.strokeWidth] = -strokeWidth
|
||||
if let assColor = self["OutlineColour"] {
|
||||
attributes[.strokeColor] = UIColor(assColor: assColor)
|
||||
}
|
||||
}
|
||||
if let assColor = self["BackColour"],
|
||||
let shadowOffset = self["Shadow"].flatMap(Double.init),
|
||||
shadowOffset > 0
|
||||
{
|
||||
let shadow = NSShadow()
|
||||
shadow.shadowOffset = CGSize(width: CGFloat(shadowOffset), height: CGFloat(shadowOffset))
|
||||
shadow.shadowBlurRadius = shadowOffset
|
||||
shadow.shadowColor = UIColor(assColor: assColor)
|
||||
attributes[.shadow] = shadow
|
||||
}
|
||||
}
|
||||
var textPosition = TextPosition()
|
||||
textPosition.ass(alignment: self["Alignment"])
|
||||
if let marginL = self["MarginL"].flatMap(Double.init) {
|
||||
textPosition.leftMargin = CGFloat(marginL)
|
||||
}
|
||||
if let marginR = self["MarginR"].flatMap(Double.init) {
|
||||
textPosition.rightMargin = CGFloat(marginR)
|
||||
}
|
||||
if let marginV = self["MarginV"].flatMap(Double.init) {
|
||||
textPosition.verticalMargin = CGFloat(marginV)
|
||||
}
|
||||
return ASSStyle(attrs: attributes, textPosition: textPosition)
|
||||
}
|
||||
// swiftlint:enable cyclomatic_complexity
|
||||
}
|
||||
|
||||
public class VTTParse: KSParseProtocol {
|
||||
public func canParse(scanner: Scanner) -> Bool {
|
||||
let result = scanner.scanString("WEBVTT")
|
||||
if result != nil {
|
||||
scanner.charactersToBeSkipped = nil
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
00:00.430 --> 00:03.380
|
||||
简中封装 by Q66
|
||||
*/
|
||||
public func parsePart(scanner: Scanner) -> SubtitlePart? {
|
||||
var timeStrs: String?
|
||||
repeat {
|
||||
timeStrs = scanner.scanUpToCharacters(from: .newlines)
|
||||
_ = scanner.scanCharacters(from: .newlines)
|
||||
} while !(timeStrs?.contains("-->") ?? false) && !scanner.isAtEnd
|
||||
guard let timeStrs else {
|
||||
return nil
|
||||
}
|
||||
let timeArray: [String] = timeStrs.components(separatedBy: "-->")
|
||||
if timeArray.count == 2 {
|
||||
let startString = timeArray[0]
|
||||
let endString = timeArray[1]
|
||||
_ = scanner.scanCharacters(from: .newlines)
|
||||
var text = ""
|
||||
var newLine: String? = nil
|
||||
repeat {
|
||||
if let str = scanner.scanUpToCharacters(from: .newlines) {
|
||||
text += str
|
||||
}
|
||||
newLine = scanner.scanCharacters(from: .newlines)
|
||||
if newLine == "\n" || newLine == "\r\n" {
|
||||
text += "\n"
|
||||
}
|
||||
} while newLine == "\n" || newLine == "\r\n"
|
||||
var textPosition = TextPosition()
|
||||
return SubtitlePart(startString.parseDuration(), endString.parseDuration(), attributedString: text.build(textPosition: &textPosition))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public class SrtParse: KSParseProtocol {
|
||||
public func canParse(scanner: Scanner) -> Bool {
|
||||
let result = scanner.string.contains(" --> ")
|
||||
if result {
|
||||
scanner.charactersToBeSkipped = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
45
|
||||
00:02:52,184 --> 00:02:53,617
|
||||
{\an4}慢慢来
|
||||
*/
|
||||
public func parsePart(scanner: Scanner) -> SubtitlePart? {
|
||||
var decimal: String?
|
||||
repeat {
|
||||
decimal = scanner.scanUpToCharacters(from: .newlines)
|
||||
_ = scanner.scanCharacters(from: .newlines)
|
||||
} while decimal.flatMap(Int.init) == nil
|
||||
let startString = scanner.scanUpToString("-->")
|
||||
// skip spaces and newlines by default.
|
||||
_ = scanner.scanString("-->")
|
||||
if let startString,
|
||||
let endString = scanner.scanUpToCharacters(from: .newlines)
|
||||
{
|
||||
_ = scanner.scanCharacters(from: .newlines)
|
||||
var text = ""
|
||||
var newLine: String? = nil
|
||||
repeat {
|
||||
if let str = scanner.scanUpToCharacters(from: .newlines) {
|
||||
text += str
|
||||
}
|
||||
newLine = scanner.scanCharacters(from: .newlines)
|
||||
if newLine == "\n" || newLine == "\r\n" {
|
||||
text += "\n"
|
||||
}
|
||||
} while newLine == "\n" || newLine == "\r\n"
|
||||
var textPosition = TextPosition()
|
||||
return SubtitlePart(startString.parseDuration(), endString.parseDuration(), attributedString: text.build(textPosition: &textPosition))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user