MiniPlayer/Sources/PlayerView.swift

242 lines
7.5 KiB
Swift

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
}
}
}
}
}