From 4b94f11664ed86c6e62b9f1f79a9dd430f81fae9 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sun, 21 Jun 2026 23:43:20 +0800 Subject: [PATCH] feat: hidden toolbar with logo toggle, semi-transparent controls, playlist popup window --- Sources/MiniPlayerApp.swift | 222 ++++++++++++++++++++++++++++++++---- Sources/PlayerBridge.swift | 80 ++++++++++++- Sources/Resources/player.ui | 57 --------- 3 files changed, 276 insertions(+), 83 deletions(-) diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index 85dce52..19eca2c 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -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 diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index 5379a4e..19477e3 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -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) { diff --git a/Sources/Resources/player.ui b/Sources/Resources/player.ui index c56e05c..d09c735 100644 --- a/Sources/Resources/player.ui +++ b/Sources/Resources/player.ui @@ -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" }] - } - ] - } - ] } ] }