first commit

This commit is contained in:
JinJiangHuang
2026-03-01 14:04:44 +08:00
commit 61e97da938
62 changed files with 9947 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
import SwiftUI
struct EmptyStateView: View {
let icon: String
let title: String
var message: String? = nil
var body: some View {
VStack(spacing: 20) {
ZStack {
Circle()
.fill(Color.orange.opacity(0.15))
.frame(width: 120, height: 120)
.overlay(
Circle().stroke(Color.orange.opacity(0.3), lineWidth: 1)
)
Image(systemName: icon)
.font(.system(size: 46, weight: .light))
.foregroundStyle(
LinearGradient(colors: [.orange, .red.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)
)
}
.padding(.bottom, 8)
Text(title)
.font(.title3.bold())
.foregroundColor(.white.opacity(0.9))
.tracking(1)
if let message = message {
Text(message)
.font(.callout)
.foregroundColor(.white.opacity(0.5))
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
}
.padding(40)
.glassCard(cornerRadius: 30)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
+279
View File
@@ -0,0 +1,279 @@
import SwiftUI
/// - Android HomeActivity TabView
struct ContentView: View {
@EnvironmentObject var appState: AppState
@StateObject private var settingsVM = SettingsViewModel()
@State private var selectedTab = 0
@State private var showSetup = false
var body: some View {
Group {
if appState.isConfigLoaded {
mainTabView
} else {
setupView
}
}
.preferredColorScheme(.dark)
.onAppear {
//
let savedUrl = UserDefaults.standard.string(forKey: HawkConfig.API_URL) ?? ""
if !savedUrl.isEmpty {
Task {
await appState.loadConfig(url: savedUrl)
}
}
}
}
// MARK: -
private var mainTabView: some View {
#if os(iOS)
TabView(selection: $selectedTab) {
HomeView()
.tabItem {
Label("首页", systemImage: "house.fill")
}
.tag(0)
LiveView()
.tabItem {
Label("直播", systemImage: "tv.fill")
}
.tag(1)
SearchView()
.tabItem {
Label("搜索", systemImage: "magnifyingglass")
}
.tag(2)
FavoritesView()
.tabItem {
Label("收藏", systemImage: "heart.fill")
}
.tag(3)
SettingsView()
.tabItem {
Label("设置", systemImage: "gearshape.fill")
}
.tag(4)
}
.tint(.orange)
#else
NavigationSplitView(columnVisibility: $appState.splitViewVisibility) {
List(selection: $selectedTab) {
Label("首页", systemImage: "house.fill")
.tag(0)
Label("直播", systemImage: "tv.fill")
.tag(1)
Label("搜索", systemImage: "magnifyingglass")
.tag(2)
Label("收藏", systemImage: "heart.fill")
.tag(3)
Label("历史", systemImage: "clock.fill")
.tag(5)
Label("设置", systemImage: "gearshape.fill")
.tag(4)
}
.navigationTitle("TVBox")
.listStyle(.sidebar)
} detail: {
switch selectedTab {
case 0: HomeView()
case 1: LiveView()
case 2: SearchView()
case 3: FavoritesView()
case 4: SettingsView()
case 5: HistoryView()
default: HomeView()
}
}
#endif
}
// MARK: -
private var setupView: some View {
ZStack {
//
AppTheme.primaryGradient
.ignoresSafeArea()
//
VStack {
HStack {
Circle()
.fill(Color.orange.opacity(0.15))
.frame(width: 300, height: 300)
.blur(radius: 80)
.offset(x: -100, y: -100)
Spacer()
}
Spacer()
HStack {
Spacer()
Circle()
.fill(Color.red.opacity(0.15))
.frame(width: 300, height: 300)
.blur(radius: 80)
.offset(x: 100, y: 100)
}
}
.ignoresSafeArea()
ScrollView {
VStack(spacing: 32) {
// Logo
VStack(spacing: 20) {
ZStack {
Circle()
.fill(AppTheme.accentGradient)
.frame(width: 100, height: 100)
.blur(radius: 20)
.opacity(0.5)
Image(systemName: "play.tv.fill")
.font(.system(size: 80))
.foregroundStyle(
AppTheme.accentGradient
)
.shadow(color: .red.opacity(0.3), radius: 15, x: 0, y: 10)
}
VStack(spacing: 8) {
Text("TVBox")
.font(.system(size: 48, weight: .heavy, design: .rounded))
.foregroundColor(.white)
.tracking(2)
Text("极致视听 · 简洁至上")
.font(.subheadline)
.foregroundColor(.white.opacity(0.6))
.tracking(4)
}
}
.padding(.top, 60)
//
VStack(spacing: 24) {
VStack(alignment: .leading, spacing: 12) {
Text("接口配置")
.font(.headline)
.foregroundColor(.white)
.padding(.leading, 4)
HStack {
Image(systemName: "link")
.foregroundColor(.orange)
TextField("请输入接口地址 (URL)", text: $settingsVM.apiUrl)
.textFieldStyle(.plain)
.foregroundColor(.white)
#if os(iOS)
.autocapitalization(.none)
.keyboardType(.URL)
#endif
Button {
#if os(iOS)
if let text = UIPasteboard.general.string {
settingsVM.apiUrl = text
}
#else
if let text = NSPasteboard.general.string(forType: .string) {
settingsVM.apiUrl = text
}
#endif
} label: {
Image(systemName: "doc.on.clipboard")
.foregroundColor(.orange)
}
.buttonStyle(.plain)
}
.padding()
.glassCard(cornerRadius: 15)
}
//
Button {
Task {
await settingsVM.loadConfig()
if settingsVM.configSuccess {
await appState.loadConfig(url: settingsVM.apiUrl)
}
}
} label: {
HStack {
if settingsVM.isLoadingConfig {
ProgressView()
.tint(.white)
.padding(.trailing, 8)
}
Text(settingsVM.isLoadingConfig ? "正在解析配置..." : "开启影音之旅")
.fontWeight(.bold)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(AppTheme.accentGradient)
.foregroundColor(.white)
.clipShape(Capsule())
.shadow(color: .red.opacity(0.4), radius: 12, x: 0, y: 6)
}
.disabled(settingsVM.isLoadingConfig || settingsVM.apiUrl.isEmpty)
//
if !settingsVM.apiHistory.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("最近使用")
.font(.caption)
.foregroundColor(.white.opacity(0.5))
.padding(.horizontal, 4)
ForEach(settingsVM.apiHistory.prefix(3), id: \.self) { url in
Button {
settingsVM.apiUrl = url
} label: {
HStack {
Image(systemName: "clock.arrow.2.circlepath")
.font(.caption)
Text(url)
.font(.caption)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 8))
}
.padding(.vertical, 10)
.padding(.horizontal, 16)
.foregroundColor(.white.opacity(0.7))
.glassCard(cornerRadius: 10)
}
.buttonStyle(.plain)
}
}
}
}
.padding(.horizontal, 30)
//
if let error = settingsVM.configError {
HStack {
Image(systemName: "exclamationmark.circle.fill")
Text(error)
}
.font(.caption)
.foregroundColor(.red)
.padding()
.glassCard(cornerRadius: 10)
.padding(.horizontal, 30)
}
Spacer(minLength: 50)
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets.
+588
View File
@@ -0,0 +1,588 @@
import SwiftUI
import SwiftData
#if os(macOS)
import AppKit
#endif
/// - Android DetailActivity
struct DetailView: View {
let video: Movie.Video
@StateObject private var viewModel = DetailViewModel()
@StateObject private var sharedVLCController = VLCPlayerController()
@EnvironmentObject var appState: AppState
@Environment(\.modelContext) private var modelContext
@State private var showFullScreen = false
@State private var lastPersistedProgress: Double = 0
@State private var isCollected = false
var body: some View {
ScrollView {
VStack(spacing: 0) {
//
if viewModel.isPlaying, let url = viewModel.playUrl, !showFullScreen {
PlayerView(
urlString: url,
startPosition: viewModel.currentPlaybackSeconds(),
onProgressChanged: handlePlaybackProgress,
onPlaybackEnded: playNextEpisodeIfNeeded,
onToggleFullScreen: {
openFullScreenPlayer()
},
canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded,
vlcController: sharedVLCController
)
.id("\(viewModel.selectedFlag)-\(viewModel.selectedEpisodeIndex)-\(url)")
.aspectRatio(16/9, contentMode: .fit)
.background(Color.black)
.onTapGesture(count: 2) {
openFullScreenPlayer()
}
}
//
videoInfoSection
.padding(.horizontal, 20)
.padding(.top, 16)
// 线
if viewModel.flags.count > 1 {
flagSelector
.padding(.horizontal, 20)
.padding(.top, 16)
}
//
if viewModel.hasQualityChoices {
qualitySelector
.padding(.horizontal, 20)
.padding(.top, 16)
}
//
if !viewModel.currentEpisodes.isEmpty {
episodeSection
.padding(.top, 16)
}
//
if let info = viewModel.vodInfo, !info.des.isEmpty {
descriptionSection(info.des)
.padding(.horizontal, 20)
.padding(.top, 16)
}
}
.padding(.bottom, 40)
}
.background(AppTheme.primaryGradient)
.navigationTitle(video.name)
#if os(macOS)
.toolbar(showFullScreen ? .hidden : .visible, for: .windowToolbar)
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.task(id: "\(video.sourceKey)-\(video.id)") {
await viewModel.loadDetail(video: video)
restorePlaybackFromHistory()
refreshCollectState()
}
.onDisappear {
viewModel.commitPlaybackProgressSnapshot()
persistHistoryIfNeeded(force: true)
showFullScreen = false
sharedVLCController.stop()
#if os(macOS)
appState.exitPlayerFullScreen()
#endif
}
#if os(macOS)
.overlay {
if showFullScreen, let url = viewModel.playUrl {
FullScreenPlayerView(
urlString: url,
startPosition: viewModel.currentPlaybackSeconds(),
onProgressChanged: handlePlaybackProgress,
onPlaybackEnded: playNextEpisodeIfNeeded,
canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded,
vlcController: sharedVLCController,
onCloseRequested: closeMacFullScreenOverlay
)
.ignoresSafeArea()
.transition(.opacity)
.zIndex(2)
}
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in
if showFullScreen {
showFullScreen = false
}
appState.exitPlayerFullScreen()
}
#endif
#if os(iOS)
.fullScreenCover(isPresented: $showFullScreen) {
if let url = viewModel.playUrl {
FullScreenPlayerView(
urlString: url,
startPosition: viewModel.currentPlaybackSeconds(),
onProgressChanged: handlePlaybackProgress,
onPlaybackEnded: playNextEpisodeIfNeeded,
canPlayNext: canPlayNextEpisode,
onPlayNext: playNextEpisodeIfNeeded,
vlcController: sharedVLCController
)
}
}
#endif
}
// MARK: -
@ViewBuilder
private var videoInfoSection: some View {
HStack(alignment: .top, spacing: 20) {
videoPoster
videoDetails
Spacer()
}
.padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius)
}
@ViewBuilder
private var videoPoster: some View {
CachedAsyncImage(url: URL.posterURL(from: video.pic)) { image in
image.resizable().aspectRatio(2/3, contentMode: .fill)
} placeholder: {
ZStack {
Color.white.opacity(0.05)
Image(systemName: "film.fill").foregroundColor(.white.opacity(0.2))
}
.aspectRatio(2/3, contentMode: .fill)
}
.frame(width: 130)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius))
.shadow(color: .black.opacity(0.5), radius: 10, x: 0, y: 5)
}
@ViewBuilder
private var videoDetails: some View {
VStack(alignment: .leading, spacing: 10) {
Text(viewModel.vodInfo?.name ?? video.name)
.font(.system(size: 24, weight: .bold))
.foregroundColor(.white)
if let info = viewModel.vodInfo {
VStack(alignment: .leading, spacing: 6) {
infoRow("年份", info.year)
infoRow("地区", info.area)
infoRow("类型", info.typeName)
infoRow("导演", info.director)
infoRow("演员", info.actor)
}
}
Spacer(minLength: 10)
HStack(spacing: 10) {
playButton
collectButton
}
}
}
@ViewBuilder
private var playButton: some View {
if !viewModel.isPlaying && viewModel.vodInfo != nil {
Button {
viewModel.selectEpisode(index: 0)
saveHistoryForCurrentEpisode()
} label: {
HStack(spacing: 8) {
Image(systemName: "play.fill")
Text("立即播放")
}
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 28)
.padding(.vertical, 14)
.background(AppTheme.accentGradient)
.clipShape(Capsule())
.shadow(color: .red.opacity(0.4), radius: 10, x: 0, y: 5)
}
.buttonStyle(.plain)
}
}
private var collectButton: some View {
Button {
toggleCollect()
} label: {
HStack(spacing: 8) {
Image(systemName: isCollected ? "heart.fill" : "heart")
Text(isCollected ? "已收藏" : "收藏")
}
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
Group {
if isCollected {
AppTheme.accentGradient
} else {
Color.white.opacity(0.08)
}
}
)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Color.white.opacity(isCollected ? 0 : 0.2), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
@ViewBuilder
private func infoRow(_ label: String, _ value: String) -> some View {
if !value.isEmpty {
HStack(alignment: .top, spacing: 4) {
Text(label)
.font(.caption)
.foregroundColor(.gray)
.frame(width: 36, alignment: .leading)
Text(value)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
.lineLimit(2)
}
}
}
// MARK: - 线
@ViewBuilder
private var flagSelector: some View {
VStack(alignment: .leading, spacing: 12) {
Text("播放线路")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
flagScrollView
}
.padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius)
}
@ViewBuilder
private var flagScrollView: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(viewModel.flags, id: \.self) { flag in
flagButton(flag)
}
}
}
}
@ViewBuilder
private func flagButton(_ flag: String) -> some View {
Button {
withAnimation {
viewModel.selectFlag(flag)
}
if viewModel.isPlaying {
saveHistoryForCurrentEpisode()
}
} label: {
Text(flag)
.font(.system(size: 14, weight: viewModel.selectedFlag == flag ? .bold : .medium))
.foregroundColor(viewModel.selectedFlag == flag ? .white : .white.opacity(0.6))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
ZStack {
if viewModel.selectedFlag == flag {
AppTheme.accentGradient
} else {
Color.white.opacity(0.05)
}
}
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
// MARK: -
@ViewBuilder
private var qualitySelector: some View {
VStack(alignment: .leading, spacing: 12) {
Text("视频清晰度")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(viewModel.qualityOptions) { option in
qualityButton(option)
}
}
}
}
.padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius)
}
@ViewBuilder
private func qualityButton(_ option: PlaybackQualityOption) -> some View {
Button {
withAnimation {
viewModel.selectQuality(option)
}
if viewModel.isPlaying {
saveHistoryForCurrentEpisode()
}
} label: {
Text(option.name)
.font(.system(size: 14, weight: viewModel.selectedQualityId == option.id ? .bold : .medium))
.foregroundColor(viewModel.selectedQualityId == option.id ? .white : .white.opacity(0.6))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
ZStack {
if viewModel.selectedQualityId == option.id {
AppTheme.accentGradient
} else {
Color.white.opacity(0.05)
}
}
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
// MARK: -
private var episodeSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("选集播放")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 20)
EpisodeListView(
episodes: viewModel.currentEpisodes,
selectedIndex: viewModel.selectedEpisodeIndex,
onSelect: { index in
withAnimation {
viewModel.selectEpisode(index: index)
}
saveHistoryForCurrentEpisode()
}
)
}
}
// MARK: -
private func descriptionSection(_ des: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("影片简介")
.font(.system(size: 16, weight: .bold))
.foregroundColor(.white)
Text(des)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.6))
.lineSpacing(4)
.lineLimit(nil)
}
.padding(15)
.glassCard(cornerRadius: AppTheme.glassRadius)
}
private var canPlayNextEpisode: Bool {
viewModel.selectedEpisodeIndex + 1 < viewModel.currentEpisodes.count
}
private func saveHistoryForCurrentEpisode(progressOverride: Double? = nil) {
let episodeName = viewModel.vodInfo?.currentEpisode?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let episodeLabel = episodeName.isEmpty ? "\(viewModel.selectedEpisodeIndex + 1)" : episodeName
let progress = max(progressOverride ?? viewModel.currentPlaybackSeconds(), 0)
let timeLabel = progress > 0 ? Int(progress).durationString : ""
let playNote = timeLabel.isEmpty ? episodeLabel : "\(episodeLabel) \(timeLabel)"
let playbackState = VodPlaybackState(
flag: viewModel.selectedFlag,
episodeIndex: viewModel.selectedEpisodeIndex,
progressSeconds: progress
)
Task { @MainActor in
CacheStore.shared.addRecord(
video,
playNote: playNote,
playbackState: playbackState,
context: modelContext
)
}
}
private func handlePlaybackProgress(_ seconds: Double, _: Double?) {
viewModel.updatePlaybackProgress(seconds: seconds)
persistHistoryIfNeeded(force: false, currentProgress: seconds)
}
private func persistHistoryIfNeeded(force: Bool, currentProgress: Double? = nil) {
guard viewModel.isPlaying else { return }
let progress = max(currentProgress ?? viewModel.currentPlaybackSeconds(), 0)
guard progress.isFinite else { return }
if !force && abs(progress - lastPersistedProgress) < 20 {
return
}
lastPersistedProgress = progress
saveHistoryForCurrentEpisode(progressOverride: progress)
}
private func restorePlaybackFromHistory() {
guard let playbackState = CacheStore.shared.getPlaybackState(
vodId: video.id,
sourceKey: video.sourceKey,
context: modelContext
) else { return }
viewModel.applyPlaybackState(playbackState)
lastPersistedProgress = max(playbackState.progressSeconds, 0)
}
private func refreshCollectState() {
isCollected = CacheStore.shared.isCollected(
vodId: video.id,
sourceKey: video.sourceKey,
context: modelContext
)
}
private func toggleCollect() {
if isCollected {
CacheStore.shared.removeCollect(
vodId: video.id,
sourceKey: video.sourceKey,
context: modelContext
)
} else {
CacheStore.shared.addCollect(video, context: modelContext)
}
refreshCollectState()
}
private func playNextEpisodeIfNeeded() {
var moved = false
withAnimation {
moved = viewModel.playNext()
}
if moved {
saveHistoryForCurrentEpisode()
}
}
private func openFullScreenPlayer() {
#if os(iOS)
showFullScreen = true
#else
guard viewModel.playUrl != nil else { return }
showFullScreen = true
appState.enterPlayerFullScreen()
requestMacWindowFullScreen(enter: true)
#endif
}
#if os(macOS)
private func requestMacWindowFullScreen(enter: Bool) {
DispatchQueue.main.async {
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return }
let isFullScreen = window.styleMask.contains(.fullScreen)
if enter != isFullScreen {
window.toggleFullScreen(nil)
}
}
}
private func closeMacFullScreenOverlay() {
let window = NSApp.keyWindow ?? NSApp.mainWindow
if window?.styleMask.contains(.fullScreen) == true {
requestMacWindowFullScreen(enter: false)
return
}
showFullScreen = false
appState.exitPlayerFullScreen()
}
#endif
}
///
struct FullScreenPlayerView: View {
let urlString: String
var startPosition: Double = 0
var onProgressChanged: ((Double, Double?) -> Void)? = nil
var onPlaybackEnded: (() -> Void)? = nil
var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil
var vlcController: VLCPlayerController? = nil
var onCloseRequested: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
PlayerView(
urlString: urlString,
startPosition: startPosition,
onProgressChanged: onProgressChanged,
onPlaybackEnded: onPlaybackEnded,
onToggleFullScreen: {
if let onCloseRequested {
onCloseRequested()
} else {
dismiss()
}
},
canPlayNext: canPlayNext,
onPlayNext: onPlayNext,
vlcController: vlcController
)
.ignoresSafeArea()
VStack {
HStack {
Button {
if let onCloseRequested {
onCloseRequested()
} else {
dismiss()
}
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundColor(.white.opacity(0.8))
}
Spacer()
}
.padding()
Spacer()
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import SwiftUI
/// - Android SeriesAdapter
struct EpisodeListView: View {
let episodes: [VodInfo.Episode]
let selectedIndex: Int
let onSelect: (Int) -> Void
@State private var currentGroup = 0
private let groupSize = 50
private var groupCount: Int {
max(1, (episodes.count + groupSize - 1) / groupSize)
}
private var currentEpisodes: [VodInfo.Episode] {
let start = currentGroup * groupSize
let end = min(start + groupSize, episodes.count)
guard start < episodes.count else { return [] }
return Array(episodes[start..<end])
}
var body: some View {
VStack(spacing: 12) {
//
if groupCount > 1 {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(0..<groupCount, id: \.self) { group in
let start = group * groupSize + 1
let end = min((group + 1) * groupSize, episodes.count)
Button {
withAnimation {
currentGroup = group
}
} label: {
Text("\(start)-\(end)")
.font(.system(size: 11, weight: .medium))
.foregroundColor(currentGroup == group ? .white : .white.opacity(0.5))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
ZStack {
if currentGroup == group {
Capsule().fill(Color.white.opacity(0.15))
} else {
Capsule().fill(Color.white.opacity(0.05))
}
}
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
}
}
//
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: [
GridItem(.fixed(44)),
GridItem(.fixed(44))
], spacing: 10) {
ForEach(Array(currentEpisodes.enumerated()), id: \.offset) { index, episode in
let actualIndex = currentGroup * groupSize + index
Button {
onSelect(actualIndex)
} label: {
Text(episode.name)
.font(.system(size: 13, weight: actualIndex == selectedIndex ? .bold : .medium))
.foregroundColor(actualIndex == selectedIndex ? .white : .white.opacity(0.7))
.frame(minWidth: 70)
.frame(height: 44)
.background(
ZStack {
if actualIndex == selectedIndex {
AppTheme.accentGradient
} else {
Color.white.opacity(0.05)
}
}
)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(actualIndex == selectedIndex ? Color.clear : Color.white.opacity(0.1), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
}
.frame(height: 100)
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import SwiftUI
import SwiftData
/// - Android CollectActivity
struct FavoritesView: View {
@Query(sort: \VodCollect.updateTime, order: .reverse)
private var favorites: [VodCollect]
@Environment(\.modelContext) private var modelContext
#if os(iOS)
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
]
#else
private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
]
#endif
var body: some View {
NavigationStack {
Group {
if favorites.isEmpty {
emptyState
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(favorites) { item in
NavigationLink(value: movieVideo(from: item)) {
favoriteCard(item)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
modelContext.delete(item)
try? modelContext.save()
} label: {
Label("取消收藏", systemImage: "heart.slash")
}
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
}
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
.navigationTitle("收藏")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video)
}
}
}
private var emptyState: some View {
EmptyStateView(
icon: "heart.text.square",
title: "暂无收藏",
message: "遇到喜欢的影片别忘了点下收藏按钮哦!"
)
.padding(40)
}
private func favoriteCard(_ item: VodCollect) -> some View {
VStack(alignment: .leading, spacing: 6) {
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
image.resizable().aspectRatio(2/3, contentMode: .fill)
} placeholder: {
Rectangle().fill(Color.gray.opacity(0.3))
.aspectRatio(2/3, contentMode: .fill)
.overlay(Image(systemName: "film").foregroundColor(.gray))
}
.clipShape(RoundedRectangle(cornerRadius: 8))
Text(item.vodName)
.font(.caption)
.foregroundColor(.white)
.lineLimit(2)
}
}
private func movieVideo(from item: VodCollect) -> Movie.Video {
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
import SwiftData
/// - Android HistoryActivity
struct HistoryView: View {
@Query(sort: \VodRecord.updateTime, order: .reverse)
private var records: [VodRecord]
@Environment(\.modelContext) private var modelContext
#if os(iOS)
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
]
#else
private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
]
#endif
var body: some View {
NavigationStack {
Group {
if records.isEmpty {
emptyState
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(records) { item in
NavigationLink(value: movieVideo(from: item)) {
recordCard(item)
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
modelContext.delete(item)
try? modelContext.save()
} label: {
Label("删除记录", systemImage: "trash")
}
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
}
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
.navigationTitle("历史记录")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
if !records.isEmpty {
ToolbarItem(placement: .automatic) {
Button {
Task {
CacheStore.shared.clearHistory(context: modelContext)
}
} label: {
Text("清空")
.foregroundColor(.orange)
}
}
}
}
.navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video)
}
}
}
private var emptyState: some View {
EmptyStateView(
icon: "clock.arrow.circlepath",
title: "暂无播放记录",
message: "您还没有看任何视频,赶快去首页探索吧!"
)
.padding(40)
}
private func recordCard(_ item: VodRecord) -> some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomLeading) {
CachedAsyncImage(url: URL.posterURL(from: item.vodPic)) { image in
image.resizable().aspectRatio(2/3, contentMode: .fill)
} placeholder: {
Rectangle().fill(Color.gray.opacity(0.3))
.aspectRatio(2/3, contentMode: .fill)
.overlay(Image(systemName: "film").foregroundColor(.gray))
}
.clipShape(RoundedRectangle(cornerRadius: 8))
//
if !item.playNote.isEmpty {
Text(item.playNote)
.font(.system(size: 9))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.black.opacity(0.7))
.cornerRadius(4)
.padding(4)
}
}
Text(item.vodName)
.font(.caption)
.foregroundColor(.white)
.lineLimit(2)
Text(item.updateTime.displayString)
.font(.system(size: 10))
.foregroundColor(.gray)
}
}
private func movieVideo(from item: VodRecord) -> Movie.Video {
Movie.Video(id: item.vodId, name: item.vodName, pic: item.vodPic, sourceKey: item.sourceKey)
}
}
+349
View File
@@ -0,0 +1,349 @@
import SwiftUI
/// - Android HomeActivity + UserFragment
struct HomeView: View {
@StateObject private var viewModel = HomeViewModel()
@EnvironmentObject var appState: AppState
@State private var categoryScrollAnchorId: String?
@State private var categoryDragTranslation: CGFloat = 0
//
#if os(iOS)
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
]
#else
private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
]
#endif
var body: some View {
NavigationStack {
VStack(spacing: 0) {
//
headerBar
//
if !viewModel.sorts.isEmpty {
categoryTabBar
}
//
contentArea
}
.background(AppTheme.primaryGradient)
}
.task {
await viewModel.loadSorts()
if let first = viewModel.sorts.first {
viewModel.selectSort(first)
}
}
}
// MARK: -
private var headerBar: some View {
HStack(spacing: 15) {
//
Menu {
ForEach(ApiConfig.shared.sourceBeanList.filter { $0.isSupportedInSwift }) { source in
Button {
ApiConfig.shared.setHomeSource(source)
Task { await viewModel.refresh() }
} label: {
HStack {
Text(source.name)
if source.key == ApiConfig.shared.homeSourceBean?.key {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.foregroundColor(.orange)
Text(ApiConfig.shared.homeSourceBean?.name ?? "TVBox")
.font(.system(size: 15, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .bold))
.foregroundColor(.white.opacity(0.5))
.padding(.leading, 2)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Color.white.opacity(0.1))
.clipShape(Capsule())
.overlay(Capsule().stroke(Color.white.opacity(0.1), lineWidth: 0.5))
}
.menuStyle(.borderlessButton)
.fixedSize()
Spacer()
//
HomeClockView()
}
.padding(.horizontal, 20)
.padding(.top, 15)
.padding(.bottom, 10)
}
// MARK: -
private var categoryTabBar: some View {
ScrollViewReader { proxy in
HStack(spacing: 8) {
categoryMoveButton(
systemName: "chevron.left",
enabled: canMoveCategory(by: -1)
) {
moveCategoryTabs(by: -3, proxy: proxy)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(viewModel.sorts) { sort in
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
viewModel.selectSort(sort)
}
categoryScrollAnchorId = sort.id
scrollCategoryBar(to: sort.id, proxy: proxy)
} label: {
Text(sort.name)
.font(.system(size: 14, weight: viewModel.selectedSort?.id == sort.id ? .bold : .medium))
.foregroundColor(viewModel.selectedSort?.id == sort.id ? .orange : .white.opacity(0.8))
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background(
ZStack {
if viewModel.selectedSort?.id == sort.id {
RoundedRectangle(cornerRadius: 12)
.fill(Color.orange.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.orange.opacity(0.5), lineWidth: 1)
)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.05))
}
}
)
}
.buttonStyle(.plain)
.id(sort.id)
}
}
.padding(.horizontal, 8)
}
.simultaneousGesture(categoryDragGesture(proxy: proxy))
.onAppear {
syncCategoryScrollAnchorIfNeeded()
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
}
.onChange(of: viewModel.sorts.map(\.id)) { oldValue, newValue in
syncCategoryScrollAnchorIfNeeded()
scrollCategoryBar(to: categoryScrollAnchorId, proxy: proxy, animated: false)
}
.onChange(of: viewModel.selectedSort?.id) { oldId, newId in
guard let newId else { return }
categoryScrollAnchorId = newId
scrollCategoryBar(to: newId, proxy: proxy)
}
categoryMoveButton(
systemName: "chevron.right",
enabled: canMoveCategory(by: 1)
) {
moveCategoryTabs(by: 3, proxy: proxy)
}
}
.padding(.horizontal, 14)
}
.padding(.vertical, 8)
}
private func categoryMoveButton(systemName: String, enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white.opacity(enabled ? 0.9 : 0.35))
.frame(width: 26, height: 26)
.background(Color.white.opacity(0.08))
.clipShape(Circle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
private func canMoveCategory(by direction: Int) -> Bool {
guard !viewModel.sorts.isEmpty else { return false }
let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0
if direction < 0 {
return currentIndex > 0
}
return currentIndex < viewModel.sorts.count - 1
}
private func categoryIndex(for id: String?) -> Int? {
guard let id else { return nil }
return viewModel.sorts.firstIndex(where: { $0.id == id })
}
private func syncCategoryScrollAnchorIfNeeded() {
guard !viewModel.sorts.isEmpty else {
categoryScrollAnchorId = nil
return
}
if let selectedId = viewModel.selectedSort?.id,
viewModel.sorts.contains(where: { $0.id == selectedId }) {
categoryScrollAnchorId = selectedId
return
}
if let anchorId = categoryScrollAnchorId,
viewModel.sorts.contains(where: { $0.id == anchorId }) {
return
}
categoryScrollAnchorId = viewModel.sorts.first?.id
}
private func moveCategoryTabs(by delta: Int, proxy: ScrollViewProxy) {
guard !viewModel.sorts.isEmpty else { return }
let currentIndex = categoryIndex(for: categoryScrollAnchorId) ?? 0
let newIndex = min(max(0, currentIndex + delta), viewModel.sorts.count - 1)
guard newIndex != currentIndex else { return }
let targetId = viewModel.sorts[newIndex].id
categoryScrollAnchorId = targetId
scrollCategoryBar(to: targetId, proxy: proxy)
}
private func scrollCategoryBar(to id: String?, proxy: ScrollViewProxy, animated: Bool = true) {
guard let id else { return }
if animated {
withAnimation(.easeInOut(duration: 0.2)) {
proxy.scrollTo(id, anchor: .center)
}
} else {
proxy.scrollTo(id, anchor: .center)
}
}
private func categoryDragGesture(proxy: ScrollViewProxy) -> some Gesture {
DragGesture(minimumDistance: 12)
.onChanged { value in
let delta = value.translation.width - categoryDragTranslation
if delta <= -28 {
moveCategoryTabs(by: 1, proxy: proxy)
categoryDragTranslation = value.translation.width
} else if delta >= 28 {
moveCategoryTabs(by: -1, proxy: proxy)
categoryDragTranslation = value.translation.width
}
}
.onEnded { _ in
categoryDragTranslation = 0
}
}
// MARK: -
private var contentArea: some View {
Group {
if viewModel.isLoading && viewModel.categoryVideos.isEmpty && viewModel.homeVideos.isEmpty {
VStack {
Spacer()
ProgressView()
.scaleEffect(1.5)
.tint(.orange)
Text("加载中...")
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.top, 12)
Spacer()
}
} else if let error = viewModel.errorMessage, viewModel.categoryVideos.isEmpty && viewModel.homeVideos.isEmpty {
VStack(spacing: 12) {
Spacer()
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.orange)
Text(error)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
//
if let source = ApiConfig.shared.homeSourceBean, !source.isSupportedInSwift {
Text("当前源类型: \(source.typeDescription)")
.font(.caption)
.foregroundColor(.secondary)
}
Button("重试") {
Task { await viewModel.refresh() }
}
.buttonStyle(.borderedProminent)
.tint(.orange)
Spacer()
}
} else {
let videos = viewModel.selectedSort?.id == "home"
? viewModel.homeVideos
: viewModel.categoryVideos
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(videos) { video in
NavigationLink(value: video) {
VodCardView(video: video)
}
.buttonStyle(.plain)
.onAppear {
Task { await viewModel.loadMoreIfNeeded(currentItem: video) }
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
//
if viewModel.selectedSort?.id != "home" && viewModel.hasMore {
ProgressView()
.padding()
}
}
.refreshable {
await viewModel.refresh()
}
}
}
.navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video)
}
}
}
private struct HomeClockView: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { timeline in
Text(timeline.date.homeDateString)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.glassCard(cornerRadius: 12)
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import SwiftUI
///
struct VodCardView: View {
let video: Movie.Video
@State private var isHovered = false
var body: some View {
VStack(alignment: .leading, spacing: 10) {
//
ZStack(alignment: .bottomLeading) {
CachedAsyncImage(url: URL.posterURL(from: video.pic)) { image in
image
.resizable()
.aspectRatio(2/3, contentMode: .fill)
} placeholder: {
placeholderImage
.overlay(ProgressView().tint(.white))
}
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius))
.shadow(color: .black.opacity(0.4), radius: 8, x: 0, y: 4)
//
if !video.note.isEmpty {
LinearGradient(
colors: [.black.opacity(0.8), .clear],
startPoint: .bottom,
endPoint: .center
)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius))
}
//
if !video.note.isEmpty {
Text(video.note)
.font(.system(size: 10, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule().fill(Color.orange.opacity(0.9))
)
.padding(8)
}
}
.scaleEffect(isHovered ? 1.05 : 1.0)
.animation(.spring(response: 0.3, dampingFraction: 0.6), value: isHovered)
.onHover { hovering in
isHovered = hovering
}
//
VStack(alignment: .leading, spacing: 2) {
Text(video.name)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
.lineLimit(1)
if !video.type.isEmpty {
Text(video.type)
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.5))
}
}
.padding(.horizontal, 4)
}
}
private var placeholderImage: some View {
RoundedRectangle(cornerRadius: AppTheme.cardRadius)
.fill(Color.white.opacity(0.05))
.aspectRatio(2/3, contentMode: .fill)
.overlay(
Image(systemName: "film.fill")
.font(.system(size: 30))
.foregroundColor(.white.opacity(0.2))
)
}
}
+541
View File
@@ -0,0 +1,541 @@
import SwiftUI
import AVKit
#if os(macOS)
import AppKit
#endif
/// - Android LivePlayActivity
struct LiveView: View {
@StateObject private var viewModel = LiveViewModel()
@EnvironmentObject var appState: AppState
@State private var avPlayer: AVPlayer?
@AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue
@State private var showChannelDrawer = true
@State private var isWindowFullScreen = false
private let currentChannelInfoMaxWidth: CGFloat = 600
@State private var itemStatusObserver: NSKeyValueObservation?
@State private var playbackFailedObserver: NSObjectProtocol?
@State private var playbackStalledObserver: NSObjectProtocol?
@State private var failedSourceIndices: Set<Int> = []
@State private var trackedChannelId: String = ""
@State private var showCurrentChannelInfo = true
@State private var channelInfoTimer: Timer?
private let channelInfoAutoHideDelay: TimeInterval = 3.0
@State private var vlcInteractionToken = 0
private var selectedEngine: PlayerEngine {
PlayerEngine.fromStoredValue(playTypeRaw)
}
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
if viewModel.channelGroups.isEmpty {
emptyState
} else {
//
if selectedEngine == .vlc {
if let urlString = viewModel.currentChannel?.currentUrl, !urlString.isEmpty {
VLCLivePlayerView(
urlString: urlString,
activityToken: vlcInteractionToken,
onPlaybackFailed: {
handlePlaybackFailure(trigger: "vlc_error")
},
onToggleFullScreen: {
toggleWindowFullScreen()
}
)
.ignoresSafeArea()
.id("vlc-live-\(urlString)-\(viewModel.currentChannel?.id ?? "")")
}
} else if let player = avPlayer {
PlatformVideoPlayer(player: player)
.ignoresSafeArea()
}
// UI
overlayUI
}
}
.navigationTitle("直播")
#if os(macOS)
.toolbar(isWindowFullScreen ? .hidden : .visible, for: .windowToolbar)
#endif
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
viewModel.loadChannels()
wakeUpCurrentChannelInfo()
}
.onChange(of: viewModel.currentChannel?.currentUrl) { _, newValue in
if selectedEngine == .system {
playChannel(url: newValue)
} else {
cleanupPlayer()
}
wakeUpCurrentChannelInfo()
}
.onChange(of: viewModel.currentChannel?.id) { _, _ in
resetFailureTracking(for: viewModel.currentChannel)
wakeUpCurrentChannelInfo()
}
.onChange(of: playTypeRaw) { _, _ in
if selectedEngine == .system {
playChannel(url: viewModel.currentChannel?.currentUrl)
} else {
cleanupPlayer()
}
}
.onDisappear {
cleanupPlayer()
cancelChannelInfoAutoHide()
#if os(macOS)
appState.exitPlayerFullScreen()
isWindowFullScreen = false
#endif
}
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { _ in
isWindowFullScreen = true
}
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in
isWindowFullScreen = false
appState.exitPlayerFullScreen()
}
.onExitCommand {
if showChannelDrawer {
withAnimation(.easeInOut(duration: 0.2)) {
showChannelDrawer = false
}
}
}
#endif
}
}
// MARK: -
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "tv.slash")
.font(.system(size: 48))
.foregroundColor(.gray)
Text("暂无直播源")
.font(.headline)
.foregroundColor(.gray)
Text("请在设置中配置包含直播源的接口")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
// MARK: - UI
private var overlayUI: some View {
ZStack(alignment: .leading) {
if showChannelDrawer {
Color.black.opacity(0.22)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
showChannelDrawer = false
}
}
}
VStack(spacing: 0) {
HStack {
channelDrawerToggleButton
Spacer()
}
.padding(.top, 18)
.padding(.horizontal, 16)
Spacer()
//
if let channel = viewModel.currentChannel, showCurrentChannelInfo {
currentChannelInfo(channel)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
if showChannelDrawer {
channelDrawer
.padding(.leading, 12)
.padding(.vertical, 20)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.2), value: showCurrentChannelInfo)
.simultaneousGesture(
TapGesture().onEnded {
reportUserActivity()
}
)
#if os(macOS)
.onContinuousHover { phase in
switch phase {
case .active(_):
reportUserActivity()
case .ended:
break
}
}
#endif
}
private var channelDrawer: some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
Label("频道菜单", systemImage: "list.bullet.rectangle.portrait")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white.opacity(0.9))
Spacer()
Button {
withAnimation(.easeInOut(duration: 0.2)) {
showChannelDrawer = false
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .bold))
.foregroundColor(.white.opacity(0.8))
.frame(width: 22, height: 22)
.background(Color.white.opacity(0.12))
.clipShape(Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.white.opacity(0.08))
HStack(spacing: 0) {
channelGroupList
.frame(width: 150)
Divider()
.overlay(Color.white.opacity(0.1))
channelList
.frame(width: 240)
}
}
.frame(width: 390)
.frame(maxHeight: .infinity, alignment: .top)
.glassCard(cornerRadius: 14)
}
private var channelDrawerToggleButton: some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
showChannelDrawer.toggle()
}
} label: {
HStack(spacing: 8) {
Image(systemName: showChannelDrawer ? "sidebar.left" : "sidebar.right")
Text(showChannelDrawer ? "收起菜单" : "频道菜单")
.font(.system(size: 13, weight: .semibold))
}
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.black.opacity(0.35))
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
#if os(macOS)
.keyboardShortcut("m", modifiers: [.command])
#endif
}
private func currentChannelInfo(_ channel: LiveChannelItem) -> some View {
HStack(spacing: 15) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Circle().fill(Color.orange).frame(width: 8, height: 8)
Text(channel.channelName)
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
}
if channel.sourceNum > 1 {
Text("正在播放:线路 \(channel.sourceIndex + 1) / \(channel.sourceNum)")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
}
}
Spacer()
HStack(spacing: 12) {
// 线
if channel.sourceNum > 1 {
Button {
wakeUpCurrentChannelInfo()
resetFailureTracking(for: viewModel.currentChannel)
viewModel.switchSource()
} label: {
HStack(spacing: 6) {
Image(systemName: "shuffle")
Text("切换线路")
}
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 9)
.background(AppTheme.accentGradient)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
#if os(macOS)
Button {
wakeUpCurrentChannelInfo()
toggleWindowFullScreen()
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
Text("全屏")
}
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 9)
.background(Color.white.opacity(0.15))
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
#endif
}
}
.padding(20)
.glassCard(cornerRadius: AppTheme.glassRadius)
.frame(maxWidth: currentChannelInfoMaxWidth)
.frame(maxWidth: .infinity)
.padding(20)
}
private func wakeUpCurrentChannelInfo() {
withAnimation(.easeInOut(duration: 0.2)) {
showCurrentChannelInfo = true
}
channelInfoTimer?.invalidate()
guard viewModel.currentChannel != nil else { return }
channelInfoTimer = Timer.scheduledTimer(withTimeInterval: channelInfoAutoHideDelay, repeats: false) { _ in
withAnimation(.easeOut(duration: 0.3)) {
showCurrentChannelInfo = false
}
}
}
private func cancelChannelInfoAutoHide() {
channelInfoTimer?.invalidate()
channelInfoTimer = nil
}
private func reportUserActivity() {
wakeUpCurrentChannelInfo()
vlcInteractionToken &+= 1
}
// MARK: -
private var channelGroupList: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(viewModel.channelGroups.enumerated()), id: \.offset) { index, group in
Button {
withAnimation {
viewModel.selectGroup(index)
}
} label: {
Text(group.groupName)
.font(.system(size: 14, weight: viewModel.selectedGroupIndex == index ? .bold : .medium))
.foregroundColor(viewModel.selectedGroupIndex == index ? .orange : .white.opacity(0.8))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
viewModel.selectedGroupIndex == index
? Color.white.opacity(0.1)
: Color.clear
)
}
.buttonStyle(.plain)
}
}
}
}
// MARK: -
private var channelList: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(viewModel.currentChannels.enumerated()), id: \.offset) { index, channel in
Button {
withAnimation {
viewModel.selectedChannelIndex = index
viewModel.selectChannel(channel)
}
} label: {
HStack {
Text(channel.channelName)
.font(.system(size: 14, weight: viewModel.currentChannel?.channelName == channel.channelName ? .bold : .medium))
.foregroundColor(viewModel.currentChannel?.channelName == channel.channelName ? .orange : .white.opacity(0.8))
Spacer()
if channel.sourceNum > 1 {
Text("\(channel.sourceNum)")
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.3))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().stroke(Color.white.opacity(0.2), lineWidth: 0.5))
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
viewModel.currentChannel?.channelName == channel.channelName
? Color.orange.opacity(0.15)
: Color.clear
)
}
.buttonStyle(.plain)
}
}
}
}
// MARK: -
private func playChannel(url: String?) {
guard let urlStr = url, let url = URL(string: urlStr) else {
handlePlaybackFailure(trigger: "invalid_url")
return
}
cleanupPlayer()
let playerItem = AVPlayerItem(url: url)
//
playerItem.preferredForwardBufferDuration = 3
playerItem.canUseNetworkResourcesForLiveStreamingWhilePaused = false
observePlaybackFailure(for: playerItem)
let newPlayer = AVPlayer(playerItem: playerItem)
newPlayer.play()
avPlayer = newPlayer
}
private func cleanupPlayer() {
if let observer = playbackFailedObserver {
NotificationCenter.default.removeObserver(observer)
playbackFailedObserver = nil
}
if let observer = playbackStalledObserver {
NotificationCenter.default.removeObserver(observer)
playbackStalledObserver = nil
}
itemStatusObserver = nil
avPlayer?.pause()
avPlayer?.replaceCurrentItem(with: nil)
avPlayer = nil
}
private func observePlaybackFailure(for item: AVPlayerItem) {
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
if observedItem.status == .failed {
DispatchQueue.main.async {
handlePlaybackFailure(trigger: "status_failed")
}
}
}
playbackFailedObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: item,
queue: .main
) { _ in
handlePlaybackFailure(trigger: "item_failed")
}
playbackStalledObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemPlaybackStalled,
object: item,
queue: .main
) { _ in
handlePlaybackFailure(trigger: "playback_stalled")
}
}
private func resetFailureTracking(for channel: LiveChannelItem?) {
failedSourceIndices = []
trackedChannelId = channel?.id ?? ""
}
private func handlePlaybackFailure(trigger: String) {
guard let channel = viewModel.currentChannel else { return }
guard channel.sourceNum > 1 else { return }
if trackedChannelId != channel.id {
resetFailureTracking(for: channel)
}
let failedIndex = channel.sourceIndex
guard !failedSourceIndices.contains(failedIndex) else { return }
failedSourceIndices.insert(failedIndex)
guard switchToNextAvailableSource(totalSources: channel.sourceNum) else {
print("直播线路全部尝试失败: channel=\(channel.channelName), trigger=\(trigger)")
return
}
}
private func switchToNextAvailableSource(totalSources: Int) -> Bool {
guard failedSourceIndices.count < totalSources else { return false }
for _ in 0..<totalSources {
viewModel.switchSource()
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
if !failedSourceIndices.contains(nextIndex) {
return true
}
}
return false
}
#if os(macOS)
private func toggleWindowFullScreen() {
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return }
let enteringFullScreen = !window.styleMask.contains(.fullScreen)
if enteringFullScreen {
isWindowFullScreen = true
appState.enterPlayerFullScreen()
}
window.toggleFullScreen(nil)
}
#else
private func toggleWindowFullScreen() {}
#endif
}
+739
View File
@@ -0,0 +1,739 @@
import SwiftUI
import AVKit
#if os(macOS)
import AppKit
#else
import UIKit
#endif
/// macOS 使 AVPlayerView SwiftUI.VideoPlayer macOS
struct PlatformVideoPlayer: View {
let player: AVPlayer
var body: some View {
#if os(macOS)
MacOSPlayerView(player: player)
#else
VideoPlayer(player: player)
#endif
}
}
#if os(macOS)
private struct MacOSPlayerView: NSViewRepresentable {
let player: AVPlayer
func makeNSView(context: Context) -> AVPlayerView {
let view = AVPlayerView()
view.controlsStyle = .none //
view.showsFullScreenToggleButton = false
view.videoGravity = .resizeAspect
view.player = player
return view
}
func updateNSView(_ nsView: AVPlayerView, context: Context) {
if nsView.player !== player {
nsView.player = player
}
}
static func dismantleNSView(_ nsView: AVPlayerView, coordinator: ()) {
nsView.player?.pause()
nsView.player = nil
}
}
#endif
/// - Android PlayFragment
struct PlayerView: View {
let urlString: String
var startPosition: Double = 0
var onProgressChanged: ((Double, Double?) -> Void)? = nil
var onPlaybackEnded: (() -> Void)? = nil
var onToggleFullScreen: (() -> Void)? = nil
var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil
var vlcController: VLCPlayerController? = nil
@AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue
private var selectedEngine: PlayerEngine {
PlayerEngine.fromStoredValue(playTypeRaw)
}
var body: some View {
Group {
switch selectedEngine {
case .system:
AVPlayerContentView(
urlString: urlString,
startPosition: startPosition,
onProgressChanged: onProgressChanged,
onPlaybackEnded: onPlaybackEnded,
onToggleFullScreen: onToggleFullScreen,
canPlayNext: canPlayNext,
onPlayNext: onPlayNext
)
case .vlc:
VLCVodPlayerView(
urlString: urlString,
startPosition: startPosition,
onProgressChanged: onProgressChanged,
onPlaybackEnded: onPlaybackEnded,
onToggleFullScreen: onToggleFullScreen,
canPlayNext: canPlayNext,
onPlayNext: onPlayNext,
sharedController: vlcController
)
}
}
.id(selectedEngine.rawValue)
}
}
/// AVPlayer
struct AVPlayerContentView: View {
let urlString: String
var startPosition: Double = 0
var onProgressChanged: ((Double, Double?) -> Void)? = nil
var onPlaybackEnded: (() -> Void)? = nil
var onToggleFullScreen: (() -> Void)? = nil
var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil
@State private var player: AVPlayer?
@State private var playbackEndObserver: NSObjectProtocol?
@State private var timeObserverToken: Any?
// UI
@State private var isPlaying = false
@State private var currentTime: Double = 0
@State private var duration: Double = 0
@State private var volume: Double = 1.0
@State private var rate: Float = 1.0
@State private var isPreparing = true
@State private var showControls = true
@State private var controlsTimer: Timer?
@State private var osdIcon: String?
@State private var osdOpacity: Double = 0
@State private var osdTimer: Timer?
@State private var isDraggingProgress = false
@State private var draggingSeconds: Double = 0
@State private var playerObservers: [NSKeyValueObservation] = []
var body: some View {
ZStack {
Group {
if let player = player {
PlatformVideoPlayer(player: player)
} else {
ZStack {
Color.black
ProgressView()
.tint(.white)
}
}
}
.onTapGesture(count: 2) {
onToggleFullScreen?()
}
.onTapGesture(count: 1) {
togglePlayPauseWithOSD()
}
if isPreparing {
ProgressView()
.tint(.white)
}
if let osdIcon = osdIcon {
Image(systemName: osdIcon)
.font(.system(size: 60, weight: .semibold))
.foregroundColor(.white)
.padding(30)
.background(.ultraThinMaterial)
.clipShape(Circle())
.opacity(osdOpacity)
.allowsHitTesting(false)
}
}
.overlay(alignment: .bottom) {
GeometryReader { proxy in
if player != nil {
playbackControls(containerWidth: proxy.size.width)
.opacity(showControls ? 1.0 : 0.0)
.animation(.easeInOut(duration: 0.3), value: showControls)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
}
}
.overlay {
SystemPlayerKeyboardCaptureView(
onLeft: { seek(by: -seekStep) },
onRight: { seek(by: seekStep) },
onTogglePlayPause: { togglePlayPause() },
onToggleFullScreen: { onToggleFullScreen?() },
onVolumeDown: { wakeUpControls(); adjustVolume(by: -volumeStep) },
onVolumeUp: { wakeUpControls(); adjustVolume(by: volumeStep) }
)
.frame(width: 1, height: 1)
.opacity(0.01)
.allowsHitTesting(false)
}
.onContinuousHover { phase in
switch phase {
case .active(_): wakeUpControls()
case .ended: break
}
}
.onAppear {
setupPlayer()
wakeUpControls()
}
.onChange(of: urlString) { _, _ in
setupPlayer()
wakeUpControls()
}
.onDisappear {
cleanupPlayer()
controlsTimer?.invalidate()
osdTimer?.invalidate()
}
}
private func setupPlayer() {
guard let url = URL(string: urlString) else { return }
//
cleanupPlayer()
let playerItem = AVPlayerItem(url: url)
let newPlayer = AVPlayer(playerItem: playerItem)
//
playerObservers = [
newPlayer.observe(\.timeControlStatus, options: [.new]) { p, _ in
let status = p.timeControlStatus
DispatchQueue.main.async { isPlaying = status == .playing }
},
newPlayer.observe(\.reasonForWaitingToPlay, options: [.new]) { p, _ in
let reason = p.reasonForWaitingToPlay
DispatchQueue.main.async { isPreparing = reason != nil }
},
newPlayer.observe(\.volume, options: [.new]) { p, _ in
let vol = Double(p.volume)
DispatchQueue.main.async { volume = vol }
},
newPlayer.observe(\.rate, options: [.new]) { p, _ in
let r = p.rate
DispatchQueue.main.async { rate = r }
}
]
observePlaybackProgress(for: newPlayer)
observePlaybackEnd(for: newPlayer)
player = newPlayer
startPlayback(for: newPlayer)
}
private func cleanupPlayer() {
if let token = timeObserverToken {
player?.removeTimeObserver(token)
timeObserverToken = nil
}
if let observer = playbackEndObserver {
NotificationCenter.default.removeObserver(observer)
playbackEndObserver = nil
}
playerObservers.forEach { $0.invalidate() }
playerObservers.removeAll()
player?.pause()
player?.replaceCurrentItem(with: nil)
player = nil
}
private func startPlayback(for player: AVPlayer) {
let target = max(startPosition, 0)
if target > 0 {
let seekTime = CMTime(seconds: target, preferredTimescale: 600)
player.seek(to: seekTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in
reportProgress(for: player)
player.play()
}
} else {
player.play()
}
}
private func togglePlayPause() {
guard let player = player else { return }
if player.rate == 0 {
player.play()
} else {
player.pause()
}
}
private func togglePlayPauseWithOSD() {
togglePlayPause()
showOSD(icon: isPlaying ? "pause.fill" : "play.fill")
}
private func wakeUpControls() {
withAnimation { showControls = true }
controlsTimer?.invalidate()
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
withAnimation(.easeOut(duration: 0.5)) {
showControls = false
}
}
}
private func showOSD(icon: String) {
osdIcon = icon
osdOpacity = 1.0
osdTimer?.invalidate()
osdTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
withAnimation(.easeOut(duration: 0.5)) {
osdOpacity = 0.0
}
}
}
private func observePlaybackEnd(for player: AVPlayer) {
guard let item = player.currentItem else { return }
playbackEndObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { _ in
onPlaybackEnded?()
}
}
private func observePlaybackProgress(for player: AVPlayer) {
let interval = CMTime(seconds: 1, preferredTimescale: 2)
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { _ in
reportProgress(for: player)
}
}
private func reportProgress(for player: AVPlayer?) {
guard let player else { return }
let current = player.currentTime().seconds
guard current.isFinite, current >= 0 else { return }
if !isDraggingProgress {
self.currentTime = current
self.draggingSeconds = current
}
let rawDuration = player.currentItem?.duration.seconds
if let rawDuration, rawDuration.isFinite, rawDuration >= 0 {
self.duration = rawDuration
}
onProgressChanged?(current, duration > 0 ? duration : nil)
}
private var seekStep: Double {
let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP)
return Double(saved > 0 ? saved : 10)
}
private var volumeStep: Double { 0.1 }
private var progressUpperBound: Double {
max(duration, max(currentTime, 1))
}
private func playbackControls(containerWidth: CGFloat) -> some View {
VStack(spacing: 8) {
//
HStack(spacing: 12) {
Text(currentTime.durationString)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.frame(width: 45, alignment: .leading)
Slider(
value: Binding(
get: { isDraggingProgress ? draggingSeconds : currentTime },
set: {
draggingSeconds = $0
wakeUpControls()
}
),
in: 0...progressUpperBound,
onEditingChanged: { editing in
isDraggingProgress = editing
wakeUpControls()
if !editing {
seek(to: draggingSeconds)
}
}
)
.accentColor(.white)
.disabled(duration <= 0)
Text(duration.durationString)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.frame(width: 45, alignment: .trailing)
}
.padding(.horizontal, 4)
//
HStack(spacing: 0) {
//
HStack(spacing: 16) {
playbackRateMenu
}
.frame(width: 150, alignment: .leading)
Spacer()
// (退)
HStack(spacing: 24) {
Button {
wakeUpControls()
seek(by: -seekStep)
showOSD(icon: "gobackward.\(Int(seekStep))")
} label: {
Image(systemName: "gobackward.\(Int(seekStep))")
.font(.system(size: 18, weight: .medium))
}
.buttonStyle(.plain)
Button {
wakeUpControls()
togglePlayPauseWithOSD()
} label: {
ZStack {
Circle()
.fill(Color.white.opacity(0.15))
.frame(width: 38, height: 38)
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18, weight: .bold))
}
}
.buttonStyle(.plain)
Button {
wakeUpControls()
seek(by: seekStep)
showOSD(icon: "goforward.\(Int(seekStep))")
} label: {
Image(systemName: "goforward.\(Int(seekStep))")
.font(.system(size: 18, weight: .medium))
}
.buttonStyle(.plain)
if let onPlayNext {
Button {
guard canPlayNext else { return }
wakeUpControls()
onPlayNext()
showOSD(icon: "forward.end.fill")
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 18, weight: .medium))
}
.buttonStyle(.plain)
.disabled(!canPlayNext)
.opacity(canPlayNext ? 1 : 0.4)
}
}
Spacer()
//
HStack(spacing: 14) {
HStack(spacing: 6) {
Button {
wakeUpControls()
let newVolume = volume > 0 ? 0.0 : 1.0
player?.volume = Float(newVolume)
showOSD(icon: newVolume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill")
} label: {
Image(systemName: volumeIconName)
.font(.system(size: 14, weight: .bold))
.frame(width: 20)
}
.buttonStyle(.plain)
Slider(
value: Binding(
get: { volume },
set: {
player?.volume = Float($0)
wakeUpControls()
}
),
in: 0...1.0
)
.accentColor(.white.opacity(0.8))
.frame(width: 80)
}
if let onToggleFullScreen {
Button {
wakeUpControls()
onToggleFullScreen()
} label: {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 15, weight: .bold))
}
.buttonStyle(.plain)
}
}
.frame(width: 150, alignment: .trailing)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 10)
.foregroundColor(.white)
.glassCard(cornerRadius: 18)
.padding(.horizontal, 20)
.padding(.bottom, 6)
.frame(width: containerWidth * 0.7)
.environment(\.colorScheme, .dark)
}
private var playbackRateMenu: some View {
Menu {
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { r in
Button {
wakeUpControls()
player?.rate = Float(r)
showOSD(icon: "speedometer")
} label: {
HStack {
Text("\(String(format: "%.1f", r))x")
if Float(r) == rate {
Spacer()
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Text("\(String(format: "%.1f", rate))x")
Image(systemName: "chevron.up")
.font(.system(size: 8, weight: .bold))
}
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.12))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private var volumeIconName: String {
if volume <= 0 { return "speaker.slash.fill" }
if volume < 0.5 { return "speaker.wave.1.fill" }
return "speaker.wave.2.fill"
}
private func seek(to seconds: Double) {
guard let player = player else { return }
let time = CMTime(seconds: seconds, preferredTimescale: 600)
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
}
private func seek(by offset: Double) {
guard let player else { return }
let current = player.currentTime().seconds
guard current.isFinite else { return }
let wasPlaying = player.rate != 0 || player.timeControlStatus == .waitingToPlayAtSpecifiedRate
var target = max(current + offset, 0)
if let duration = player.currentItem?.duration.seconds, duration.isFinite {
target = min(target, duration)
}
player.seek(to: CMTime(seconds: target, preferredTimescale: 600)) { _ in
reportProgress(for: player)
if wasPlaying {
player.play()
}
}
}
private func adjustVolume(by delta: Double) {
guard let player else { return }
let current = Double(player.volume)
let target = min(max(current + delta, 0), 1)
player.volume = Float(target)
showOSD(icon: target <= 0 ? "speaker.slash.fill" : "speaker.wave.2.fill")
}
}
#if os(macOS)
private struct SystemPlayerKeyboardCaptureView: NSViewRepresentable {
let onLeft: () -> Void
let onRight: () -> Void
let onTogglePlayPause: () -> Void
let onToggleFullScreen: () -> Void
let onVolumeDown: () -> Void
let onVolumeUp: () -> Void
func makeNSView(context: Context) -> SystemPlayerKeyCaptureNSView {
let view = SystemPlayerKeyCaptureNSView(frame: .zero)
applyCallbacks(to: view)
DispatchQueue.main.async {
view.activate()
}
return view
}
func updateNSView(_ nsView: SystemPlayerKeyCaptureNSView, context: Context) {
applyCallbacks(to: nsView)
DispatchQueue.main.async {
nsView.activate()
}
}
private func applyCallbacks(to view: SystemPlayerKeyCaptureNSView) {
view.onLeft = onLeft
view.onRight = onRight
view.onTogglePlayPause = onTogglePlayPause
view.onToggleFullScreen = onToggleFullScreen
view.onVolumeDown = onVolumeDown
view.onVolumeUp = onVolumeUp
}
}
private final class SystemPlayerKeyCaptureNSView: NSView {
var onLeft: (() -> Void)?
var onRight: (() -> Void)?
var onTogglePlayPause: (() -> Void)?
var onToggleFullScreen: (() -> Void)?
var onVolumeDown: (() -> Void)?
var onVolumeUp: (() -> Void)?
override var acceptsFirstResponder: Bool { true }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
activate()
}
func activate() {
window?.makeFirstResponder(self)
}
override func keyDown(with event: NSEvent) {
if event.modifierFlags.intersection([.command, .control, .option]).isEmpty == false {
super.keyDown(with: event)
return
}
switch event.keyCode {
case 123: // left
onLeft?()
return
case 124: // right
onRight?()
return
case 125: // down
onVolumeDown?()
return
case 126: // up
onVolumeUp?()
return
case 49: // space
onTogglePlayPause?()
return
default: break
}
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
switch key {
case "k": onTogglePlayPause?()
case "f": onToggleFullScreen?()
default: super.keyDown(with: event)
}
}
}
#else
private struct SystemPlayerKeyboardCaptureView: UIViewRepresentable {
let onLeft: () -> Void
let onRight: () -> Void
let onTogglePlayPause: () -> Void
let onToggleFullScreen: () -> Void
let onVolumeDown: () -> Void
let onVolumeUp: () -> Void
func makeUIView(context: Context) -> SystemPlayerKeyCaptureUIView {
let view = SystemPlayerKeyCaptureUIView(frame: .zero)
applyCallbacks(to: view)
DispatchQueue.main.async {
view.activate()
}
return view
}
func updateUIView(_ uiView: SystemPlayerKeyCaptureUIView, context: Context) {
applyCallbacks(to: uiView)
DispatchQueue.main.async {
uiView.activate()
}
}
private func applyCallbacks(to view: SystemPlayerKeyCaptureUIView) {
view.onLeft = onLeft
view.onRight = onRight
view.onTogglePlayPause = onTogglePlayPause
view.onToggleFullScreen = onToggleFullScreen
view.onVolumeDown = onVolumeDown
view.onVolumeUp = onVolumeUp
}
}
private final class SystemPlayerKeyCaptureUIView: UIView {
var onLeft: (() -> Void)?
var onRight: (() -> Void)?
var onTogglePlayPause: (() -> Void)?
var onToggleFullScreen: (() -> Void)?
var onVolumeDown: (() -> Void)?
var onVolumeUp: (() -> Void)?
override var canBecomeFirstResponder: Bool { true }
override var keyCommands: [UIKeyCommand]? {
[
UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleLeft)),
UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleRight)),
UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleVolumeDown)),
UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleVolumeUp)),
UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleTogglePlayPause)),
UIKeyCommand(input: "k", modifierFlags: [], action: #selector(handleTogglePlayPause)),
UIKeyCommand(input: "f", modifierFlags: [], action: #selector(handleToggleFullScreen))
]
}
override func didMoveToWindow() {
super.didMoveToWindow()
activate()
}
func activate() {
becomeFirstResponder()
}
@objc private func handleLeft() { onLeft?() }
@objc private func handleRight() { onRight?() }
@objc private func handleVolumeDown() { onVolumeDown?() }
@objc private func handleVolumeUp() { onVolumeUp?() }
@objc private func handleTogglePlayPause() { onTogglePlayPause?() }
@objc private func handleToggleFullScreen() { onToggleFullScreen?() }
}
#endif
+1752
View File
@@ -0,0 +1,1752 @@
import SwiftUI
import Darwin
#if canImport(VLCKitSPM)
import VLCKitSPM
#if os(iOS)
import UIKit
#endif
@MainActor
final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDelegate {
static let supportedPlaybackRates: [Float] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
private static let defaultVolume = 100
private static let maxVolume = 200
private static let drawableSizeChangeThreshold: CGFloat = 24
private static let drawableRebindMinimumInterval: TimeInterval = 1.2
private static let playerInstanceSelector = NSSelectorFromString("playerInstance")
private static let libVLCStopAsync: LibVLCStopAsyncFunction? = {
// RTLD_DEFAULT Swift -2 C RTLD_DEFAULT
let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2)
return "libvlc_media_player_stop_async".withCString { symbolName in
guard let symbol = dlsym(defaultHandle, symbolName) else { return nil }
return unsafeBitCast(symbol, to: LibVLCStopAsyncFunction.self)
}
}()
private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void
let mediaPlayer = VLCMediaPlayer()
@Published var isPreparing = true
@Published var isPlaying = false
@Published var currentTimeSeconds: Double = 0
@Published var durationSeconds: Double = 0
@Published var playbackRate: Float = 1.0
@Published var volume: Int = defaultVolume
#if os(macOS)
private let persistentDrawableView = NSView(frame: .zero)
private weak var lastAttachedContainer: NSView?
#else
private let persistentDrawableView = UIView(frame: .zero)
private weak var lastAttachedContainer: UIView?
#endif
private var rebindWorkItems: [DispatchWorkItem] = []
private var lastDrawableContainerIdentifier: ObjectIdentifier?
private var lastDrawableContainerSize: CGSize = .zero
private var lastDrawableRebindAt: Date = .distantPast
var hasValidDuration: Bool {
durationSeconds > 0
}
private var progressTimer: Timer?
private var pendingSeekSeconds: Double?
private var isLive = false
private var isInBufferingState = false
private var pendingVodBufferingConfirmWorkItem: DispatchWorkItem?
private var bufferingBaselineSecondsVod: Double = 0
private var decodeMode: VideoDecodeMode = .auto
private var decodeModeOverride: VideoDecodeMode?
private var hasAttemptedSoftDecodeFallback = false
private var bufferMode: VLCBufferMode = .defaultMode
private var bufferingFallbackWorkItem: DispatchWorkItem?
private var delayedPreparingWorkItem: DispatchWorkItem?
private var bufferingEventCountVod = 0
private var lastVodBufferingCountedAt: Date = .distantPast
private var hasAttemptedVodCacheBoost = false
private var useVodCacheBoost = false
private var currentMediaURLString: String?
private var currentMediaIsLive = false
private var currentMediaDecodeMode: VideoDecodeMode = .auto
private var currentMediaBufferMode: VLCBufferMode = .defaultMode
private var onProgressChanged: ((Double, Double?) -> Void)?
private var onPlaybackEnded: (() -> Void)?
private var onPlaybackFailed: (() -> Void)?
private let progressUpdateIntervalVod: TimeInterval = 0.5
private let progressUpdateIntervalLive: TimeInterval = 1.0
private let bufferingFallbackThresholdVod: TimeInterval = 6.0
private let bufferingFallbackThresholdLive: TimeInterval = 4.0
private let bufferingConfirmDelayVod: TimeInterval = 0.35
private let bufferingIndicatorDelayVod: TimeInterval = 1.2
private let vodBufferingEventDebounceInterval: TimeInterval = 1.5
private let vodBufferingProgressAdvanceThreshold: Double = 0.25
private let vodCacheBoostTriggerCount = 2
private let vodCacheBoostExtraNetwork = 4500
private let vodCacheBoostExtraLive = 3500
private let vodCacheBoostExtraFile = 5500
private let progressPublishThreshold: Double = 0.25
private let durationPublishThreshold: Double = 0.5
private var lastNonZeroVolume = defaultVolume
override init() {
super.init()
let savedObj = UserDefaults.standard.object(forKey: HawkConfig.PLAY_SPEED)
let savedRate = savedObj != nil ? Float(UserDefaults.standard.double(forKey: HawkConfig.PLAY_SPEED)) : 1.0
playbackRate = Self.normalizedPlaybackRate(from: savedRate)
decodeMode = VideoDecodeMode.fromStoredValue(
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE)
)
bufferMode = VLCBufferMode.fromStoredValue(
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
)
let savedVolumeObj = UserDefaults.standard.object(forKey: HawkConfig.PLAY_VOLUME)
let savedVolume = savedVolumeObj != nil ? UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VOLUME) : Self.defaultVolume
volume = Self.normalizedVolume(from: savedVolume)
if volume > 0 {
lastNonZeroVolume = volume
}
#if os(macOS)
persistentDrawableView.wantsLayer = true
persistentDrawableView.layer?.backgroundColor = NSColor.black.cgColor
#else
persistentDrawableView.backgroundColor = .black
#endif
mediaPlayer.delegate = self
mediaPlayer.drawable = persistentDrawableView
}
func play(
url: URL,
startPosition: Double,
isLive: Bool,
onProgressChanged: ((Double, Double?) -> Void)?,
onPlaybackEnded: (() -> Void)?,
onPlaybackFailed: (() -> Void)?
) {
let targetURLString = url.absoluteString
let isNewMedia = currentMediaURLString != targetURLString || currentMediaIsLive != isLive
if isNewMedia {
resetPlaybackRecoveryState()
}
syncDecodeModeFromSettings()
syncBufferModeFromSettings()
// /
if currentMediaURLString == targetURLString,
currentMediaIsLive == isLive,
currentMediaDecodeMode == decodeMode,
currentMediaBufferMode == bufferMode,
mediaPlayer.media != nil {
self.onProgressChanged = onProgressChanged
self.onPlaybackEnded = onPlaybackEnded
self.onPlaybackFailed = onPlaybackFailed
self.isLive = isLive
applyPlaybackRate()
applyVolume()
refreshPlaybackFlags()
emitProgress()
return
}
stopProgressTimer()
cancelBufferingFallbackTimer()
cancelDelayedPreparingIndicator()
cancelPendingVodBufferingConfirmation()
isInBufferingState = false
self.onProgressChanged = onProgressChanged
self.onPlaybackEnded = onPlaybackEnded
self.onPlaybackFailed = onPlaybackFailed
self.isLive = isLive
pendingSeekSeconds = isLive ? nil : max(startPosition, 0)
setPlaybackStatus(preparing: true, playing: false)
resetProgressState()
mediaPlayer.stop()
let media = VLCMedia(url: url)
var cacheConfig = Self.cacheConfig(isLive: isLive, bufferMode: bufferMode)
if !isLive && useVodCacheBoost {
cacheConfig.network += vodCacheBoostExtraNetwork
cacheConfig.live += vodCacheBoostExtraLive
cacheConfig.file += vodCacheBoostExtraFile
}
let enableFrameDrop = isLive ? bufferMode.enableFrameDrop : true
let enableSkipFrames = isLive && bufferMode.enableFrameDrop
var mediaOptions: [String: Any] = [
"network-caching": cacheConfig.network,
"live-caching": cacheConfig.live,
"file-caching": cacheConfig.file,
"drop-late-frames": enableFrameDrop ? 1 : 0,
"skip-frames": enableSkipFrames ? 1 : 0,
"http-reconnect": 1
]
if let hwOption = decodeMode.vlcHardwareDecodeOption {
mediaOptions["avcodec-hw"] = hwOption
}
if isLive {
mediaOptions["avcodec-fast"] = 1
}
if url.scheme?.lowercased() == "rtsp" {
mediaOptions["rtsp-tcp"] = 1
}
media.addOptions(mediaOptions)
mediaPlayer.media = media
mediaPlayer.play()
applyPlaybackRate()
applyVolume()
startProgressTimer()
currentMediaURLString = targetURLString
currentMediaIsLive = isLive
currentMediaDecodeMode = decodeMode
currentMediaBufferMode = bufferMode
}
func stop() {
stopProgressTimer()
resetPlaybackRecoveryState()
cancelScheduledRebinds()
stopMediaPlayer()
mediaPlayer.media = nil
onProgressChanged = nil
onPlaybackEnded = nil
onPlaybackFailed = nil
pendingSeekSeconds = nil
setPlaybackStatus(preparing: false, playing: false)
resetProgressState()
currentMediaURLString = nil
currentMediaIsLive = false
currentMediaDecodeMode = .auto
currentMediaBufferMode = .defaultMode
}
func togglePlayback() {
if isPlaying {
mediaPlayer.pause()
} else {
mediaPlayer.play()
applyPlaybackRate()
}
}
func setPlaybackRate(_ rate: Float) {
let normalized = Self.normalizedPlaybackRate(from: rate)
playbackRate = normalized
UserDefaults.standard.set(Double(normalized), forKey: HawkConfig.PLAY_SPEED)
applyPlaybackRate()
}
func increasePlaybackRate() {
guard let index = Self.supportedPlaybackRates.firstIndex(of: playbackRate),
index + 1 < Self.supportedPlaybackRates.count else { return }
setPlaybackRate(Self.supportedPlaybackRates[index + 1])
}
func decreasePlaybackRate() {
guard let index = Self.supportedPlaybackRates.firstIndex(of: playbackRate),
index - 1 >= 0 else { return }
setPlaybackRate(Self.supportedPlaybackRates[index - 1])
}
func setVolume(_ value: Int) {
let normalized = Self.normalizedVolume(from: value)
if volume != normalized {
volume = normalized
}
if normalized > 0 {
lastNonZeroVolume = normalized
}
UserDefaults.standard.set(normalized, forKey: HawkConfig.PLAY_VOLUME)
applyVolume()
}
func toggleMute() {
if volume == 0 {
let restored = lastNonZeroVolume > 0 ? lastNonZeroVolume : Self.defaultVolume
setVolume(restored)
} else {
setVolume(0)
}
}
#if os(macOS)
func attachDrawable(to container: NSView) {
let containerIdentifier = ObjectIdentifier(container)
let containerSize = container.bounds.size
let containerChanged = lastDrawableContainerIdentifier != containerIdentifier
let sizeChanged = hasSignificantContainerSizeChange(to: containerSize)
let canRebindForSizeChange = canRebindDrawableForSizeChange()
lastAttachedContainer = container
lastDrawableContainerIdentifier = containerIdentifier
lastDrawableContainerSize = containerSize
let shouldRebind = containerChanged || (sizeChanged && canRebindForSizeChange) || persistentDrawableView.superview !== container || mediaPlayer.drawable == nil
if persistentDrawableView.superview !== container {
persistentDrawableView.removeFromSuperview()
persistentDrawableView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(persistentDrawableView)
NSLayoutConstraint.activate([
persistentDrawableView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
persistentDrawableView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
persistentDrawableView.topAnchor.constraint(equalTo: container.topAnchor),
persistentDrawableView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
container.layoutSubtreeIfNeeded()
}
guard shouldRebind else { return }
cancelScheduledRebinds()
refreshDrawableBinding()
scheduleDelayedDrawableRebind(for: container)
resumePlaybackAfterDrawableRebindIfNeeded()
}
func detachDrawable(from container: NSView) {
if lastAttachedContainer === container {
lastAttachedContainer = nil
lastDrawableContainerIdentifier = nil
lastDrawableContainerSize = .zero
}
cancelScheduledRebinds()
if persistentDrawableView.superview === container {
persistentDrawableView.removeFromSuperview()
}
}
#else
func attachDrawable(to container: UIView) {
let containerIdentifier = ObjectIdentifier(container)
let containerSize = container.bounds.size
let containerChanged = lastDrawableContainerIdentifier != containerIdentifier
let sizeChanged = hasSignificantContainerSizeChange(to: containerSize)
let canRebindForSizeChange = canRebindDrawableForSizeChange()
lastAttachedContainer = container
lastDrawableContainerIdentifier = containerIdentifier
lastDrawableContainerSize = containerSize
let shouldRebind = containerChanged || (sizeChanged && canRebindForSizeChange) || persistentDrawableView.superview !== container || mediaPlayer.drawable == nil
if persistentDrawableView.superview !== container {
persistentDrawableView.removeFromSuperview()
persistentDrawableView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(persistentDrawableView)
NSLayoutConstraint.activate([
persistentDrawableView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
persistentDrawableView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
persistentDrawableView.topAnchor.constraint(equalTo: container.topAnchor),
persistentDrawableView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
container.layoutIfNeeded()
}
guard shouldRebind else { return }
cancelScheduledRebinds()
refreshDrawableBinding()
scheduleDelayedDrawableRebind(for: container)
resumePlaybackAfterDrawableRebindIfNeeded()
}
func detachDrawable(from container: UIView) {
if lastAttachedContainer === container {
lastAttachedContainer = nil
lastDrawableContainerIdentifier = nil
lastDrawableContainerSize = .zero
}
cancelScheduledRebinds()
if persistentDrawableView.superview === container {
persistentDrawableView.removeFromSuperview()
}
}
#endif
private func refreshDrawableBinding() {
// macOS
mediaPlayer.drawable = nil
mediaPlayer.drawable = persistentDrawableView
lastDrawableRebindAt = Date()
}
private func stopMediaPlayer() {
// libvlc stop线 stop
if let playerPointer = playerInstancePointer(),
let stopAsync = Self.libVLCStopAsync {
stopAsync(playerPointer)
return
}
mediaPlayer.stop()
}
private func playerInstancePointer() -> UnsafeMutableRawPointer? {
let selector = Self.playerInstanceSelector
guard mediaPlayer.responds(to: selector) else { return nil }
typealias PlayerInstanceGetter = @convention(c) (AnyObject, Selector) -> UnsafeMutableRawPointer?
let imp = mediaPlayer.method(for: selector)
let getter = unsafeBitCast(imp, to: PlayerInstanceGetter.self)
return getter(mediaPlayer, selector)
}
private func cancelScheduledRebinds() {
rebindWorkItems.forEach { $0.cancel() }
rebindWorkItems.removeAll()
}
private func hasSignificantContainerSizeChange(to newSize: CGSize) -> Bool {
let previousSize = lastDrawableContainerSize
guard previousSize != .zero else { return false }
let widthChanged = abs(newSize.width - previousSize.width) > Self.drawableSizeChangeThreshold
let heightChanged = abs(newSize.height - previousSize.height) > Self.drawableSizeChangeThreshold
return widthChanged || heightChanged
}
private func canRebindDrawableForSizeChange() -> Bool {
Date().timeIntervalSince(lastDrawableRebindAt) >= Self.drawableRebindMinimumInterval
}
private func resumePlaybackAfterDrawableRebindIfNeeded() {
guard mediaPlayer.media != nil else { return }
if !mediaPlayer.isPlaying,
mediaPlayer.state != .opening,
mediaPlayer.state != .buffering {
mediaPlayer.play()
}
applyPlaybackRate()
}
#if os(macOS)
private func scheduleDelayedDrawableRebind(for container: NSView) {
[0.05, 0.18].forEach { delay in
let workItem = DispatchWorkItem { [weak self, weak container] in
guard let self, let container else { return }
guard self.lastAttachedContainer === container else { return }
guard self.persistentDrawableView.superview === container else { return }
self.refreshDrawableBinding()
self.resumePlaybackAfterDrawableRebindIfNeeded()
}
rebindWorkItems.append(workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
}
#else
private func scheduleDelayedDrawableRebind(for container: UIView) {
[0.05, 0.18].forEach { delay in
let workItem = DispatchWorkItem { [weak self, weak container] in
guard let self, let container else { return }
guard self.lastAttachedContainer === container else { return }
guard self.persistentDrawableView.superview === container else { return }
self.refreshDrawableBinding()
self.resumePlaybackAfterDrawableRebindIfNeeded()
}
rebindWorkItems.append(workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
}
#endif
func seek(by offset: Double) {
guard !isLive else { return }
var target = max(currentTimeSeconds + offset, 0)
if hasValidDuration {
target = min(target, durationSeconds)
}
seek(to: target)
}
func seek(to seconds: Double) {
guard !isLive else { return }
let maxSeconds = min(durationSeconds > 0 ? durationSeconds : Double(Int32.max) / 1000.0, Double(Int32.max) / 1000.0)
let value = max(0, min(seconds, maxSeconds))
mediaPlayer.time = VLCTime(int: Int32(value * 1000.0))
emitProgress()
}
nonisolated func mediaPlayerStateChanged(_ aNotification: Notification) {
Task { @MainActor [weak self] in
self?.handlePlayerStateChanged()
}
}
nonisolated func mediaPlayerTimeChanged(_ aNotification: Notification) {
// 使 VLC time 线
}
private func handlePlayerStateChanged() {
switch mediaPlayer.state {
case .opening, .buffering:
handleBufferingState()
case .playing:
handlePlayingState()
case .paused:
isInBufferingState = false
cancelDelayedPreparingIndicator()
cancelPendingVodBufferingConfirmation()
setPlaybackStatus(preparing: false, playing: false)
cancelBufferingFallbackTimer()
case .ended:
isInBufferingState = false
cancelDelayedPreparingIndicator()
cancelPendingVodBufferingConfirmation()
setPlaybackStatus(preparing: false, playing: false)
cancelBufferingFallbackTimer()
onPlaybackEnded?()
case .error:
isInBufferingState = false
cancelDelayedPreparingIndicator()
cancelPendingVodBufferingConfirmation()
setPlaybackStatus(preparing: false, playing: false)
cancelBufferingFallbackTimer()
onPlaybackFailed?()
case .stopped:
isInBufferingState = false
cancelDelayedPreparingIndicator()
cancelPendingVodBufferingConfirmation()
setPlaybackStatus(preparing: false, playing: false)
cancelBufferingFallbackTimer()
default:
break
}
}
private func startProgressTimer() {
stopProgressTimer()
let interval = isLive ? progressUpdateIntervalLive : progressUpdateIntervalVod
let timer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.emitProgress()
}
}
timer.tolerance = interval * 0.25
progressTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
private func stopProgressTimer() {
progressTimer?.invalidate()
progressTimer = nil
}
private func applyPendingSeekIfNeeded() {
guard let pendingSeekSeconds, pendingSeekSeconds > 0 else { return }
seek(to: pendingSeekSeconds)
self.pendingSeekSeconds = nil
}
private func emitProgress() {
refreshPlaybackFlags()
guard !isLive else { return }
let current = currentSeconds()
guard current.isFinite, current >= 0 else { return }
let roundedCurrent = (current / progressPublishThreshold).rounded() * progressPublishThreshold
let didUpdateCurrent = abs(roundedCurrent - currentTimeSeconds) >= progressPublishThreshold
if didUpdateCurrent {
currentTimeSeconds = roundedCurrent
}
var didUpdateDuration = false
if let duration = durationSecondsFromMedia() {
if abs(duration - durationSeconds) >= durationPublishThreshold {
durationSeconds = duration
didUpdateDuration = true
}
}
guard didUpdateCurrent || didUpdateDuration else { return }
onProgressChanged?(currentTimeSeconds, hasValidDuration ? durationSeconds : nil)
}
private func refreshPlaybackFlags() {
// buffering/opening
// 使 isPlaying
if mediaPlayer.isPlaying {
isInBufferingState = false
cancelDelayedPreparingIndicator()
cancelPendingVodBufferingConfirmation()
setPlaybackStatus(preparing: false, playing: true)
cancelBufferingFallbackTimer()
return
}
switch mediaPlayer.state {
case .opening, .buffering:
setPlaybackStatus(preparing: true, playing: false)
case .playing:
setPlaybackStatus(preparing: false, playing: true)
case .paused, .stopped, .ended, .error:
setPlaybackStatus(preparing: false, playing: false)
default:
break
}
}
private func applyPlaybackRate() {
mediaPlayer.rate = playbackRate
}
private func applyVolume() {
mediaPlayer.audio?.volume = Int32(volume)
}
private func syncDecodeModeFromSettings() {
if let decodeModeOverride {
decodeMode = decodeModeOverride
return
}
decodeMode = VideoDecodeMode.fromStoredValue(
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_DECODE_MODE)
)
}
private func syncBufferModeFromSettings() {
bufferMode = VLCBufferMode.fromStoredValue(
UserDefaults.standard.integer(forKey: HawkConfig.PLAY_VLC_BUFFER_MODE)
)
}
private static func normalizedPlaybackRate(from raw: Float) -> Float {
guard !supportedPlaybackRates.isEmpty else { return 1.0 }
return supportedPlaybackRates.min(by: { abs($0 - raw) < abs($1 - raw) }) ?? 1.0
}
private static func normalizedVolume(from raw: Int) -> Int {
min(max(raw, 0), maxVolume)
}
private static func cacheConfig(isLive: Bool, bufferMode: VLCBufferMode) -> (network: Int, live: Int, file: Int) {
bufferMode.cacheConfig(isLive: isLive)
}
private func scheduleBufferingFallbackIfNeeded() {
guard bufferingFallbackWorkItem == nil else { return }
guard !hasAttemptedSoftDecodeFallback else { return }
if !isLive && !hasAttemptedVodCacheBoost { return }
guard decodeMode != .software else { return }
guard mediaPlayer.media != nil else { return }
let delay = isLive ? bufferingFallbackThresholdLive : bufferingFallbackThresholdVod
let workItem = DispatchWorkItem { [weak self] in
Task { @MainActor [weak self] in
self?.attemptSoftDecodeFallbackIfNeeded()
}
}
bufferingFallbackWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func cancelBufferingFallbackTimer() {
bufferingFallbackWorkItem?.cancel()
bufferingFallbackWorkItem = nil
}
private func attemptSoftDecodeFallbackIfNeeded() {
cancelBufferingFallbackTimer()
guard !hasAttemptedSoftDecodeFallback else { return }
guard decodeMode != .software else { return }
guard let urlString = currentMediaURLString, let url = URL(string: urlString) else { return }
guard mediaPlayer.media != nil else { return }
hasAttemptedSoftDecodeFallback = true
decodeModeOverride = .software
let resumePosition = isLive ? 0 : max(currentSeconds(), 0)
play(
url: url,
startPosition: resumePosition,
isLive: isLive,
onProgressChanged: onProgressChanged,
onPlaybackEnded: onPlaybackEnded,
onPlaybackFailed: onPlaybackFailed
)
}
private func handleBufferingState() {
if isLive {
if !isInBufferingState {
isInBufferingState = true
}
setPlaybackStatus(preparing: true, playing: false)
scheduleBufferingFallbackIfNeeded()
return
}
if isInBufferingState {
if isPlaying {
isPlaying = false
}
scheduleDelayedPreparingIndicatorForVod()
scheduleBufferingFallbackIfNeeded()
return
}
scheduleVodBufferingConfirmationIfNeeded()
}
private func handlePlayingState() {
cancelPendingVodBufferingConfirmation()
isInBufferingState = false
cancelDelayedPreparingIndicator()
setPlaybackStatus(preparing: false, playing: true)
cancelBufferingFallbackTimer()
applyPlaybackRate()
applyVolume()
applyPendingSeekIfNeeded()
}
private func scheduleDelayedPreparingIndicatorForVod() {
guard !isLive else { return }
guard delayedPreparingWorkItem == nil else { return }
guard !isPreparing else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard self.isInBufferingState, !self.mediaPlayer.isPlaying else { return }
guard self.isStillStalledSinceBufferingStartedVod() else { return }
self.setPlaybackStatus(preparing: true, playing: false)
}
delayedPreparingWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + bufferingIndicatorDelayVod, execute: workItem)
}
private func cancelDelayedPreparingIndicator() {
delayedPreparingWorkItem?.cancel()
delayedPreparingWorkItem = nil
}
private func scheduleVodBufferingConfirmationIfNeeded() {
guard !isLive else { return }
guard pendingVodBufferingConfirmWorkItem == nil else { return }
bufferingBaselineSecondsVod = max(currentSeconds(), currentTimeSeconds)
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingVodBufferingConfirmWorkItem = nil
guard self.isBufferingLikeState(self.mediaPlayer.state) else { return }
guard !self.mediaPlayer.isPlaying else { return }
guard self.isStillStalledSinceBufferingStartedVod() else { return }
self.enterConfirmedVodBufferingState()
}
pendingVodBufferingConfirmWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + bufferingConfirmDelayVod, execute: workItem)
}
private func cancelPendingVodBufferingConfirmation() {
pendingVodBufferingConfirmWorkItem?.cancel()
pendingVodBufferingConfirmWorkItem = nil
}
private func enterConfirmedVodBufferingState() {
guard !isLive else { return }
guard !mediaPlayer.isPlaying else { return }
if !isInBufferingState {
isInBufferingState = true
markVodBufferingEventIfNeeded()
if shouldAttemptVodCacheBoost() {
attemptVodCacheBoostIfNeeded()
return
}
}
if isPlaying {
isPlaying = false
}
scheduleDelayedPreparingIndicatorForVod()
scheduleBufferingFallbackIfNeeded()
}
private func markVodBufferingEventIfNeeded() {
let now = Date()
guard now.timeIntervalSince(lastVodBufferingCountedAt) >= vodBufferingEventDebounceInterval else { return }
lastVodBufferingCountedAt = now
bufferingEventCountVod += 1
}
private func isStillStalledSinceBufferingStartedVod() -> Bool {
let current = max(currentSeconds(), currentTimeSeconds)
return (current - bufferingBaselineSecondsVod) < vodBufferingProgressAdvanceThreshold
}
private func isBufferingLikeState(_ state: VLCMediaPlayerState) -> Bool {
switch state {
case .opening, .buffering:
return true
default:
return false
}
}
private func shouldAttemptVodCacheBoost() -> Bool {
!isLive && !hasAttemptedVodCacheBoost && bufferingEventCountVod >= vodCacheBoostTriggerCount
}
private func attemptVodCacheBoostIfNeeded() {
guard !isLive else { return }
guard !hasAttemptedVodCacheBoost else { return }
guard let urlString = currentMediaURLString, let url = URL(string: urlString) else { return }
guard mediaPlayer.media != nil else { return }
hasAttemptedVodCacheBoost = true
useVodCacheBoost = true
cancelDelayedPreparingIndicator()
cancelBufferingFallbackTimer()
let resumePosition = max(currentSeconds(), 0)
play(
url: url,
startPosition: resumePosition,
isLive: false,
onProgressChanged: onProgressChanged,
onPlaybackEnded: onPlaybackEnded,
onPlaybackFailed: onPlaybackFailed
)
}
private func resetPlaybackRecoveryState() {
cancelBufferingFallbackTimer()
cancelDelayedPreparingIndicator()
cancelPendingVodBufferingConfirmation()
hasAttemptedSoftDecodeFallback = false
decodeModeOverride = nil
isInBufferingState = false
bufferingBaselineSecondsVod = 0
bufferingEventCountVod = 0
lastVodBufferingCountedAt = .distantPast
hasAttemptedVodCacheBoost = false
useVodCacheBoost = false
}
private func setPlaybackStatus(preparing: Bool, playing: Bool) {
if isPreparing != preparing {
isPreparing = preparing
}
if isPlaying != playing {
isPlaying = playing
}
}
private func resetProgressState() {
if currentTimeSeconds != 0 {
currentTimeSeconds = 0
}
if durationSeconds != 0 {
durationSeconds = 0
}
}
private func currentSeconds() -> Double {
let raw = mediaPlayer.time.intValue
if raw < 0 { return 0 }
return Double(raw) / 1000.0
}
private func durationSecondsFromMedia() -> Double? {
let raw = mediaPlayer.media?.length.intValue ?? 0
guard raw > 0 else { return nil }
return Double(raw) / 1000.0
}
}
struct VLCVodPlayerView: View {
let urlString: String
var startPosition: Double = 0
var onProgressChanged: ((Double, Double?) -> Void)? = nil
var onPlaybackEnded: (() -> Void)? = nil
var onToggleFullScreen: (() -> Void)? = nil
var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil
var sharedController: VLCPlayerController? = nil
@StateObject private var ownedController = VLCPlayerController()
@State private var isDraggingProgress = false
@State private var draggingSeconds: Double = 0
@State private var showControls = true
@State private var controlsTimer: Timer?
@State private var osdIcon: String?
@State private var osdOpacity: Double = 0
@State private var osdTimer: Timer?
@State private var startPlaybackTask: Task<Void, Never>?
private var controller: VLCPlayerController {
sharedController ?? ownedController
}
var body: some View {
ZStack {
VLCDrawableView(controller: controller)
.background(Color.black)
.onTapGesture(count: 2) {
onToggleFullScreen?()
}
.onTapGesture(count: 1) {
togglePlaybackWithOSD()
}
if controller.isPreparing {
ProgressView()
.tint(.white)
}
if let osdIcon = osdIcon {
Image(systemName: osdIcon)
.font(.system(size: 60, weight: .semibold))
.foregroundColor(.white)
.padding(30)
.background(.ultraThinMaterial)
.clipShape(Circle())
.opacity(osdOpacity)
.allowsHitTesting(false)
}
}
.overlay(alignment: .bottom) {
GeometryReader { proxy in
playbackControls(containerWidth: proxy.size.width)
.padding(12)
.opacity(showControls ? 1.0 : 0.0)
.animation(.easeInOut(duration: 0.3), value: showControls)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
}
.overlay {
KeyboardShortcutCaptureView(
onLeft: { wakeUpControls(); controller.seek(by: -seekStep); showOSD(icon: "gobackward.\(Int(seekStep))") },
onRight: { wakeUpControls(); controller.seek(by: seekStep); showOSD(icon: "goforward.\(Int(seekStep))") },
onTogglePlayPause: { wakeUpControls(); togglePlaybackWithOSD() },
onToggleFullScreen: { wakeUpControls(); onToggleFullScreen?() },
onDecreaseSpeed: { wakeUpControls(); controller.decreasePlaybackRate(); showOSD(icon: "tortoise.fill") },
onIncreaseSpeed: { wakeUpControls(); controller.increasePlaybackRate(); showOSD(icon: "hare.fill") },
onVolumeDown: {
wakeUpControls()
controller.setVolume(controller.volume - volumeStep)
showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill")
},
onVolumeUp: {
wakeUpControls()
controller.setVolume(controller.volume + volumeStep)
showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill")
}
)
.frame(width: 1, height: 1)
.opacity(0.01)
.allowsHitTesting(false)
}
.onContinuousHover { phase in
switch phase {
case .active(_): wakeUpControls()
case .ended: break
}
}
.onAppear {
startPlayback()
wakeUpControls()
}
.onChange(of: urlString) { _, _ in
startPlayback()
wakeUpControls()
}
.onChange(of: controller.currentTimeSeconds) { _, newValue in
if !isDraggingProgress {
draggingSeconds = newValue
}
}
.onDisappear {
startPlaybackTask?.cancel()
startPlaybackTask = nil
if sharedController == nil {
controller.stop()
}
controlsTimer?.invalidate()
osdTimer?.invalidate()
}
}
private func wakeUpControls() {
withAnimation { showControls = true }
controlsTimer?.invalidate()
controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in
withAnimation(.easeOut(duration: 0.5)) {
showControls = false
}
}
}
private func showOSD(icon: String) {
osdIcon = icon
osdOpacity = 1.0
osdTimer?.invalidate()
osdTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
withAnimation(.easeOut(duration: 0.5)) {
osdOpacity = 0.0
}
}
}
private func togglePlaybackWithOSD() {
controller.togglePlayback()
showOSD(icon: controller.isPlaying ? "pause.fill" : "play.fill")
}
private func startPlayback() {
guard let url = URL(string: urlString) else { return }
let targetStartPosition = max(startPosition, 0)
draggingSeconds = targetStartPosition
startPlaybackTask?.cancel()
startPlaybackTask = Task { @MainActor in
// 线
await Task.yield()
guard !Task.isCancelled else { return }
controller.play(
url: url,
startPosition: targetStartPosition,
isLive: false,
onProgressChanged: onProgressChanged,
onPlaybackEnded: onPlaybackEnded,
onPlaybackFailed: nil
)
}
}
private var seekStep: Double {
let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP)
return Double(saved > 0 ? saved : 10)
}
private var volumeStep: Int { 10 }
private var progressUpperBound: Double {
max(controller.durationSeconds, max(controller.currentTimeSeconds, 1))
}
private var currentDisplaySeconds: Double {
isDraggingProgress ? draggingSeconds : controller.currentTimeSeconds
}
private var totalDisplayText: String {
controller.hasValidDuration ? controller.durationSeconds.durationString : "--:--"
}
private func playbackControls(containerWidth: CGFloat) -> some View {
VStack(spacing: 8) {
//
HStack(spacing: 12) {
Text(currentDisplaySeconds.durationString)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.frame(width: 45, alignment: .leading)
Slider(
value: Binding(
get: { isDraggingProgress ? draggingSeconds : controller.currentTimeSeconds },
set: {
draggingSeconds = $0
wakeUpControls()
}
),
in: 0...progressUpperBound,
onEditingChanged: { editing in
isDraggingProgress = editing
wakeUpControls()
if !editing {
controller.seek(to: draggingSeconds)
}
}
)
.accentColor(.white)
.disabled(!controller.hasValidDuration)
Text(totalDisplayText)
.font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.frame(width: 45, alignment: .trailing)
}
.padding(.horizontal, 4)
//
HStack(spacing: 0) {
//
HStack(spacing: 16) {
playbackRateMenu
}
.frame(width: 150, alignment: .leading)
Spacer()
//
HStack(spacing: 24) {
Button {
wakeUpControls()
controller.seek(by: -seekStep)
showOSD(icon: "gobackward.\(Int(seekStep))")
} label: {
Image(systemName: "gobackward.\(Int(seekStep))")
.font(.system(size: 18, weight: .medium))
}
.buttonStyle(.plain)
Button {
wakeUpControls()
togglePlaybackWithOSD()
} label: {
ZStack {
Circle()
.fill(Color.white.opacity(0.15))
.frame(width: 38, height: 38)
Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18, weight: .bold))
}
}
.buttonStyle(.plain)
Button {
wakeUpControls()
controller.seek(by: seekStep)
showOSD(icon: "goforward.\(Int(seekStep))")
} label: {
Image(systemName: "goforward.\(Int(seekStep))")
.font(.system(size: 18, weight: .medium))
}
.buttonStyle(.plain)
if let onPlayNext {
Button {
guard canPlayNext else { return }
wakeUpControls()
onPlayNext()
showOSD(icon: "forward.end.fill")
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 18, weight: .medium))
}
.buttonStyle(.plain)
.disabled(!canPlayNext)
.opacity(canPlayNext ? 1 : 0.4)
}
}
Spacer()
//
HStack(spacing: 14) {
HStack(spacing: 6) {
Button {
wakeUpControls()
controller.toggleMute()
showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill")
} label: {
Image(systemName: volumeIconName)
.font(.system(size: 14, weight: .bold))
.frame(width: 20)
}
.buttonStyle(.plain)
Slider(
value: Binding(
get: { Double(controller.volume) },
set: {
controller.setVolume(Int($0.rounded()))
wakeUpControls()
}
),
in: 0...200,
step: 1
)
.accentColor(.white.opacity(0.8))
.frame(width: 80)
}
if let onToggleFullScreen {
Button {
wakeUpControls()
onToggleFullScreen()
} label: {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 15, weight: .bold))
}
.buttonStyle(.plain)
}
}
.frame(width: 150, alignment: .trailing)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 10)
.foregroundColor(.white)
.glassCard(cornerRadius: 18)
.padding(.horizontal, 20)
.padding(.bottom, 6)
.frame(width: containerWidth * 0.7)
.environment(\.colorScheme, .dark)
}
private var playbackRateMenu: some View {
Menu {
ForEach(VLCPlayerController.supportedPlaybackRates, id: \.self) { rate in
Button {
wakeUpControls()
controller.setPlaybackRate(rate)
showOSD(icon: "speedometer")
} label: {
HStack {
Text(playbackRateLabel(rate))
if rate == controller.playbackRate {
Spacer()
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Text(playbackRateLabel(controller.playbackRate))
Image(systemName: "chevron.up")
.font(.system(size: 8, weight: .bold))
}
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.white.opacity(0.12))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func playbackRateLabel(_ rate: Float) -> String {
if rate.rounded() == rate {
return "\(Int(rate))x"
}
if (rate * 10).rounded() == rate * 10 {
return "\(String(format: "%.1f", rate))x"
}
return "\(String(format: "%.2f", rate))x"
}
private var volumeIconName: String {
switch controller.volume {
case ...0:
return "speaker.slash.fill"
case 1...66:
return "speaker.wave.1.fill"
default:
return "speaker.wave.2.fill"
}
}
}
struct VLCLivePlayerView: View {
let urlString: String
var activityToken: Int = 0
var onPlaybackFailed: (() -> Void)? = nil
var onToggleFullScreen: (() -> Void)? = nil
@StateObject private var controller = VLCPlayerController()
private var volumeStep: Int { 10 }
@State private var osdIcon: String?
@State private var osdOpacity: Double = 0
@State private var osdTimer: Timer?
var body: some View {
ZStack {
VLCDrawableView(controller: controller)
.background(Color.black)
.onTapGesture(count: 2) {
onToggleFullScreen?()
}
.onTapGesture(count: 1) {
togglePlaybackWithOSD()
}
if controller.isPreparing {
ProgressView()
.tint(.white)
}
if let osdIcon = osdIcon {
Image(systemName: osdIcon)
.font(.system(size: 60, weight: .semibold))
.foregroundColor(.white)
.padding(30)
.background(.ultraThinMaterial)
.clipShape(Circle())
.opacity(osdOpacity)
.allowsHitTesting(false)
}
}
.overlay {
KeyboardShortcutCaptureView(
onLeft: { },
onRight: { },
onTogglePlayPause: { togglePlaybackWithOSD() },
onToggleFullScreen: { onToggleFullScreen?() },
onDecreaseSpeed: { },
onIncreaseSpeed: { },
onVolumeDown: {
controller.setVolume(controller.volume - volumeStep)
showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill")
},
onVolumeUp: {
controller.setVolume(controller.volume + volumeStep)
showOSD(icon: controller.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill")
}
)
.frame(width: 1, height: 1)
.opacity(0.01)
.allowsHitTesting(false)
}
.onAppear {
startPlayback()
}
.onChange(of: urlString) { _, _ in
startPlayback()
}
.onDisappear {
controller.stop()
osdTimer?.invalidate()
}
}
private func showOSD(icon: String) {
osdIcon = icon
osdOpacity = 1.0
osdTimer?.invalidate()
osdTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in
withAnimation(.easeOut(duration: 0.5)) {
osdOpacity = 0.0
}
}
}
private func togglePlaybackWithOSD() {
controller.togglePlayback()
showOSD(icon: controller.isPlaying ? "pause.fill" : "play.fill")
}
private func startPlayback() {
guard let url = URL(string: urlString) else {
onPlaybackFailed?()
return
}
controller.play(
url: url,
startPosition: 0,
isLive: true,
onProgressChanged: nil,
onPlaybackEnded: nil,
onPlaybackFailed: onPlaybackFailed
)
}
}
private struct VLCDrawableView: View {
let controller: VLCPlayerController
var body: some View {
#if os(macOS)
VLCMacDrawableView(controller: controller)
#else
VLCIOSDrawableView(controller: controller)
#endif
}
}
private struct KeyboardShortcutCaptureView: View {
let onLeft: () -> Void
let onRight: () -> Void
let onTogglePlayPause: () -> Void
let onToggleFullScreen: () -> Void
let onDecreaseSpeed: () -> Void
let onIncreaseSpeed: () -> Void
let onVolumeDown: () -> Void
let onVolumeUp: () -> Void
var body: some View {
#if os(macOS)
MacKeyboardCaptureView(
onLeft: onLeft,
onRight: onRight,
onTogglePlayPause: onTogglePlayPause,
onToggleFullScreen: onToggleFullScreen,
onDecreaseSpeed: onDecreaseSpeed,
onIncreaseSpeed: onIncreaseSpeed,
onVolumeDown: onVolumeDown,
onVolumeUp: onVolumeUp
)
#else
IOSKeyboardCaptureView(
onLeft: onLeft,
onRight: onRight,
onTogglePlayPause: onTogglePlayPause,
onToggleFullScreen: onToggleFullScreen,
onDecreaseSpeed: onDecreaseSpeed,
onIncreaseSpeed: onIncreaseSpeed,
onVolumeDown: onVolumeDown,
onVolumeUp: onVolumeUp
)
#endif
}
}
#if os(macOS)
private struct VLCMacDrawableView: NSViewRepresentable {
let controller: VLCPlayerController
final class Coordinator {
let controller: VLCPlayerController
init(controller: VLCPlayerController) {
self.controller = controller
}
}
func makeCoordinator() -> Coordinator {
Coordinator(controller: controller)
}
func makeNSView(context: Context) -> VLCOutputNSView {
let view = VLCOutputNSView(frame: .zero)
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.black.cgColor
view.onLifecycle = { container in
context.coordinator.controller.attachDrawable(to: container)
}
view.requestLifecycleUpdate()
return view
}
func updateNSView(_ nsView: VLCOutputNSView, context: Context) {
nsView.onLifecycle = { container in
context.coordinator.controller.attachDrawable(to: container)
}
nsView.requestLifecycleUpdate()
}
static func dismantleNSView(_ nsView: VLCOutputNSView, coordinator: Coordinator) {
nsView.onLifecycle = nil
coordinator.controller.detachDrawable(from: nsView)
}
}
private final class VLCOutputNSView: NSView {
var onLifecycle: ((NSView) -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
requestLifecycleUpdate()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
requestLifecycleUpdate()
}
override func layout() {
super.layout()
requestLifecycleUpdate()
}
func requestLifecycleUpdate() {
onLifecycle?(self)
}
}
private struct MacKeyboardCaptureView: NSViewRepresentable {
let onLeft: () -> Void
let onRight: () -> Void
let onTogglePlayPause: () -> Void
let onToggleFullScreen: () -> Void
let onDecreaseSpeed: () -> Void
let onIncreaseSpeed: () -> Void
let onVolumeDown: () -> Void
let onVolumeUp: () -> Void
func makeNSView(context: Context) -> MacKeyCaptureNSView {
let view = MacKeyCaptureNSView(frame: .zero)
applyCallbacks(to: view)
DispatchQueue.main.async {
view.activate()
}
return view
}
func updateNSView(_ nsView: MacKeyCaptureNSView, context: Context) {
applyCallbacks(to: nsView)
DispatchQueue.main.async {
nsView.activate()
}
}
private func applyCallbacks(to view: MacKeyCaptureNSView) {
view.onLeft = onLeft
view.onRight = onRight
view.onTogglePlayPause = onTogglePlayPause
view.onToggleFullScreen = onToggleFullScreen
view.onDecreaseSpeed = onDecreaseSpeed
view.onIncreaseSpeed = onIncreaseSpeed
view.onVolumeDown = onVolumeDown
view.onVolumeUp = onVolumeUp
}
}
private final class MacKeyCaptureNSView: NSView {
var onLeft: (() -> Void)?
var onRight: (() -> Void)?
var onTogglePlayPause: (() -> Void)?
var onToggleFullScreen: (() -> Void)?
var onDecreaseSpeed: (() -> Void)?
var onIncreaseSpeed: (() -> Void)?
var onVolumeDown: (() -> Void)?
var onVolumeUp: (() -> Void)?
override var acceptsFirstResponder: Bool { true }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
activate()
}
func activate() {
window?.makeFirstResponder(self)
}
override func keyDown(with event: NSEvent) {
if event.modifierFlags.intersection([.command, .control, .option]).isEmpty == false {
super.keyDown(with: event)
return
}
switch event.keyCode {
case 123: // left
onLeft?()
return
case 124: // right
onRight?()
return
case 125: // down
onVolumeDown?()
return
case 126: // up
onVolumeUp?()
return
case 49: // space
onTogglePlayPause?()
return
default:
break
}
let key = event.charactersIgnoringModifiers?.lowercased() ?? ""
switch key {
case "k":
onTogglePlayPause?()
case "f":
onToggleFullScreen?()
case "[":
onDecreaseSpeed?()
case "]":
onIncreaseSpeed?()
default:
super.keyDown(with: event)
}
}
}
#else
private struct VLCIOSDrawableView: UIViewRepresentable {
let controller: VLCPlayerController
final class Coordinator {
let controller: VLCPlayerController
init(controller: VLCPlayerController) {
self.controller = controller
}
}
func makeCoordinator() -> Coordinator {
Coordinator(controller: controller)
}
func makeUIView(context: Context) -> VLCOutputUIView {
let view = VLCOutputUIView(frame: .zero)
view.backgroundColor = .black
view.onLifecycle = { container in
context.coordinator.controller.attachDrawable(to: container)
}
view.requestLifecycleUpdate()
return view
}
func updateUIView(_ uiView: VLCOutputUIView, context: Context) {
uiView.onLifecycle = { container in
context.coordinator.controller.attachDrawable(to: container)
}
uiView.requestLifecycleUpdate()
}
static func dismantleUIView(_ uiView: VLCOutputUIView, coordinator: Coordinator) {
uiView.onLifecycle = nil
coordinator.controller.detachDrawable(from: uiView)
}
}
private final class VLCOutputUIView: UIView {
var onLifecycle: ((UIView) -> Void)?
override func didMoveToWindow() {
super.didMoveToWindow()
requestLifecycleUpdate()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
requestLifecycleUpdate()
}
override func layoutSubviews() {
super.layoutSubviews()
requestLifecycleUpdate()
}
func requestLifecycleUpdate() {
onLifecycle?(self)
}
}
private struct IOSKeyboardCaptureView: UIViewRepresentable {
let onLeft: () -> Void
let onRight: () -> Void
let onTogglePlayPause: () -> Void
let onToggleFullScreen: () -> Void
let onDecreaseSpeed: () -> Void
let onIncreaseSpeed: () -> Void
let onVolumeDown: () -> Void
let onVolumeUp: () -> Void
func makeUIView(context: Context) -> IOSKeyCaptureView {
let view = IOSKeyCaptureView(frame: .zero)
applyCallbacks(to: view)
DispatchQueue.main.async {
view.activate()
}
return view
}
func updateUIView(_ uiView: IOSKeyCaptureView, context: Context) {
applyCallbacks(to: uiView)
DispatchQueue.main.async {
uiView.activate()
}
}
private func applyCallbacks(to view: IOSKeyCaptureView) {
view.onLeft = onLeft
view.onRight = onRight
view.onTogglePlayPause = onTogglePlayPause
view.onToggleFullScreen = onToggleFullScreen
view.onDecreaseSpeed = onDecreaseSpeed
view.onIncreaseSpeed = onIncreaseSpeed
view.onVolumeDown = onVolumeDown
view.onVolumeUp = onVolumeUp
}
}
private final class IOSKeyCaptureView: UIView {
var onLeft: (() -> Void)?
var onRight: (() -> Void)?
var onTogglePlayPause: (() -> Void)?
var onToggleFullScreen: (() -> Void)?
var onDecreaseSpeed: (() -> Void)?
var onIncreaseSpeed: (() -> Void)?
var onVolumeDown: (() -> Void)?
var onVolumeUp: (() -> Void)?
override var canBecomeFirstResponder: Bool { true }
override var keyCommands: [UIKeyCommand]? {
[
UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleLeft)),
UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleRight)),
UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleVolumeDown)),
UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleVolumeUp)),
UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleTogglePlayPause)),
UIKeyCommand(input: "k", modifierFlags: [], action: #selector(handleTogglePlayPause)),
UIKeyCommand(input: "f", modifierFlags: [], action: #selector(handleToggleFullScreen)),
UIKeyCommand(input: "[", modifierFlags: [], action: #selector(handleDecreaseSpeed)),
UIKeyCommand(input: "]", modifierFlags: [], action: #selector(handleIncreaseSpeed))
]
}
override func didMoveToWindow() {
super.didMoveToWindow()
activate()
}
func activate() {
becomeFirstResponder()
}
@objc private func handleLeft() {
onLeft?()
}
@objc private func handleRight() {
onRight?()
}
@objc private func handleTogglePlayPause() {
onTogglePlayPause?()
}
@objc private func handleToggleFullScreen() {
onToggleFullScreen?()
}
@objc private func handleDecreaseSpeed() {
onDecreaseSpeed?()
}
@objc private func handleIncreaseSpeed() {
onIncreaseSpeed?()
}
@objc private func handleVolumeDown() {
onVolumeDown?()
}
@objc private func handleVolumeUp() {
onVolumeUp?()
}
}
#endif
#else
final class VLCPlayerController: ObservableObject {
func stop() {}
}
struct VLCVodPlayerView: View {
let urlString: String
var startPosition: Double = 0
var onProgressChanged: ((Double, Double?) -> Void)? = nil
var onPlaybackEnded: (() -> Void)? = nil
var onToggleFullScreen: (() -> Void)? = nil
var canPlayNext: Bool = false
var onPlayNext: (() -> Void)? = nil
var body: some View {
AVPlayerContentView(
urlString: urlString,
startPosition: startPosition,
onProgressChanged: onProgressChanged,
onPlaybackEnded: onPlaybackEnded,
onToggleFullScreen: onToggleFullScreen,
canPlayNext: canPlayNext,
onPlayNext: onPlayNext
)
}
}
struct VLCLivePlayerView: View {
let urlString: String
var onPlaybackFailed: (() -> Void)? = nil
var onToggleFullScreen: (() -> Void)? = nil
var body: some View {
Color.black
}
}
#endif
+223
View File
@@ -0,0 +1,223 @@
import SwiftUI
/// - Android SearchActivity
struct SearchView: View {
@StateObject private var viewModel = SearchViewModel()
#if os(iOS)
private let columns = [
GridItem(.adaptive(minimum: 120, maximum: 160), spacing: 12)
]
#else
private let columns = [
GridItem(.adaptive(minimum: 140, maximum: 180), spacing: 16)
]
#endif
var body: some View {
NavigationStack {
VStack(spacing: 0) {
//
searchBar
//
if viewModel.isSearching {
Spacer()
ProgressView("搜索中...")
.tint(.orange)
Spacer()
} else if !viewModel.results.isEmpty {
searchResults
} else if viewModel.keyword.isEmpty {
searchHistorySection
} else if let error = viewModel.errorMessage {
Spacer()
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundColor(.gray)
Text(error)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
}
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
.navigationTitle("搜索")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
// MARK: -
private var searchBar: some View {
HStack(spacing: 12) {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white.opacity(0.8))
TextField("搜索影片...", text: $viewModel.keyword)
.textFieldStyle(.plain)
.font(.system(size: 16))
.foregroundColor(.white)
.submitLabel(.search)
.onSubmit {
Task { await viewModel.search() }
}
#if os(iOS)
.autocapitalization(.none)
#endif
if !viewModel.keyword.isEmpty {
Button {
withAnimation {
viewModel.keyword = ""
viewModel.results = []
}
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.white.opacity(0.4))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.white.opacity(0.05))
.cornerRadius(16)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(LinearGradient(colors: [.orange.opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing), lineWidth: 1)
)
Button {
Task { await viewModel.search() }
} label: {
Text("搜索")
.font(.system(size: 15, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(LinearGradient(colors: [.orange, .red], startPoint: .leading, endPoint: .trailing))
.cornerRadius(14)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 10)
}
// MARK: -
private var searchResults: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(viewModel.results) { video in
NavigationLink(value: video) {
VodCardView(video: video)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.navigationDestination(for: Movie.Video.self) { video in
DetailView(video: video)
}
}
// MARK: -
private var searchHistorySection: some View {
VStack(alignment: .leading, spacing: 12) {
if !viewModel.searchHistory.isEmpty {
HStack {
Text("搜索历史")
.font(.headline)
.foregroundColor(.white)
Spacer()
Button {
viewModel.clearHistory()
} label: {
HStack(spacing: 4) {
Image(systemName: "trash")
Text("清空")
}
.font(.caption)
.foregroundColor(.gray)
}
}
.padding(.horizontal, 20)
.padding(.top, 16)
FlowLayout(spacing: 8) {
ForEach(viewModel.searchHistory, id: \.self) { keyword in
Button {
viewModel.keyword = keyword
Task { await viewModel.search() }
} label: {
Text(keyword)
.font(.subheadline)
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Color.white.opacity(0.1))
.cornerRadius(16)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
}
Spacer()
}
}
}
///
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = arrangement(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrangement(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)
for (index, position) in result.positions.enumerated() {
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
}
}
private func arrangement(proposal: ProposedViewSize, subviews: Subviews) -> (size: CGSize, positions: [CGPoint]) {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
var maxX: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentX + size.width > maxWidth && currentX > 0 {
currentX = 0
currentY += lineHeight + spacing
lineHeight = 0
}
positions.append(CGPoint(x: currentX, y: currentY))
lineHeight = max(lineHeight, size.height)
currentX += size.width + spacing
maxX = max(maxX, currentX)
}
return (CGSize(width: maxX, height: currentY + lineHeight), positions)
}
}
+478
View File
@@ -0,0 +1,478 @@
import SwiftUI
/// - Android SettingActivity + ModelSettingFragment
struct SettingsView: View {
@StateObject private var viewModel = SettingsViewModel()
@StateObject private var apiConfig = ApiConfig.shared
@EnvironmentObject var appState: AppState
@State private var showApiInput = false
@State private var showAbout = false
@State private var sourceSearchText = ""
@State private var showingPicker: PickerType = .none
enum PickerType {
case none
case player
case decode
case vlcBuffer
case playTimeStep
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// API
SectionCard(title: "数据源") {
SettingsRow(icon: "link", title: "接口地址", value: viewModel.apiUrl.isEmpty ? "未配置" : viewModel.apiUrl) {
showApiInput = true
}
Divider().background(Color.white.opacity(0.1))
if !apiConfig.sourceBeanList.isEmpty {
NavigationLink {
sourcePickerView
} label: {
SettingsRow(icon: "server.rack", title: "主页数据源", value: apiConfig.homeSourceBean?.name ?? "", action: nil)
}
}
}
//
SectionCard(title: "播放设置") {
SettingsRow(icon: "play.rectangle", title: "播放器", value: viewModel.playerEngine.title) {
if viewModel.playerEngineOptions.count > 1 {
showingPicker = .player
}
}
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "cpu", title: "视频解码", value: viewModel.decodeMode.title) {
showingPicker = .decode
}
if PlayerEngine.isVLCAvailable {
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "externaldrive.badge.wifi", title: "VLC缓冲", value: viewModel.vlcBufferMode.title) {
showingPicker = .vlcBuffer
}
}
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "forward", title: "快进步长", value: "\(viewModel.playTimeStep)") {
showingPicker = .playTimeStep
}
}
//
SectionCard(title: "功能") {
NavigationLink {
HistoryView()
} label: {
SettingsRow(icon: "clock", title: "播放历史", value: "", action: nil)
}
Divider().background(Color.white.opacity(0.1))
NavigationLink {
FavoritesView()
} label: {
SettingsRow(icon: "heart", title: "我的收藏", value: "", action: nil)
}
}
//
SectionCard(title: "缓存") {
SettingsRow(icon: "trash", title: "清除缓存", value: viewModel.cacheSizeString) {
viewModel.clearCache()
}
}
//
SectionCard(title: "关于") {
SettingsRow(icon: "info.circle", title: "版本", value: "1.0.0", action: nil)
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "globe", title: "站点数量", value: "\(apiConfig.sourceBeanList.count)", action: nil)
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "wand.and.stars", title: "解析数量", value: "\(apiConfig.parseBeanList.count)", action: nil)
Divider().background(Color.white.opacity(0.1))
SettingsRow(icon: "tv", title: "直播分组", value: "\(apiConfig.liveChannelGroupList.count)", action: nil)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
.background(AppTheme.primaryGradient.ignoresSafeArea())
.navigationTitle("设置")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbarBackground(.hidden, for: .navigationBar)
#endif
.sheet(isPresented: $showApiInput) {
apiInputSheet
}
}
.overlay(pickerOverlay)
}
// MARK: - Overlay
@ViewBuilder
private var pickerOverlay: some View {
switch showingPicker {
case .player:
SelectionModal(
title: "选择播放器",
icon: "play.rectangle.fill",
items: viewModel.playerEngineOptions,
selectedItem: viewModel.playerEngine,
itemTitle: { $0.title },
onSelect: { engine in
viewModel.setPlayerEngine(engine)
showingPicker = .none
},
onCancel: { showingPicker = .none }
)
case .decode:
SelectionModal(
title: "视频解码模式",
icon: "cpu.fill",
items: viewModel.decodeModeOptions,
selectedItem: viewModel.decodeMode,
itemTitle: { $0.title },
onSelect: { mode in
viewModel.setDecodeMode(mode)
showingPicker = .none
},
onCancel: { showingPicker = .none }
)
case .vlcBuffer:
SelectionModal(
title: "VLC 缓冲策略",
icon: "externaldrive.fill",
items: viewModel.vlcBufferModeOptions,
selectedItem: viewModel.vlcBufferMode,
itemTitle: { $0.title },
onSelect: { mode in
viewModel.setVLCBufferMode(mode)
showingPicker = .none
},
onCancel: { showingPicker = .none }
)
case .playTimeStep:
SelectionModal(
title: "快进步长",
icon: "forward.fill",
items: viewModel.playTimeStepOptions,
selectedItem: viewModel.playTimeStep,
itemTitle: { "\($0)" },
onSelect: { step in
viewModel.setPlayTimeStep(step)
showingPicker = .none
},
onCancel: { showingPicker = .none }
)
case .none:
EmptyView()
}
}
// MARK: - API
private var apiInputSheet: some View {
NavigationStack {
VStack(spacing: 16) {
HStack {
Image(systemName: "link")
.foregroundColor(.secondary)
TextField("请输入接口地址", text: $viewModel.apiUrl)
.textFieldStyle(.plain)
#if os(iOS)
.autocapitalization(.none)
.keyboardType(.URL)
#endif
}
.padding()
.background(Color.secondary.opacity(0.1))
.cornerRadius(10)
//
HStack {
Button {
#if os(iOS)
if let text = UIPasteboard.general.string {
viewModel.apiUrl = text
}
#else
if let text = NSPasteboard.general.string(forType: .string) {
viewModel.apiUrl = text
}
#endif
} label: {
Label("粘贴", systemImage: "doc.on.clipboard")
.font(.subheadline)
}
Spacer()
}
//
if !viewModel.apiHistory.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("历史记录")
.font(.caption)
.foregroundColor(.secondary)
ForEach(viewModel.apiHistory, id: \.self) { url in
HStack {
Button {
viewModel.apiUrl = url
} label: {
HStack {
Image(systemName: "clock")
.font(.caption)
Text(url)
.font(.caption)
.lineLimit(1)
}
.foregroundColor(.secondary)
}
Spacer()
Button {
viewModel.removeApiHistory(url)
} label: {
Image(systemName: "xmark.circle")
.font(.caption)
.foregroundColor(.gray)
}
}
}
}
}
if let error = viewModel.configError {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
Spacer()
}
.padding()
.navigationTitle("配置接口")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { showApiInput = false }
}
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
await viewModel.loadConfig()
if viewModel.configSuccess {
await appState.loadConfig(url: viewModel.apiUrl)
showApiInput = false
}
}
} label: {
if viewModel.isLoadingConfig {
ProgressView()
} else {
Text("确认")
}
}
.disabled(viewModel.isLoadingConfig || viewModel.apiUrl.isEmpty)
}
}
}
#if os(iOS)
.presentationDetents([.medium, .large])
#endif
}
// MARK: -
private var filteredSources: [SourceBean] {
let sources = apiConfig.sourceBeanList
if sourceSearchText.isEmpty {
return sources
} else {
return sources.filter { $0.name.localizedCaseInsensitiveContains(sourceSearchText) || $0.api.localizedCaseInsensitiveContains(sourceSearchText) }
}
}
private var sourcePickerView: some View {
VStack(spacing: 0) {
//
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("搜索数据源", text: $sourceSearchText)
.textFieldStyle(.plain)
if !sourceSearchText.isEmpty {
Button(action: { sourceSearchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(12)
.glassCard(cornerRadius: 12)
.padding(.horizontal, 20)
.padding(.vertical, 12)
ScrollView {
LazyVStack(spacing: 12) {
ForEach(filteredSources) { source in
Button {
apiConfig.setHomeSource(source)
appState.currentSourceKey = source.key
} label: {
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(source.name)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(source.isSupportedInSwift ? .white : .white.opacity(0.5))
//
Text(source.typeDescription)
.font(.system(size: 10, weight: .bold))
.foregroundColor(source.isSupportedInSwift ? .orange : .gray)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Capsule().fill(
source.isSupportedInSwift ? Color.orange.opacity(0.2) : Color.gray.opacity(0.2)
)
)
if !source.isSupportedInSwift {
Text("暂不支持")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.red.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Capsule().fill(Color.red.opacity(0.15)))
}
}
Text(source.api)
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
}
Spacer()
HStack(spacing: 12) {
if source.isSearchable {
Image(systemName: "magnifyingglass")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.green.opacity(0.8))
}
if source.key == apiConfig.homeSourceBean?.key {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.orange)
} else {
Circle()
.strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
.frame(width: 20, height: 20)
}
}
}
.padding(16)
.glassCard(cornerRadius: 16)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(
source.key == apiConfig.homeSourceBean?.key ? Color.orange.opacity(0.5) : Color.clear,
lineWidth: 1
)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
}
.background(AppTheme.primaryGradient.ignoresSafeArea())
.navigationTitle("选择数据源")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
// MARK: -
struct SectionCard<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(.white.opacity(0.6))
.padding(.leading, 8)
VStack(spacing: 0) {
content()
}
.glassCard(cornerRadius: 16)
}
}
}
struct SettingsRow: View {
let icon: String
let title: String
let value: String
let action: (() -> Void)?
var body: some View {
Group {
if let action = action {
Button(action: action) {
rowContent
}
.buttonStyle(.plain)
} else {
rowContent
}
}
}
private var rowContent: some View {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 16))
.foregroundColor(.orange)
.frame(width: 24)
Text(title)
.font(.body)
.foregroundColor(.white.opacity(0.9))
Spacer()
Text(value)
.font(.subheadline)
.foregroundColor(.white.opacity(0.5))
.lineLimit(1)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.3))
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.contentShape(Rectangle())
}
}