mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 08:52:59 +01:00
feat: KSPlayer as an option for iOS + other improvements (#1266)
This commit is contained in:
committed by
GitHub
parent
d1795c9df8
commit
74d86b5d12
426
docs/ks-player/SwiftUIUsage.md
Normal file
426
docs/ks-player/SwiftUIUsage.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# SwiftUI Usage
|
||||
|
||||
KSPlayer provides full SwiftUI support with `KSVideoPlayer` (a UIViewRepresentable) and `KSVideoPlayerView` (a complete player view with controls).
|
||||
|
||||
**Minimum Requirements:** iOS 16.0, macOS 13.0, tvOS 16.0
|
||||
|
||||
## KSVideoPlayerView
|
||||
|
||||
`KSVideoPlayerView` is a complete video player with built-in controls, subtitle display, and settings.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```swift
|
||||
import KSPlayer
|
||||
import SwiftUI
|
||||
|
||||
struct VideoScreen: View {
|
||||
let url = URL(string: "https://example.com/video.mp4")!
|
||||
|
||||
var body: some View {
|
||||
KSVideoPlayerView(url: url, options: KSOptions())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Custom Title
|
||||
|
||||
```swift
|
||||
KSVideoPlayerView(
|
||||
url: url,
|
||||
options: KSOptions(),
|
||||
title: "My Video Title"
|
||||
)
|
||||
```
|
||||
|
||||
### With Coordinator and Subtitle Data Source
|
||||
|
||||
```swift
|
||||
struct VideoScreen: View {
|
||||
@StateObject private var coordinator = KSVideoPlayer.Coordinator()
|
||||
let url: URL
|
||||
let subtitleDataSource: SubtitleDataSouce?
|
||||
|
||||
var body: some View {
|
||||
KSVideoPlayerView(
|
||||
coordinator: coordinator,
|
||||
url: url,
|
||||
options: KSOptions(),
|
||||
title: "Video Title",
|
||||
subtitleDataSouce: subtitleDataSource
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## KSVideoPlayer
|
||||
|
||||
`KSVideoPlayer` is the lower-level UIViewRepresentable that provides the video rendering surface. Use this when you want full control over the UI.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```swift
|
||||
import KSPlayer
|
||||
import SwiftUI
|
||||
|
||||
struct CustomPlayerView: View {
|
||||
@StateObject private var coordinator = KSVideoPlayer.Coordinator()
|
||||
let url: URL
|
||||
let options: KSOptions
|
||||
|
||||
var body: some View {
|
||||
KSVideoPlayer(coordinator: coordinator, url: url, options: options)
|
||||
.onStateChanged { layer, state in
|
||||
print("State changed: \(state)")
|
||||
}
|
||||
.onPlay { currentTime, totalTime in
|
||||
print("Playing: \(currentTime)/\(totalTime)")
|
||||
}
|
||||
.onFinish { layer, error in
|
||||
if let error = error {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Initializer
|
||||
|
||||
```swift
|
||||
public struct KSVideoPlayer {
|
||||
public init(
|
||||
coordinator: Coordinator,
|
||||
url: URL,
|
||||
options: KSOptions
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## KSVideoPlayer.Coordinator
|
||||
|
||||
The Coordinator manages player state and provides bindings for SwiftUI views.
|
||||
|
||||
### Creating a Coordinator
|
||||
|
||||
```swift
|
||||
@StateObject private var coordinator = KSVideoPlayer.Coordinator()
|
||||
```
|
||||
|
||||
### Published Properties
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
public final class Coordinator: ObservableObject {
|
||||
// Playback state (read-only computed property)
|
||||
public var state: KSPlayerState { get }
|
||||
|
||||
// Mute control
|
||||
@Published public var isMuted: Bool = false
|
||||
|
||||
// Volume (0.0 to 1.0)
|
||||
@Published public var playbackVolume: Float = 1.0
|
||||
|
||||
// Content mode toggle
|
||||
@Published public var isScaleAspectFill: Bool = false
|
||||
|
||||
// Playback rate (1.0 = normal)
|
||||
@Published public var playbackRate: Float = 1.0
|
||||
|
||||
// Controls visibility
|
||||
@Published public var isMaskShow: Bool = true
|
||||
|
||||
// Subtitle model
|
||||
public var subtitleModel: SubtitleModel
|
||||
|
||||
// Time model for progress display
|
||||
public var timemodel: ControllerTimeModel
|
||||
|
||||
// The underlying player layer
|
||||
public var playerLayer: KSPlayerLayer?
|
||||
}
|
||||
```
|
||||
|
||||
### Coordinator Methods
|
||||
|
||||
```swift
|
||||
// Skip forward/backward by seconds
|
||||
public func skip(interval: Int)
|
||||
|
||||
// Seek to specific time
|
||||
public func seek(time: TimeInterval)
|
||||
|
||||
// Show/hide controls with optional auto-hide
|
||||
public func mask(show: Bool, autoHide: Bool = true)
|
||||
|
||||
// Reset player state (called automatically on view dismissal)
|
||||
public func resetPlayer()
|
||||
```
|
||||
|
||||
### Using Coordinator for Playback Control
|
||||
|
||||
```swift
|
||||
struct PlayerView: View {
|
||||
@StateObject private var coordinator = KSVideoPlayer.Coordinator()
|
||||
let url: URL
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
KSVideoPlayer(coordinator: coordinator, url: url, options: KSOptions())
|
||||
|
||||
HStack {
|
||||
Button("Play") {
|
||||
coordinator.playerLayer?.play()
|
||||
}
|
||||
|
||||
Button("Pause") {
|
||||
coordinator.playerLayer?.pause()
|
||||
}
|
||||
|
||||
Button("-15s") {
|
||||
coordinator.skip(interval: -15)
|
||||
}
|
||||
|
||||
Button("+15s") {
|
||||
coordinator.skip(interval: 15)
|
||||
}
|
||||
}
|
||||
|
||||
Slider(value: $coordinator.playbackVolume, in: 0...1)
|
||||
|
||||
Toggle("Mute", isOn: $coordinator.isMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## View Modifiers
|
||||
|
||||
### onStateChanged
|
||||
|
||||
Called when playback state changes:
|
||||
|
||||
```swift
|
||||
KSVideoPlayer(coordinator: coordinator, url: url, options: options)
|
||||
.onStateChanged { layer, state in
|
||||
switch state {
|
||||
case .initialized: break
|
||||
case .preparing: break
|
||||
case .readyToPlay:
|
||||
// Access metadata
|
||||
if let title = layer.player.dynamicInfo?.metadata["title"] {
|
||||
print("Title: \(title)")
|
||||
}
|
||||
case .buffering: break
|
||||
case .bufferFinished: break
|
||||
case .paused: break
|
||||
case .playedToTheEnd: break
|
||||
case .error: break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### onPlay
|
||||
|
||||
Called periodically during playback with current and total time:
|
||||
|
||||
```swift
|
||||
.onPlay { currentTime, totalTime in
|
||||
let progress = currentTime / totalTime
|
||||
print("Progress: \(Int(progress * 100))%")
|
||||
}
|
||||
```
|
||||
|
||||
### onFinish
|
||||
|
||||
Called when playback ends (naturally or with error):
|
||||
|
||||
```swift
|
||||
.onFinish { layer, error in
|
||||
if let error = error {
|
||||
print("Playback failed: \(error.localizedDescription)")
|
||||
} else {
|
||||
print("Playback completed")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### onBufferChanged
|
||||
|
||||
Called when buffering status changes:
|
||||
|
||||
```swift
|
||||
.onBufferChanged { bufferedCount, consumeTime in
|
||||
// bufferedCount: 0 = initial loading
|
||||
print("Buffer count: \(bufferedCount), time: \(consumeTime)")
|
||||
}
|
||||
```
|
||||
|
||||
### onSwipe (iOS only)
|
||||
|
||||
Called on swipe gestures:
|
||||
|
||||
```swift
|
||||
#if canImport(UIKit)
|
||||
.onSwipe { direction in
|
||||
switch direction {
|
||||
case .up: print("Swipe up")
|
||||
case .down: print("Swipe down")
|
||||
case .left: print("Swipe left")
|
||||
case .right: print("Swipe right")
|
||||
default: break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
## ControllerTimeModel
|
||||
|
||||
Used for displaying playback time:
|
||||
|
||||
```swift
|
||||
public class ControllerTimeModel: ObservableObject {
|
||||
@Published public var currentTime: Int = 0
|
||||
@Published public var totalTime: Int = 1
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```swift
|
||||
struct TimeDisplay: View {
|
||||
@ObservedObject var timeModel: ControllerTimeModel
|
||||
|
||||
var body: some View {
|
||||
Text("\(timeModel.currentTime) / \(timeModel.totalTime)")
|
||||
}
|
||||
}
|
||||
|
||||
// In your player view:
|
||||
TimeDisplay(timeModel: coordinator.timemodel)
|
||||
```
|
||||
|
||||
## Subtitle Integration
|
||||
|
||||
Access subtitles through the coordinator:
|
||||
|
||||
```swift
|
||||
struct SubtitlePicker: View {
|
||||
@ObservedObject var subtitleModel: SubtitleModel
|
||||
|
||||
var body: some View {
|
||||
Picker("Subtitle", selection: $subtitleModel.selectedSubtitleInfo) {
|
||||
Text("Off").tag(nil as (any SubtitleInfo)?)
|
||||
ForEach(subtitleModel.subtitleInfos, id: \.subtitleID) { info in
|
||||
Text(info.name).tag(info as (any SubtitleInfo)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
SubtitlePicker(subtitleModel: coordinator.subtitleModel)
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```swift
|
||||
import KSPlayer
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct FullPlayerView: View {
|
||||
@StateObject private var coordinator = KSVideoPlayer.Coordinator()
|
||||
@State private var url: URL
|
||||
@State private var title: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(url: URL, title: String) {
|
||||
_url = State(initialValue: url)
|
||||
_title = State(initialValue: title)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
KSVideoPlayer(coordinator: coordinator, url: url, options: KSOptions())
|
||||
.onStateChanged { layer, state in
|
||||
if state == .readyToPlay {
|
||||
if let movieTitle = layer.player.dynamicInfo?.metadata["title"] {
|
||||
title = movieTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFinish { _, error in
|
||||
if error != nil {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
coordinator.isMaskShow.toggle()
|
||||
}
|
||||
|
||||
// Custom controls overlay
|
||||
if coordinator.isMaskShow {
|
||||
VStack {
|
||||
HStack {
|
||||
Button("Back") { dismiss() }
|
||||
Spacer()
|
||||
Text(title)
|
||||
}
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 40) {
|
||||
Button(action: { coordinator.skip(interval: -15) }) {
|
||||
Image(systemName: "gobackward.15")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
if coordinator.state.isPlaying {
|
||||
coordinator.playerLayer?.pause()
|
||||
} else {
|
||||
coordinator.playerLayer?.play()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: coordinator.state.isPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
|
||||
Button(action: { coordinator.skip(interval: 15) }) {
|
||||
Image(systemName: "goforward.15")
|
||||
}
|
||||
}
|
||||
.font(.largeTitle)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## URL Change Handling
|
||||
|
||||
The player automatically detects URL changes:
|
||||
|
||||
```swift
|
||||
struct DynamicPlayerView: View {
|
||||
@StateObject private var coordinator = KSVideoPlayer.Coordinator()
|
||||
@State private var currentURL: URL
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
KSVideoPlayer(coordinator: coordinator, url: currentURL, options: KSOptions())
|
||||
|
||||
Button("Load Next Video") {
|
||||
currentURL = URL(string: "https://example.com/next-video.mp4")!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user