This commit is contained in:
Jin
2026-05-14 17:50:06 +08:00
parent 63e29be346
commit fdde6c51ce
25 changed files with 2970 additions and 295 deletions
+16 -11
View File
@@ -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()
}
}
+43 -3
View File
@@ -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
}
}
+35 -34
View File
@@ -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
}
///
+46 -45
View File
@@ -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
View File
@@ -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)
}
}
}
+17
View File
@@ -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
}
///
+195
View File
@@ -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
+139 -4
View File
@@ -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
+273
View File
@@ -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
+251 -12
View File
@@ -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)
}
+288 -17
View File
@@ -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
}
+70
View File
@@ -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
+4
View File
@@ -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)