feat: add player status overlay (loading/buffering/error)
- Add PlayerStatus enum with loading/buffering/playing/error states - Track loading elapsed time with countdown timer - Detect buffering via timeControlStatus - Handle playback errors with error message + retry button - Show timeout warning after 15s of loading - Buffering indicator in top-right corner (non-blocking) - Localization strings for all status messages
This commit is contained in:
parent
b1937bfc16
commit
610f619a35
@ -108,4 +108,11 @@ enum L {
|
||||
static let hiddenUnavailable = NSLocalizedString("hidden_unavailable", value: "hidden (unavailable)", comment: "")
|
||||
static let statusForbidden = NSLocalizedString("status_forbidden", value: "Forbidden (403)", comment: "")
|
||||
static let statusUnavailable = NSLocalizedString("status_unavailable", value: "Unavailable (timeout/error)", comment: "")
|
||||
|
||||
// 播放状态
|
||||
static let statusConnecting = NSLocalizedString("status_connecting", value: "Connecting...", comment: "")
|
||||
static let statusBuffering = NSLocalizedString("status_buffering", value: "Buffering...", comment: "")
|
||||
static let statusError = NSLocalizedString("status_error", value: "Playback Error", comment: "")
|
||||
static let statusTimeout = NSLocalizedString("status_timeout", value: "Connection timeout. Check URL and network.", comment: "")
|
||||
static let statusRetry = NSLocalizedString("status_retry", value: "Retry", comment: "")
|
||||
}
|
||||
|
||||
@ -275,6 +275,9 @@ struct PlayerContentView: View {
|
||||
return true
|
||||
}
|
||||
|
||||
// 播放状态指示(loading/buffering/error)
|
||||
PlayerStatusOverlay(bridge: bridge)
|
||||
|
||||
// 左上角应用图标 — 点击切换控制栏
|
||||
MiniPlayerIcon()
|
||||
.frame(width: 32, height: 32)
|
||||
@ -590,3 +593,125 @@ struct TrackSelectDialog: View {
|
||||
.frame(minWidth: 250)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 播放状态覆盖层
|
||||
struct PlayerStatusOverlay: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch bridge.playerStatus {
|
||||
case .loading:
|
||||
loadingView
|
||||
case .buffering:
|
||||
bufferingView
|
||||
case .error(let msg):
|
||||
errorView(msg)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(isError)
|
||||
.animation(.easeInOut(duration: 0.3), value: bridge.playerStatus)
|
||||
}
|
||||
|
||||
private var isError: Bool {
|
||||
if case .error = bridge.playerStatus { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
// 加载中:居中大 spinner + 文字 + 已用时间
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(1.5)
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
Text(L.statusConnecting)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
|
||||
if bridge.loadingElapsed > 0 {
|
||||
Text("\(bridge.loadingElapsed)s")
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
|
||||
if bridge.loadingElapsed > 15 {
|
||||
Text(L.statusTimeout)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.yellow.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black.opacity(0.4))
|
||||
}
|
||||
|
||||
// 缓冲中:右上角小指示器
|
||||
private var bufferingView: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.scaleEffect(0.7)
|
||||
Text(L.statusBuffering)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule().fill(Color.black.opacity(0.6))
|
||||
)
|
||||
.padding(16)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// 错误:居中显示错误信息 + 重试按钮
|
||||
private func errorView(_ msg: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.red.opacity(0.8))
|
||||
|
||||
Text(L.statusError)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(msg)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 60)
|
||||
.lineLimit(3)
|
||||
|
||||
Button(action: {
|
||||
if bridge.currentIndex >= 0 {
|
||||
bridge.playIndex(bridge.currentIndex)
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
Text(L.statusRetry)
|
||||
}
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule().fill(Color.accentColor.opacity(0.8))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black.opacity(0.6))
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,26 @@ struct MediaItem: Identifiable, Equatable {
|
||||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
||||
}
|
||||
|
||||
/// 播放器加载状态
|
||||
enum PlayerStatus: Equatable {
|
||||
case idle // 无媒体
|
||||
case loading // 正在连接/加载
|
||||
case buffering // 播放中缓冲
|
||||
case playing // 正常播放
|
||||
case error(String) // 播放错误
|
||||
|
||||
static func == (lhs: PlayerStatus, rhs: PlayerStatus) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.idle, .idle), (.loading, .loading), (.buffering, .buffering), (.playing, .playing):
|
||||
return true
|
||||
case (.error(let a), .error(let b)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 循环模式
|
||||
enum RepeatMode: String, CaseIterable {
|
||||
case none = "none"
|
||||
@ -69,6 +89,8 @@ final class PlayerBridge: ObservableObject {
|
||||
@Published var queue: [MediaItem] = []
|
||||
@Published var currentIndex: Int = -1
|
||||
@Published var repeatMode: RepeatMode = .all
|
||||
@Published var playerStatus: PlayerStatus = .idle
|
||||
@Published var loadingElapsed: Int = 0 // 加载已用秒数
|
||||
|
||||
// MARK: - 内部状态
|
||||
let player = AVPlayer()
|
||||
@ -81,6 +103,7 @@ final class PlayerBridge: ObservableObject {
|
||||
private var endObserverToken: NSObjectProtocol?
|
||||
private var preferredTrackIndex: Int = 0
|
||||
private var isTearingDown = false
|
||||
private var loadingTimer: Timer?
|
||||
|
||||
#if os(macOS)
|
||||
private var fullscreenWindow: NSWindow?
|
||||
@ -190,6 +213,22 @@ final class PlayerBridge: ObservableObject {
|
||||
currentTimeText = "00:00"
|
||||
totalTimeText = "00:00"
|
||||
|
||||
// 开始加载状态追踪
|
||||
playerStatus = .loading
|
||||
loadingElapsed = 0
|
||||
loadingTimer?.invalidate()
|
||||
loadingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
if case .loading = self.playerStatus {
|
||||
self.loadingElapsed += 1
|
||||
} else {
|
||||
self.loadingTimer?.invalidate()
|
||||
self.loadingTimer = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查找媒体库条目,获取保存的位置
|
||||
let savedPosition: Double = {
|
||||
if let libID = item.libraryID {
|
||||
@ -201,8 +240,12 @@ final class PlayerBridge: ObservableObject {
|
||||
// KVO 可能从非主线程回调,用 DispatchQueue.main.async
|
||||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||||
print("[MiniPlayer] KVO status changed: \(pi.status.rawValue), error=\(pi.error?.localizedDescription ?? "none")")
|
||||
if pi.status == .readyToPlay {
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async {
|
||||
switch pi.status {
|
||||
case .readyToPlay:
|
||||
self?.playerStatus = .playing
|
||||
self?.loadingTimer?.invalidate()
|
||||
self?.loadingTimer = nil
|
||||
self?.loadDuration(pi)
|
||||
// 恢复播放位置(仅对点播内容,直播流跳过)
|
||||
let dur = self?.cachedDuration ?? 0
|
||||
@ -213,6 +256,14 @@ final class PlayerBridge: ObservableObject {
|
||||
print("[MiniPlayer] Resumed at \(pos)s")
|
||||
}
|
||||
}
|
||||
case .failed:
|
||||
let errMsg = pi.error?.localizedDescription ?? "Unknown error"
|
||||
self?.playerStatus = .error(errMsg)
|
||||
self?.loadingTimer?.invalidate()
|
||||
self?.loadingTimer = nil
|
||||
self?.isPlaying = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -269,6 +320,17 @@ final class PlayerBridge: ObservableObject {
|
||||
// 更新播放状态(替代 KVO,避免后台线程竞态)
|
||||
isPlaying = (player.rate > 0 && player.error == nil)
|
||||
|
||||
// 检测缓冲状态(仅在已 readyToPlay 后检测,避免覆盖 loading)
|
||||
if case .playing = playerStatus {
|
||||
if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
|
||||
playerStatus = .buffering
|
||||
}
|
||||
} else if case .buffering = playerStatus {
|
||||
if player.timeControlStatus == .playing {
|
||||
playerStatus = .playing
|
||||
}
|
||||
}
|
||||
|
||||
currentTimeText = formatTime(current)
|
||||
if total.isFinite && total > 0 {
|
||||
totalTimeText = formatTime(total)
|
||||
@ -448,6 +510,7 @@ final class PlayerBridge: ObservableObject {
|
||||
currentIndex = -1
|
||||
player.replaceCurrentItem(with: nil)
|
||||
currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0
|
||||
playerStatus = .idle
|
||||
} else {
|
||||
currentIndex = min(index, queue.count - 1)
|
||||
playIndex(currentIndex)
|
||||
@ -635,6 +698,8 @@ final class PlayerBridge: ObservableObject {
|
||||
// 1. 先停 Timer
|
||||
updateTimer?.invalidate()
|
||||
updateTimer = nil
|
||||
loadingTimer?.invalidate()
|
||||
loadingTimer = nil
|
||||
|
||||
// 2. 移除 KVO 观察者
|
||||
itemStatusObserver?.invalidate(); itemStatusObserver = nil
|
||||
@ -650,5 +715,6 @@ final class PlayerBridge: ObservableObject {
|
||||
// deinit 是 nonisolated,只做最小安全清理
|
||||
// 大部分清理应在 cleanup() 中完成
|
||||
updateTimer?.invalidate()
|
||||
loadingTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user