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:
yumoqing 2026-06-30 23:10:20 +08:00
parent b1937bfc16
commit 610f619a35
3 changed files with 200 additions and 2 deletions

View File

@ -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: "")
}

View File

@ -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))
}
}

View File

@ -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()
}
}