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:
77
KSPlayer-main/Sources/KSPlayer/SwiftUI/AirPlayView.swift
Normal file
77
KSPlayer-main/Sources/KSPlayer/SwiftUI/AirPlayView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
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")!)
|
||||
// }
|
||||
// }
|
||||
@@ -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 {
|
||||
// 因为图片字幕想要实时的显示,那就需要seek。所以需要走select 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
|
||||
// 需要有一个变量text。不然会自动帮忙加很多0
|
||||
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 模拟器加keyboardShortcut会导致KSVideoPlayer.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
|
||||
}
|
||||
}
|
||||
91
KSPlayer-main/Sources/KSPlayer/SwiftUI/LiveTextImage.swift
Normal file
91
KSPlayer-main/Sources/KSPlayer/SwiftUI/LiveTextImage.swift
Normal 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
|
||||
}
|
||||
}
|
||||
171
KSPlayer-main/Sources/KSPlayer/SwiftUI/Slider.swift
Normal file
171
KSPlayer-main/Sources/KSPlayer/SwiftUI/Slider.swift
Normal 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
|
||||
Reference in New Issue
Block a user