feat: hidden toolbar with logo toggle, semi-transparent controls, playlist popup window
This commit is contained in:
parent
4e58377582
commit
4b94f11664
@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import SwiftBricks
|
||||
import AVFoundation
|
||||
|
||||
@main
|
||||
struct MiniPlayerApp: App {
|
||||
@ -41,41 +42,214 @@ struct MiniPlayerApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主内容视图 — 加载BricksJSON + 注册自定义widget
|
||||
// MARK: - 主内容视图
|
||||
struct ContentView: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
ZStack {
|
||||
// 视频背景
|
||||
if let engine = bridge.engine, let schema = bridge.schema {
|
||||
BricksView(schema: 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("初始化...")
|
||||
Color.black
|
||||
}
|
||||
|
||||
// Logo 图标(左上角)
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: { bridge.showToolbar.toggle() }) {
|
||||
Text("🎬")
|
||||
.font(.system(size: 28))
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.top, 12)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// 底部 Toolbar(半透明)
|
||||
if bridge.showToolbar {
|
||||
VStack {
|
||||
Spacer()
|
||||
ControlToolbar(bridge: bridge)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
// Toast
|
||||
if let msg = bridge.toastMessage {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(msg)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.7))
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
.padding(.bottom, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.sheet(isPresented: $bridge.showURLDialog) {
|
||||
URLInputDialog(bridge: bridge)
|
||||
}
|
||||
#if os(macOS)
|
||||
.sheet(isPresented: $bridge.showTrackDialog) {
|
||||
TrackSelectDialog(bridge: bridge)
|
||||
}
|
||||
#endif
|
||||
.animation(.easeInOut(duration: 0.25), value: bridge.showToolbar)
|
||||
}
|
||||
}
|
||||
|
||||
/// URL输入弹窗
|
||||
// MARK: - 底部控制栏
|
||||
struct ControlToolbar: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
// 进度条行
|
||||
HStack(spacing: 8) {
|
||||
Text(bridge.currentTimeText)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 44, alignment: .trailing)
|
||||
|
||||
ProgressSliderWidget(bridge: bridge, schema: dummySchema, engine: bridge.engine!)
|
||||
.frame(height: 20)
|
||||
|
||||
Text(bridge.totalTimeText)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 44, alignment: .leading)
|
||||
}
|
||||
|
||||
// 按钮行
|
||||
HStack(spacing: 10) {
|
||||
ToolbarButton(label: "📂 打开") { bridge.openFileDialog() }
|
||||
ToolbarButton(label: "⏮") { bridge.playPrev() }
|
||||
ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause() }
|
||||
ToolbarButton(label: "⏭") { bridge.playNext() }
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(bridge.currentTrackLabel)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
|
||||
ToolbarButton(label: "🎵 音轨") { bridge.showTrackDialog = true }
|
||||
ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.rawValue)") { bridge.cycleRepeatMode() }
|
||||
ToolbarButton(label: "⛶") { bridge.toggleFullscreen() }
|
||||
ToolbarButton(label: "📋 列表 (\(bridge.queue.count))") { bridge.togglePlaylistWindow() }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.black.opacity(0.55))
|
||||
}
|
||||
|
||||
private var dummySchema: ControlSchema {
|
||||
ControlSchema(id: "progress_slider", widgettype: "ProgressSlider", options: ControlOptions(), binds: nil, subwidgets: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 半透明按钮
|
||||
struct ToolbarButton: View {
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(hovering ? .white.opacity(0.15) : .clear)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovering = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 播放列表弹窗
|
||||
struct PlaylistWindowView: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 顶部操作栏
|
||||
HStack {
|
||||
Button("📂 添加文件") { bridge.openFileDialog() }
|
||||
Button("🔗 添加URL") { bridge.showURLDialog = true }
|
||||
Spacer()
|
||||
Text("\(bridge.queue.count) 项")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.regularMaterial)
|
||||
|
||||
Divider()
|
||||
|
||||
// 列表
|
||||
if bridge.queue.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("暂无媒体,请添加文件或URL")
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 2) {
|
||||
ForEach(Array(bridge.queue.enumerated()), id: \.element.id) { idx, item in
|
||||
HStack {
|
||||
if idx == bridge.currentIndex {
|
||||
Text("▶").foregroundColor(.accentColor).font(.caption)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.name)
|
||||
.font(.system(size: 13))
|
||||
.lineLimit(1)
|
||||
Text(item.mediaType)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
bridge.removeItem(at: idx)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(idx == bridge.currentIndex ? Color.accentColor.opacity(0.15) : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
bridge.playIndex(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 350, minHeight: 400)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL输入弹窗
|
||||
struct URLInputDialog: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
@State private var url = ""
|
||||
@ -104,7 +278,7 @@ struct URLInputDialog: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 音轨选择弹窗
|
||||
// MARK: - 音轨选择弹窗
|
||||
struct TrackSelectDialog: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@ -51,6 +51,14 @@ final class PlayerBridge: ObservableObject {
|
||||
@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"
|
||||
|
||||
// 播放列表窗口
|
||||
private var playlistWindow: NSWindow?
|
||||
|
||||
// MARK: - 内部状态
|
||||
|
||||
@ -99,6 +107,9 @@ final class PlayerBridge: ObservableObject {
|
||||
|
||||
// 设置时间监听
|
||||
setupTimeObserver()
|
||||
|
||||
// 监听播放状态
|
||||
setupPlaybackStatusObserver()
|
||||
}
|
||||
|
||||
// MARK: - 加载UI定义文件
|
||||
@ -306,8 +317,11 @@ final class PlayerBridge: ObservableObject {
|
||||
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)")
|
||||
currentTimeText = formatTime(current)
|
||||
totalTimeText = formatTime(total)
|
||||
} else {
|
||||
eng.store.setValue(id: "time_current", value: formatTime(current))
|
||||
currentTimeText = formatTime(current)
|
||||
}
|
||||
}
|
||||
|
||||
@ -318,6 +332,7 @@ final class PlayerBridge: ObservableObject {
|
||||
if secs.isFinite && secs > 0 {
|
||||
cachedDuration = secs
|
||||
engine?.store.setValue(id: "time_total", value: formatTime(secs))
|
||||
totalTimeText = formatTime(secs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -375,6 +390,7 @@ final class PlayerBridge: ObservableObject {
|
||||
private func updateTrackLabel() {
|
||||
let label = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||||
engine?.store.setValue(id: "track_label", value: label)
|
||||
currentTrackLabel = label
|
||||
}
|
||||
|
||||
// MARK: - 循环模式
|
||||
@ -482,15 +498,75 @@ final class PlayerBridge: ObservableObject {
|
||||
eng.store.setValue(id: "playlist_info", value: "播放列表: \(queue.count) 项")
|
||||
}
|
||||
|
||||
private func updatePlayButton(isPlaying: Bool) {
|
||||
let label = isPlaying ? "⏸" : "▶️"
|
||||
private func updatePlayButton(isPlaying playing: Bool) {
|
||||
let label = playing ? "⏸" : "▶️"
|
||||
engine?.store.setValue(id: "btn_play", value: label)
|
||||
isPlaying = playing
|
||||
}
|
||||
|
||||
private func updatePlaylistHighlight() {
|
||||
// 高亮当前播放项(简化实现)
|
||||
}
|
||||
|
||||
// MARK: - 播放状态监听
|
||||
|
||||
private func setupPlaybackStatusObserver() {
|
||||
player.publisher(for: \.timeControlStatus)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] status in
|
||||
guard let self = self else { return }
|
||||
self.isPlaying = (status == .playing)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - 播放列表窗口
|
||||
|
||||
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 = "播放列表 (\(queue.count))"
|
||||
win.isReleasedWhenClosed = false
|
||||
win.center()
|
||||
|
||||
let hostView = NSHostingView(rootView: PlaylistWindowView(bridge: self))
|
||||
win.contentView = hostView
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
playlistWindow = win
|
||||
#endif
|
||||
}
|
||||
|
||||
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"
|
||||
} else {
|
||||
currentIndex = min(index, queue.count - 1)
|
||||
playIndex(currentIndex)
|
||||
}
|
||||
} else if index < currentIndex {
|
||||
currentIndex -= 1
|
||||
}
|
||||
updatePlaylist()
|
||||
}
|
||||
|
||||
// MARK: - Toast
|
||||
|
||||
private func showToast(_ message: String) {
|
||||
|
||||
@ -7,63 +7,6 @@
|
||||
"widgettype": "VideoPlayer",
|
||||
"id": "video_player",
|
||||
"options": { "width": "100%", "height": "100%", "bgcolor": "#000000" }
|
||||
},
|
||||
{
|
||||
"widgettype": "VBox",
|
||||
"options": { "width": "100%", "bgcolor": "#1a1a1a", "padding": "6px", "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_open_file",
|
||||
"options": { "label": "📂 打开", "css": "text" },
|
||||
"binds": [{ "wid": "self", "event": "click", "actiontype": "event", "target": "player.open_file" }]
|
||||
},
|
||||
{
|
||||
"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_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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user