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:
2026-01-21 22:12:08 -06:00
commit 872354b834
283 changed files with 338296 additions and 0 deletions

View File

@@ -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 {
// 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
}
}