first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// This file has been moved to tvbox/Utils/Extensions.swift to ensure compatibility across targets.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user