Files
simvision/KSPlayer-main/Sources/KSPlayer/SwiftUI/Slider.swift
Michael Simard 872354b834 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>
2026-01-21 22:12:08 -06:00

172 lines
6.2 KiB
Swift

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