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 hiddenUnavailable = NSLocalizedString("hidden_unavailable", value: "hidden (unavailable)", comment: "")
|
||||||
static let statusForbidden = NSLocalizedString("status_forbidden", value: "Forbidden (403)", comment: "")
|
static let statusForbidden = NSLocalizedString("status_forbidden", value: "Forbidden (403)", comment: "")
|
||||||
static let statusUnavailable = NSLocalizedString("status_unavailable", value: "Unavailable (timeout/error)", 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 播放状态指示(loading/buffering/error)
|
||||||
|
PlayerStatusOverlay(bridge: bridge)
|
||||||
|
|
||||||
// 左上角应用图标 — 点击切换控制栏
|
// 左上角应用图标 — 点击切换控制栏
|
||||||
MiniPlayerIcon()
|
MiniPlayerIcon()
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
@ -590,3 +593,125 @@ struct TrackSelectDialog: View {
|
|||||||
.frame(minWidth: 250)
|
.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 }
|
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 {
|
enum RepeatMode: String, CaseIterable {
|
||||||
case none = "none"
|
case none = "none"
|
||||||
@ -69,6 +89,8 @@ final class PlayerBridge: ObservableObject {
|
|||||||
@Published var queue: [MediaItem] = []
|
@Published var queue: [MediaItem] = []
|
||||||
@Published var currentIndex: Int = -1
|
@Published var currentIndex: Int = -1
|
||||||
@Published var repeatMode: RepeatMode = .all
|
@Published var repeatMode: RepeatMode = .all
|
||||||
|
@Published var playerStatus: PlayerStatus = .idle
|
||||||
|
@Published var loadingElapsed: Int = 0 // 加载已用秒数
|
||||||
|
|
||||||
// MARK: - 内部状态
|
// MARK: - 内部状态
|
||||||
let player = AVPlayer()
|
let player = AVPlayer()
|
||||||
@ -81,6 +103,7 @@ final class PlayerBridge: ObservableObject {
|
|||||||
private var endObserverToken: NSObjectProtocol?
|
private var endObserverToken: NSObjectProtocol?
|
||||||
private var preferredTrackIndex: Int = 0
|
private var preferredTrackIndex: Int = 0
|
||||||
private var isTearingDown = false
|
private var isTearingDown = false
|
||||||
|
private var loadingTimer: Timer?
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var fullscreenWindow: NSWindow?
|
private var fullscreenWindow: NSWindow?
|
||||||
@ -190,6 +213,22 @@ final class PlayerBridge: ObservableObject {
|
|||||||
currentTimeText = "00:00"
|
currentTimeText = "00:00"
|
||||||
totalTimeText = "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 = {
|
let savedPosition: Double = {
|
||||||
if let libID = item.libraryID {
|
if let libID = item.libraryID {
|
||||||
@ -201,8 +240,12 @@ final class PlayerBridge: ObservableObject {
|
|||||||
// KVO 可能从非主线程回调,用 DispatchQueue.main.async
|
// KVO 可能从非主线程回调,用 DispatchQueue.main.async
|
||||||
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
|
||||||
print("[MiniPlayer] KVO status changed: \(pi.status.rawValue), error=\(pi.error?.localizedDescription ?? "none")")
|
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)
|
self?.loadDuration(pi)
|
||||||
// 恢复播放位置(仅对点播内容,直播流跳过)
|
// 恢复播放位置(仅对点播内容,直播流跳过)
|
||||||
let dur = self?.cachedDuration ?? 0
|
let dur = self?.cachedDuration ?? 0
|
||||||
@ -213,6 +256,14 @@ final class PlayerBridge: ObservableObject {
|
|||||||
print("[MiniPlayer] Resumed at \(pos)s")
|
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,避免后台线程竞态)
|
// 更新播放状态(替代 KVO,避免后台线程竞态)
|
||||||
isPlaying = (player.rate > 0 && player.error == nil)
|
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)
|
currentTimeText = formatTime(current)
|
||||||
if total.isFinite && total > 0 {
|
if total.isFinite && total > 0 {
|
||||||
totalTimeText = formatTime(total)
|
totalTimeText = formatTime(total)
|
||||||
@ -448,6 +510,7 @@ final class PlayerBridge: ObservableObject {
|
|||||||
currentIndex = -1
|
currentIndex = -1
|
||||||
player.replaceCurrentItem(with: nil)
|
player.replaceCurrentItem(with: nil)
|
||||||
currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0
|
currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0
|
||||||
|
playerStatus = .idle
|
||||||
} else {
|
} else {
|
||||||
currentIndex = min(index, queue.count - 1)
|
currentIndex = min(index, queue.count - 1)
|
||||||
playIndex(currentIndex)
|
playIndex(currentIndex)
|
||||||
@ -635,6 +698,8 @@ final class PlayerBridge: ObservableObject {
|
|||||||
// 1. 先停 Timer
|
// 1. 先停 Timer
|
||||||
updateTimer?.invalidate()
|
updateTimer?.invalidate()
|
||||||
updateTimer = nil
|
updateTimer = nil
|
||||||
|
loadingTimer?.invalidate()
|
||||||
|
loadingTimer = nil
|
||||||
|
|
||||||
// 2. 移除 KVO 观察者
|
// 2. 移除 KVO 观察者
|
||||||
itemStatusObserver?.invalidate(); itemStatusObserver = nil
|
itemStatusObserver?.invalidate(); itemStatusObserver = nil
|
||||||
@ -650,5 +715,6 @@ final class PlayerBridge: ObservableObject {
|
|||||||
// deinit 是 nonisolated,只做最小安全清理
|
// deinit 是 nonisolated,只做最小安全清理
|
||||||
// 大部分清理应在 cleanup() 中完成
|
// 大部分清理应在 cleanup() 中完成
|
||||||
updateTimer?.invalidate()
|
updateTimer?.invalidate()
|
||||||
|
loadingTimer?.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user