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:
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