MiniPlayer/Sources/MiniPlayerApp.swift
yumoqing 20f4dec0b9 feat: fullscreen video + click icon to toggle control toolbar
- Video fills entire window via VideoPlayerRepresentable
- MiniPlayerIcon in top-left corner toggles ControlToolbar visibility
- ControlToolbar slides up from bottom with animation
- Removed Bricks JSON UI dependency for main view
- All toolbar features preserved: open/prev/play/next/volume/tracks/repeat/fullscreen/playlist
2026-06-22 20:47:27 +08:00

390 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
import SwiftBricks
@main
struct MiniPlayerApp: App {
@StateObject private var bridge = PlayerBridge()
@StateObject private var engine = BricksEngine()
var body: some Scene {
WindowGroup {
BricksAppView(bridge: bridge, engine: engine)
.frame(minWidth: 900, minHeight: 600)
.onAppear {
bridge.setup()
setupBridgeEvents()
loadI18n()
}
}
#if os(macOS)
.commands {
CommandGroup(replacing: .newItem) {}
CommandMenu(L.playback) {
Button(L.playPause) { bridge.togglePlayPause() }
.keyboardShortcut(" ", modifiers: [])
Divider()
Button(L.prev) { bridge.playPrev() }
.keyboardShortcut("[", modifiers: [])
Button(L.next) { bridge.playNext() }
.keyboardShortcut("]", modifiers: [])
Divider()
Button(L.fullscreen) { bridge.toggleFullscreen() }
.keyboardShortcut("f", modifiers: .command)
Divider()
Button(L.volUp) { bridge.adjustVolume(by: 0.1) }
.keyboardShortcut("=", modifiers: .command)
Button(L.volDown) { bridge.adjustVolume(by: -0.1) }
.keyboardShortcut("-", modifiers: .command)
Divider()
Button(L.repeatMode) { bridge.cycleRepeatMode() }
.keyboardShortcut("r", modifiers: .command)
}
CommandMenu(L.file) {
Button(L.openFile) { bridge.openFileDialog() }
.keyboardShortcut("o", modifiers: .command)
Divider()
Button(L.addURL) { bridge.showURLDialog = true }
.keyboardShortcut("u", modifiers: .command)
}
}
#endif
}
// BricksEngine PlayerBridge
private func setupBridgeEvents() {
// PlayerBridge AVPlayer Store VideoPlayerControl
engine.store.setValue(id: "videoPlayer.player", value: bridge.player)
// Store Input
engine.eventBus.on("videoPlayer.setURL") { [weak bridge, weak engine] data in
guard let bridge, let engine else { return }
// URL Input widget store
var url: String? = data["url"] as? String
if url == nil || url?.isEmpty == true {
url = engine.store.getValue(id: "videoUrlInput") as? String
}
if let url, !url.isEmpty {
bridge.addURL(url)
}
}
// seek AVPlayer
engine.eventBus.on("videoPlayer.seek") { [weak bridge] data in
guard let bridge else { return }
if let timeStr = data["time"] as? String, let time = Double(timeStr) {
bridge.player.seek(to: CMTime(seconds: time, preferredTimescale: 600))
}
}
//
engine.eventBus.on("videoPlayer.setVolume") { [weak bridge] data in
guard let bridge else { return }
if let volStr = data["volume"] as? String, let vol = Float(volStr) {
bridge.setVolume(vol)
}
}
//
engine.eventBus.on("videoPlayer.fullscreen") { [weak bridge] data in
guard let bridge else { return }
bridge.toggleFullscreen()
}
// /
engine.eventBus.on("videoPlayer.prev") { [weak bridge] data in
guard let bridge else { return }
bridge.playPrev()
}
engine.eventBus.on("videoPlayer.next") { [weak bridge] data in
guard let bridge else { return }
bridge.playNext()
}
//
engine.eventBus.on("videoPlayer.openFile") { [weak bridge] data in
guard let bridge else { return }
bridge.openFileDialog()
}
}
// i18n
private func loadI18n() {
//
let locale = Locale.current.language.languageCode?.identifier ?? "en"
let langFile = (locale == "zh") ? "zh" : "en"
if let url = Bundle.module.url(forResource: "i18n/\(langFile)", withExtension: "json"),
let data = try? Data(contentsOf: url),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: String] {
engine.i18n.loadMessages(dict)
engine.i18n.locale = langFile
}
}
}
// MARK: - BricksView wrapper
struct BricksAppView: View {
@ObservedObject var bridge: PlayerBridge
@ObservedObject var engine: BricksEngine
@State private var showToolbar = false
@State private var isHoveringIcon = false
var body: some View {
ZStack(alignment: .topLeading) {
//
VideoPlayerRepresentable(player: bridge.player)
.ignoresSafeArea()
//
MiniPlayerIcon()
.frame(width: 32, height: 32)
.padding(12)
.opacity(isHoveringIcon ? 1.0 : 0.6)
.onHover { isHoveringIcon = $0 }
.onTapGesture {
withAnimation(.easeInOut(duration: 0.25)) {
showToolbar.toggle()
}
}
// /
VStack {
Spacer()
if showToolbar {
ControlToolbar(bridge: bridge, isHovering: .constant(false)) {
// onTouch:
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
.sheet(isPresented: $bridge.showURLDialog) {
URLInputDialog(bridge: bridge)
}
.sheet(isPresented: $bridge.showTrackDialog) {
TrackSelectDialog(bridge: bridge)
}
.sheet(isPresented: $bridge.showPlaylistSheet) {
PlaylistWindowView(bridge: bridge)
}
}
}
// MARK: -
struct ControlToolbar: View {
@ObservedObject var bridge: PlayerBridge
@Binding var isHovering: Bool
let onTouch: () -> Void
var body: some View {
VStack(spacing: 6) {
//
HStack(spacing: 8) {
Text(bridge.currentTimeText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white)
.frame(width: 50, alignment: .trailing)
ProgressSlider(player: bridge.player, bridge: bridge)
.frame(height: 20)
Text(bridge.totalTimeText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white)
.frame(width: 50, alignment: .leading)
}
//
HStack(spacing: 10) {
#if os(macOS)
ToolbarButton(label: "📂 \(L.open)") { bridge.openFileDialog(); onTouch() }
#endif
ToolbarButton(label: "") { bridge.playPrev(); onTouch() }
ToolbarButton(label: bridge.isPlaying ? "" : "▶️") { bridge.togglePlayPause(); onTouch() }
ToolbarButton(label: "") { bridge.playNext(); onTouch() }
Spacer()
//
ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1); onTouch() }
//
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.white.opacity(0.2))
.frame(height: 4)
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor)
.frame(width: geo.size.width * CGFloat(bridge.volume), height: 4)
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let ratio = max(0, min(1, value.location.x / geo.size.width))
bridge.setVolume(Float(ratio))
onTouch()
}
)
}
.frame(width: 80, height: 20)
Text("\(Int(bridge.volume * 100))")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.frame(width: 30)
ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1); onTouch() }
Spacer()
Text(bridge.currentTrackLabel)
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.7))
ToolbarButton(label: L.audioTrack) { bridge.showTrackDialog = true; onTouch() }
ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.displayName)") { bridge.cycleRepeatMode(); onTouch() }
ToolbarButton(label: "") { bridge.toggleFullscreen(); onTouch() }
ToolbarButton(label: "📋 \(L.list) (\(bridge.queue.count))") { bridge.togglePlaylistWindow(); onTouch() }
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.black.opacity(0.55))
.onHover { hovering in
isHovering = hovering
if hovering { onTouch() }
}
}
}
// 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(L.addFile) { bridge.openFileDialog() }
Button(L.addURLBtn) { bridge.showURLDialog = true }
Spacer()
Text("\(bridge.queue.count) \(L.itemsCount)")
.font(.caption).foregroundColor(.secondary)
}
.padding(12)
.background(.regularMaterial)
Divider()
if bridge.queue.isEmpty {
VStack { Spacer(); Text(L.noMedia).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) : .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 = ""
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text(L.addMediaURL).font(.headline)
TextField("https://example.com/video.m3u8", text: $url)
.textFieldStyle(.roundedBorder)
HStack {
Button(L.cancel) { dismiss() }
Spacer()
Button(L.add) {
if !url.isEmpty { bridge.addURL(url); dismiss() }
}
.keyboardShortcut(.defaultAction)
.disabled(url.isEmpty)
}
}
.padding(20)
.frame(width: 400)
}
}
// MARK: -
struct TrackSelectDialog: View {
@ObservedObject var bridge: PlayerBridge
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 12) {
Text(L.selectTrack).font(.headline)
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
Button(action: { bridge.selectTrack(index: idx); dismiss() }) {
HStack {
Text("\(L.track) \(idx + 1)")
Spacer()
if idx == bridge.currentTrackIndex {
Text("").foregroundColor(.green)
}
}
.padding(8).contentShape(Rectangle())
}.buttonStyle(.plain)
}
Divider()
Button(L.close) { dismiss() }
}
.padding(20)
.frame(minWidth: 250)
}
}