refactor: rewrite MiniPlayer using SwiftBricks framework
- UI defined in player.json (Bricks JSON schema) - Custom widgets: VideoPlayer (AVPlayer layer), ProgressSlider (seek bar) - PlayerBridge connects AVPlayer to BricksEngine event bus - All interactions via binds/events (no imperative UI code) - Depends on SwiftBricks SPM package
This commit is contained in:
parent
c700f27b16
commit
c69ec38dc3
25
Package.swift
Normal file
25
Package.swift
Normal file
@ -0,0 +1,25 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MiniPlayer",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
.macOS(.v14)
|
||||
],
|
||||
products: [
|
||||
.library(name: "MiniPlayer", targets: ["MiniPlayer"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../SwiftBricks")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "MiniPlayer",
|
||||
dependencies: ["SwiftBricks"],
|
||||
resources: [
|
||||
.copy("Resources")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@ -1,114 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var engine: PlayerEngine
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@State private var showURLInput = false
|
||||
@State private var urlInput = ""
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if engine.isFullscreen {
|
||||
PlayerView()
|
||||
.onTapGesture(count: 2) {
|
||||
engine.toggleFullscreen()
|
||||
}
|
||||
} else {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
playlistSidebar
|
||||
} detail: {
|
||||
PlayerView()
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.navigationSplitViewColumnWidth(min: 240, ideal: 300, max: 400)
|
||||
}
|
||||
}
|
||||
.alert("添加流媒体 URL", isPresented: $showURLInput) {
|
||||
TextField("M3U8 或其他流媒体 URL", text: $urlInput)
|
||||
Button("添加") {
|
||||
addStreamURL()
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
} message: {
|
||||
Text("输入 M3U8、MP4 或其他流媒体地址")
|
||||
}
|
||||
.keyboardShortcut(.escape, action: {
|
||||
if engine.isFullscreen { engine.toggleFullscreen() }
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - 侧边栏
|
||||
|
||||
private var playlistSidebar: some View {
|
||||
VStack(spacing: 0) {
|
||||
PlaylistView()
|
||||
|
||||
Divider()
|
||||
|
||||
// 底部工具栏: 添加流URL + 循环模式
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
urlInput = ""
|
||||
showURLInput = true
|
||||
} label: {
|
||||
Label("添加流 URL", systemImage: "link")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 循环模式
|
||||
Button {
|
||||
engine.repeatMode = engine.repeatMode.next
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: engine.repeatMode.icon)
|
||||
Text(repeatModeText)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(engine.repeatMode == .none ? .secondary : .blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var repeatModeText: String {
|
||||
switch engine.repeatMode {
|
||||
case .none: return "不循环"
|
||||
case .single: return "单曲"
|
||||
case .all: return "列表"
|
||||
}
|
||||
}
|
||||
|
||||
private func addStreamURL() {
|
||||
guard !urlInput.isEmpty, let url = URL(string: urlInput) else { return }
|
||||
engine.addToQueue(urls: [url])
|
||||
}
|
||||
}
|
||||
|
||||
// ESC 快捷键辅助
|
||||
extension View {
|
||||
func keyboardShortcut(_ key: KeyEquivalent, action: @escaping () -> Void) -> some View {
|
||||
self.background(
|
||||
KeyboardShortcutView(key: key, action: action)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyboardShortcutView: View {
|
||||
let key: KeyEquivalent
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
EmptyView()
|
||||
}
|
||||
.keyboardShortcut(key, modifiers: [])
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
@ -1,31 +1,138 @@
|
||||
import SwiftUI
|
||||
import SwiftBricks
|
||||
|
||||
@main
|
||||
struct MiniPlayerApp: App {
|
||||
@StateObject private var engine = PlayerEngine()
|
||||
@StateObject private var bridge = PlayerBridge()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(engine)
|
||||
ContentView(bridge: bridge)
|
||||
.frame(minWidth: 640, minHeight: 480)
|
||||
.onAppear { bridge.setup() }
|
||||
}
|
||||
#if os(macOS)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
CommandMenu("播放") {
|
||||
Button("下一个") { engine.playNext() }
|
||||
.keyboardShortcut(.rightArrow, modifiers: [.command])
|
||||
Button("上一个") { engine.playPrevious() }
|
||||
.keyboardShortcut(.leftArrow, modifiers: [.command])
|
||||
Button("播放/暂停") { bridge.togglePlayPause() }
|
||||
.keyboardShortcut(" ", modifiers: [])
|
||||
Divider()
|
||||
Button("全屏") { engine.toggleFullscreen() }
|
||||
.keyboardShortcut("f", modifiers: [.command])
|
||||
Button("上一首") { bridge.playPrev() }
|
||||
.keyboardShortcut("[", modifiers: [])
|
||||
Button("下一首") { bridge.playNext() }
|
||||
.keyboardShortcut("]", modifiers: [])
|
||||
Divider()
|
||||
Button("循环: 单曲") { engine.repeatMode = .single }
|
||||
Button("循环: 列表") { engine.repeatMode = .all }
|
||||
Button("循环: 关闭") { engine.repeatMode = .none }
|
||||
Button("全屏") { bridge.toggleFullscreen() }
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
Divider()
|
||||
Button("循环模式") { bridge.cycleRepeatMode() }
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
}
|
||||
CommandMenu("文件") {
|
||||
Button("打开文件...") { bridge.openFileDialog() }
|
||||
.keyboardShortcut("o", modifiers: .command)
|
||||
Divider()
|
||||
Button("添加URL...") { bridge.showURLDialog = true }
|
||||
.keyboardShortcut("u", modifiers: .command)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// 主内容视图 — 加载BricksJSON + 注册自定义widget
|
||||
struct ContentView: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let engine = bridge.engine {
|
||||
BricksView(schema: bridge.schema!, engine: engine)
|
||||
.overlay(alignment: .top) {
|
||||
if let error = bridge.toastMessage {
|
||||
Text(error)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.7))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
.padding(.top, 8)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $bridge.showURLDialog) {
|
||||
URLInputDialog(bridge: bridge)
|
||||
}
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $bridge.showTrackDialog) {
|
||||
TrackSelectDialog(bridge: bridge)
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
ProgressView("初始化...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// URL输入弹窗
|
||||
struct URLInputDialog: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
@State private var url = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("添加媒体URL").font(.headline)
|
||||
TextField("https://example.com/video.m3u8", text: $url)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Button("取消") { dismiss() }
|
||||
Spacer()
|
||||
Button("添加") {
|
||||
if !url.isEmpty {
|
||||
bridge.addURL(url)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(url.isEmpty)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
|
||||
/// 音轨选择弹窗
|
||||
struct TrackSelectDialog: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("选择音轨").font(.headline)
|
||||
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
|
||||
Button(action: {
|
||||
bridge.selectTrack(index: idx)
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
Text("Track \(idx + 1)")
|
||||
Spacer()
|
||||
if idx == bridge.currentTrackIndex {
|
||||
Text("✓").foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Divider()
|
||||
Button("关闭") { dismiss() }
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 250)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
// 媒体类型
|
||||
enum MediaType: String, Codable {
|
||||
case video, audio, stream
|
||||
}
|
||||
|
||||
// 循环模式
|
||||
enum RepeatMode: String, CaseIterable {
|
||||
case none // 不循环
|
||||
case single // 单曲循环
|
||||
case all // 列表循环
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .none: return "repeat"
|
||||
case .single: return "repeat.1"
|
||||
case .all: return "repeat"
|
||||
}
|
||||
}
|
||||
|
||||
var next: RepeatMode {
|
||||
switch self {
|
||||
case .none: return .single
|
||||
case .single: return .all
|
||||
case .all: return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体项
|
||||
struct MediaItem: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
let name: String
|
||||
let type: MediaType
|
||||
|
||||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
439
Sources/PlayerBridge.swift
Normal file
439
Sources/PlayerBridge.swift
Normal file
@ -0,0 +1,439 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftBricks
|
||||
|
||||
/// 播放队列项
|
||||
struct MediaItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
var url: URL
|
||||
var name: String
|
||||
var mediaType: String // video/audio/stream
|
||||
|
||||
static func == (lhs: MediaItem, rhs: MediaItem) -> Bool { lhs.id == rhs.id }
|
||||
}
|
||||
|
||||
/// 循环模式
|
||||
enum RepeatMode: String, CaseIterable {
|
||||
case none = "不循环"
|
||||
case single = "单曲循环"
|
||||
case all = "列表循环"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PlayerBridge — 连接AVPlayer与BricksEngine
|
||||
/// 管理播放队列、音轨、全屏、循环模式
|
||||
@MainActor
|
||||
final class PlayerBridge: ObservableObject {
|
||||
|
||||
// MARK: - Published状态
|
||||
|
||||
@Published var engine: BricksEngine?
|
||||
@Published var schema: ControlSchema?
|
||||
@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
|
||||
|
||||
// MARK: - 内部状态
|
||||
|
||||
let player = AVPlayer()
|
||||
var queue: [MediaItem] = []
|
||||
var currentIndex: Int = -1
|
||||
var repeatMode: RepeatMode = .all
|
||||
|
||||
private var timeObserver: Any?
|
||||
private var endObserver: Any?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
func setup() {
|
||||
let eng = BricksEngine()
|
||||
|
||||
// 注册自定义widget
|
||||
eng.registerWidget(type: "VideoPlayer") { [weak self] schema, engine in
|
||||
AnyView(VideoPlayerWidget(bridge: self!, schema: schema, engine: engine))
|
||||
}
|
||||
eng.registerWidget(type: "ProgressSlider") { [weak self] schema, engine in
|
||||
AnyView(ProgressSliderWidget(bridge: self!, schema: schema, engine: engine))
|
||||
}
|
||||
|
||||
engine = eng
|
||||
|
||||
// 加载player.json
|
||||
loadPlayerJSON(engine: eng)
|
||||
|
||||
// 注册事件监听
|
||||
registerEvents(engine: eng)
|
||||
|
||||
// 设置播放结束监听
|
||||
setupEndObserver()
|
||||
|
||||
// 设置时间监听
|
||||
setupTimeObserver()
|
||||
}
|
||||
|
||||
// MARK: - 加载JSON
|
||||
|
||||
private func loadPlayerJSON(engine: BricksEngine) {
|
||||
// 从Bundle加载player.json
|
||||
let bundle = Bundle.module
|
||||
|
||||
if let url = bundle.url(forResource: "player", withExtension: "json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let json = String(data: data, encoding: .utf8) {
|
||||
do {
|
||||
try engine.loadJSON(json)
|
||||
schema = engine.rootSchema
|
||||
} catch {
|
||||
showToast("JSON加载失败: \(error.localizedDescription)")
|
||||
}
|
||||
} else {
|
||||
// Fallback: 内嵌JSON
|
||||
let fallback = """
|
||||
{"id":"app","widgettype":"VBox","options":{"width":"100%","height":"100%"},"subwidgets":[
|
||||
{"widgettype":"VideoPlayer","id":"video_player","options":{"width":"100%","bgcolor":"#000"}},
|
||||
{"widgettype":"HBox","options":{"spacing":8,"alignItems":"center","padding":"8px"},
|
||||
"subwidgets":[
|
||||
{"widgettype":"Button","id":"btn_prev","options":{"label":"⏮","css":"text"},
|
||||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.prev"}]},
|
||||
{"widgettype":"Button","id":"btn_play","options":{"label":"▶️","css":"text"},
|
||||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.toggle"}]},
|
||||
{"widgettype":"Button","id":"btn_next","options":{"label":"⏭","css":"text"},
|
||||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.next"}]},
|
||||
{"widgettype":"Filler"},
|
||||
{"widgettype":"Button","id":"btn_repeat","options":{"label":"🔁","css":"text"},
|
||||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.cycle_repeat"}]},
|
||||
{"widgettype":"Button","id":"btn_fullscreen","options":{"label":"⛶","css":"text"},
|
||||
"binds":[{"wid":"self","event":"click","actiontype":"event","target":"player.fullscreen"}]}
|
||||
]},
|
||||
{"widgettype":"Text","id":"playlist_info","options":{"text":"播放列表: 空"}}
|
||||
]}
|
||||
"""
|
||||
do {
|
||||
try engine.loadJSON(fallback)
|
||||
schema = engine.rootSchema
|
||||
} catch {
|
||||
showToast("Fallback JSON失败: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 事件注册
|
||||
|
||||
private func registerEvents(engine: BricksEngine) {
|
||||
let bus = engine.eventBus
|
||||
|
||||
bus.on("player.toggle") { [weak self] _ in
|
||||
self?.togglePlayPause()
|
||||
}
|
||||
bus.on("player.prev") { [weak self] _ in
|
||||
self?.playPrev()
|
||||
}
|
||||
bus.on("player.next") { [weak self] _ in
|
||||
self?.playNext()
|
||||
}
|
||||
bus.on("player.cycle_repeat") { [weak self] _ in
|
||||
self?.cycleRepeatMode()
|
||||
}
|
||||
bus.on("player.fullscreen") { [weak self] _ in
|
||||
self?.toggleFullscreen()
|
||||
}
|
||||
bus.on("player.show_tracks") { [weak self] _ in
|
||||
self?.showTrackDialog = true
|
||||
}
|
||||
bus.on("player.add_url") { [weak self] data in
|
||||
if let url = data["url"] as? String, !url.isEmpty {
|
||||
self?.addURL(url)
|
||||
}
|
||||
}
|
||||
bus.on("player.open_file") { [weak self] _ in
|
||||
self?.openFileDialog()
|
||||
}
|
||||
bus.on("player.play_selected") { [weak self] data in
|
||||
if let index = data["index"] as? Int {
|
||||
self?.playIndex(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 播放控制
|
||||
|
||||
func togglePlayPause() {
|
||||
if player.timeControlStatus == .playing {
|
||||
player.pause()
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
let idx = (currentIndex + 1) % queue.count
|
||||
playIndex(idx)
|
||||
}
|
||||
}
|
||||
|
||||
func playIndex(_ index: Int) {
|
||||
guard index >= 0 && index < queue.count else { return }
|
||||
currentIndex = index
|
||||
let item = queue[index]
|
||||
|
||||
let playerItem = AVPlayerItem(url: item.url)
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
player.play()
|
||||
|
||||
// 加载音轨信息
|
||||
loadTrackInfo(playerItem)
|
||||
|
||||
// 更新UI
|
||||
updatePlayButton(isPlaying: true)
|
||||
updatePlaylistHighlight()
|
||||
|
||||
showToast("正在播放: \(item.name)")
|
||||
}
|
||||
|
||||
// MARK: - 播放结束处理
|
||||
|
||||
private func setupEndObserver() {
|
||||
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 {
|
||||
updatePlayButton(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) {
|
||||
guard let eng = engine, let item = player.currentItem else { return }
|
||||
|
||||
let current = time.seconds
|
||||
let total = item.duration.seconds
|
||||
|
||||
if total.isFinite && total > 0 {
|
||||
eng.store.setValue(id: "time_current", value: formatTime(current))
|
||||
eng.store.setValue(id: "time_total", value: formatTime(total))
|
||||
eng.store.setValue(id: "progress_slider", value: "\(current)/\(total)")
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite && seconds >= 0 else { return "00:00" }
|
||||
let m = Int(seconds) / 60
|
||||
let s = Int(seconds) % 60
|
||||
return String(format: "%02d:%02d", m, s)
|
||||
}
|
||||
|
||||
// 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)" }
|
||||
currentTrackIndex = 0
|
||||
|
||||
// 选择默认音轨
|
||||
if let defaultOpt = group.defaultOption,
|
||||
let idx = options.firstIndex(of: defaultOpt) {
|
||||
currentTrackIndex = idx
|
||||
}
|
||||
|
||||
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
|
||||
updateTrackLabel()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateTrackLabel() {
|
||||
let label = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||||
engine?.store.setValue(id: "track_label", value: label)
|
||||
}
|
||||
|
||||
// MARK: - 循环模式
|
||||
|
||||
func cycleRepeatMode() {
|
||||
repeatMode = repeatMode.next
|
||||
let label = "\(repeatMode.icon) \(repeatMode.rawValue)"
|
||||
engine?.store.setValue(id: "btn_repeat", value: label)
|
||||
showToast("循环模式: \(repeatMode.rawValue)")
|
||||
}
|
||||
|
||||
// MARK: - 全屏
|
||||
|
||||
func toggleFullscreen() {
|
||||
isFullscreen.toggle()
|
||||
#if os(macOS)
|
||||
if let window = NSApp.keyWindow {
|
||||
window.toggleFullScreen(nil)
|
||||
}
|
||||
#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")
|
||||
}
|
||||
}
|
||||
#elseif os(iOS)
|
||||
showURLDialog = true // iOS用URL输入代替文件选择
|
||||
#endif
|
||||
}
|
||||
|
||||
func addURL(_ urlString: String) {
|
||||
guard let url = URL(string: urlString) else {
|
||||
showToast("无效URL")
|
||||
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) {
|
||||
let item = MediaItem(id: UUID().uuidString, url: url, name: name, mediaType: type)
|
||||
queue.append(item)
|
||||
updatePlaylist()
|
||||
|
||||
// 如果队列为空或只有一个,自动播放
|
||||
if queue.count == 1 {
|
||||
playIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 播放列表UI更新
|
||||
|
||||
private func updatePlaylist() {
|
||||
guard let eng = engine else { return }
|
||||
|
||||
// 更新playlist_panel的内容
|
||||
if queue.isEmpty {
|
||||
eng.store.setValue(id: "playlist_empty", value: "暂无媒体,请添加文件或URL")
|
||||
} else {
|
||||
let list = queue.enumerated().map { idx, item in
|
||||
"\(idx + 1). \(item.name) [\(item.mediaType)]"
|
||||
}.joined(separator: "\n")
|
||||
eng.store.setValue(id: "playlist_empty", value: list)
|
||||
}
|
||||
|
||||
eng.store.setValue(id: "playlist_info", value: "播放列表: \(queue.count) 项")
|
||||
}
|
||||
|
||||
private func updatePlayButton(isPlaying: Bool) {
|
||||
let label = isPlaying ? "⏸" : "▶️"
|
||||
engine?.store.setValue(id: "btn_play", value: label)
|
||||
}
|
||||
|
||||
private func updatePlaylistHighlight() {
|
||||
// 高亮当前播放项(简化实现)
|
||||
}
|
||||
|
||||
// MARK: - Toast
|
||||
|
||||
private func showToast(_ message: String) {
|
||||
toastMessage = message
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
if self.toastMessage == message {
|
||||
self.toastMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer = timeObserver {
|
||||
player.removeTimeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,246 +0,0 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class PlayerEngine: ObservableObject {
|
||||
let player = AVPlayer()
|
||||
|
||||
@Published var queue: [MediaItem] = []
|
||||
@Published var currentIndex: Int = -1
|
||||
@Published var isPlaying: Bool = false
|
||||
@Published var currentTime: Double = 0
|
||||
@Published var duration: Double = 0
|
||||
@Published var repeatMode: RepeatMode = .all
|
||||
@Published var isFullscreen: Bool = false
|
||||
|
||||
// 音轨
|
||||
@Published var audioTracks: [AVMediaSelectionOption] = []
|
||||
@Published var selectedAudioTrack: AVMediaSelectionOption?
|
||||
|
||||
private var currentAsset: AVAsset?
|
||||
private var timeObserver: Any?
|
||||
private var itemObserver: NSObjectProtocol?
|
||||
private var bookmarks: [URL: Data] = [:]
|
||||
|
||||
var currentItem: MediaItem? {
|
||||
guard currentIndex >= 0, currentIndex < queue.count else { return nil }
|
||||
return queue[currentIndex]
|
||||
}
|
||||
|
||||
init() {
|
||||
let interval = CMTime(seconds: 0.25, preferredTimescale: 600)
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.currentTime = time.seconds
|
||||
if let dur = self.player.currentItem?.duration.seconds, dur.isFinite && dur > 0 {
|
||||
self.duration = dur
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.onItemFinished() }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let obs = timeObserver { player.removeTimeObserver(obs) }
|
||||
if let obs = itemObserver { NotificationCenter.default.removeObserver(obs) }
|
||||
}
|
||||
|
||||
// MARK: - 播放控制
|
||||
|
||||
func play() {
|
||||
player.play()
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
if isPlaying { pause() } else { play() }
|
||||
}
|
||||
|
||||
func seek(to seconds: Double) {
|
||||
player.seek(to: CMTime(seconds: seconds, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero)
|
||||
}
|
||||
|
||||
func seekForward(_ seconds: Double = 10) {
|
||||
seek(to: min(currentTime + seconds, duration))
|
||||
}
|
||||
|
||||
func seekBackward(_ seconds: Double = 10) {
|
||||
seek(to: max(currentTime - seconds, 0))
|
||||
}
|
||||
|
||||
// MARK: - 列表控制
|
||||
|
||||
func play(index: Int) {
|
||||
guard index >= 0, index < queue.count else { return }
|
||||
currentIndex = index
|
||||
loadAndPlay(item: queue[index])
|
||||
}
|
||||
|
||||
func playNext() {
|
||||
guard !queue.isEmpty else { return }
|
||||
let next = (currentIndex + 1) % queue.count
|
||||
play(index: next)
|
||||
}
|
||||
|
||||
func playPrevious() {
|
||||
guard !queue.isEmpty else { return }
|
||||
if currentTime > 3 {
|
||||
seek(to: 0)
|
||||
return
|
||||
}
|
||||
let prev = (currentIndex - 1 + queue.count) % queue.count
|
||||
play(index: prev)
|
||||
}
|
||||
|
||||
func addToQueue(urls: [URL]) {
|
||||
for url in urls {
|
||||
let type: MediaType = {
|
||||
let ext = url.pathExtension.lowercased()
|
||||
if ["m3u8", "m3u"].contains(ext) { return .stream }
|
||||
if ["mp3", "aac", "flac", "wav", "ogg", "m4a", "wma", "opus"].contains(ext) { return .audio }
|
||||
return .video
|
||||
}()
|
||||
let name = url.deletingPathExtension().lastPathComponent
|
||||
queue.append(MediaItem(url: url, name: name, type: type))
|
||||
}
|
||||
if currentIndex == -1, let first = queue.first {
|
||||
currentIndex = 0
|
||||
loadAndPlay(item: first)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(at offsets: IndexSet) {
|
||||
let removingCurrent = offsets.contains(currentIndex)
|
||||
queue.remove(atOffsets: offsets)
|
||||
if queue.isEmpty {
|
||||
currentIndex = -1
|
||||
pause()
|
||||
player.replaceCurrentItem(with: nil)
|
||||
duration = 0
|
||||
currentTime = 0
|
||||
} else if removingCurrent {
|
||||
currentIndex = min(currentIndex, queue.count - 1)
|
||||
play(index: currentIndex)
|
||||
} else {
|
||||
// Adjust index if items before current were removed
|
||||
let before = offsets.filter { $0 < currentIndex }.count
|
||||
currentIndex -= before
|
||||
}
|
||||
}
|
||||
|
||||
func move(from source: IndexSet, to destination: Int) {
|
||||
let oldCurrent = currentIndex
|
||||
queue.move(fromOffsets: source, toOffset: destination)
|
||||
// Recalculate currentIndex
|
||||
if let oldPos = queue.firstIndex(where: { $0.url == currentItem?.url }) {
|
||||
currentIndex = oldPos
|
||||
} else {
|
||||
currentIndex = oldCurrent
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 音轨
|
||||
|
||||
func selectAudioTrack(_ option: AVMediaSelectionOption) {
|
||||
guard let group = currentAsset?.mediaSelectionGroup(forMediaCharacteristic: .audible) else { return }
|
||||
player.currentItem?.select(option, in: group)
|
||||
selectedAudioTrack = option
|
||||
}
|
||||
|
||||
// MARK: - 全屏
|
||||
|
||||
func toggleFullscreen() {
|
||||
isFullscreen.toggle()
|
||||
}
|
||||
|
||||
// MARK: - 内部
|
||||
|
||||
private func onItemFinished() {
|
||||
switch repeatMode {
|
||||
case .single:
|
||||
seek(to: 0)
|
||||
play()
|
||||
case .all:
|
||||
playNext()
|
||||
case .none:
|
||||
if currentIndex < queue.count - 1 {
|
||||
playNext()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAndPlay(item: MediaItem) {
|
||||
#if os(iOS)
|
||||
// Start accessing security-scoped resource for local files
|
||||
if item.url.startAccessingSecurityScopedResource() {
|
||||
// Store bookmark for persistent access
|
||||
if let bookmark = try? item.url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) {
|
||||
bookmarks[item.url] = bookmark
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
let asset: AVAsset
|
||||
if item.type == .stream {
|
||||
asset = AVURLAsset(url: item.url)
|
||||
} else {
|
||||
asset = AVURLAsset(url: item.url)
|
||||
}
|
||||
|
||||
currentAsset = asset
|
||||
audioTracks = []
|
||||
selectedAudioTrack = nil
|
||||
|
||||
let playerItem = AVPlayerItem(asset: asset)
|
||||
player.replaceCurrentItem(with: playerItem)
|
||||
|
||||
// Load duration and audio tracks
|
||||
Task {
|
||||
do {
|
||||
let dur = try await asset.load(.duration)
|
||||
if dur.seconds.isFinite && dur.seconds > 0 {
|
||||
self.duration = dur.seconds
|
||||
}
|
||||
|
||||
let group = try await asset.loadMediaSelectionGroup(for: .audible)
|
||||
if let group {
|
||||
let options = group.options
|
||||
self.audioTracks = options
|
||||
self.selectedAudioTrack = options.first
|
||||
}
|
||||
} catch {
|
||||
print("Asset load error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
play()
|
||||
}
|
||||
|
||||
// MARK: - 时间格式化
|
||||
|
||||
static func formatTime(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite && seconds >= 0 else { return "0: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: "%d:%02d", m, s)
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
// 跨平台视频渲染层
|
||||
#if os(iOS)
|
||||
struct PlayerLayerView: UIViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeUIView(context: Context) -> PlayerUIView {
|
||||
let view = PlayerUIView()
|
||||
view.playerLayer.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PlayerUIView, context: Context) {
|
||||
uiView.playerLayer.player = player
|
||||
}
|
||||
|
||||
class PlayerUIView: UIView {
|
||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
backgroundColor = .black
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct PlayerLayerView: NSViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeNSView(context: Context) -> PlayerNSView {
|
||||
let view = PlayerNSView()
|
||||
view.playerLayer.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: PlayerNSView, context: Context) {
|
||||
nsView.playerLayer.player = player
|
||||
}
|
||||
|
||||
class PlayerNSView: NSView {
|
||||
let playerLayer = AVPlayerLayer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
wantsLayer = true
|
||||
layer = playerLayer
|
||||
layer?.backgroundColor = NSColor.black.cgColor
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -1,241 +0,0 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
struct PlayerView: View {
|
||||
@EnvironmentObject var engine: PlayerEngine
|
||||
@State private var showControls = true
|
||||
@State private var isSeeking = false
|
||||
@State private var controlsTimer: Timer?
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
if engine.currentItem != nil {
|
||||
PlayerLayerView(player: engine.player)
|
||||
.onTapGesture {
|
||||
toggleControls()
|
||||
}
|
||||
|
||||
if showControls {
|
||||
controlsOverlay
|
||||
.transition(.opacity)
|
||||
}
|
||||
} else {
|
||||
emptyState
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.all, edges: engine.isFullscreen ? .all : [])
|
||||
.onAppear {
|
||||
focused = true
|
||||
resetControlsTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 空状态
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "play.rectangle.on.rectangle")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("添加文件开始播放")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("支持 MP4, MKV, AVI, M4V, MOV, MP3, FLAC, M3U8 等格式")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 控制层
|
||||
|
||||
private var controlsOverlay: some View {
|
||||
VStack {
|
||||
// 顶部栏: 标题 + 全屏按钮
|
||||
HStack {
|
||||
if let item = engine.currentItem {
|
||||
Text(item.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
}
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
engine.toggleFullscreen()
|
||||
} label: {
|
||||
Image(systemName: engine.isFullscreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right")
|
||||
.font(.title3)
|
||||
.padding(10)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 底部控制区
|
||||
VStack(spacing: 12) {
|
||||
// 进度条
|
||||
progressSection
|
||||
// 按钮行
|
||||
buttonRow
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.clear, .black.opacity(0.6)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 进度条
|
||||
|
||||
private var progressSection: some View {
|
||||
VStack(spacing: 4) {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { isSeeking ? engine.currentTime : engine.currentTime },
|
||||
set: { newValue in
|
||||
engine.currentTime = newValue
|
||||
isSeeking = true
|
||||
}
|
||||
),
|
||||
in: 0...max(engine.duration, 0.1)
|
||||
) { editing in
|
||||
if !editing {
|
||||
engine.seek(to: engine.currentTime)
|
||||
isSeeking = false
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.tint(.white)
|
||||
#else
|
||||
.tint(.accentColor)
|
||||
#endif
|
||||
|
||||
HStack {
|
||||
Text(PlayerEngine.formatTime(engine.currentTime))
|
||||
.font(.caption.monospacedDigit())
|
||||
Spacer()
|
||||
Text(PlayerEngine.formatTime(engine.duration))
|
||||
.font(.caption.monospacedDigit())
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 按钮行
|
||||
|
||||
private var buttonRow: some View {
|
||||
HStack(spacing: 24) {
|
||||
// 音轨
|
||||
Menu {
|
||||
ForEach(Array(engine.audioTracks.enumerated()), id: \.offset) { idx, track in
|
||||
Button {
|
||||
engine.selectAudioTrack(track)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(track.displayName.isEmpty ? "音轨 \(idx + 1)" : track.displayName)
|
||||
if track == engine.selectedAudioTrack {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "waveform")
|
||||
.font(.title3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(engine.audioTracks.count <= 1)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 上一个
|
||||
Button { engine.playPrevious() } label: {
|
||||
Image(systemName: "backward.fill")
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// 快退
|
||||
Button { engine.seekBackward() } label: {
|
||||
Image(systemName: "gobackward.10")
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// 播放/暂停
|
||||
Button { engine.togglePlay() } label: {
|
||||
Image(systemName: engine.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 44))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// 快进
|
||||
Button { engine.seekForward() } label: {
|
||||
Image(systemName: "goforward.10")
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// 下一个
|
||||
Button { engine.playNext() } label: {
|
||||
Image(systemName: "forward.fill")
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 循环
|
||||
Button {
|
||||
engine.repeatMode = engine.repeatMode.next
|
||||
} label: {
|
||||
Image(systemName: engine.repeatMode.icon)
|
||||
.font(.title3)
|
||||
.overlay(alignment: .bottom) {
|
||||
if engine.repeatMode != .none {
|
||||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: 4, height: 4)
|
||||
.offset(y: -4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
// MARK: - 辅助
|
||||
|
||||
private func toggleControls() {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showControls.toggle()
|
||||
}
|
||||
resetControlsTimer()
|
||||
}
|
||||
|
||||
private func resetControlsTimer() {
|
||||
controlsTimer?.invalidate()
|
||||
controlsTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: false) { _ in
|
||||
Task { @MainActor in
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
showControls = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct PlaylistView: View {
|
||||
@EnvironmentObject var engine: PlayerEngine
|
||||
@State private var showFileImporter = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 顶部工具栏
|
||||
HStack {
|
||||
Text("播放列表")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(engine.queue.count)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary, in: Capsule())
|
||||
|
||||
Button {
|
||||
showFileImporter = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if !engine.queue.isEmpty {
|
||||
Button {
|
||||
engine.queue.removeAll()
|
||||
engine.currentIndex = -1
|
||||
engine.pause()
|
||||
engine.player.replaceCurrentItem(with: nil)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
// 列表
|
||||
if engine.queue.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "music.note.list")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("列表为空")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
Button("添加文件") {
|
||||
showFileImporter = true
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List {
|
||||
ForEach(Array(engine.queue.enumerated()), id: \.element.id) { idx, item in
|
||||
playlistRow(item: item, index: idx)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
engine.play(index: idx)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
engine.remove(at: offsets)
|
||||
}
|
||||
.onMove { source, destination in
|
||||
engine.move(from: source, to: destination)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showFileImporter,
|
||||
allowedContentTypes: supportedTypes,
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
if case .success(let urls) = result {
|
||||
engine.addToQueue(urls: urls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 列表行
|
||||
|
||||
@ViewBuilder
|
||||
private func playlistRow(item: MediaItem, index: Int) -> some View {
|
||||
let isCurrent = index == engine.currentIndex
|
||||
|
||||
HStack(spacing: 10) {
|
||||
// 序号/播放指示
|
||||
ZStack {
|
||||
if isCurrent {
|
||||
Image(systemName: engine.isPlaying ? "waveform" : "pause.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.blue)
|
||||
.symbolEffect(.variableColor.iterative, isActive: engine.isPlaying && isCurrent)
|
||||
} else {
|
||||
Text("\(index + 1)")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(width: 20)
|
||||
|
||||
// 类型图标
|
||||
Image(systemName: iconName(for: item.type))
|
||||
.font(.caption)
|
||||
.foregroundStyle(isCurrent ? .blue : .secondary)
|
||||
|
||||
// 文件名
|
||||
Text(item.name)
|
||||
.font(.system(size: 13))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(isCurrent ? .primary : .secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.listRowBackground(isCurrent ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||
}
|
||||
|
||||
private func iconName(for type: MediaType) -> String {
|
||||
switch type {
|
||||
case .video: return "film"
|
||||
case .audio: return "music.note"
|
||||
case .stream: return "dot.radiowaves.left.and.right"
|
||||
}
|
||||
}
|
||||
|
||||
// 支持的文件类型
|
||||
private var supportedTypes: [UTType] {
|
||||
var types: [UTType] = [
|
||||
.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .mp3,
|
||||
.mpeg4Audio, .avi, .url
|
||||
]
|
||||
// 扩展常见格式
|
||||
for ext in ["mkv", "flac", "wav", "ogg", "m3u8", "m3u", "ts", "mov", "webm"] {
|
||||
if let uttype = UTType(filenameExtension: ext) {
|
||||
types.append(uttype)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
}
|
||||
72
Sources/ProgressSlider.swift
Normal file
72
Sources/ProgressSlider.swift
Normal file
@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import SwiftBricks
|
||||
|
||||
/// ProgressSlider自定义Widget — 播放进度条+拖动seek
|
||||
struct ProgressSliderWidget: View {
|
||||
let bridge: PlayerBridge
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
@State private var progress: Double = 0
|
||||
@State private var duration: Double = 0
|
||||
@State private var isDragging: Bool = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
// 背景轨道
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.secondary.opacity(0.3))
|
||||
.frame(height: 4)
|
||||
|
||||
// 已播放进度
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: geo.size.width * progressRatio, height: 4)
|
||||
|
||||
// 拖动滑块
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 14, height: 14)
|
||||
.offset(x: geo.size.width * progressRatio - 7)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
.frame(height: 20)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
isDragging = true
|
||||
let ratio = max(0, min(1, value.location.x / geo.size.width))
|
||||
if duration > 0 {
|
||||
let seekTime = ratio * duration
|
||||
bridge.player.seek(to: CMTime(seconds: seekTime, preferredTimescale: 600))
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
isDragging = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: 20)
|
||||
.onReceive(engine.store.$values) { values in
|
||||
if let val = values["progress_slider"] as? String {
|
||||
let parts = val.split(separator: "/")
|
||||
if parts.count == 2,
|
||||
let cur = Double(parts[0]),
|
||||
let total = Double(parts[1]) {
|
||||
if !isDragging {
|
||||
progress = cur
|
||||
duration = total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progressRatio: Double {
|
||||
guard duration > 0 else { return 0 }
|
||||
return max(0, min(1, progress / duration))
|
||||
}
|
||||
}
|
||||
109
Sources/Resources/player.json
Normal file
109
Sources/Resources/player.json
Normal file
@ -0,0 +1,109 @@
|
||||
{
|
||||
"id": "app",
|
||||
"widgettype": "VBox",
|
||||
"options": { "width": "100%", "height": "100%", "spacing": 0, "padding": "0" },
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "VideoPlayer",
|
||||
"id": "video_player",
|
||||
"options": { "width": "100%", "bgcolor": "#000000" },
|
||||
"binds": [
|
||||
{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.toggle" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": { "width": "100%", "css": "card", "padding": "8px", "spacing": 4 },
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": { "width": "100%", "spacing": 6, "alignItems": "center" },
|
||||
"subwidgets": [
|
||||
{ "widgettype": "Text", "id": "time_current", "options": { "text": "00:00", "i18n": false } },
|
||||
{ "widgettype": "ProgressSlider", "id": "progress_slider", "options": { "width": "100%" } },
|
||||
{ "widgettype": "Text", "id": "time_total", "options": { "text": "00:00", "i18n": false } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": { "width": "100%", "spacing": 4, "alignItems": "center" },
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Button", "id": "btn_prev",
|
||||
"options": { "label": "⏮", "css": "text" },
|
||||
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.prev" }]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button", "id": "btn_play",
|
||||
"options": { "label": "▶️", "css": "text" },
|
||||
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.toggle" }]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button", "id": "btn_next",
|
||||
"options": { "label": "⏭", "css": "text" },
|
||||
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.next" }]
|
||||
},
|
||||
{ "widgettype": "Filler" },
|
||||
{
|
||||
"widgettype": "Text", "id": "track_label",
|
||||
"options": { "text": "🎵 Track 1", "i18n": false }
|
||||
},
|
||||
{
|
||||
"widgettype": "Button", "id": "btn_track",
|
||||
"options": { "label": "音轨", "css": "text" },
|
||||
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.show_tracks" }]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button", "id": "btn_repeat",
|
||||
"options": { "label": "🔁 列表循环", "css": "text" },
|
||||
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.cycle_repeat" }]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button", "id": "btn_fullscreen",
|
||||
"options": { "label": "⛶", "css": "text" },
|
||||
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.fullscreen" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "HBox",
|
||||
"options": { "width": "100%", "padding": "4px", "spacing": 6, "alignItems": "center" },
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "InlineForm",
|
||||
"id": "add_form",
|
||||
"options": {
|
||||
"show_label": false,
|
||||
"submit_label": "添加URL",
|
||||
"fields": [
|
||||
{ "name": "url", "placeholder": "M3U8 / 视频URL", "uitype": "str", "cwidth": 30 }
|
||||
]
|
||||
},
|
||||
"binds": [{ "wid": "self", "event": "submit", "actiontype": "event", "target": "player.add_url" }]
|
||||
},
|
||||
{
|
||||
"widgettype": "Button", "id": "btn_open_file",
|
||||
"options": { "label": "📂 打开文件", "css": "text" },
|
||||
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.open_file" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"widgettype": "VScrollPanel",
|
||||
"id": "playlist_panel",
|
||||
"options": { "width": "100%", "css": "filler" },
|
||||
"subwidgets": [
|
||||
{
|
||||
"widgettype": "Title5",
|
||||
"options": { "text": "播放列表", "i18n": true }
|
||||
},
|
||||
{
|
||||
"widgettype": "Text", "id": "playlist_empty",
|
||||
"options": { "text": "暂无媒体,请添加文件或URL", "i18n": true, "color": "#888888" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
97
Sources/VideoPlayerView.swift
Normal file
97
Sources/VideoPlayerView.swift
Normal file
@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import SwiftBricks
|
||||
|
||||
/// VideoPlayer自定义Widget — 注册到BricksEngine的"VideoPlayer"类型
|
||||
struct VideoPlayerWidget: View {
|
||||
let bridge: PlayerBridge
|
||||
let schema: ControlSchema
|
||||
@ObservedObject var engine: BricksEngine
|
||||
|
||||
var body: some View {
|
||||
VideoPlayerRepresentable(player: bridge.player)
|
||||
.background(Color.black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(16/9, contentMode: .fit)
|
||||
.onTapGesture(count: 2) {
|
||||
bridge.toggleFullscreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 跨平台AVPlayer渲染
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
struct VideoPlayerRepresentable: UIViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeUIView(context: Context) -> PlayerUIView {
|
||||
let view = PlayerUIView()
|
||||
view.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PlayerUIView, context: Context) {
|
||||
uiView.player = player
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerUIView: UIView {
|
||||
override static var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
|
||||
var player: AVPlayer? {
|
||||
get { (layer as? AVPlayerLayer)?.player }
|
||||
set { (layer as? AVPlayerLayer)?.player = newValue }
|
||||
}
|
||||
|
||||
override var contentMode: UIView.ContentMode {
|
||||
get { (layer as? AVPlayerLayer)?.videoGravity == .resizeAspectFill ? .scaleAspectFill : .scaleAspectFit }
|
||||
set {
|
||||
(layer as? AVPlayerLayer)?.videoGravity = newValue == .scaleAspectFill ? .resizeAspectFill : .resizeAspect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
|
||||
struct VideoPlayerRepresentable: NSViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeNSView(context: Context) -> PlayerNSView {
|
||||
let view = PlayerNSView()
|
||||
view.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: PlayerNSView, context: Context) {
|
||||
nsView.player = player
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerNSView: NSView {
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
wantsLayer = true
|
||||
let playerLayer = AVPlayerLayer()
|
||||
playerLayer.videoGravity = .resizeAspect
|
||||
layer = playerLayer
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
(layer as? AVPlayerLayer)?.frame = bounds
|
||||
}
|
||||
|
||||
var player: AVPlayer? {
|
||||
get { (layer as? AVPlayerLayer)?.player }
|
||||
set { (layer as? AVPlayerLayer)?.player = newValue }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Loading…
x
Reference in New Issue
Block a user