Files
simvision/KSPlayer-main/Sources/KSPlayer/SwiftUI/KSVideoPlayerViewBuilder.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

194 lines
5.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 {
// seekselect 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
// text0
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 keyboardShortcutKSVideoPlayer.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
}
}