Initial commit: SimVision tvOS streaming app

Features:
- VOD library with movie grouping and version detection
- TV show library with season/episode organization
- TMDB integration for trending shows and recently aired episodes
- Recent releases section with TMDB release date sorting
- Watch history tracking with continue watching
- Playlist caching (12-hour TTL) for offline support
- M3U playlist parsing with XStream API support
- Authentication with credential storage

Technical:
- SwiftUI for tvOS
- Actor-based services for thread safety
- Persistent caching for playlists, TMDB data, and watch history
- KSPlayer integration for video playback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 22:12:08 -06:00
commit 872354b834
283 changed files with 338296 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
//
// AirPlayView.swift
// KSPlayer
//
// Created by kintan on 2023/5/4.
//
import AVKit
import SwiftUI
#if !os(xrOS)
public struct AirPlayView: UIViewRepresentable {
public init() {}
#if canImport(UIKit)
public typealias UIViewType = AVRoutePickerView
public func makeUIView(context _: Context) -> UIViewType {
let routePickerView = AVRoutePickerView()
routePickerView.tintColor = .white
return routePickerView
}
public func updateUIView(_: UIViewType, context _: Context) {}
#else
public typealias NSViewType = AVRoutePickerView
public func makeNSView(context _: Context) -> NSViewType {
let routePickerView = AVRoutePickerView()
routePickerView.isRoutePickerButtonBordered = false
return routePickerView
}
public func updateNSView(_: NSViewType, context _: Context) {}
#endif
}
#endif
public extension View {
/// Applies the given transform if the given condition evaluates to `true`.
/// - Parameters:
/// - condition: The condition to evaluate.
/// - transform: The transform to apply to the source `View`.
/// - Returns: Either the original `View` or the modified `View` if the condition is `true`.
@ViewBuilder
func `if`(_ condition: @autoclosure () -> Bool, transform: (Self) -> some View) -> some View {
if condition() {
transform(self)
} else {
self
}
}
@ViewBuilder
func `if`(_ condition: @autoclosure () -> Bool, if ifTransform: (Self) -> some View, else elseTransform: (Self) -> some View) -> some View {
if condition() {
ifTransform(self)
} else {
elseTransform(self)
}
}
@ViewBuilder
func ifLet<T: Any>(_ optionalValue: T?, transform: (Self, T) -> some View) -> some View {
if let value = optionalValue {
transform(self, value)
} else {
self
}
}
}
extension Bool {
static var iOS16: Bool {
guard #available(iOS 16, *) else {
return true
}
return false
}
}

View File

@@ -0,0 +1,783 @@
//
// File.swift
// KSPlayer
//
// Created by kintan on 2022/1/29.
//
import AVFoundation
import MediaPlayer
import SwiftUI
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
@MainActor
public struct KSVideoPlayerView: View {
private let subtitleDataSouce: SubtitleDataSouce?
@State
private var title: String
@StateObject
private var playerCoordinator: KSVideoPlayer.Coordinator
@Environment(\.dismiss)
private var dismiss
@FocusState
private var focusableField: FocusableField? {
willSet {
isDropdownShow = newValue == .info
}
}
public let options: KSOptions
@State
private var isDropdownShow = false
@State
private var showVideoSetting = false
@State
public var url: URL {
didSet {
#if os(macOS)
NSDocumentController.shared.noteNewRecentDocumentURL(url)
#endif
}
}
public init(url: URL, options: KSOptions, title: String? = nil) {
self.init(coordinator: KSVideoPlayer.Coordinator(), url: url, options: options, title: title, subtitleDataSouce: nil)
}
public init(coordinator: KSVideoPlayer.Coordinator, url: URL, options: KSOptions, title: String? = nil, subtitleDataSouce: SubtitleDataSouce? = nil) {
self.init(coordinator: coordinator, url: .init(wrappedValue: url), options: options, title: .init(wrappedValue: title ?? url.lastPathComponent), subtitleDataSouce: subtitleDataSouce)
}
public init(coordinator: KSVideoPlayer.Coordinator, url: State<URL>, options: KSOptions, title: State<String>, subtitleDataSouce: SubtitleDataSouce?) {
_url = url
_playerCoordinator = .init(wrappedValue: coordinator)
_title = title
#if os(macOS)
NSDocumentController.shared.noteNewRecentDocumentURL(url.wrappedValue)
#endif
self.options = options
self.subtitleDataSouce = subtitleDataSouce
}
public var body: some View {
ZStack {
GeometryReader { proxy in
playView
HStack {
Spacer()
VideoSubtitleView(model: playerCoordinator.subtitleModel)
.allowsHitTesting(false) //
Spacer()
}
.padding()
controllerView(playerWidth: proxy.size.width)
#if os(tvOS)
.ignoresSafeArea()
#endif
#if os(tvOS)
if isDropdownShow {
VideoSettingView(config: playerCoordinator, subtitleModel: playerCoordinator.subtitleModel, subtitleTitle: title)
.focused($focusableField, equals: .info)
}
#endif
}
}
.preferredColorScheme(.dark)
.tint(.white)
.persistentSystemOverlays(.hidden)
.toolbar(.hidden, for: .automatic)
#if os(tvOS)
.onPlayPauseCommand {
if playerCoordinator.state.isPlaying {
playerCoordinator.playerLayer?.pause()
} else {
playerCoordinator.playerLayer?.play()
}
}
.onExitCommand {
if playerCoordinator.isMaskShow {
playerCoordinator.isMaskShow = false
} else {
switch focusableField {
case .play:
dismiss()
default:
focusableField = .play
}
}
}
#endif
}
private var playView: some View {
KSVideoPlayer(coordinator: playerCoordinator, url: url, options: options)
.onStateChanged { playerLayer, state in
if state == .readyToPlay {
if let movieTitle = playerLayer.player.dynamicInfo?.metadata["title"] {
title = movieTitle
}
}
}
.onBufferChanged { bufferedCount, consumeTime in
print("bufferedCount \(bufferedCount), consumeTime \(consumeTime)")
}
#if canImport(UIKit)
.onSwipe { _ in
playerCoordinator.isMaskShow = true
}
#endif
.ignoresSafeArea()
.onAppear {
focusableField = .play
if let subtitleDataSouce {
playerCoordinator.subtitleModel.addSubtitle(dataSouce: subtitleDataSouce)
}
// playerCoordinatoronDisappearremoveMonitor
// #if os(macOS)
// NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
// isMaskShow = overView
// return $0
// }
// #endif
}
#if os(iOS) || os(xrOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if !os(iOS)
.focusable(!playerCoordinator.isMaskShow)
.focused($focusableField, equals: .play)
#endif
#if !os(xrOS)
.onKeyPressLeftArrow {
playerCoordinator.skip(interval: -15)
}
.onKeyPressRightArrow {
playerCoordinator.skip(interval: 15)
}
.onKeyPressSapce {
if playerCoordinator.state.isPlaying {
playerCoordinator.playerLayer?.pause()
} else {
playerCoordinator.playerLayer?.play()
}
}
#endif
#if os(macOS)
.onTapGesture(count: 2) {
guard let view = playerCoordinator.playerLayer?.player.view else {
return
}
view.window?.toggleFullScreen(nil)
view.needsLayout = true
view.layoutSubtreeIfNeeded()
}
.onExitCommand {
playerCoordinator.playerLayer?.player.view?.exitFullScreenMode()
}
.onMoveCommand { direction in
switch direction {
case .left:
playerCoordinator.skip(interval: -15)
case .right:
playerCoordinator.skip(interval: 15)
case .up:
playerCoordinator.playerLayer?.player.playbackVolume += 0.2
case .down:
playerCoordinator.playerLayer?.player.playbackVolume -= 0.2
@unknown default:
break
}
}
#else
.onTapGesture {
playerCoordinator.isMaskShow.toggle()
}
#endif
#if os(tvOS)
.onMoveCommand { direction in
switch direction {
case .left:
playerCoordinator.skip(interval: -15)
case .right:
playerCoordinator.skip(interval: 15)
case .up:
playerCoordinator.mask(show: true, autoHide: false)
case .down:
focusableField = .info
@unknown default:
break
}
}
#else
.onHover { _ in
playerCoordinator.isMaskShow = true
}
.onDrop(of: ["public.file-url"], isTargeted: nil) { providers -> Bool in
providers.first?.loadDataRepresentation(forTypeIdentifier: "public.file-url") { data, _ in
if let data, let path = NSString(data: data, encoding: 4), let url = URL(string: path as String) {
openURL(url)
}
}
return true
}
#endif
}
private func controllerView(playerWidth: Double) -> some View {
VStack {
VideoControllerView(config: playerCoordinator, subtitleModel: playerCoordinator.subtitleModel, title: $title, volumeSliderSize: playerWidth / 4)
#if !os(xrOS)
// opacity0View
if playerCoordinator.isMaskShow {
VideoTimeShowView(config: playerCoordinator, model: playerCoordinator.timemodel)
.onAppear {
focusableField = .controller
}
.onDisappear {
focusableField = .play
}
}
#endif
}
#if os(xrOS)
.ornament(visibility: playerCoordinator.isMaskShow ? .visible : .hidden, attachmentAnchor: .scene(.bottom)) {
ornamentView(playerWidth: playerWidth)
}
.sheet(isPresented: $showVideoSetting) {
NavigationStack {
VideoSettingView(config: playerCoordinator, subtitleModel: playerCoordinator.subtitleModel, subtitleTitle: title)
}
.buttonStyle(.plain)
}
#elseif os(tvOS)
.padding(.horizontal, 80)
.padding(.bottom, 80)
.background(overlayGradient)
#endif
.focused($focusableField, equals: .controller)
.opacity(playerCoordinator.isMaskShow ? 1 : 0)
.padding()
}
private let overlayGradient = LinearGradient(
stops: [
Gradient.Stop(color: .black.opacity(0), location: 0.22),
Gradient.Stop(color: .black.opacity(0.7), location: 1),
],
startPoint: .top,
endPoint: .bottom
)
private func ornamentView(playerWidth: Double) -> some View {
VStack(alignment: .leading) {
KSVideoPlayerViewBuilder.titleView(title: title, config: playerCoordinator)
ornamentControlsView(playerWidth: playerWidth)
}
.frame(width: playerWidth / 1.5)
.buttonStyle(.plain)
.padding(.vertical, 24)
.padding(.horizontal, 36)
#if os(xrOS)
.glassBackgroundEffect()
#endif
}
private func ornamentControlsView(playerWidth _: Double) -> some View {
HStack {
KSVideoPlayerViewBuilder.playbackControlView(config: playerCoordinator, spacing: 16)
Spacer()
VideoTimeShowView(config: playerCoordinator, model: playerCoordinator.timemodel, timeFont: .title3.monospacedDigit())
Spacer()
Group {
KSVideoPlayerViewBuilder.contentModeButton(config: playerCoordinator)
KSVideoPlayerViewBuilder.subtitleButton(config: playerCoordinator)
KSVideoPlayerViewBuilder.playbackRateButton(playbackRate: $playerCoordinator.playbackRate)
KSVideoPlayerViewBuilder.infoButton(showVideoSetting: $showVideoSetting)
}
.font(.largeTitle)
}
}
fileprivate enum FocusableField {
case play, controller, info
}
public func openURL(_ url: URL) {
runOnMainThread {
if url.isSubtitle {
let info = URLSubtitleInfo(url: url)
playerCoordinator.subtitleModel.selectedSubtitleInfo = info
} else if url.isAudio || url.isMovie {
self.url = url
title = url.lastPathComponent
}
}
}
}
extension View {
func onKeyPressLeftArrow(action: @escaping () -> Void) -> some View {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) {
return onKeyPress(.leftArrow) {
action()
return .handled
}
} else {
return self
}
}
func onKeyPressRightArrow(action: @escaping () -> Void) -> some View {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) {
return onKeyPress(.rightArrow) {
action()
return .handled
}
} else {
return self
}
}
func onKeyPressSapce(action: @escaping () -> Void) -> some View {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, *) {
return onKeyPress(.space) {
action()
return .handled
}
} else {
return self
}
}
}
@available(iOS 16, tvOS 16, macOS 13, *)
struct VideoControllerView: View {
@ObservedObject
fileprivate var config: KSVideoPlayer.Coordinator
@ObservedObject
fileprivate var subtitleModel: SubtitleModel
@Binding
fileprivate var title: String
fileprivate var volumeSliderSize: Double?
@State
private var showVideoSetting = false
@Environment(\.dismiss)
private var dismiss
public var body: some View {
VStack {
#if os(tvOS)
Spacer()
HStack {
Text(title)
.lineLimit(2)
.layoutPriority(3)
ProgressView()
.opacity(config.state == .buffering ? 1 : 0)
Spacer()
.layoutPriority(2)
HStack {
Button {
if config.state.isPlaying {
config.playerLayer?.pause()
} else {
config.playerLayer?.play()
}
} label: {
Image(systemName: config.state == .error ? "play.slash.fill" : (config.state.isPlaying ? "pause.circle.fill" : "play.circle.fill"))
}
.frame(width: 56)
if let audioTracks = config.playerLayer?.player.tracks(mediaType: .audio), !audioTracks.isEmpty {
audioButton(audioTracks: audioTracks)
}
muteButton
.frame(width: 56)
contentModeButton
.frame(width: 56)
subtitleButton
playbackRateButton
pipButton
.frame(width: 56)
infoButton
.frame(width: 56)
}
.font(.caption)
}
#else
HStack {
#if !os(xrOS)
Button {
dismiss()
} label: {
Image(systemName: "x.circle.fill")
}
#if !os(tvOS)
if config.playerLayer?.player.allowsExternalPlayback == true {
AirPlayView().fixedSize()
}
#endif
#endif
Spacer()
if let audioTracks = config.playerLayer?.player.tracks(mediaType: .audio), !audioTracks.isEmpty {
audioButton(audioTracks: audioTracks)
#if os(xrOS)
.aspectRatio(1, contentMode: .fit)
.glassBackgroundEffect()
#endif
}
muteButton
#if !os(xrOS)
contentModeButton
subtitleButton
#endif
}
Spacer()
#if !os(xrOS)
KSVideoPlayerViewBuilder.playbackControlView(config: config)
Spacer()
HStack {
KSVideoPlayerViewBuilder.titleView(title: title, config: config)
Spacer()
playbackRateButton
pipButton
infoButton
}
#endif
#endif
}
#if !os(tvOS)
.font(.title)
.buttonStyle(.borderless)
#endif
.sheet(isPresented: $showVideoSetting) {
VideoSettingView(config: config, subtitleModel: config.subtitleModel, subtitleTitle: title)
}
}
private var muteButton: some View {
#if os(xrOS)
HStack {
Slider(value: $config.playbackVolume, in: 0 ... 1)
.onChange(of: config.playbackVolume) { _, newValue in
config.isMuted = newValue == 0
}
.frame(width: volumeSliderSize ?? 100)
.tint(.white.opacity(0.8))
.padding(.leading, 16)
KSVideoPlayerViewBuilder.muteButton(config: config)
}
.padding(16)
.glassBackgroundEffect()
#else
KSVideoPlayerViewBuilder.muteButton(config: config)
#endif
}
private var contentModeButton: some View {
KSVideoPlayerViewBuilder.contentModeButton(config: config)
}
private func audioButton(audioTracks: [MediaPlayerTrack]) -> some View {
MenuView(selection: Binding {
audioTracks.first { $0.isEnabled }?.trackID
} set: { value in
if let track = audioTracks.first(where: { $0.trackID == value }) {
config.playerLayer?.player.select(track: track)
}
}) {
ForEach(audioTracks, id: \.trackID) { track in
Text(track.description).tag(track.trackID as Int32?)
}
} label: {
Image(systemName: "waveform.circle.fill")
#if os(xrOS)
.padding()
.clipShape(Circle())
#endif
}
}
private var subtitleButton: some View {
KSVideoPlayerViewBuilder.subtitleButton(config: config)
}
private var playbackRateButton: some View {
KSVideoPlayerViewBuilder.playbackRateButton(playbackRate: $config.playbackRate)
}
private var pipButton: some View {
Button {
config.playerLayer?.isPipActive.toggle()
} label: {
Image(systemName: "rectangle.on.rectangle.circle.fill")
}
}
private var infoButton: some View {
KSVideoPlayerViewBuilder.infoButton(showVideoSetting: $showVideoSetting)
}
}
@available(iOS 15, tvOS 16, macOS 12, *)
public struct MenuView<Label, SelectionValue, Content>: View where Label: View, SelectionValue: Hashable, Content: View {
public let selection: Binding<SelectionValue>
@ViewBuilder
public let content: () -> Content
@ViewBuilder
public let label: () -> Label
@State
private var showMenu = false
public var body: some View {
if #available(tvOS 17, *) {
Menu {
Picker(selection: selection) {
content()
} label: {
EmptyView()
}
.pickerStyle(.inline)
} label: {
label()
}
.menuIndicator(.hidden)
} else {
Picker(selection: selection, content: content, label: label)
#if !os(macOS)
.pickerStyle(.navigationLink)
#endif
.frame(height: 50)
#if os(tvOS)
.frame(width: 110)
#endif
}
}
}
@available(iOS 15, tvOS 15, macOS 12, *)
struct VideoTimeShowView: View {
@ObservedObject
fileprivate var config: KSVideoPlayer.Coordinator
@ObservedObject
fileprivate var model: ControllerTimeModel
fileprivate var timeFont: Font?
public var body: some View {
if config.playerLayer?.player.seekable ?? false {
HStack {
Text(model.currentTime.toString(for: .minOrHour)).font(timeFont ?? .caption2.monospacedDigit())
Slider(value: Binding {
Float(model.currentTime)
} set: { newValue, _ in
model.currentTime = Int(newValue)
}, in: 0 ... Float(model.totalTime)) { onEditingChanged in
if onEditingChanged {
config.playerLayer?.pause()
} else {
config.seek(time: TimeInterval(model.currentTime))
}
}
.frame(maxHeight: 20)
#if os(xrOS)
.tint(.white.opacity(0.8))
#endif
Text((model.totalTime).toString(for: .minOrHour)).font(timeFont ?? .caption2.monospacedDigit())
}
.font(.system(.title2))
} else {
Text("Live Streaming")
}
}
}
extension EventModifiers {
static let none = Self()
}
@available(iOS 16, tvOS 16, macOS 13, *)
struct VideoSubtitleView: View {
@ObservedObject
fileprivate var model: SubtitleModel
var body: some View {
ZStack {
ForEach(model.parts) { part in
part.subtitleView
}
}
}
fileprivate static func imageView(_ image: UIImage) -> some View {
#if enableFeatureLiveText && canImport(VisionKit) && !targetEnvironment(simulator)
if #available(macCatalyst 17.0, *) {
return LiveTextImage(uiImage: image)
} else {
return Image(uiImage: image)
.resizable()
}
#else
return Image(uiImage: image)
.resizable()
#endif
}
}
private extension SubtitlePart {
@available(iOS 16, tvOS 16, macOS 13, *)
@MainActor
var subtitleView: some View {
VStack {
if let image {
Spacer()
GeometryReader { geometry in
let fitRect = image.fitRect(geometry.size)
VideoSubtitleView.imageView(image)
.offset(CGSize(width: fitRect.origin.x, height: fitRect.origin.y))
.frame(width: fitRect.size.width, height: fitRect.size.height)
}
// scaledToFit
// .scaledToFit()
.padding()
} else if let text {
let textPosition = textPosition ?? SubtitleModel.textPosition
if textPosition.verticalAlign == .bottom || textPosition.verticalAlign == .center {
Spacer()
}
Text(AttributedString(text))
.font(Font(SubtitleModel.textFont))
.shadow(color: .black.opacity(0.9), radius: 1, x: 1, y: 1)
.foregroundColor(SubtitleModel.textColor)
.italic(SubtitleModel.textItalic)
.background(SubtitleModel.textBackgroundColor)
.multilineTextAlignment(.center)
.alignmentGuide(textPosition.horizontalAlign) {
$0[.leading]
}
.padding(textPosition.edgeInsets)
#if !os(tvOS)
.textSelection(.enabled)
#endif
if textPosition.verticalAlign == .top || textPosition.verticalAlign == .center {
Spacer()
}
} else {
// swiftUIbug
Text("")
}
}
}
}
@available(iOS 16, tvOS 16, macOS 13, *)
struct VideoSettingView: View {
@ObservedObject
fileprivate var config: KSVideoPlayer.Coordinator
@ObservedObject
fileprivate var subtitleModel: SubtitleModel
@State
fileprivate var subtitleTitle: String
@Environment(\.dismiss)
private var dismiss
var body: some View {
PlatformView {
let videoTracks = config.playerLayer?.player.tracks(mediaType: .video)
if let videoTracks, !videoTracks.isEmpty {
Picker(selection: Binding {
videoTracks.first { $0.isEnabled }?.trackID
} set: { value in
if let track = videoTracks.first(where: { $0.trackID == value }) {
config.playerLayer?.player.select(track: track)
}
}) {
ForEach(videoTracks, id: \.trackID) { track in
Text(track.description).tag(track.trackID as Int32?)
}
} label: {
Label("Video Track", systemImage: "video.fill")
}
LabeledContent("Video Type", value: (videoTracks.first { $0.isEnabled }?.dynamicRange ?? .sdr).description)
}
TextField("Sutitle delay", value: $subtitleModel.subtitleDelay, format: .number)
TextField("Title", text: $subtitleTitle)
Button("Search Sutitle") {
subtitleModel.searchSubtitle(query: subtitleTitle, languages: ["zh-cn"])
}
LabeledContent("Stream Type", value: (videoTracks?.first { $0.isEnabled }?.fieldOrder ?? .progressive).description)
if let dynamicInfo = config.playerLayer?.player.dynamicInfo {
DynamicInfoView(dynamicInfo: dynamicInfo)
}
if let fileSize = config.playerLayer?.player.fileSize, fileSize > 0 {
LabeledContent("File Size", value: fileSize.kmFormatted + "B")
}
}
#if os(macOS) || targetEnvironment(macCatalyst) || os(xrOS)
.toolbar {
Button("Done") {
dismiss()
}
.keyboardShortcut(.defaultAction)
}
#endif
}
}
@available(iOS 16, tvOS 16, macOS 13, *)
public struct DynamicInfoView: View {
@ObservedObject
fileprivate var dynamicInfo: DynamicInfo
public var body: some View {
LabeledContent("Display FPS", value: dynamicInfo.displayFPS, format: .number)
LabeledContent("Audio Video sync", value: dynamicInfo.audioVideoSyncDiff, format: .number)
LabeledContent("Dropped Frames", value: dynamicInfo.droppedVideoFrameCount + dynamicInfo.droppedVideoPacketCount, format: .number)
LabeledContent("Bytes Read", value: dynamicInfo.bytesRead.kmFormatted + "B")
LabeledContent("Audio bitrate", value: dynamicInfo.audioBitrate.kmFormatted + "bps")
LabeledContent("Video bitrate", value: dynamicInfo.videoBitrate.kmFormatted + "bps")
}
}
@available(iOS 15, tvOS 16, macOS 12, *)
public struct PlatformView<Content: View>: View {
private let content: () -> Content
public var body: some View {
#if os(tvOS)
ScrollView {
content()
.padding()
}
.pickerStyle(.navigationLink)
#else
Form {
content()
}
#if os(macOS)
.padding()
#endif
#endif
}
public init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
}
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
struct KSVideoPlayerView_Previews: PreviewProvider {
static var previews: some View {
let url = URL(string: "http://clips.vorwaerts-gmbh.de/big_buck_bunny.mp4")!
KSVideoPlayerView(coordinator: KSVideoPlayer.Coordinator(), url: url, options: KSOptions())
}
}
// struct AVContentView: View {
// var body: some View {
// StructAVPlayerView().frame(width: UIScene.main.bounds.width, height: 400, alignment: .center)
// }
// }
//
// struct StructAVPlayerView: UIViewRepresentable {
// let playerVC = AVPlayerViewController()
// typealias UIViewType = UIView
// func makeUIView(context _: Context) -> UIView {
// playerVC.view
// }
//
// func updateUIView(_: UIView, context _: Context) {
// playerVC.player = AVPlayer(url: URL(string: "https://bitmovin-a.akamaihd.net/content/dataset/multi-codec/hevc/stream_fmp4.m3u8")!)
// }
// }

View File

@@ -0,0 +1,193 @@
//
// KSVideoPlayerViewBuilder.swift
//
//
// Created by Ian Magallan Bosch on 17.03.24.
//
import SwiftUI
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
enum KSVideoPlayerViewBuilder {
@MainActor
static func playbackControlView(config: KSVideoPlayer.Coordinator, spacing: CGFloat? = nil) -> some View {
HStack(spacing: spacing) {
// Playback controls don't need spacers for visionOS, since the controls are laid out in a HStack.
#if os(xrOS)
backwardButton(config: config)
playButton(config: config)
forwardButton(config: config)
#else
Spacer()
backwardButton(config: config)
Spacer()
playButton(config: config)
Spacer()
forwardButton(config: config)
Spacer()
#endif
}
}
@MainActor
static func contentModeButton(config: KSVideoPlayer.Coordinator) -> some View {
Button {
config.isScaleAspectFill.toggle()
} label: {
Image(systemName: config.isScaleAspectFill ? "rectangle.arrowtriangle.2.inward" : "rectangle.arrowtriangle.2.outward")
}
}
@MainActor
static func subtitleButton(config: KSVideoPlayer.Coordinator) -> some View {
MenuView(selection: Binding {
config.subtitleModel.selectedSubtitleInfo?.subtitleID
} set: { value in
let info = config.subtitleModel.subtitleInfos.first { $0.subtitleID == value }
config.subtitleModel.selectedSubtitleInfo = info
if let info = info as? MediaPlayerTrack {
// seekselect track
config.playerLayer?.player.select(track: info)
}
}) {
Text("Off").tag(nil as String?)
ForEach(config.subtitleModel.subtitleInfos, id: \.subtitleID) { track in
Text(track.name).tag(track.subtitleID as String?)
}
} label: {
Image(systemName: "text.bubble.fill")
}
}
@MainActor
static func playbackRateButton(playbackRate: Binding<Float>) -> some View {
MenuView(selection: playbackRate) {
ForEach([0.5, 1.0, 1.25, 1.5, 2.0] as [Float]) { value in
// text0
let text = "\(value) x"
Text(text).tag(value)
}
} label: {
Image(systemName: "gauge.with.dots.needle.67percent")
}
}
@MainActor
static func titleView(title: String, config: KSVideoPlayer.Coordinator) -> some View {
HStack {
Text(title)
.font(.title3)
ProgressView()
.opacity(config.state == .buffering ? 1 : 0)
}
}
@MainActor
static func muteButton(config: KSVideoPlayer.Coordinator) -> some View {
Button {
config.isMuted.toggle()
} label: {
Image(systemName: config.isMuted ? speakerDisabledSystemName : speakerSystemName)
}
.shadow(color: .black, radius: 1)
}
static func infoButton(showVideoSetting: Binding<Bool>) -> some View {
Button {
showVideoSetting.wrappedValue.toggle()
} label: {
Image(systemName: "info.circle.fill")
}
// iOS keyboardShortcutKSVideoPlayer.Coordinator
#if !os(tvOS)
.keyboardShortcut("i", modifiers: [.command])
#endif
}
}
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private extension KSVideoPlayerViewBuilder {
static var playSystemName: String {
#if os(xrOS)
"play.fill"
#else
"play.circle.fill"
#endif
}
static var pauseSystemName: String {
#if os(xrOS)
"pause.fill"
#else
"pause.circle.fill"
#endif
}
static var speakerSystemName: String {
#if os(xrOS)
"speaker.fill"
#else
"speaker.wave.2.circle.fill"
#endif
}
static var speakerDisabledSystemName: String {
#if os(xrOS)
"speaker.slash.fill"
#else
"speaker.slash.circle.fill"
#endif
}
@MainActor
@ViewBuilder
static func backwardButton(config: KSVideoPlayer.Coordinator) -> some View {
if config.playerLayer?.player.seekable ?? false {
Button {
config.skip(interval: -15)
} label: {
Image(systemName: "gobackward.15")
.font(.largeTitle)
}
#if !os(tvOS)
.keyboardShortcut(.leftArrow, modifiers: .none)
#endif
}
}
@MainActor
@ViewBuilder
static func forwardButton(config: KSVideoPlayer.Coordinator) -> some View {
if config.playerLayer?.player.seekable ?? false {
Button {
config.skip(interval: 15)
} label: {
Image(systemName: "goforward.15")
.font(.largeTitle)
}
#if !os(tvOS)
.keyboardShortcut(.rightArrow, modifiers: .none)
#endif
}
}
@MainActor
static func playButton(config: KSVideoPlayer.Coordinator) -> some View {
Button {
if config.state.isPlaying {
config.playerLayer?.pause()
} else {
config.playerLayer?.play()
}
} label: {
Image(systemName: config.state == .error ? "play.slash.fill" : (config.state.isPlaying ? pauseSystemName : playSystemName))
.font(.largeTitle)
}
#if os(xrOS)
.contentTransition(.symbolEffect(.replace))
#endif
#if !os(tvOS)
.keyboardShortcut(.space, modifiers: .none)
#endif
}
}

View File

@@ -0,0 +1,91 @@
//
// LiveTextImage.swift
// KSPlayer
//
// Created by kintan on 2023/5/4.
//
import SwiftUI
#if canImport(VisionKit)
import VisionKit
@available(iOS 16.0, macOS 13.0, macCatalyst 17.0, *)
@MainActor
public struct LiveTextImage: UIViewRepresentable {
public let uiImage: UIImage
private let analyzer = ImageAnalyzer()
#if canImport(UIKit)
public typealias UIViewType = UIImageView
private let interaction = ImageAnalysisInteraction()
public init(uiImage: UIImage) {
self.uiImage = uiImage
}
public func makeUIView(context _: Context) -> UIViewType {
let imageView = LiveTextImageView()
imageView.addInteraction(interaction)
return imageView
}
public func updateUIView(_ view: UIViewType, context _: Context) {
updateView(view)
}
#else
public typealias NSViewType = UIImageView
@MainActor
private let interaction = ImageAnalysisOverlayView()
public func makeNSView(context _: Context) -> NSViewType {
let imageView = LiveTextImageView()
interaction.autoresizingMask = [.width, .height]
interaction.frame = imageView.bounds
interaction.trackingImageView = imageView
imageView.addSubview(interaction)
return imageView
}
public func updateNSView(_ view: NSViewType, context _: Context) {
updateView(view)
}
#endif
@MainActor
private func updateView(_ view: UIImageView) {
view.image = uiImage
view.sizeToFit()
let image = uiImage
Task { @MainActor in
do {
let configuration = ImageAnalyzer.Configuration([.text])
let analysis = try await analyzer.analyze(image, orientation: .up, configuration: configuration)
interaction.preferredInteractionTypes = .textSelection
interaction.analysis = analysis
} catch {
print(error.localizedDescription)
}
}
}
}
#endif
#if os(macOS)
public extension Image {
init(uiImage: UIImage) {
self.init(nsImage: uiImage)
}
}
#endif
public extension UIImage {
func fitRect(_ fitSize: CGSize) -> CGRect {
let hZoom = fitSize.width / size.width
let vZoom = fitSize.height / size.height
let zoom = min(min(hZoom, vZoom), 1)
let newSize = size * zoom
return CGRect(origin: CGPoint(x: (fitSize.width - newSize.width) / 2, y: fitSize.height - newSize.height), size: newSize)
}
}
class LiveTextImageView: UIImageView {
override var intrinsicContentSize: CGSize {
.zero
}
}

View File

@@ -0,0 +1,171 @@
//
// Slider.swift
// KSPlayer
//
// Created by kintan on 2023/5/4.
//
import SwiftUI
#if os(tvOS)
import Combine
@available(tvOS 15.0, *)
public struct Slider: View {
private let value: Binding<Float>
private let bounds: ClosedRange<Float>
private let onEditingChanged: (Bool) -> Void
@FocusState
private var isFocused: Bool
public init(value: Binding<Float>, in bounds: ClosedRange<Float> = 0 ... 1, onEditingChanged: @escaping (Bool) -> Void = { _ in }) {
self.value = value
self.bounds = bounds
self.onEditingChanged = onEditingChanged
}
public var body: some View {
TVOSSlide(value: value, bounds: bounds, isFocused: _isFocused, onEditingChanged: onEditingChanged)
.focused($isFocused)
}
}
@available(tvOS 15.0, *)
public struct TVOSSlide: UIViewRepresentable {
fileprivate let value: Binding<Float>
fileprivate let bounds: ClosedRange<Float>
@FocusState
public var isFocused: Bool
public let onEditingChanged: (Bool) -> Void
public typealias UIViewType = TVSlide
public func makeUIView(context _: Context) -> UIViewType {
TVSlide(value: value, bounds: bounds, onEditingChanged: onEditingChanged)
}
public func updateUIView(_ view: UIViewType, context _: Context) {
if isFocused {
if view.processView.tintColor == .white {
view.processView.tintColor = .red
}
} else {
view.processView.tintColor = .white
}
//
let process = (value.wrappedValue - bounds.lowerBound) / (bounds.upperBound - bounds.lowerBound)
if process != view.processView.progress {
view.processView.progress = process
}
}
}
public class TVSlide: UIControl {
fileprivate let processView = UIProgressView()
private var beganValue = Float(0.0)
private let onEditingChanged: (Bool) -> Void
fileprivate var value: Binding<Float>
fileprivate let ranges: ClosedRange<Float>
private var moveDirection: UISwipeGestureRecognizer.Direction?
private var pressTime = CACurrentMediaTime()
private var delayItem: DispatchWorkItem?
private lazy var timer: Timer = .scheduledTimer(withTimeInterval: 0.15, repeats: true) { [weak self] _ in
guard let self, let moveDirection = self.moveDirection else {
return
}
let rate = min(10, Int((CACurrentMediaTime() - self.pressTime) / 2) + 1)
let wrappedValue = self.value.wrappedValue + Float((moveDirection == .right ? 10 : -10) * rate)
if wrappedValue >= self.ranges.lowerBound, wrappedValue <= self.ranges.upperBound {
self.value.wrappedValue = wrappedValue
}
self.onEditingChanged(true)
}
public init(value: Binding<Float>, bounds: ClosedRange<Float>, onEditingChanged: @escaping (Bool) -> Void) {
self.value = value
ranges = bounds
self.onEditingChanged = onEditingChanged
super.init(frame: .zero)
processView.translatesAutoresizingMaskIntoConstraints = false
processView.tintColor = .white
addSubview(processView)
NSLayoutConstraint.activate([
processView.topAnchor.constraint(equalTo: topAnchor),
processView.leadingAnchor.constraint(equalTo: leadingAnchor),
processView.trailingAnchor.constraint(equalTo: trailingAnchor),
processView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(actionPanGesture(sender:)))
addGestureRecognizer(panGestureRecognizer)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override open func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let presse = presses.first else {
return
}
delayItem?.cancel()
delayItem = nil
switch presse.type {
case .leftArrow:
moveDirection = .left
pressTime = CACurrentMediaTime()
timer.fireDate = Date.distantPast
case .rightArrow:
moveDirection = .right
pressTime = CACurrentMediaTime()
timer.fireDate = Date.distantPast
case .select:
timer.fireDate = Date.distantFuture
onEditingChanged(false)
default: super.pressesBegan(presses, with: event)
}
}
override open func pressesEnded(_ presses: Set<UIPress>, with _: UIPressesEvent?) {
timer.fireDate = Date.distantFuture
guard let presse = presses.first, presse.type == .leftArrow || presse.type == .rightArrow else {
return
}
delayItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.onEditingChanged(false)
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.5,
execute: delayItem!)
}
@objc private func actionPanGesture(sender: UIPanGestureRecognizer) {
let translation = sender.translation(in: self)
if abs(translation.y) > abs(translation.x) {
return
}
switch sender.state {
case .began, .possible:
delayItem?.cancel()
delayItem = nil
beganValue = value.wrappedValue
case .changed:
let wrappedValue = beganValue + Float(translation.x) / Float(frame.size.width) * (ranges.upperBound - ranges.lowerBound) / 5
if wrappedValue <= ranges.upperBound, wrappedValue >= ranges.lowerBound {
value.wrappedValue = wrappedValue
onEditingChanged(true)
}
case .ended:
delayItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.onEditingChanged(false)
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.5,
execute: delayItem!)
case .cancelled, .failed:
// value.wrappedValue = beganValue
break
@unknown default:
break
}
}
}
#endif