first commit
This commit is contained in:
@@ -0,0 +1,541 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// 直播页 - 对应 Android 版 LivePlayActivity
|
||||
struct LiveView: View {
|
||||
@StateObject private var viewModel = LiveViewModel()
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var avPlayer: AVPlayer?
|
||||
@AppStorage(HawkConfig.PLAY_TYPE) private var playTypeRaw = PlayerEngine.system.rawValue
|
||||
@State private var showChannelDrawer = true
|
||||
@State private var isWindowFullScreen = false
|
||||
private let currentChannelInfoMaxWidth: CGFloat = 600
|
||||
@State private var itemStatusObserver: NSKeyValueObservation?
|
||||
@State private var playbackFailedObserver: NSObjectProtocol?
|
||||
@State private var playbackStalledObserver: NSObjectProtocol?
|
||||
@State private var failedSourceIndices: Set<Int> = []
|
||||
@State private var trackedChannelId: String = ""
|
||||
@State private var showCurrentChannelInfo = true
|
||||
@State private var channelInfoTimer: Timer?
|
||||
private let channelInfoAutoHideDelay: TimeInterval = 3.0
|
||||
@State private var vlcInteractionToken = 0
|
||||
|
||||
private var selectedEngine: PlayerEngine {
|
||||
PlayerEngine.fromStoredValue(playTypeRaw)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
if viewModel.channelGroups.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
// 播放器
|
||||
if selectedEngine == .vlc {
|
||||
if let urlString = viewModel.currentChannel?.currentUrl, !urlString.isEmpty {
|
||||
VLCLivePlayerView(
|
||||
urlString: urlString,
|
||||
activityToken: vlcInteractionToken,
|
||||
onPlaybackFailed: {
|
||||
handlePlaybackFailure(trigger: "vlc_error")
|
||||
},
|
||||
onToggleFullScreen: {
|
||||
toggleWindowFullScreen()
|
||||
}
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.id("vlc-live-\(urlString)-\(viewModel.currentChannel?.id ?? "")")
|
||||
}
|
||||
} else if let player = avPlayer {
|
||||
PlatformVideoPlayer(player: player)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
// 覆盖 UI
|
||||
overlayUI
|
||||
}
|
||||
}
|
||||
.navigationTitle("直播")
|
||||
#if os(macOS)
|
||||
.toolbar(isWindowFullScreen ? .hidden : .visible, for: .windowToolbar)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.onAppear {
|
||||
viewModel.loadChannels()
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
.onChange(of: viewModel.currentChannel?.currentUrl) { _, newValue in
|
||||
if selectedEngine == .system {
|
||||
playChannel(url: newValue)
|
||||
} else {
|
||||
cleanupPlayer()
|
||||
}
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
.onChange(of: viewModel.currentChannel?.id) { _, _ in
|
||||
resetFailureTracking(for: viewModel.currentChannel)
|
||||
wakeUpCurrentChannelInfo()
|
||||
}
|
||||
.onChange(of: playTypeRaw) { _, _ in
|
||||
if selectedEngine == .system {
|
||||
playChannel(url: viewModel.currentChannel?.currentUrl)
|
||||
} else {
|
||||
cleanupPlayer()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
cleanupPlayer()
|
||||
cancelChannelInfoAutoHide()
|
||||
#if os(macOS)
|
||||
appState.exitPlayerFullScreen()
|
||||
isWindowFullScreen = false
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didEnterFullScreenNotification)) { _ in
|
||||
isWindowFullScreen = true
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didExitFullScreenNotification)) { _ in
|
||||
isWindowFullScreen = false
|
||||
appState.exitPlayerFullScreen()
|
||||
}
|
||||
.onExitCommand {
|
||||
if showChannelDrawer {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showChannelDrawer = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 空状态
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "tv.slash")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.gray)
|
||||
Text("暂无直播源")
|
||||
.font(.headline)
|
||||
.foregroundColor(.gray)
|
||||
Text("请在设置中配置包含直播源的接口")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 覆盖 UI
|
||||
|
||||
private var overlayUI: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
if showChannelDrawer {
|
||||
Color.black.opacity(0.22)
|
||||
.ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showChannelDrawer = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
channelDrawerToggleButton
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 底部当前频道信息
|
||||
if let channel = viewModel.currentChannel, showCurrentChannelInfo {
|
||||
currentChannelInfo(channel)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
if showChannelDrawer {
|
||||
channelDrawer
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 20)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: showCurrentChannelInfo)
|
||||
.simultaneousGesture(
|
||||
TapGesture().onEnded {
|
||||
reportUserActivity()
|
||||
}
|
||||
)
|
||||
#if os(macOS)
|
||||
.onContinuousHover { phase in
|
||||
switch phase {
|
||||
case .active(_):
|
||||
reportUserActivity()
|
||||
case .ended:
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var channelDrawer: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 10) {
|
||||
Label("频道菜单", systemImage: "list.bullet.rectangle.portrait")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showChannelDrawer = false
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.frame(width: 22, height: 22)
|
||||
.background(Color.white.opacity(0.12))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.08))
|
||||
|
||||
HStack(spacing: 0) {
|
||||
channelGroupList
|
||||
.frame(width: 150)
|
||||
|
||||
Divider()
|
||||
.overlay(Color.white.opacity(0.1))
|
||||
|
||||
channelList
|
||||
.frame(width: 240)
|
||||
}
|
||||
}
|
||||
.frame(width: 390)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.glassCard(cornerRadius: 14)
|
||||
}
|
||||
|
||||
private var channelDrawerToggleButton: some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showChannelDrawer.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: showChannelDrawer ? "sidebar.left" : "sidebar.right")
|
||||
Text(showChannelDrawer ? "收起菜单" : "频道菜单")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.black.opacity(0.35))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if os(macOS)
|
||||
.keyboardShortcut("m", modifiers: [.command])
|
||||
#endif
|
||||
}
|
||||
|
||||
private func currentChannelInfo(_ channel: LiveChannelItem) -> some View {
|
||||
HStack(spacing: 15) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 10) {
|
||||
Circle().fill(Color.orange).frame(width: 8, height: 8)
|
||||
Text(channel.channelName)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
if channel.sourceNum > 1 {
|
||||
Text("正在播放:线路 \(channel.sourceIndex + 1) / \(channel.sourceNum)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
// 切换线路按钮
|
||||
if channel.sourceNum > 1 {
|
||||
Button {
|
||||
wakeUpCurrentChannelInfo()
|
||||
resetFailureTracking(for: viewModel.currentChannel)
|
||||
viewModel.switchSource()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "shuffle")
|
||||
Text("切换线路")
|
||||
}
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 9)
|
||||
.background(AppTheme.accentGradient)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Button {
|
||||
wakeUpCurrentChannelInfo()
|
||||
toggleWindowFullScreen()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
Text("全屏")
|
||||
}
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 9)
|
||||
.background(Color.white.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: AppTheme.glassRadius)
|
||||
.frame(maxWidth: currentChannelInfoMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private func wakeUpCurrentChannelInfo() {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showCurrentChannelInfo = true
|
||||
}
|
||||
channelInfoTimer?.invalidate()
|
||||
guard viewModel.currentChannel != nil else { return }
|
||||
|
||||
channelInfoTimer = Timer.scheduledTimer(withTimeInterval: channelInfoAutoHideDelay, repeats: false) { _ in
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
showCurrentChannelInfo = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelChannelInfoAutoHide() {
|
||||
channelInfoTimer?.invalidate()
|
||||
channelInfoTimer = nil
|
||||
}
|
||||
|
||||
private func reportUserActivity() {
|
||||
wakeUpCurrentChannelInfo()
|
||||
vlcInteractionToken &+= 1
|
||||
}
|
||||
|
||||
// MARK: - 频道分组
|
||||
|
||||
private var channelGroupList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(viewModel.channelGroups.enumerated()), id: \.offset) { index, group in
|
||||
Button {
|
||||
withAnimation {
|
||||
viewModel.selectGroup(index)
|
||||
}
|
||||
} label: {
|
||||
Text(group.groupName)
|
||||
.font(.system(size: 14, weight: viewModel.selectedGroupIndex == index ? .bold : .medium))
|
||||
.foregroundColor(viewModel.selectedGroupIndex == index ? .orange : .white.opacity(0.8))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
viewModel.selectedGroupIndex == index
|
||||
? Color.white.opacity(0.1)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 频道列表
|
||||
|
||||
private var channelList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(viewModel.currentChannels.enumerated()), id: \.offset) { index, channel in
|
||||
Button {
|
||||
withAnimation {
|
||||
viewModel.selectedChannelIndex = index
|
||||
viewModel.selectChannel(channel)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(channel.channelName)
|
||||
.font(.system(size: 14, weight: viewModel.currentChannel?.channelName == channel.channelName ? .bold : .medium))
|
||||
.foregroundColor(viewModel.currentChannel?.channelName == channel.channelName ? .orange : .white.opacity(0.8))
|
||||
Spacer()
|
||||
if channel.sourceNum > 1 {
|
||||
Text("\(channel.sourceNum)")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.white.opacity(0.3))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().stroke(Color.white.opacity(0.2), lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
viewModel.currentChannel?.channelName == channel.channelName
|
||||
? Color.orange.opacity(0.15)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 播放
|
||||
|
||||
private func playChannel(url: String?) {
|
||||
guard let urlStr = url, let url = URL(string: urlStr) else {
|
||||
handlePlaybackFailure(trigger: "invalid_url")
|
||||
return
|
||||
}
|
||||
|
||||
cleanupPlayer()
|
||||
|
||||
let playerItem = AVPlayerItem(url: url)
|
||||
// 直播场景优先实时性,避免过多缓冲导致内存上涨
|
||||
playerItem.preferredForwardBufferDuration = 3
|
||||
playerItem.canUseNetworkResourcesForLiveStreamingWhilePaused = false
|
||||
observePlaybackFailure(for: playerItem)
|
||||
|
||||
let newPlayer = AVPlayer(playerItem: playerItem)
|
||||
newPlayer.play()
|
||||
avPlayer = newPlayer
|
||||
}
|
||||
|
||||
private func cleanupPlayer() {
|
||||
if let observer = playbackFailedObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
playbackFailedObserver = nil
|
||||
}
|
||||
if let observer = playbackStalledObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
playbackStalledObserver = nil
|
||||
}
|
||||
itemStatusObserver = nil
|
||||
|
||||
avPlayer?.pause()
|
||||
avPlayer?.replaceCurrentItem(with: nil)
|
||||
avPlayer = nil
|
||||
}
|
||||
|
||||
private func observePlaybackFailure(for item: AVPlayerItem) {
|
||||
itemStatusObserver = item.observe(\.status, options: [.new]) { observedItem, _ in
|
||||
if observedItem.status == .failed {
|
||||
DispatchQueue.main.async {
|
||||
handlePlaybackFailure(trigger: "status_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playbackFailedObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { _ in
|
||||
handlePlaybackFailure(trigger: "item_failed")
|
||||
}
|
||||
|
||||
playbackStalledObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemPlaybackStalled,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { _ in
|
||||
handlePlaybackFailure(trigger: "playback_stalled")
|
||||
}
|
||||
}
|
||||
|
||||
private func resetFailureTracking(for channel: LiveChannelItem?) {
|
||||
failedSourceIndices = []
|
||||
trackedChannelId = channel?.id ?? ""
|
||||
}
|
||||
|
||||
private func handlePlaybackFailure(trigger: String) {
|
||||
guard let channel = viewModel.currentChannel else { return }
|
||||
guard channel.sourceNum > 1 else { return }
|
||||
|
||||
if trackedChannelId != channel.id {
|
||||
resetFailureTracking(for: channel)
|
||||
}
|
||||
|
||||
let failedIndex = channel.sourceIndex
|
||||
guard !failedSourceIndices.contains(failedIndex) else { return }
|
||||
failedSourceIndices.insert(failedIndex)
|
||||
|
||||
guard switchToNextAvailableSource(totalSources: channel.sourceNum) else {
|
||||
print("直播线路全部尝试失败: channel=\(channel.channelName), trigger=\(trigger)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func switchToNextAvailableSource(totalSources: Int) -> Bool {
|
||||
guard failedSourceIndices.count < totalSources else { return false }
|
||||
|
||||
for _ in 0..<totalSources {
|
||||
viewModel.switchSource()
|
||||
guard let nextIndex = viewModel.currentChannel?.sourceIndex else { return false }
|
||||
if !failedSourceIndices.contains(nextIndex) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func toggleWindowFullScreen() {
|
||||
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return }
|
||||
let enteringFullScreen = !window.styleMask.contains(.fullScreen)
|
||||
if enteringFullScreen {
|
||||
isWindowFullScreen = true
|
||||
appState.enterPlayerFullScreen()
|
||||
}
|
||||
window.toggleFullScreen(nil)
|
||||
}
|
||||
#else
|
||||
private func toggleWindowFullScreen() {}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user