MiniPlayer/Sources/MiniPlayerApp.swift

1035 lines
38 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
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
import UniformTypeIdentifiers
#endif
#if os(macOS)
// 退
class AppDel: NSObject, NSApplicationDelegate {
private var closeObserver: Any?
func applicationDidFinishLaunching(_ notification: Notification) {
//
closeObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: nil,
queue: .main
) { _ in
//
DispatchQueue.main.async {
let nonPanelWindows = NSApp.windows.filter { !($0 is NSPanel) && $0.isVisible }
NSLog("[MiniPlayer] Window closed. Visible non-panel windows: %d", nonPanelWindows.count)
if nonPanelWindows.isEmpty {
NSLog("[MiniPlayer] No visible main windows, terminating app")
NSApp.terminate(nil)
}
}
}
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
if RecorderState.isFinalizing {
NSLog("[MiniPlayer] ⏳ Termination blocked: recording is finalizing")
RecorderState.pendingTerminate = true
//
let alert = NSAlert()
alert.messageText = "正在保存录制文件..."
alert.informativeText = "请等待录制保存完成后再关闭应用。"
alert.alertStyle = .informational
alert.addButton(withTitle: "确定")
alert.runModal()
return .terminateLater
}
return .terminateNow
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
deinit {
if let obs = closeObserver {
NotificationCenter.default.removeObserver(obs)
}
}
}
#endif
#if !SWIFT_PACKAGE
@main
#endif
public struct MiniPlayerApp: App {
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDel.self) var appDelegate
#endif
@StateObject private var bridge = PlayerBridge()
@StateObject private var engine = BricksEngine()
@Environment(\.scenePhase) private var scenePhase
@State private var hasRestored = false
public init() {}
public var body: some Scene {
WindowGroup {
BricksAppView(bridge: bridge, engine: engine)
#if os(macOS)
.frame(minWidth: 900, minHeight: 600)
#endif
.onAppear {
bridge.setup()
setupBridgeEvents()
loadI18n()
// iOS:
#if os(iOS)
if !hasRestored {
hasRestored = true
// setup KVO
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
bridge.restoreSessionState()
}
}
#endif
}
.onChange(of: scenePhase) { _, newPhase in
#if os(iOS)
switch newPhase {
case .background, .inactive:
// App /
bridge.saveSessionState()
case .active:
break
@unknown default:
break
}
#endif
}
}
#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)
Divider()
Button(L.mediaLibrary) { bridge.toggleLibraryWindow() }
.keyboardShortcut("l", modifiers: [.command, .shift])
Button(L.importM3U) { bridge.toggleLibraryWindow() }
.keyboardShortcut("i", modifiers: [.command, .shift])
}
}
#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 os(macOS)
let bundle: Bundle = {
if let mainResources = Bundle.main.url(forResource: "i18n", withExtension: nil) {
return Bundle.main
}
#if SWIFT_PACKAGE
return Bundle.module
#else
return Bundle.main
#endif
}()
#else
let bundle = Bundle.main
#endif
if let url = bundle.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: - AVPlayerLayer
#if os(macOS)
struct PlayerLayerView: NSViewRepresentable {
let player: AVPlayer
let onPlayerLayerReady: ((AVPlayerLayer) -> Void)?
init(player: AVPlayer, onPlayerLayerReady: ((AVPlayerLayer) -> Void)? = nil) {
self.player = player
self.onPlayerLayerReady = onPlayerLayerReady
}
func makeNSView(context: Context) -> NSView {
let view = PlayerLayerHostView()
view.playerLayer.player = player
view.playerLayer.videoGravity = .resizeAspect
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
if let host = nsView as? PlayerLayerHostView {
host.playerLayer.player = player
}
}
}
class PlayerLayerHostView: NSView {
let playerLayer = AVPlayerLayer()
override init(frame: CGRect) {
super.init(frame: frame)
wantsLayer = true
layer = playerLayer
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layout() {
super.layout()
playerLayer.frame = bounds
}
}
#endif
#if os(iOS)
struct PlayerLayerView: UIViewRepresentable {
let player: AVPlayer
let onPlayerLayerReady: ((AVPlayerLayer) -> Void)?
init(player: AVPlayer, onPlayerLayerReady: ((AVPlayerLayer) -> Void)? = nil) {
self.player = player
self.onPlayerLayerReady = onPlayerLayerReady
}
func makeUIView(context: Context) -> PlayerLayerHostViewiOS {
let view = PlayerLayerHostViewiOS()
view.playerLayer.player = player
view.playerLayer.videoGravity = .resizeAspect
// playerLayer PiP
DispatchQueue.main.async {
onPlayerLayerReady?(view.playerLayer)
}
return view
}
func updateUIView(_ uiView: PlayerLayerHostViewiOS, context: Context) {
uiView.playerLayer.player = player
}
}
class PlayerLayerHostViewiOS: UIView {
let playerLayer = AVPlayerLayer()
override init(frame: CGRect) {
super.init(frame: frame)
layer.addSublayer(playerLayer)
}
required init?(coder: NSCoder) { fatalError() }
override func layoutSubviews() {
super.layoutSubviews()
playerLayer.frame = bounds
}
}
#endif
// MARK: - .onHover background tracking area
#if os(macOS)
class WindowMouseMonitor {
static let shared = WindowMouseMonitor()
private var monitor: Any?
private var lastState: Bool?
func startMonitoring() {
guard monitor == nil else { return }
monitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) { [weak self] event in
self?.handleMouseMoved(event)
return event
}
}
private func handleMouseMoved(_ event: NSEvent) {
guard let win = NSApplication.shared.windows.first(where: { $0.isVisible && !($0 is NSPanel) }) else { return }
let mouseScreenPos = NSEvent.mouseLocation
let isInside = win.frame.contains(mouseScreenPos)
guard isInside != lastState else { return }
lastState = isInside
NSLog("[MiniPlayer] WindowMouseMonitor: mouseInside=%d", isInside)
//
let currentlyVisible = win.titleVisibility == .visible
guard currentlyVisible != isInside else { return }
win.titlebarAppearsTransparent = !isInside
win.titleVisibility = isInside ? .visible : .hidden
win.standardWindowButton(.closeButton)?.isHidden = !isInside
win.standardWindowButton(.miniaturizeButton)?.isHidden = !isInside
win.standardWindowButton(.zoomButton)?.isHidden = !isInside
}
}
#endif
// MARK: -
struct PlayerContentView: View {
@ObservedObject var bridge: PlayerBridge
@State private var showToolbar = false
@State private var isHoveringIcon = false
@State private var hideTimer: Timer?
@State private var isHoveringToolbar = false
#if os(iOS)
@State private var isLocked = false
@State private var lastInteraction = Date()
@State private var lockCheckTimer: Timer?
#endif
private func resetHideTimer() {
hideTimer?.invalidate()
guard showToolbar else { return }
hideTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: false) { _ in
withAnimation(.easeInOut(duration: 0.5)) {
showToolbar = false
}
}
}
#if os(iOS)
private func recordInteraction() {
lastInteraction = Date()
if isLocked {
//
//
}
resetHideTimer()
}
#endif
var body: some View {
ZStack(alignment: .topLeading) {
Color.black.ignoresSafeArea()
// AVPlayerLayer AVPlayerView
PlayerLayerView(player: bridge.player) { playerLayer in
#if os(iOS)
// PlayerLayer PiP
bridge.pipController.setup(with: playerLayer)
#endif
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
#if os(iOS)
.allowsHitTesting(!isLocked)
#endif
.onTapGesture(count: 2) {
#if os(iOS)
recordInteraction()
#endif
bridge.toggleFullscreen()
}
.onTapGesture {
#if os(iOS)
recordInteraction()
#endif
bridge.togglePlayPause()
}
#if os(macOS)
.onDrop(of: [.fileURL, .text], isTargeted: nil) { providers in
for provider in providers {
// URL
provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { data, _ in
guard let data = data as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return }
DispatchQueue.main.async {
bridge.addItem(url: url, name: url.lastPathComponent, type: "file")
}
}
// URL (http/https)
provider.loadItem(forTypeIdentifier: "public.text", options: nil) { data, _ in
guard let data = data as? Data, let text = String(data: data, encoding: .utf8),
let url = URL(string: text), url.scheme == "http" || url.scheme == "https" else { return }
DispatchQueue.main.async {
let name = url.lastPathComponent.isEmpty ? (url.host ?? text) : url.lastPathComponent
bridge.addItem(url: url, name: name, type: "url")
}
}
}
return true
}
#endif
// loading/buffering/error
PlayerStatusOverlay(bridge: bridge)
// iOS / macOS
MiniPlayerIcon()
.frame(width: 32, height: 32)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.black.opacity(0.3))
)
.opacity(isHoveringIcon ? 1.0 : 0.6)
.onHover { isHoveringIcon = $0 }
.contentShape(Rectangle())
.highPriorityGesture(
TapGesture().onEnded {
#if os(iOS)
lastInteraction = Date()
if isLocked {
isLocked = false
withAnimation(.easeInOut(duration: 0.25)) {
showToolbar = true
}
resetHideTimer()
} else {
isLocked = true
showToolbar = false
}
#else
withAnimation(.easeInOut(duration: 0.25)) {
showToolbar.toggle()
}
resetHideTimer()
#endif
}
)
// 30
VStack {
Spacer()
if showToolbar {
ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar) {
#if os(iOS)
recordInteraction()
#else
resetHideTimer()
#endif
}
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.6))
)
.padding(12)
.onHover { hovering in
isHoveringToolbar = hovering
if hovering { resetHideTimer() }
}
.transition(.move(edge: .bottom).combined(with: .opacity))
#if os(iOS)
.allowsHitTesting(!isLocked)
#endif
}
}
}
.onAppear {
#if os(macOS)
// 绿
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
guard let win = NSApplication.shared.windows.first(where: { $0.isVisible }) else { return }
win.titleVisibility = .hidden
win.titlebarAppearsTransparent = true
win.isMovableByWindowBackground = true
win.standardWindowButton(.closeButton)?.isHidden = true
win.standardWindowButton(.miniaturizeButton)?.isHidden = true
win.standardWindowButton(.zoomButton)?.isHidden = true
// mouseMoved
win.acceptsMouseMovedEvents = true
// .onHover
WindowMouseMonitor.shared.startMonitoring()
}
#endif
#if os(iOS)
// 530
lockCheckTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
DispatchQueue.main.async {
if !isLocked && Date().timeIntervalSince(lastInteraction) > 30 {
isLocked = true
}
}
}
#endif
}
.onChange(of: showToolbar) { _, newValue in
if newValue {
resetHideTimer()
} else {
hideTimer?.invalidate()
}
}
.onDisappear {
hideTimer?.invalidate()
#if os(iOS)
lockCheckTimer?.invalidate()
lockCheckTimer = nil
#endif
}
}
}
// MARK: - BricksView wrapper
struct BricksAppView: View {
@ObservedObject var bridge: PlayerBridge
@ObservedObject var engine: BricksEngine
var body: some View {
PlayerContentView(bridge: bridge)
.sheet(isPresented: $bridge.showURLDialog) {
URLInputDialog(bridge: bridge)
}
.sheet(isPresented: $bridge.showTrackDialog) {
TrackSelectDialog(bridge: bridge)
}
.sheet(isPresented: $bridge.showPlaylistSheet) {
PlaylistWindowView(bridge: bridge)
}
.sheet(isPresented: $bridge.showLibrarySheet) {
MediaLibraryView(library: bridge.library, bridge: bridge)
}
#if os(iOS)
.fileImporter(
isPresented: $bridge.showFileImporter,
allowedContentTypes: [.movie, .video, .audio, .mpeg4Movie, .quickTimeMovie, .mp3, .wav, .mpeg4Audio, .plainText, .item],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
bridge.handleFileImport(urls)
case .failure(let error):
bridge.showToastMsg(error.localizedDescription)
}
}
.sheet(isPresented: Binding<Bool>(
get: { bridge.pendingExportURL != nil },
set: { if !$0 { bridge.pendingExportURL = nil } }
)) {
if let url = bridge.pendingExportURL {
ActivityShareSheet(url: url) {
bridge.pendingExportURL = nil
}
}
}
#endif
}
}
// 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: 11, design: .monospaced))
.foregroundColor(.white)
.frame(width: 45, alignment: .trailing)
ProgressSlider(player: bridge.player, bridge: bridge)
.frame(height: 20)
Text(bridge.totalTimeText)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.frame(width: 45, alignment: .leading)
}
//
HStack(spacing: 10) {
//
#if os(macOS)
if bridge.screenRecorder.isRecording {
HStack(spacing: 4) {
Image(systemName: "stop.circle.fill")
.foregroundColor(.red)
.font(.system(size: 18))
Text(bridge.screenRecorder.durationText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.red)
}
.contentShape(Rectangle())
.onTapGesture { bridge.toggleRecording(); onTouch() }
} else {
TB(icon: "record.circle") {
bridge.toggleRecording(); onTouch()
}
}
#elseif os(iOS)
if bridge.hlsRecorder.isRecording {
HStack(spacing: 4) {
Image(systemName: "stop.circle.fill")
.foregroundColor(.red)
.font(.system(size: 18))
Text(bridge.hlsRecorder.durationText)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.red)
}
.contentShape(Rectangle())
.onTapGesture { bridge.toggleRecording(); onTouch() }
} else {
TB(icon: "record.circle") {
bridge.toggleRecording(); onTouch()
}
}
#endif
Divider().frame(height: 20).padding(.horizontal, 4)
TB(icon: "folder") { bridge.openFileDialog(); onTouch() }
TB(icon: "link") { bridge.showURLDialog = true; onTouch() }
#if os(macOS)
TB(icon: "qrcode.viewfinder") { bridge.showQRScannerWindow(); onTouch() }
#endif
TB(icon: "backward.fill") { bridge.playPrev(); onTouch() }
TB(icon: bridge.isPlaying ? "pause.fill" : "play.fill") { bridge.togglePlayPause(); onTouch() }
TB(icon: "forward.fill") { bridge.playNext(); onTouch() }
Divider().frame(height: 20).padding(.horizontal, 4)
//
TB(icon: volumeIcon()) { 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
bridge.setVolume(max(0, min(1, Float(value.location.x / geo.size.width))))
onTouch()
}
)
}
.frame(width: 60, height: 20)
Text("\(Int(bridge.volume * 100))")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.frame(width: 25)
Divider().frame(height: 20).padding(.horizontal, 4)
//
if bridge.availableTracks.count <= 1 {
Button(action: {}) {
Text("🎵")
.font(.system(size: 14))
.foregroundColor(.gray.opacity(0.4))
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
.disabled(true)
} else {
Button(action: { bridge.cycleTrack(); onTouch() }) {
Text(bridge.currentTrackLabel)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.white)
.frame(height: 28)
.padding(.horizontal, 4)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
}
.buttonStyle(.plain)
}
//
TB(icon: repeatIcon()) { bridge.cycleRepeatMode(); onTouch() }
//
TB(icon: "arrow.up.left.and.arrow.down.right") { bridge.toggleFullscreen(); onTouch() }
#if os(iOS)
//
if bridge.pipController.isPiPSupported {
TB(icon: bridge.pipController.isPiPActive ? "pip.exit" : "pip.enter") {
bridge.pipController.togglePiP()
onTouch()
}
}
// AirPlay
AirPlayButton()
.frame(width: 28, height: 28)
#endif
//
TB(icon: "list.bullet") { bridge.togglePlaylistWindow(); onTouch() }
//
TB(icon: "books.vertical") { bridge.toggleLibraryWindow(); onTouch() }
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
private func volumeIcon() -> String {
if bridge.volume <= 0 { return "speaker.slash.fill" }
if bridge.volume < 0.5 { return "speaker.wave.1.fill" }
return "speaker.wave.3.fill"
}
private func repeatIcon() -> String {
switch bridge.repeatMode {
case .none: return "repeat"
case .single: return "repeat.1"
case .all: return "repeat"
}
}
}
//
struct TB: View {
let icon: String
let action: () -> Void
@State private var hovering = false
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 14))
.foregroundColor(.white)
.frame(width: 28, height: 28)
.background(hovering ? Color.white.opacity(0.2) : .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)
}
}
// MARK: -
struct PlayerStatusOverlay: View {
@ObservedObject var bridge: PlayerBridge
var body: some View {
ZStack {
switch bridge.playerStatus {
case .loading:
loadingView
case .buffering:
bufferingView
case .error(let msg):
errorView(msg)
default:
EmptyView()
}
}
.allowsHitTesting(isError)
.animation(.easeInOut(duration: 0.3), value: bridge.playerStatus)
}
private var isError: Bool {
if case .error = bridge.playerStatus { return true }
return false
}
// spinner + +
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.5)
.frame(width: 48, height: 48)
Text(L.statusConnecting)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
if bridge.loadingElapsed > 0 {
Text("\(bridge.loadingElapsed)s")
.font(.system(size: 13, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
}
if bridge.loadingElapsed > 15 {
Text(L.statusTimeout)
.font(.system(size: 12))
.foregroundColor(.yellow.opacity(0.8))
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.4))
}
//
private var bufferingView: some View {
VStack {
HStack {
Spacer()
HStack(spacing: 6) {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(0.7)
Text(L.statusBuffering)
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.9))
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule().fill(Color.black.opacity(0.6))
)
.padding(16)
}
Spacer()
}
}
// +
private func errorView(_ msg: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundColor(.red.opacity(0.8))
Text(L.statusError)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
Text(msg)
.font(.system(size: 13))
.foregroundColor(.white.opacity(0.7))
.multilineTextAlignment(.center)
.padding(.horizontal, 60)
.lineLimit(3)
Button(action: {
if bridge.currentIndex >= 0 {
bridge.playIndex(bridge.currentIndex)
}
}) {
HStack(spacing: 6) {
Image(systemName: "arrow.clockwise")
Text(L.statusRetry)
}
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 8)
.background(
Capsule().fill(Color.accentColor.opacity(0.8))
)
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.6))
}
}
#if os(iOS)
// MARK: - URL Identifiable .sheet(item:)
extension URL: @retroactive Identifiable {
public var id: String { absoluteString }
}
// MARK: - UIViewControllerRepresentable
struct ActivityShareSheet: UIViewControllerRepresentable {
let url: URL
let onDismiss: () -> Void
func makeUIViewController(context: Context) -> UIActivityViewController {
let vc = UIActivityViewController(activityItems: [url], applicationActivities: nil)
vc.completionWithItemsHandler = { _, _, _, _ in
onDismiss()
}
return vc
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#endif