fix iOS
This commit is contained in:
@@ -85,7 +85,9 @@ struct ContentView: View {
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
LiveView()
|
||||
LiveView(onExit: {
|
||||
selectedTab = 0
|
||||
})
|
||||
.tabItem {
|
||||
Label("直播", systemImage: "tv.fill")
|
||||
}
|
||||
@@ -97,19 +99,16 @@ struct ContentView: View {
|
||||
}
|
||||
.tag(2)
|
||||
|
||||
FavoritesView()
|
||||
ProfileView()
|
||||
.tabItem {
|
||||
Label("收藏", systemImage: "heart.fill")
|
||||
Label("个人中心", systemImage: "person.fill")
|
||||
}
|
||||
.tag(3)
|
||||
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Label("设置", systemImage: "gearshape.fill")
|
||||
}
|
||||
.tag(4)
|
||||
}
|
||||
.tint(.orange)
|
||||
.onChange(of: selectedTab) { _, _ in
|
||||
HapticManager.shared.selection()
|
||||
}
|
||||
#else
|
||||
NavigationSplitView(columnVisibility: $appState.splitViewVisibility) {
|
||||
List(selection: $selectedTab) {
|
||||
@@ -133,9 +132,15 @@ struct ContentView: View {
|
||||
case 0: HomeView()
|
||||
case 1: LiveView()
|
||||
case 2: SearchView()
|
||||
case 3: FavoritesView()
|
||||
case 3:
|
||||
NavigationStack {
|
||||
FavoritesView()
|
||||
}
|
||||
case 4: SettingsView()
|
||||
case 5: HistoryView()
|
||||
case 5:
|
||||
NavigationStack {
|
||||
HistoryView()
|
||||
}
|
||||
default: HomeView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ struct DetailView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var showFullScreen = false
|
||||
/// VLC 全屏退出动画期间为 true,防止内联播放器与全屏播放器同时争抢 drawable
|
||||
@State private var isFullScreenDismissing = false
|
||||
#if os(macOS)
|
||||
@State private var pendingMacWindowFullScreen = false
|
||||
#endif
|
||||
@@ -23,7 +25,7 @@ struct DetailView: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// 播放器区域
|
||||
if !showFullScreen, viewModel.isPlaying, let url = viewModel.playUrl {
|
||||
if !showFullScreen, !isFullScreenDismissing, viewModel.isPlaying, let url = viewModel.playUrl {
|
||||
PlayerView(
|
||||
urlString: url,
|
||||
startPosition: viewModel.currentPlaybackSeconds(),
|
||||
@@ -136,7 +138,9 @@ struct DetailView: View {
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.fullScreenCover(isPresented: $showFullScreen) {
|
||||
.fullScreenCover(isPresented: $showFullScreen, onDismiss: {
|
||||
isFullScreenDismissing = false
|
||||
}) {
|
||||
if let url = viewModel.playUrl {
|
||||
FullScreenPlayerView(
|
||||
urlString: url,
|
||||
@@ -146,7 +150,11 @@ struct DetailView: View {
|
||||
canPlayNext: canPlayNextEpisode,
|
||||
onPlayNext: playNextEpisodeIfNeeded,
|
||||
systemController: sharedSystemController,
|
||||
vlcController: sharedVLCController
|
||||
vlcController: sharedVLCController,
|
||||
onCloseRequested: {
|
||||
isFullScreenDismissing = true
|
||||
showFullScreen = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +163,35 @@ struct DetailView: View {
|
||||
|
||||
// MARK: - 视频信息
|
||||
|
||||
#if os(iOS)
|
||||
@ViewBuilder
|
||||
private var videoInfoSection: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Poster centered, height capped to 30% of screen
|
||||
let posterHeight = UIScreen.main.bounds.height * 0.30
|
||||
CachedAsyncImage(url: URL.posterURL(from: video.pic)) { image in
|
||||
image.resizable().aspectRatio(2/3, contentMode: .fit)
|
||||
} placeholder: {
|
||||
Color.white.opacity(0.05)
|
||||
.aspectRatio(2/3, contentMode: .fit)
|
||||
}
|
||||
.frame(maxHeight: posterHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.cardRadius))
|
||||
|
||||
// Info below poster
|
||||
videoDetails
|
||||
|
||||
// Action buttons with 48pt height
|
||||
HStack(spacing: 12) {
|
||||
playButton
|
||||
collectButton
|
||||
}
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
.padding(15)
|
||||
.glassCard(cornerRadius: AppTheme.glassRadius)
|
||||
}
|
||||
#else
|
||||
@ViewBuilder
|
||||
private var videoInfoSection: some View {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
@@ -167,6 +204,7 @@ struct DetailView: View {
|
||||
.padding(15)
|
||||
.glassCard(cornerRadius: AppTheme.glassRadius)
|
||||
}
|
||||
#endif
|
||||
|
||||
@ViewBuilder
|
||||
private var videoPoster: some View {
|
||||
@@ -201,12 +239,14 @@ struct DetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Spacer(minLength: 10)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
playButton
|
||||
collectButton
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,48 +22,49 @@ struct FavoritesView: View {
|
||||
#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)
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("删除收藏失败: \(error)")
|
||||
}
|
||||
} label: {
|
||||
Label("取消收藏", systemImage: "heart.slash")
|
||||
favoritesContent
|
||||
}
|
||||
|
||||
/// 收藏内容视图(不含 NavigationStack 包裹)。
|
||||
/// iOS 下由外层 ProfileView/SettingsView 的 NavigationStack 管理导航;
|
||||
/// macOS 下由 ContentView 的 NavigationSplitView detail 区域使用独立 NavigationStack。
|
||||
private var favoritesContent: some View {
|
||||
Group {
|
||||
if favorites.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
// 每个收藏项都可直接跳转详情,并支持右键取消收藏。
|
||||
ForEach(favorites) { item in
|
||||
NavigationLink(destination: DetailView(video: movieVideo(from: item))) {
|
||||
favoriteCard(item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
modelContext.delete(item)
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("删除收藏失败: \(error)")
|
||||
}
|
||||
} label: {
|
||||
Label("取消收藏", systemImage: "heart.slash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
|
||||
.navigationTitle("收藏")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
// 通过 Movie.Video 作为路由载体,保持与首页/搜索页一致的详情入口。
|
||||
.navigationDestination(for: Movie.Video.self) { video in
|
||||
DetailView(video: video)
|
||||
}
|
||||
}
|
||||
.background(Color(red: 0.08, green: 0.08, blue: 0.1))
|
||||
.navigationTitle("收藏")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 空列表占位。
|
||||
|
||||
@@ -22,62 +22,63 @@ struct HistoryView: View {
|
||||
#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)
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("删除历史记录失败: \(error)")
|
||||
}
|
||||
} label: {
|
||||
Label("删除记录", systemImage: "trash")
|
||||
historyContent
|
||||
}
|
||||
|
||||
/// 历史记录内容视图(不含 NavigationStack 包裹)。
|
||||
/// iOS 下由外层 ProfileView/SettingsView 的 NavigationStack 管理导航;
|
||||
/// macOS 下由 ContentView 的 NavigationSplitView detail 区域使用独立 NavigationStack。
|
||||
private var historyContent: some View {
|
||||
Group {
|
||||
if records.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
// 记录卡片支持跳转详情与右键删除。
|
||||
ForEach(records) { item in
|
||||
NavigationLink(destination: DetailView(video: movieVideo(from: item))) {
|
||||
recordCard(item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
modelContext.delete(item)
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
print("删除历史记录失败: \(error)")
|
||||
}
|
||||
} label: {
|
||||
Label("删除记录", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-147
@@ -5,12 +5,11 @@ 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)
|
||||
GridItem(.adaptive(minimum: 100, maximum: 140), spacing: 10)
|
||||
]
|
||||
#else
|
||||
private let columns = [
|
||||
@@ -42,11 +41,11 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 顶部栏
|
||||
// MARK: - 顶部栏(源选择器)
|
||||
|
||||
private var headerBar: some View {
|
||||
HStack(spacing: 15) {
|
||||
// 应用名(可切换源)
|
||||
HStack(spacing: 12) {
|
||||
// 源切换按钮
|
||||
Menu {
|
||||
ForEach(ApiConfig.shared.sourceBeanList.filter { $0.isSupportedInSwift }) { source in
|
||||
Button {
|
||||
@@ -63,132 +62,77 @@ struct HomeView: View {
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles")
|
||||
Image(systemName: "play.tv.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.orange)
|
||||
Text(ApiConfig.shared.homeSourceBean?.name ?? "TVBox")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.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)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(viewModel.sorts) { sort in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
viewModel.selectSort(sort)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(sort.id)
|
||||
categoryScrollAnchorId = sort.id
|
||||
scrollCategoryBar(to: sort.id, proxy: proxy)
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Text(sort.name)
|
||||
.font(.system(size: 14, weight: viewModel.selectedSort?.id == sort.id ? .bold : .regular))
|
||||
.foregroundColor(viewModel.selectedSort?.id == sort.id ? .white : .white.opacity(0.6))
|
||||
|
||||
// 底部指示条
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color.orange)
|
||||
.frame(width: 20, height: 3)
|
||||
.opacity(viewModel.selectedSort?.id == sort.id ? 1 : 0)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.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, 12)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.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
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func categoryIndex(for id: String?) -> Int? {
|
||||
@@ -216,17 +160,6 @@ struct HomeView: View {
|
||||
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 }
|
||||
|
||||
@@ -239,23 +172,6 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -309,7 +225,11 @@ struct HomeView: View {
|
||||
NavigationLink(value: video) {
|
||||
VodCardView(video: video)
|
||||
}
|
||||
#if os(iOS)
|
||||
.buttonStyle(VodCardPressStyle())
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.onAppear {
|
||||
Task { await viewModel.loadMoreIfNeeded(currentItem: video) }
|
||||
}
|
||||
@@ -335,15 +255,3 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - VodCard Press Style (iOS only)
|
||||
|
||||
#if os(iOS)
|
||||
/// 按压缩放动画样式 - 为 VodCard 提供触觉反馈式的按压效果
|
||||
struct VodCardPressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 视频卡片组件
|
||||
struct VodCardView: View {
|
||||
/// 卡片对应的视频数据。
|
||||
@@ -68,6 +81,10 @@ struct VodCardView: View {
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
#if os(iOS)
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 海报占位图,避免图片加载失败导致卡片高度塌陷。
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
/// 直播频道全屏覆盖式选择器 - 替代 macOS 上的 390pt 抽屉
|
||||
/// 采用左侧分组列表 + 右侧频道列表的双栏布局
|
||||
struct ChannelOverlayView: View {
|
||||
let channelGroups: [LiveChannelGroup]
|
||||
@Binding var selectedGroupIndex: Int
|
||||
let currentChannels: [LiveChannelItem]
|
||||
let currentChannel: LiveChannelItem?
|
||||
let onSelectGroup: (Int) -> Void
|
||||
let onSelectChannel: (LiveChannelItem) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
|
||||
/// 计算左侧分组列表宽度(屏幕宽度的 32%)
|
||||
/// - Parameter screenWidth: 屏幕宽度
|
||||
/// - Returns: 分组列表宽度,范围在 [screenWidth * 0.30, screenWidth * 0.35]
|
||||
static func computeGroupWidth(screenWidth: CGFloat) -> CGFloat {
|
||||
return screenWidth * 0.32
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// Dimmed background - tap to dismiss
|
||||
Color.black.opacity(0.5)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// Content panel
|
||||
VStack(spacing: 0) {
|
||||
// Handle bar for drag-to-dismiss
|
||||
handleBar
|
||||
|
||||
// Two-column layout
|
||||
HStack(spacing: 0) {
|
||||
groupList
|
||||
.frame(width: Self.computeGroupWidth(screenWidth: geometry.size.width))
|
||||
|
||||
Divider()
|
||||
.overlay(Color.white.opacity(0.1))
|
||||
|
||||
channelList
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: geometry.size.height * 0.85)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.offset(y: max(dragOffset, 0))
|
||||
.gesture(dismissDragGesture)
|
||||
.padding(.top, geometry.safeAreaInsets.top + 40)
|
||||
}
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: dragOffset)
|
||||
}
|
||||
|
||||
// MARK: - Handle Bar
|
||||
|
||||
private var handleBar: some View {
|
||||
VStack(spacing: 8) {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.4))
|
||||
.frame(width: 36, height: 5)
|
||||
.padding(.top, 10)
|
||||
|
||||
Text("频道列表")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.white.opacity(0.05))
|
||||
}
|
||||
|
||||
// MARK: - Group List (Left Column)
|
||||
|
||||
private var groupList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if channelGroups.isEmpty {
|
||||
Text("暂无分组")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.padding(.vertical, 20)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
ForEach(Array(channelGroups.enumerated()), id: \.offset) { index, group in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
onSelectGroup(index)
|
||||
}
|
||||
} label: {
|
||||
Text(group.groupName)
|
||||
.font(.system(size: 14, weight: selectedGroupIndex == index ? .bold : .medium))
|
||||
.foregroundColor(selectedGroupIndex == index ? .orange : .white.opacity(0.8))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(minHeight: 48)
|
||||
.background(
|
||||
selectedGroupIndex == index
|
||||
? Color.white.opacity(0.1)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Channel List (Right Column)
|
||||
|
||||
private var channelList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if currentChannels.isEmpty {
|
||||
Text("暂无频道")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.padding(.vertical, 20)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
ForEach(Array(currentChannels.enumerated()), id: \.offset) { _, channel in
|
||||
Button {
|
||||
HapticManager.shared.mediumImpact()
|
||||
onSelectChannel(channel)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(channel.channelName)
|
||||
.font(.system(size: 14, weight: currentChannel?.channelName == channel.channelName ? .bold : .medium))
|
||||
.foregroundColor(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))
|
||||
}
|
||||
|
||||
if currentChannel?.channelName == channel.channelName {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minHeight: 48)
|
||||
.background(
|
||||
currentChannel?.channelName == channel.channelName
|
||||
? Color.orange.opacity(0.15)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag to Dismiss Gesture
|
||||
|
||||
private var dismissDragGesture: some Gesture {
|
||||
DragGesture()
|
||||
.onChanged { value in
|
||||
dragOffset = value.translation.height
|
||||
}
|
||||
.onEnded { value in
|
||||
if value.translation.height > 120 {
|
||||
// Dismiss if dragged down far enough
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
// Snap back
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
|
||||
dragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -6,6 +6,8 @@ import AppKit
|
||||
|
||||
/// 直播页 - 对应 Android 版 LivePlayActivity
|
||||
struct LiveView: View {
|
||||
/// 退出直播回调(iOS 用于返回首页并显示 TabBar)。
|
||||
var onExit: (() -> Void)? = nil
|
||||
/// 直播频道与选中状态管理。
|
||||
@StateObject private var viewModel = LiveViewModel()
|
||||
/// 全局应用状态(用于 macOS 全屏时调整分栏布局)。
|
||||
@@ -18,6 +20,10 @@ struct LiveView: View {
|
||||
@AppStorage(HawkConfig.PLAY_TYPE) private var legacyPlayTypeRaw = PlayerEngine.system.rawValue
|
||||
/// 是否展示左侧频道抽屉。
|
||||
@State private var showChannelDrawer = true
|
||||
#if os(iOS)
|
||||
/// iOS 全屏频道覆盖层是否显示。
|
||||
@State private var showChannelOverlay = false
|
||||
#endif
|
||||
/// 当前窗口是否处于全屏。
|
||||
@State private var isWindowFullScreen = false
|
||||
/// 底部频道信息卡最大宽度。
|
||||
@@ -97,6 +103,9 @@ struct LiveView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
#endif
|
||||
.onAppear {
|
||||
// 首次进入时加载频道并展示频道信息卡。
|
||||
@@ -170,6 +179,7 @@ struct LiveView: View {
|
||||
|
||||
private var overlayUI: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
#if os(macOS)
|
||||
if showChannelDrawer {
|
||||
Color.black.opacity(0.22)
|
||||
.ignoresSafeArea()
|
||||
@@ -180,9 +190,13 @@ struct LiveView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
#if os(iOS)
|
||||
liveBackButton
|
||||
#endif
|
||||
channelDrawerToggleButton
|
||||
Spacer()
|
||||
}
|
||||
@@ -198,19 +212,44 @@ struct LiveView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if showChannelDrawer {
|
||||
channelDrawer
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 20)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: showCurrentChannelInfo)
|
||||
#if os(macOS)
|
||||
.animation(.easeInOut(duration: 0.2), value: showChannelDrawer)
|
||||
#endif
|
||||
.simultaneousGesture(
|
||||
TapGesture().onEnded {
|
||||
reportUserActivity()
|
||||
}
|
||||
)
|
||||
#if os(iOS)
|
||||
.fullScreenCover(isPresented: $showChannelOverlay) {
|
||||
ChannelOverlayView(
|
||||
channelGroups: viewModel.channelGroups,
|
||||
selectedGroupIndex: $viewModel.selectedGroupIndex,
|
||||
currentChannels: viewModel.currentChannels,
|
||||
currentChannel: viewModel.currentChannel,
|
||||
onSelectGroup: { viewModel.selectGroup($0) },
|
||||
onSelectChannel: { channel in
|
||||
viewModel.selectChannel(channel)
|
||||
HapticManager.shared.mediumImpact()
|
||||
showChannelOverlay = false
|
||||
},
|
||||
onDismiss: { showChannelOverlay = false }
|
||||
)
|
||||
.presentationBackground(.clear)
|
||||
.transition(.move(edge: .bottom))
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: showChannelOverlay)
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.onContinuousHover { phase in
|
||||
switch phase {
|
||||
@@ -266,20 +305,58 @@ struct LiveView: View {
|
||||
.glassCard(cornerRadius: 14)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var liveBackButton: some View {
|
||||
Button {
|
||||
HapticManager.shared.lightImpact()
|
||||
onExit?()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.frame(width: 38, height: 38)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Circle())
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
#endif
|
||||
|
||||
private var channelDrawerToggleButton: some View {
|
||||
Button {
|
||||
#if os(iOS)
|
||||
HapticManager.shared.lightImpact()
|
||||
showChannelOverlay = true
|
||||
#else
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showChannelDrawer.toggle()
|
||||
}
|
||||
#endif
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
#if os(iOS)
|
||||
Image(systemName: "list.bullet")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Text("频道")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
#else
|
||||
Image(systemName: showChannelDrawer ? "sidebar.left" : "sidebar.right")
|
||||
Text(showChannelDrawer ? "收起菜单" : "频道菜单")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
#endif
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
#if os(iOS)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
#else
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
#endif
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
@@ -294,6 +371,61 @@ struct LiveView: View {
|
||||
}
|
||||
|
||||
private func currentChannelInfo(_ channel: LiveChannelItem) -> some View {
|
||||
#if os(iOS)
|
||||
// iOS: 紧凑的底部信息栏,适合单手操作
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
// 频道名称
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(Color.orange).frame(width: 8, height: 8)
|
||||
Text(channel.channelName)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 线路信息
|
||||
if channel.sourceNum > 1 {
|
||||
Text("线路 \(channel.sourceIndex + 1)/\(channel.sourceNum)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮行
|
||||
if channel.sourceNum > 1 {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
HapticManager.shared.mediumImpact()
|
||||
wakeUpCurrentChannelInfo()
|
||||
resetFailureTracking(for: viewModel.currentChannel)
|
||||
viewModel.switchSource()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "shuffle")
|
||||
.font(.system(size: 13))
|
||||
Text("切换线路")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 40)
|
||||
.background(AppTheme.accentGradient)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.glassCard(cornerRadius: 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
#else
|
||||
// macOS: 保持原有宽屏布局
|
||||
HStack(spacing: 15) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 10) {
|
||||
@@ -313,7 +445,6 @@ struct LiveView: View {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
// 切换线路按钮
|
||||
if channel.sourceNum > 1 {
|
||||
Button {
|
||||
wakeUpCurrentChannelInfo()
|
||||
@@ -334,7 +465,6 @@ struct LiveView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Button {
|
||||
wakeUpCurrentChannelInfo()
|
||||
toggleWindowFullScreen()
|
||||
@@ -355,7 +485,6 @@ struct LiveView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
@@ -363,6 +492,7 @@ struct LiveView: View {
|
||||
.frame(maxWidth: currentChannelInfoMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(20)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func wakeUpCurrentChannelInfo() {
|
||||
@@ -469,7 +599,12 @@ struct LiveView: View {
|
||||
|
||||
cleanupPlayer()
|
||||
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
// 使用 AVURLAsset 并设置自定义 HTTP 头,解决部分 CDN 拒绝无 User-Agent 请求的问题
|
||||
let headers: [String: String] = [
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
]
|
||||
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
||||
let playerItem = AVPlayerItem(asset: asset)
|
||||
// 直播场景优先实时性,避免过多缓冲导致内存上涨
|
||||
playerItem.preferredForwardBufferDuration = 3
|
||||
playerItem.canUseNetworkResourcesForLiveStreamingWhilePaused = false
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - LivePlayerGestureLayer
|
||||
|
||||
/// 直播播放器手势交互层 - 专为直播场景设计
|
||||
/// 直播不需要快进快退,仅支持:
|
||||
/// - 单击:显示/隐藏控制层
|
||||
/// - 双击:暂停/播放
|
||||
/// - 右侧垂直滑动:调节音量
|
||||
/// - 左侧垂直滑动:调节亮度
|
||||
struct LivePlayerGestureLayer: View {
|
||||
// MARK: - Callbacks
|
||||
let onTogglePlayPause: () -> Void
|
||||
let onToggleControls: () -> Void
|
||||
let onVolumeChanged: (CGFloat) -> Void
|
||||
|
||||
// MARK: - State
|
||||
@State private var gestureMode: LiveGestureMode = .none
|
||||
@State private var showIndicator = false
|
||||
@State private var dragStartX: CGFloat = 0
|
||||
@State private var accumulatedBrightnessDelta: CGFloat = 0
|
||||
@State private var accumulatedVolumeDelta: CGFloat = 0
|
||||
|
||||
enum LiveGestureMode: Equatable {
|
||||
case none
|
||||
case adjustingBrightness(delta: CGFloat)
|
||||
case adjustingVolume(delta: CGFloat)
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.gesture(dragGesture(in: geometry))
|
||||
.onTapGesture(count: 2) {
|
||||
HapticManager.shared.lightImpact()
|
||||
onTogglePlayPause()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
onToggleControls()
|
||||
}
|
||||
.overlay { gestureIndicatorOverlay }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag Gesture
|
||||
|
||||
private func dragGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||
DragGesture(minimumDistance: 15)
|
||||
.onChanged { value in
|
||||
if gestureMode == .none {
|
||||
dragStartX = value.startLocation.x
|
||||
HapticManager.shared.lightImpact()
|
||||
}
|
||||
|
||||
let absV = abs(value.translation.height)
|
||||
guard absV > 15 else { return }
|
||||
|
||||
let isLeftHalf = dragStartX < geometry.size.width / 2
|
||||
let delta = -value.translation.height / 300.0
|
||||
|
||||
if isLeftHalf {
|
||||
gestureMode = .adjustingBrightness(delta: delta)
|
||||
// Apply brightness directly
|
||||
let newBrightness = max(0, min(1, UIScreen.main.brightness + (delta - accumulatedBrightnessDelta)))
|
||||
UIScreen.main.brightness = newBrightness
|
||||
accumulatedBrightnessDelta = delta
|
||||
} else {
|
||||
gestureMode = .adjustingVolume(delta: delta)
|
||||
let volumeDelta = delta - accumulatedVolumeDelta
|
||||
onVolumeChanged(volumeDelta)
|
||||
accumulatedVolumeDelta = delta
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showIndicator = true
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
gestureMode = .none
|
||||
accumulatedBrightnessDelta = 0
|
||||
accumulatedVolumeDelta = 0
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showIndicator = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gesture Indicator Overlay
|
||||
|
||||
@ViewBuilder
|
||||
private var gestureIndicatorOverlay: some View {
|
||||
if showIndicator {
|
||||
VStack(spacing: 8) {
|
||||
indicatorIcon
|
||||
indicatorText
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.opacity(showIndicator ? 1 : 0)
|
||||
.animation(.easeInOut(duration: 0.2), value: showIndicator)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var indicatorIcon: some View {
|
||||
switch gestureMode {
|
||||
case .adjustingBrightness:
|
||||
Image(systemName: "sun.max.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.yellow)
|
||||
case .adjustingVolume:
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var indicatorText: some View {
|
||||
switch gestureMode {
|
||||
case .adjustingBrightness:
|
||||
let percentage = Int(UIScreen.main.brightness * 100)
|
||||
Text("\(percentage)%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
case .adjustingVolume:
|
||||
Text("音量")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,273 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Gesture Mode Enum
|
||||
|
||||
/// 手势模式枚举 - 表示当前激活的手势类型
|
||||
enum PlayerGestureMode: Equatable {
|
||||
case none
|
||||
case seeking(offset: Double)
|
||||
case adjustingBrightness(delta: CGFloat)
|
||||
case adjustingVolume(delta: CGFloat)
|
||||
}
|
||||
|
||||
// MARK: - PlayerGestureLayer
|
||||
|
||||
/// 播放器手势交互层 - 覆盖在播放器上方的透明手势识别层
|
||||
/// 支持水平滑动快进快退、左侧垂直滑动调节亮度、右侧垂直滑动调节音量、
|
||||
/// 双击暂停/播放、捏合缩放
|
||||
struct PlayerGestureLayer: View {
|
||||
// MARK: - Callbacks
|
||||
let onSeek: (Double) -> Void
|
||||
let onTogglePlayPause: () -> Void
|
||||
let onToggleControls: () -> Void
|
||||
let onZoomChanged: (CGFloat) -> Void
|
||||
|
||||
// MARK: - Properties
|
||||
let currentTime: Double
|
||||
let duration: Double
|
||||
|
||||
// MARK: - State
|
||||
@State private var gestureMode: PlayerGestureMode = .none
|
||||
@State private var zoomScale: CGFloat = 1.0
|
||||
@State private var showIndicator = false
|
||||
@State private var dragStartX: CGFloat = 0
|
||||
|
||||
// MARK: - Pure Computation Functions
|
||||
|
||||
/// 根据起始位置和滑动方向确定手势类型
|
||||
static func classifyGesture(
|
||||
startX: CGFloat,
|
||||
containerWidth: CGFloat,
|
||||
translation: CGSize,
|
||||
threshold: CGFloat = 10
|
||||
) -> PlayerGestureMode {
|
||||
let absH = abs(translation.width)
|
||||
let absV = abs(translation.height)
|
||||
|
||||
// 水平优先判定(快进快退)
|
||||
if absH > absV && absH > threshold {
|
||||
// 根据滑动距离动态调整快进幅度,短距离精细,长距离快速
|
||||
let normalizedOffset = translation.width / containerWidth
|
||||
let offset = Double(normalizedOffset) * 120.0 // 滑满屏幕 = 120秒
|
||||
return .seeking(offset: offset)
|
||||
}
|
||||
|
||||
// 垂直判定:左半区域 = 亮度,右半区域 = 音量
|
||||
if absV > threshold {
|
||||
let isLeftHalf = startX < containerWidth / 2
|
||||
let delta = -translation.height / 300.0 // 向上为正
|
||||
if isLeftHalf {
|
||||
return .adjustingBrightness(delta: delta)
|
||||
} else {
|
||||
return .adjustingVolume(delta: delta)
|
||||
}
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
|
||||
/// 计算快进快退目标时间(带边界钳制)
|
||||
static func computeSeekTarget(
|
||||
currentTime: Double,
|
||||
offset: Double,
|
||||
duration: Double
|
||||
) -> Double {
|
||||
let target = currentTime + offset
|
||||
return max(0, min(target, duration))
|
||||
}
|
||||
|
||||
/// 钳制调节值到 [0, 1] 范围
|
||||
static func clampAdjustment(
|
||||
currentValue: CGFloat,
|
||||
delta: CGFloat
|
||||
) -> CGFloat {
|
||||
return max(0.0, min(1.0, currentValue + delta))
|
||||
}
|
||||
|
||||
/// 钳制缩放值到 [minZoom, maxZoom] 范围
|
||||
static func clampZoom(
|
||||
scale: CGFloat,
|
||||
minZoom: CGFloat = 1.0,
|
||||
maxZoom: CGFloat = 3.0
|
||||
) -> CGFloat {
|
||||
return max(minZoom, min(maxZoom, scale))
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.gesture(dragGesture(in: geometry))
|
||||
.gesture(pinchGesture)
|
||||
.onTapGesture(count: 2) {
|
||||
HapticManager.shared.lightImpact()
|
||||
onTogglePlayPause()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
onToggleControls()
|
||||
}
|
||||
.overlay { gestureIndicatorOverlay }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag Gesture
|
||||
|
||||
private func dragGesture(in geometry: GeometryProxy) -> some Gesture {
|
||||
DragGesture(minimumDistance: 5)
|
||||
.onChanged { value in
|
||||
if gestureMode == .none {
|
||||
dragStartX = value.startLocation.x
|
||||
HapticManager.shared.lightImpact()
|
||||
}
|
||||
|
||||
let mode = Self.classifyGesture(
|
||||
startX: dragStartX,
|
||||
containerWidth: geometry.size.width,
|
||||
translation: value.translation
|
||||
)
|
||||
|
||||
if mode != .none {
|
||||
gestureMode = mode
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showIndicator = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
switch gestureMode {
|
||||
case .seeking(let offset):
|
||||
let target = Self.computeSeekTarget(
|
||||
currentTime: currentTime,
|
||||
offset: offset,
|
||||
duration: duration
|
||||
)
|
||||
onSeek(target - currentTime)
|
||||
case .adjustingBrightness, .adjustingVolume:
|
||||
break
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
gestureMode = .none
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showIndicator = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pinch Gesture
|
||||
|
||||
private var pinchGesture: some Gesture {
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
let newScale = Self.clampZoom(scale: value.magnification * zoomScale)
|
||||
onZoomChanged(newScale)
|
||||
gestureMode = .none
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showIndicator = true
|
||||
}
|
||||
}
|
||||
.onEnded { value in
|
||||
let finalScale = Self.clampZoom(scale: value.magnification * zoomScale)
|
||||
if finalScale < 1.0 {
|
||||
zoomScale = 1.0
|
||||
} else {
|
||||
zoomScale = finalScale
|
||||
}
|
||||
onZoomChanged(zoomScale)
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showIndicator = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gesture Indicator Overlay
|
||||
|
||||
@ViewBuilder
|
||||
private var gestureIndicatorOverlay: some View {
|
||||
if showIndicator {
|
||||
VStack(spacing: 8) {
|
||||
indicatorIcon
|
||||
indicatorText
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.opacity(showIndicator ? 1 : 0)
|
||||
.animation(.easeInOut(duration: 0.2), value: showIndicator)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var indicatorIcon: some View {
|
||||
switch gestureMode {
|
||||
case .seeking(let offset):
|
||||
Image(systemName: offset >= 0 ? "forward.fill" : "backward.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
case .adjustingBrightness:
|
||||
Image(systemName: "sun.max.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.yellow)
|
||||
case .adjustingVolume:
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var indicatorText: some View {
|
||||
switch gestureMode {
|
||||
case .seeking(let offset):
|
||||
let target = Self.computeSeekTarget(
|
||||
currentTime: currentTime,
|
||||
offset: offset,
|
||||
duration: duration
|
||||
)
|
||||
Text(formatTime(target))
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
case .adjustingBrightness(let delta):
|
||||
let percentage = Int(Self.clampAdjustment(
|
||||
currentValue: UIScreen.main.brightness,
|
||||
delta: delta
|
||||
) * 100)
|
||||
Text("\(percentage)%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
case .adjustingVolume(let delta):
|
||||
let percentage = Int(Self.clampAdjustment(
|
||||
currentValue: 0.5,
|
||||
delta: delta
|
||||
) * 100)
|
||||
Text("\(percentage)%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatTime(_ seconds: Double) -> String {
|
||||
let totalSeconds = Int(max(0, seconds))
|
||||
let hours = totalSeconds / 3600
|
||||
let minutes = (totalSeconds % 3600) / 60
|
||||
let secs = totalSeconds % 60
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, secs)
|
||||
} else {
|
||||
return String(format: "%02d:%02d", minutes, secs)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -26,7 +26,7 @@ private struct MacOSPlayerView: NSViewRepresentable {
|
||||
|
||||
func makeNSView(context: Context) -> AVPlayerView {
|
||||
let view = AVPlayerView()
|
||||
view.controlsStyle = .none // 禁用系统默认控制栏
|
||||
view.controlsStyle = .none
|
||||
view.showsFullScreenToggleButton = false
|
||||
view.videoGravity = .resizeAspect
|
||||
view.player = player
|
||||
@@ -61,10 +61,17 @@ final class SystemPlayerSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
let stoppingPlayer = player
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
player = nil
|
||||
mediaURLString = nil
|
||||
// AVPlayer.replaceCurrentItem(with: nil) 在播放网络流时会同步阻塞主线程数秒。
|
||||
// 将其移到后台执行,闭包持有 AVPlayer 强引用防止提前 dealloc。
|
||||
if let stoppingPlayer {
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
stoppingPlayer.replaceCurrentItem(with: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +172,7 @@ struct AVPlayerContentView: View {
|
||||
@State private var volume: Double = 1.0
|
||||
@State private var rate: Float = 1.0
|
||||
@State private var isPreparing = true
|
||||
@State private var playbackError: String?
|
||||
@State private var showControls = true
|
||||
@State private var controlsTimer: Timer?
|
||||
@State private var osdIcon: String?
|
||||
@@ -174,11 +182,16 @@ struct AVPlayerContentView: View {
|
||||
@State private var draggingSeconds: Double = 0
|
||||
@State private var playerObservers: [NSKeyValueObservation] = []
|
||||
|
||||
@State private var videoZoomScale: CGFloat = 1.0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if let player = player {
|
||||
PlatformVideoPlayer(player: player)
|
||||
#if os(iOS)
|
||||
.scaleEffect(videoZoomScale)
|
||||
#endif
|
||||
} else {
|
||||
ZStack {
|
||||
Color.black
|
||||
@@ -187,17 +200,36 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
#if !os(iOS)
|
||||
.onTapGesture(count: 2) {
|
||||
onToggleFullScreen?()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
togglePlayPauseWithOSD()
|
||||
}
|
||||
#endif
|
||||
|
||||
if isPreparing {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
|
||||
if let error = playbackError {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.yellow)
|
||||
Text("播放失败")
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
if let osdIcon = osdIcon {
|
||||
Image(systemName: osdIcon)
|
||||
@@ -210,6 +242,22 @@ struct AVPlayerContentView: View {
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.overlay {
|
||||
PlayerGestureLayer(
|
||||
onSeek: { offset in seek(by: offset) },
|
||||
onTogglePlayPause: { togglePlayPauseWithOSD() },
|
||||
onToggleControls: { wakeUpControls() },
|
||||
onZoomChanged: { scale in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
videoZoomScale = scale
|
||||
}
|
||||
},
|
||||
currentTime: currentTime,
|
||||
duration: duration
|
||||
)
|
||||
}
|
||||
#endif
|
||||
.overlay(alignment: .bottom) {
|
||||
GeometryReader { proxy in
|
||||
if player != nil {
|
||||
@@ -257,15 +305,35 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
|
||||
private func setupPlayer() {
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
guard let url = Self.sanitizedURL(from: urlString) else {
|
||||
print("[AVPlayer] URL sanitization failed for: \(urlString)")
|
||||
return
|
||||
}
|
||||
let targetURLString = url.absoluteString
|
||||
let preferredRate = normalizedSavedPlaybackRate
|
||||
rate = preferredRate
|
||||
playbackError = nil
|
||||
|
||||
if let sharedController,
|
||||
sharedController.mediaURLString == targetURLString,
|
||||
let sharedPlayer = sharedController.player {
|
||||
cleanupPlayer(keepSharedPlayer: true)
|
||||
// 强制重新关联 AVPlayerItem,修复从全屏退出后 VideoPlayer 黑屏问题。
|
||||
// SwiftUI.VideoPlayer 在复用已有 AVPlayer 时可能无法正确连接视频渲染层,
|
||||
// 通过 replaceCurrentItem 触发内部 layer 重新绑定。
|
||||
#if os(iOS)
|
||||
if let currentItem = sharedPlayer.currentItem {
|
||||
let currentTime = sharedPlayer.currentTime()
|
||||
let wasPlaying = sharedPlayer.rate != 0
|
||||
sharedPlayer.replaceCurrentItem(with: nil)
|
||||
sharedPlayer.replaceCurrentItem(with: currentItem)
|
||||
sharedPlayer.seek(to: currentTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in
|
||||
if wasPlaying {
|
||||
sharedPlayer.playImmediately(atRate: self.normalizedSavedPlaybackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
player = sharedPlayer
|
||||
applyPreferredPlaybackRate(to: sharedPlayer)
|
||||
bindPlayerObservers(for: sharedPlayer)
|
||||
@@ -276,7 +344,11 @@ struct AVPlayerContentView: View {
|
||||
// 清理旧播放器
|
||||
cleanupPlayer()
|
||||
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
// 使用 AVURLAsset 并设置自定义 HTTP 头,解决部分 CDN 拒绝无 User-Agent 请求的问题
|
||||
let asset = AVURLAsset(url: url)
|
||||
asset.resourceLoader.setDelegate(nil, queue: nil)
|
||||
let playerItem = AVPlayerItem(asset: asset)
|
||||
playerItem.preferredForwardBufferDuration = 0
|
||||
let newPlayer = AVPlayer(playerItem: playerItem)
|
||||
newPlayer.defaultRate = preferredRate
|
||||
if let sharedController {
|
||||
@@ -288,6 +360,20 @@ struct AVPlayerContentView: View {
|
||||
startPlayback(for: newPlayer)
|
||||
}
|
||||
|
||||
/// 将原始 URL 字符串转换为合法的 URL,处理未编码的特殊字符。
|
||||
private static func sanitizedURL(from urlString: String) -> URL? {
|
||||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = URL(string: trimmed) {
|
||||
return url
|
||||
}
|
||||
// 尝试对整个字符串进行百分号编码(保留已编码部分)
|
||||
if let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: encoded) {
|
||||
return url
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func bindPlayerObservers(for player: AVPlayer) {
|
||||
playerObservers = [
|
||||
player.observe(\.timeControlStatus, options: [.new]) { p, _ in
|
||||
@@ -314,6 +400,23 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
}
|
||||
]
|
||||
// 监听 AVPlayerItem 状态,捕获加载失败的具体原因
|
||||
if let item = player.currentItem {
|
||||
let itemObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
|
||||
if observedItem.status == .failed {
|
||||
let errorDesc = observedItem.error?.localizedDescription ?? "未知错误"
|
||||
DispatchQueue.main.async {
|
||||
isPreparing = false
|
||||
playbackError = errorDesc
|
||||
}
|
||||
} else if observedItem.status == .readyToPlay {
|
||||
DispatchQueue.main.async {
|
||||
playbackError = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
playerObservers.append(itemObserver)
|
||||
}
|
||||
observePlaybackProgress(for: player)
|
||||
observePlaybackEnd(for: player)
|
||||
isPlaying = player.timeControlStatus == .playing
|
||||
@@ -346,12 +449,15 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
|
||||
currentPlayer.pause()
|
||||
currentPlayer.replaceCurrentItem(with: nil)
|
||||
if sharedController?.player === currentPlayer {
|
||||
sharedController?.player = nil
|
||||
sharedController?.mediaURLString = nil
|
||||
}
|
||||
player = nil
|
||||
// replaceCurrentItem(with: nil) 在播放网络流时会阻塞主线程,移到后台
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
currentPlayer.replaceCurrentItem(with: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func startPlayback(for player: AVPlayer) {
|
||||
@@ -451,8 +557,129 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
|
||||
private func playbackControls(containerWidth: CGFloat) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
// 第一行:进度条和时间
|
||||
#if os(iOS)
|
||||
let controlWidth = containerWidth * 1.0
|
||||
#else
|
||||
let controlWidth = containerWidth * 0.7
|
||||
#endif
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
#if os(iOS)
|
||||
// iOS: 紧凑单行布局 — 进度条在上,按钮在下紧贴
|
||||
// 进度条行
|
||||
HStack(spacing: 8) {
|
||||
Text(currentTime.durationString)
|
||||
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(1)
|
||||
|
||||
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: 10, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// 控制按钮行 — 紧凑排列
|
||||
HStack(spacing: 0) {
|
||||
// 左:倍速
|
||||
playbackRateMenu
|
||||
.frame(minWidth: 36, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 中间:主控按钮
|
||||
HStack(spacing: 20) {
|
||||
Button {
|
||||
wakeUpControls()
|
||||
seek(by: -seekStep)
|
||||
showOSD(icon: "gobackward.\(Int(seekStep))")
|
||||
} label: {
|
||||
Image(systemName: "gobackward.\(Int(seekStep))")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
wakeUpControls()
|
||||
togglePlayPauseWithOSD()
|
||||
} label: {
|
||||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
wakeUpControls()
|
||||
seek(by: seekStep)
|
||||
showOSD(icon: "goforward.\(Int(seekStep))")
|
||||
} label: {
|
||||
Image(systemName: "goforward.\(Int(seekStep))")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.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: 16, weight: .medium))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canPlayNext)
|
||||
.opacity(canPlayNext ? 1 : 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右:全屏
|
||||
if let onToggleFullScreen {
|
||||
Button {
|
||||
wakeUpControls()
|
||||
onToggleFullScreen()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
#else
|
||||
// macOS: 保持两行布局
|
||||
HStack(spacing: 12) {
|
||||
Text(currentTime.durationString)
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
@@ -488,9 +715,7 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
// 第二行:控制按钮
|
||||
HStack(spacing: 0) {
|
||||
// 左侧区:倍速
|
||||
HStack(spacing: 16) {
|
||||
playbackRateMenu
|
||||
}
|
||||
@@ -498,7 +723,6 @@ struct AVPlayerContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 中间区:主控 (这里集成了您原本右下角的快进快退和下一集按钮)
|
||||
HStack(spacing: 24) {
|
||||
Button {
|
||||
wakeUpControls()
|
||||
@@ -553,7 +777,6 @@ struct AVPlayerContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧区:音量和全屏
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: 6) {
|
||||
Button {
|
||||
@@ -595,14 +818,30 @@ struct AVPlayerContentView: View {
|
||||
}
|
||||
.frame(width: 150, alignment: .trailing)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.padding(.vertical, 2)
|
||||
.foregroundColor(.white)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.clear, .black.opacity(0.6)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.padding(.bottom, 0)
|
||||
.frame(width: controlWidth)
|
||||
#else
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.foregroundColor(.white)
|
||||
.glassCard(cornerRadius: 18)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 6)
|
||||
.frame(width: containerWidth * 0.7)
|
||||
.frame(width: controlWidth)
|
||||
#endif
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
}()
|
||||
private typealias LibVLCStopAsyncFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void
|
||||
|
||||
let mediaPlayer = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions)
|
||||
private var _mediaPlayer: VLCMediaPlayer?
|
||||
var mediaPlayer: VLCMediaPlayer {
|
||||
if let existing = _mediaPlayer {
|
||||
return existing
|
||||
}
|
||||
let player = VLCMediaPlayer(options: VLCPlayerController.stablePlaybackOptions)
|
||||
_mediaPlayer = player
|
||||
player.delegate = self
|
||||
player.drawable = persistentDrawableView
|
||||
return player
|
||||
}
|
||||
@Published var isPreparing = true
|
||||
@Published var isPlaying = false
|
||||
@Published var currentTimeSeconds: Double = 0
|
||||
@@ -107,8 +117,18 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
#else
|
||||
persistentDrawableView.backgroundColor = .black
|
||||
#endif
|
||||
mediaPlayer.delegate = self
|
||||
mediaPlayer.drawable = persistentDrawableView
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let player = _mediaPlayer {
|
||||
_mediaPlayer = nil
|
||||
// VLCMediaPlayer 的 dealloc 会阻塞(libvlc_media_player_release 内部 dispatch_sync 到主线程)。
|
||||
// 将引用移到后台线程,让 dealloc 在后台发生,避免阻塞主线程。
|
||||
nonisolated(unsafe) let p = player
|
||||
DispatchQueue.global(qos: .utility).async { [p] in
|
||||
_ = p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func play(
|
||||
@@ -167,7 +187,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
"network-caching": cacheConfig.network,
|
||||
"live-caching": cacheConfig.live,
|
||||
"file-caching": cacheConfig.file,
|
||||
"http-reconnect": 1
|
||||
"http-reconnect": 1,
|
||||
"http-user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
]
|
||||
if !isLive {
|
||||
mediaOptions["avcodec-hurry-up"] = 0
|
||||
@@ -204,8 +225,6 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
stopProgressTimer()
|
||||
resetPlaybackRecoveryState()
|
||||
cancelScheduledRebinds()
|
||||
stopMediaPlayer()
|
||||
mediaPlayer.media = nil
|
||||
onProgressChanged = nil
|
||||
onPlaybackEnded = nil
|
||||
onPlaybackFailed = nil
|
||||
@@ -216,6 +235,17 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
currentMediaIsLive = false
|
||||
currentMediaDecodeMode = .auto
|
||||
currentMediaBufferMode = .defaultMode
|
||||
guard let player = _mediaPlayer else { return }
|
||||
// 立即静音(audio.volume 是轻量属性设置,不会 dispatch_sync)
|
||||
player.audio?.volume = 0
|
||||
// 将 mediaPlayer 引用移到后台释放。
|
||||
// 不调用 pause/stop/media=nil 等 VLC API — 它们会 dispatch_sync 回主线程导致卡顿。
|
||||
// VLCMediaPlayer 的 dealloc 会在后台线程自然完成清理。
|
||||
_mediaPlayer = nil
|
||||
nonisolated(unsafe) let p = player
|
||||
DispatchQueue.global(qos: .utility).async { [p] in
|
||||
_ = p
|
||||
}
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
@@ -358,9 +388,14 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
#endif
|
||||
|
||||
private func refreshDrawableBinding() {
|
||||
// 强制重绑视频输出,规避 macOS 切全屏后偶发“有声音无画面”
|
||||
#if os(macOS)
|
||||
// macOS: 强制重绑视频输出,规避切全屏后偶发“有声音无画面”
|
||||
mediaPlayer.drawable = nil
|
||||
mediaPlayer.drawable = persistentDrawableView
|
||||
#else
|
||||
// iOS: 直接赋值,不置空 drawable,避免触发 state change 导致 SwiftUI dismiss 动画中断
|
||||
mediaPlayer.drawable = persistentDrawableView
|
||||
#endif
|
||||
lastDrawableRebindAt = Date()
|
||||
}
|
||||
|
||||
@@ -371,7 +406,12 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
|
||||
stopAsync(playerPointer)
|
||||
return
|
||||
}
|
||||
mediaPlayer.stop()
|
||||
// VLCKit 3.x 没有 async stop API,同步 stop 会阻塞主线程 2-3 秒。
|
||||
// 将其放到后台队列执行,避免 UI 卡顿。
|
||||
nonisolated(unsafe) let player = mediaPlayer
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
player.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private func playerInstancePointer() -> UnsafeMutableRawPointer? {
|
||||
@@ -825,12 +865,14 @@ struct VLCVodPlayerView: View {
|
||||
ZStack {
|
||||
VLCDrawableView(controller: controller)
|
||||
.background(Color.black)
|
||||
#if !os(iOS)
|
||||
.onTapGesture(count: 2) {
|
||||
onToggleFullScreen?()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
togglePlaybackWithOSD()
|
||||
}
|
||||
#endif
|
||||
|
||||
if controller.isPreparing {
|
||||
ProgressView()
|
||||
@@ -848,10 +890,24 @@ struct VLCVodPlayerView: View {
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.overlay {
|
||||
PlayerGestureLayer(
|
||||
onSeek: { offset in controller.seek(by: offset) },
|
||||
onTogglePlayPause: { togglePlaybackWithOSD() },
|
||||
onToggleControls: { wakeUpControls() },
|
||||
onZoomChanged: { _ in },
|
||||
currentTime: controller.currentTimeSeconds,
|
||||
duration: controller.durationSeconds
|
||||
)
|
||||
}
|
||||
#endif
|
||||
.overlay(alignment: .bottom) {
|
||||
GeometryReader { proxy in
|
||||
playbackControls(containerWidth: proxy.size.width)
|
||||
#if os(macOS)
|
||||
.padding(12)
|
||||
#endif
|
||||
.opacity(showControls ? 1.0 : 0.0)
|
||||
.animation(.easeInOut(duration: 0.3), value: showControls)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
@@ -937,7 +993,10 @@ struct VLCVodPlayerView: View {
|
||||
}
|
||||
|
||||
private func startPlayback() {
|
||||
guard let url = URL(string: urlString) else { return }
|
||||
guard let url = Self.sanitizedURL(from: urlString) else {
|
||||
print("[VLC] URL sanitization failed for: \(urlString)")
|
||||
return
|
||||
}
|
||||
let targetStartPosition = max(startPosition, 0)
|
||||
draggingSeconds = targetStartPosition
|
||||
startPlaybackTask?.cancel()
|
||||
@@ -956,6 +1015,19 @@ struct VLCVodPlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将原始 URL 字符串转换为合法的 URL,处理未编码的特殊字符。
|
||||
private static func sanitizedURL(from urlString: String) -> URL? {
|
||||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = URL(string: trimmed) {
|
||||
return url
|
||||
}
|
||||
if let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: encoded) {
|
||||
return url
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var seekStep: Double {
|
||||
let saved = UserDefaults.standard.integer(forKey: HawkConfig.PLAY_TIME_STEP)
|
||||
return Double(saved > 0 ? saved : 10)
|
||||
@@ -976,8 +1048,128 @@ struct VLCVodPlayerView: View {
|
||||
}
|
||||
|
||||
private func playbackControls(containerWidth: CGFloat) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
// 第一行:进度条和时间
|
||||
#if os(iOS)
|
||||
let controlWidth = containerWidth * 1.0
|
||||
#else
|
||||
let controlWidth = containerWidth * 0.7
|
||||
#endif
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
#if os(iOS)
|
||||
// iOS: 紧凑单行布局 — 进度条在上,按钮在下紧贴
|
||||
HStack(spacing: 8) {
|
||||
Text(currentDisplaySeconds.durationString)
|
||||
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.lineLimit(1)
|
||||
|
||||
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: 10, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// 控制按钮行 — 紧凑排列
|
||||
HStack(spacing: 0) {
|
||||
// 左:倍速
|
||||
playbackRateMenu
|
||||
.frame(minWidth: 36, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 中间:主控按钮
|
||||
HStack(spacing: 20) {
|
||||
Button {
|
||||
wakeUpControls()
|
||||
controller.seek(by: -seekStep)
|
||||
showOSD(icon: "gobackward.\(Int(seekStep))")
|
||||
} label: {
|
||||
Image(systemName: "gobackward.\(Int(seekStep))")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
wakeUpControls()
|
||||
togglePlaybackWithOSD()
|
||||
} label: {
|
||||
Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
wakeUpControls()
|
||||
controller.seek(by: seekStep)
|
||||
showOSD(icon: "goforward.\(Int(seekStep))")
|
||||
} label: {
|
||||
Image(systemName: "goforward.\(Int(seekStep))")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.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: 16, weight: .medium))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canPlayNext)
|
||||
.opacity(canPlayNext ? 1 : 0.4)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右:全屏
|
||||
if let onToggleFullScreen {
|
||||
Button {
|
||||
wakeUpControls()
|
||||
onToggleFullScreen()
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.frame(minWidth: 36, minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 6)
|
||||
#else
|
||||
// macOS: 保持两行布局
|
||||
HStack(spacing: 12) {
|
||||
Text(currentDisplaySeconds.durationString)
|
||||
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||
@@ -1013,9 +1205,7 @@ struct VLCVodPlayerView: View {
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
// 第二行:控制按钮
|
||||
HStack(spacing: 0) {
|
||||
// 左侧区:倍速
|
||||
HStack(spacing: 16) {
|
||||
playbackRateMenu
|
||||
}
|
||||
@@ -1023,7 +1213,6 @@ struct VLCVodPlayerView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 中间区:主控
|
||||
HStack(spacing: 24) {
|
||||
Button {
|
||||
wakeUpControls()
|
||||
@@ -1078,7 +1267,6 @@ struct VLCVodPlayerView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧区:音量和全屏
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: 6) {
|
||||
Button {
|
||||
@@ -1120,14 +1308,30 @@ struct VLCVodPlayerView: View {
|
||||
}
|
||||
.frame(width: 150, alignment: .trailing)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.padding(.vertical, 2)
|
||||
.foregroundColor(.white)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.clear, .black.opacity(0.6)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.padding(.bottom, 0)
|
||||
.frame(width: controlWidth)
|
||||
#else
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.foregroundColor(.white)
|
||||
.glassCard(cornerRadius: 18)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 6)
|
||||
.frame(width: containerWidth * 0.7)
|
||||
.frame(width: controlWidth)
|
||||
#endif
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
@@ -1197,17 +1401,21 @@ struct VLCLivePlayerView: View {
|
||||
@State private var osdIcon: String?
|
||||
@State private var osdOpacity: Double = 0
|
||||
@State private var osdTimer: Timer?
|
||||
@State private var showControls = true
|
||||
@State private var controlsTimer: Timer?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VLCDrawableView(controller: controller)
|
||||
.background(Color.black)
|
||||
#if !os(iOS)
|
||||
.onTapGesture(count: 2) {
|
||||
onToggleFullScreen?()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
togglePlaybackWithOSD()
|
||||
}
|
||||
#endif
|
||||
|
||||
if controller.isPreparing {
|
||||
ProgressView()
|
||||
@@ -1225,6 +1433,23 @@ struct VLCLivePlayerView: View {
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.overlay {
|
||||
LivePlayerGestureLayer(
|
||||
onTogglePlayPause: { togglePlaybackWithOSD() },
|
||||
onToggleControls: { wakeUpControls() },
|
||||
onVolumeChanged: { delta in
|
||||
let newVol = max(0, min(200, controller.volume + Int(delta * 200)))
|
||||
controller.setVolume(newVol)
|
||||
}
|
||||
)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
liveControlsOverlay
|
||||
.opacity(showControls ? 1.0 : 0.0)
|
||||
.animation(.easeInOut(duration: 0.3), value: showControls)
|
||||
}
|
||||
#endif
|
||||
.overlay {
|
||||
KeyboardShortcutCaptureView(
|
||||
onLeft: { },
|
||||
@@ -1248,13 +1473,58 @@ struct VLCLivePlayerView: View {
|
||||
}
|
||||
.onAppear {
|
||||
startPlayback()
|
||||
wakeUpControls()
|
||||
}
|
||||
.onChange(of: urlString) { _, _ in
|
||||
startPlayback()
|
||||
wakeUpControls()
|
||||
}
|
||||
.onChange(of: activityToken) { _, _ in
|
||||
wakeUpControls()
|
||||
}
|
||||
.onDisappear {
|
||||
controller.stop()
|
||||
osdTimer?.invalidate()
|
||||
controlsTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS Live Controls Overlay
|
||||
|
||||
#if os(iOS)
|
||||
private var liveControlsOverlay: some View {
|
||||
HStack(spacing: 20) {
|
||||
// Play/Pause button
|
||||
Button {
|
||||
wakeUpControls()
|
||||
togglePlaybackWithOSD()
|
||||
} label: {
|
||||
Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(Color.white.opacity(0.15))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.glassCard(cornerRadius: 14)
|
||||
.padding(.bottom, 20)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Controls Timer
|
||||
|
||||
private func wakeUpControls() {
|
||||
withAnimation { showControls = true }
|
||||
controlsTimer?.invalidate()
|
||||
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { _ in
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
showControls = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1275,7 +1545,8 @@ struct VLCLivePlayerView: View {
|
||||
}
|
||||
|
||||
private func startPlayback() {
|
||||
guard let url = URL(string: urlString) else {
|
||||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: trimmed) ?? trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed).flatMap({ URL(string: $0) }) else {
|
||||
onPlaybackFailed?()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
/// 个人中心页 - 合并收藏、历史、设置入口为统一 Profile Hub
|
||||
struct ProfileView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Header section
|
||||
Section {
|
||||
profileHeader
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
// Navigation entries
|
||||
Section {
|
||||
NavigationLink(destination: FavoritesView()) {
|
||||
profileRow(icon: "heart.fill", title: "我的收藏", color: .red)
|
||||
}
|
||||
NavigationLink(destination: HistoryView()) {
|
||||
profileRow(icon: "clock.fill", title: "播放历史", color: .orange)
|
||||
}
|
||||
NavigationLink(destination: SettingsView()) {
|
||||
profileRow(icon: "gearshape.fill", title: "设置", color: .gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("个人中心")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.background(AppTheme.primaryGradient)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profile Header
|
||||
|
||||
private var profileHeader: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image("AppIcon")
|
||||
.resizable()
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
Text("TVBox v\(appVersion)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
|
||||
// MARK: - Profile Row
|
||||
|
||||
private func profileRow(icon: String, title: String, color: Color) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(color)
|
||||
.frame(width: 28)
|
||||
Text(title)
|
||||
.font(.body)
|
||||
}
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
|
||||
// MARK: - App Version
|
||||
|
||||
private var appVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -127,7 +127,11 @@ struct SearchView: View {
|
||||
NavigationLink(value: video) {
|
||||
VodCardView(video: video)
|
||||
}
|
||||
#if os(iOS)
|
||||
.buttonStyle(VodCardPressStyle())
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Reference in New Issue
Block a user