fix iOS
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user