MiniPlayer/Sources/PlayerBridge.swift
yumoqing aa19ab9799 feat: i18n support, tri-color icon, fix crash
- Add i18n: Localization.swift + zh-Hans/en Localizable.strings
- Add MiniPlayerIcon SwiftUI view (tri-color play button + ring)
- Fix crash: isInteracting/lastInteraction no longer @Published
- Fix crash: ExitFullscreen notification wrapped in DispatchQueue.main.async
- Auto-hide toolbar uses local @State + Timer (not @Published)
- Replace emoji logo with MiniPlayerIcon
- Move icon sets out of Resources/ to avoid SPM conflicts
- Package.swift: add defaultLocalization, process Resources
2026-06-22 00:40:20 +08:00

455 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import AVFoundation
import Combine
#if os(macOS)
import AppKit
#endif
///
struct MediaItem: Identifiable, Equatable {
let id: String
var url: URL
var name: String
var mediaType: String
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
}
///
enum RepeatMode: String, CaseIterable {
case none = "none"
case single = "single"
case all = "all"
var displayName: String {
switch self {
case .none: return L.repeatNone
case .single: return L.repeatSingle
case .all: return L.repeatAll
}
}
var icon: String {
switch self {
case .none: return "➡️"
case .single: return "🔂"
case .all: return "🔁"
}
}
var next: RepeatMode {
switch self {
case .none: return .single
case .single: return .all
case .all: return .none
}
}
}
@MainActor
final class PlayerBridge: ObservableObject {
// MARK: - Published
@Published var showURLDialog = false
@Published var showTrackDialog = false
@Published var toastMessage: String?
@Published var availableTracks: [String] = []
@Published var currentTrackIndex: Int = 0
@Published var isFullscreen = false
@Published var showToolbar = false
@Published var isPlaying = false
@Published var currentTimeText = "00:00"
@Published var totalTimeText = "00:00"
@Published var currentTrackLabel = "🎵 Track 1"
@Published var volume: Float = 1.0
@Published var progressRatio: Double = 0
@Published var cachedDuration: Double = 0
@Published var queue: [MediaItem] = []
@Published var currentIndex: Int = -1
@Published var repeatMode: RepeatMode = .all
// MARK: -
let player = AVPlayer()
var cancellables = Set<AnyCancellable>()
private var timeObserver: Any?
private var itemStatusObserver: NSKeyValueObservation?
private var playbackStatusObserver: NSKeyValueObservation?
private var endObserverToken: NSObjectProtocol?
private var preferredTrackIndex: Int = 0
#if os(macOS)
private var fullscreenWindow: NSWindow?
private var playlistWindow: NSWindow?
#endif
// MARK: -
func setup() {
player.volume = volume
setupTimeObserver()
setupPlaybackStatusObserver()
setupEndObserver()
}
// MARK: -
func togglePlayPause() {
if player.timeControlStatus == .playing {
player.pause()
isPlaying = false
} else {
player.play()
isPlaying = true
}
}
func playPrev() {
guard !queue.isEmpty else { return }
let idx = (currentIndex - 1 + queue.count) % queue.count
playIndex(idx)
}
func playNext() {
guard !queue.isEmpty else { return }
switch repeatMode {
case .none:
if currentIndex < queue.count - 1 { playIndex(currentIndex + 1) }
case .single:
player.seek(to: .zero); player.play()
case .all:
playIndex((currentIndex + 1) % queue.count)
}
}
func playIndex(_ index: Int) {
guard index >= 0 && index < queue.count else { return }
currentIndex = index
let item = queue[index]
// KVO
itemStatusObserver?.invalidate()
itemStatusObserver = nil
let playerItem = AVPlayerItem(url: item.url)
player.replaceCurrentItem(with: playerItem)
player.play()
isPlaying = true
cachedDuration = 0
progressRatio = 0
currentTimeText = "00:00"
totalTimeText = "00:00"
itemStatusObserver = playerItem.observe(\.status, options: [.new]) { [weak self] pi, _ in
if pi.status == .readyToPlay {
Task { @MainActor in self?.loadDuration(pi) }
}
}
loadTrackInfo(playerItem)
showToast(L.nowPlaying(item.name))
}
// MARK: -
private func setupEndObserver() {
endObserverToken = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.onPlaybackEnded() }
}
}
private func onPlaybackEnded() {
switch repeatMode {
case .none:
if currentIndex < queue.count - 1 { playNext() } else { isPlaying = false }
case .single:
player.seek(to: .zero); player.play()
case .all:
playNext()
}
}
// MARK: -
private func setupTimeObserver() {
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
Task { @MainActor in self?.updateTimeDisplay(time: time) }
}
}
private func updateTimeDisplay(time: CMTime) {
let current = time.seconds
var total = player.currentItem?.duration.seconds ?? 0
if !total.isFinite || total <= 0 { total = cachedDuration }
else { cachedDuration = total }
currentTimeText = formatTime(current)
if total.isFinite && total > 0 {
totalTimeText = formatTime(total)
progressRatio = max(0, min(1, current / total))
}
}
private func loadDuration(_ item: AVPlayerItem) {
Task {
if let dur = try? await item.asset.load(.duration) {
let secs = dur.seconds
if secs.isFinite && secs > 0 {
cachedDuration = secs
totalTimeText = formatTime(secs)
}
}
}
}
private func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite && seconds >= 0 else { return "00:00" }
let h = Int(seconds) / 3600
let m = (Int(seconds) % 3600) / 60
let s = Int(seconds) % 60
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
return String(format: "%02d:%02d", m, s)
}
// MARK: -
func adjustVolume(by delta: Float) {
volume = max(0, min(1, volume + delta))
player.volume = volume
}
func setVolume(_ val: Float) {
volume = max(0, min(1, val))
player.volume = volume
}
// MARK: -
private func loadTrackInfo(_ item: AVPlayerItem) {
Task {
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible) else {
availableTracks = ["Track 1"]
currentTrackIndex = 0
updateTrackLabel()
return
}
let options = group.options
availableTracks = options.enumerated().map { idx, _ in "Track \(idx + 1)" }
var targetIndex = preferredTrackIndex
if targetIndex >= options.count { targetIndex = 0 }
item.select(options[targetIndex], in: group)
currentTrackIndex = targetIndex
updateTrackLabel()
}
}
func selectTrack(index: Int) {
guard let item = player.currentItem else { return }
Task {
guard let group = try? await item.asset.loadMediaSelectionGroup(for: .audible),
index < group.options.count else { return }
item.select(group.options[index], in: group)
currentTrackIndex = index
preferredTrackIndex = index
updateTrackLabel()
}
}
private func updateTrackLabel() {
currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
}
// MARK: -
func cycleRepeatMode() {
repeatMode = repeatMode.next
showToast(L.repeatModeLabel(repeatMode.displayName))
}
// MARK: -
func toggleFullscreen() {
#if os(macOS)
if let fw = fullscreenWindow {
fw.close()
fullscreenWindow = nil
isFullscreen = false
NSApp.activate(ignoringOtherApps: true)
return
}
guard let screen = NSScreen.main else { return }
let win = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false)
win.level = .screenSaver
win.backgroundColor = .black
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
win.hasShadow = false
// 使 NSView + AVPlayerLayer SwiftUI
let playerView = FullscreenPlayerView(frame: screen.frame)
playerView.player = player
win.contentView = playerView
win.makeKeyAndOrderFront(nil)
// Escape退
win.makeFirstResponder(playerView)
NSApp.activate(ignoringOtherApps: true)
fullscreenWindow = win
isFullscreen = true
#endif
}
// MARK: -
func openFileDialog() {
#if os(macOS)
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.allowedContentTypes = [
.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .avi, .mp3, .wav, .mpeg4Audio
]
if panel.runModal() == .OK {
for url in panel.urls {
addItem(url: url, name: url.lastPathComponent, type: "file")
}
}
#endif
}
func addURL(_ urlString: String) {
guard let url = URL(string: urlString) else { showToast(L.invalidURL); return }
let name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent
let type = urlString.contains(".m3u8") ? "stream" : "url"
addItem(url: url, name: name, type: type)
}
private func addItem(url: URL, name: String, type: String) {
queue.append(MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type))
if queue.count == 1 { playIndex(0) }
}
func removeItem(at index: Int) {
guard index >= 0 && index < queue.count else { return }
let wasPlaying = (index == currentIndex)
queue.remove(at: index)
if wasPlaying {
if queue.isEmpty {
currentIndex = -1
player.replaceCurrentItem(with: nil)
currentTimeText = "00:00"; totalTimeText = "00:00"; progressRatio = 0
} else {
currentIndex = min(index, queue.count - 1)
playIndex(currentIndex)
}
} else if index < currentIndex {
currentIndex -= 1
}
}
// MARK: -
@Published var showPlaylistSheet = false
// @Published onHover
var lastInteraction = Date()
var isInteracting = false
func recordInteraction() { lastInteraction = Date() }
func togglePlaylistWindow() {
#if os(macOS)
if let win = playlistWindow, win.isVisible {
win.close(); playlistWindow = nil; return
}
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false
)
win.title = "\(L.playlist) (\(queue.count))"
win.isReleasedWhenClosed = false
win.center()
win.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self))
win.makeKeyAndOrderFront(nil)
playlistWindow = win
#else
showPlaylistSheet.toggle()
#endif
}
// MARK: - Toast
private func showToast(_ message: String) {
toastMessage = message
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
if self?.toastMessage == message { self?.toastMessage = nil }
}
}
// MARK: -
private func setupPlaybackStatusObserver() {
playbackStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] p, _ in
let playing = (p.timeControlStatus == .playing)
Task { @MainActor in self?.isPlaying = playing }
}
}
/// app 退
func cleanup() {
if let obs = timeObserver { player.removeTimeObserver(obs); timeObserver = nil }
itemStatusObserver?.invalidate(); itemStatusObserver = nil
playbackStatusObserver?.invalidate(); playbackStatusObserver = nil
if let token = endObserverToken { NotificationCenter.default.removeObserver(token); endObserverToken = nil }
player.pause()
player.replaceCurrentItem(with: nil)
}
deinit {
if let obs = timeObserver { player.removeTimeObserver(obs) }
itemStatusObserver?.invalidate()
playbackStatusObserver?.invalidate()
if let token = endObserverToken { NotificationCenter.default.removeObserver(token) }
player.pause()
player.replaceCurrentItem(with: nil)
}
}
// MARK: - NSView AppKit SwiftUI
#if os(macOS)
import AppKit
class FullscreenPlayerView: NSView {
var player: AVPlayer? {
get { (layer as? AVPlayerLayer)?.player }
set { (layer as? AVPlayerLayer)?.player = newValue }
}
override init(frame: NSRect) {
super.init(frame: frame)
wantsLayer = true
let playerLayer = AVPlayerLayer()
playerLayer.videoGravity = .resizeAspect
layer = playerLayer
}
required init?(coder: NSCoder) { fatalError() }
override var acceptsFirstResponder: Bool { true }
override func layout() {
super.layout()
(layer as? AVPlayerLayer)?.frame = bounds
}
override func mouseDown(with event: NSEvent) {
if event.clickCount == 2 {
// 退
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
} else {
// /
if let p = player {
if p.timeControlStatus == .playing { p.pause() } else { p.play() }
}
}
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // Escape
NotificationCenter.default.post(name: .init("ExitFullscreen"), object: nil)
}
}
}
#endif