From 610f619a3594d4ac58a26b76006663c3e4daf7aa Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 30 Jun 2026 23:10:20 +0800 Subject: [PATCH] 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 --- Sources/Localization.swift | 7 ++ Sources/MiniPlayerApp.swift | 125 ++++++++++++++++++++++++++++++++++++ Sources/PlayerBridge.swift | 70 +++++++++++++++++++- 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/Sources/Localization.swift b/Sources/Localization.swift index 2046591..4ca1dd0 100644 --- a/Sources/Localization.swift +++ b/Sources/Localization.swift @@ -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: "") } diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 5314dca..a068fa4 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -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)) + } +} diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 35b3d96..25497b4 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -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() } }