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:
@@ -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 {
|
||||
// 因为图片字幕想要实时的显示,那就需要seek。所以需要走select 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
|
||||
// 需要有一个变量text。不然会自动帮忙加很多0
|
||||
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 模拟器加keyboardShortcut会导致KSVideoPlayer.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user