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:
783
KSPlayer-main/Sources/KSPlayer/SwiftUI/KSVideoPlayerView.swift
Normal file
783
KSPlayer-main/Sources/KSPlayer/SwiftUI/KSVideoPlayerView.swift
Normal 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)
|
||||
}
|
||||
// 不要加这个,不然playerCoordinator无法释放,也可以在onDisappear调用removeMonitor释放
|
||||
// #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)
|
||||
// 设置opacity为0,还是会去更新View。所以只能这样了
|
||||
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 {
|
||||
// 需要加这个,不然图片无法清空。感觉是 swiftUI的bug。
|
||||
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")!)
|
||||
// }
|
||||
// }
|
||||
Reference in New Issue
Block a user