583 lines
20 KiB
Swift
583 lines
20 KiB
Swift
//
|
||
// WakeVideoPlayer.swift
|
||
// wake
|
||
//
|
||
// Created by Cascade on 2025/9/12.
|
||
//
|
||
|
||
import SwiftUI
|
||
import AVKit
|
||
import VideoToolbox
|
||
|
||
/// 一个遵循项目 Theme 风格的 SwiftUI 视频播放组件。
|
||
/// 支持:播放/暂停、进度条、静音、全屏、自动隐藏控件、自动播放与循环。
|
||
public struct WakeVideoPlayer: View {
|
||
// MARK: - Public Config
|
||
private let url: URL
|
||
private let autoPlay: Bool
|
||
private let isLooping: Bool
|
||
private let showsControls: Bool
|
||
private let allowFullscreen: Bool
|
||
private let muteInitially: Bool
|
||
private let videoGravity: AVLayerVideoGravity
|
||
private let fallbackURL: URL?
|
||
|
||
// MARK: - Internal State
|
||
@State private var player: AVPlayer = AVPlayer()
|
||
@State private var isPlaying: Bool = false
|
||
@State private var isMuted: Bool = false
|
||
@State private var duration: Double = 0
|
||
@State private var currentTime: Double = 0
|
||
@State private var isScrubbing: Bool = false
|
||
@State private var isControlsVisible: Bool = true
|
||
@State private var isFullscreen: Bool = false
|
||
@State private var warningMessage: String?
|
||
|
||
@State private var timeObserverToken: Any?
|
||
@State private var endObserver: Any?
|
||
|
||
// 自动隐藏控件的定时器
|
||
@State private var autoHideWorkItem: DispatchWorkItem?
|
||
|
||
public init(
|
||
url: URL,
|
||
autoPlay: Bool = true,
|
||
isLooping: Bool = false,
|
||
showsControls: Bool = true,
|
||
allowFullscreen: Bool = true,
|
||
muteInitially: Bool = false,
|
||
videoGravity: AVLayerVideoGravity = .resizeAspectFill,
|
||
fallbackURL: URL? = nil
|
||
) {
|
||
self.url = url
|
||
self.autoPlay = autoPlay
|
||
self.isLooping = isLooping
|
||
self.showsControls = showsControls
|
||
self.allowFullscreen = allowFullscreen
|
||
self.muteInitially = muteInitially
|
||
self.videoGravity = videoGravity
|
||
self.fallbackURL = fallbackURL
|
||
}
|
||
|
||
public var body: some View {
|
||
ZStack {
|
||
VideoPlayerRepresentable(player: player, videoGravity: videoGravity)
|
||
.background(Color.black)
|
||
.onTapGesture { toggleControls() }
|
||
|
||
if showsControls && isControlsVisible {
|
||
controlsOverlay
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
.onAppear(perform: setup)
|
||
.onDisappear(perform: cleanup)
|
||
.fullScreenCover(isPresented: $isFullscreen) {
|
||
FullscreenContainer(
|
||
player: player,
|
||
isPlaying: $isPlaying,
|
||
isMuted: $isMuted,
|
||
duration: $duration,
|
||
currentTime: $currentTime,
|
||
isScrubbing: $isScrubbing,
|
||
onTogglePlay: togglePlay,
|
||
onSeek: seek(to:),
|
||
onMute: toggleMute,
|
||
onDismiss: { isFullscreen = false },
|
||
videoGravity: videoGravity
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - UI
|
||
private extension WakeVideoPlayer {
|
||
var controlsOverlay: some View {
|
||
VStack(spacing: 0) {
|
||
// Top gradient (可按需添加标题/返回等)
|
||
LinearGradient(colors: [Color.black.opacity(0.35), .clear], startPoint: .top, endPoint: .bottom)
|
||
.frame(height: 80)
|
||
.frame(maxWidth: .infinity)
|
||
.allowsHitTesting(false)
|
||
|
||
if let warningMessage {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundColor(.black)
|
||
Text(warningMessage)
|
||
.font(.caption2)
|
||
.foregroundColor(.black)
|
||
.lineLimit(3)
|
||
.multilineTextAlignment(.leading)
|
||
}
|
||
.padding(8)
|
||
.background(Theme.Colors.warning.opacity(0.95))
|
||
.cornerRadius(8)
|
||
.padding(.horizontal, Theme.Spacing.lg)
|
||
.padding(.top, Theme.Spacing.md)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// Center play/pause button(大按钮便于点按)
|
||
Button(action: togglePlay) {
|
||
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||
.resizable()
|
||
.frame(width: 64, height: 64)
|
||
.foregroundColor(.white)
|
||
.shadow(color: Theme.Shadows.large, radius: 12, x: 0, y: 8)
|
||
}
|
||
.padding(.bottom, Theme.Spacing.lg)
|
||
|
||
// Bottom bar controls
|
||
VStack(spacing: Theme.Spacing.sm) {
|
||
HStack {
|
||
Text(formatTime(currentTime))
|
||
.font(.caption)
|
||
.foregroundColor(.white.opacity(0.85))
|
||
.frame(width: 46, alignment: .leading)
|
||
|
||
Slider(value: Binding(
|
||
get: { currentTime },
|
||
set: { newVal in
|
||
currentTime = min(max(0, newVal), duration)
|
||
}
|
||
), in: 0...max(duration, 0.01), onEditingChanged: { editing in
|
||
isScrubbing = editing
|
||
if !editing { seek(to: currentTime) }
|
||
})
|
||
.tint(Theme.Colors.primary)
|
||
|
||
Text(formatTime(duration))
|
||
.font(.caption)
|
||
.foregroundColor(.white.opacity(0.85))
|
||
.frame(width: 46, alignment: .trailing)
|
||
}
|
||
|
||
HStack(spacing: Theme.Spacing.lg) {
|
||
Button(action: toggleMute) {
|
||
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundColor(.white)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if allowFullscreen {
|
||
Button(action: { isFullscreen = true }) {
|
||
Image(systemName: "arrow.up.left.and.down.right.magnifyingglass")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundColor(.white)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, Theme.Spacing.lg)
|
||
.padding(.top, Theme.Spacing.sm)
|
||
.padding(.bottom, Theme.Spacing.lg + 4)
|
||
.background(
|
||
LinearGradient(colors: [Color.black.opacity(0.0), Color.black.opacity(0.55)], startPoint: .top, endPoint: .bottom)
|
||
.edgesIgnoringSafeArea(.bottom)
|
||
)
|
||
}
|
||
.onAppear { scheduleAutoHideIfNeeded() }
|
||
.onChange(of: isPlaying) { _, _ in scheduleAutoHideIfNeeded() }
|
||
}
|
||
}
|
||
|
||
// MARK: - Lifecycle & Player Setup
|
||
private extension WakeVideoPlayer {
|
||
func setup() {
|
||
// 异步分析编码并选择源
|
||
Task { @MainActor in
|
||
let srcURL = url
|
||
let asset = AVURLAsset(url: srcURL)
|
||
do {
|
||
let (hasVideo, hasHEVC) = try await analyzeAsset(asset)
|
||
// 设置提示(不阻塞)
|
||
if hasVideo && hasHEVC && !isHEVCHardwareDecodeSupported() {
|
||
warningMessage = "当前运行环境不支持 HEVC 硬解码(模拟器常见)。已尝试使用备用码流。"
|
||
}
|
||
|
||
if hasVideo && hasHEVC && !isHEVCHardwareDecodeSupported(), let fallback = fallbackURL {
|
||
prepare(with: fallback)
|
||
} else {
|
||
prepare(with: srcURL)
|
||
}
|
||
} catch {
|
||
// 分析失败时直接尝试原地址
|
||
prepare(with: srcURL)
|
||
}
|
||
}
|
||
}
|
||
|
||
func prepare(with sourceURL: URL) {
|
||
// 清理旧观察者
|
||
if let token = timeObserverToken {
|
||
player.removeTimeObserver(token)
|
||
timeObserverToken = nil
|
||
}
|
||
if let endObs = endObserver {
|
||
NotificationCenter.default.removeObserver(endObs)
|
||
endObserver = nil
|
||
}
|
||
|
||
let item = AVPlayerItem(url: sourceURL)
|
||
player.replaceCurrentItem(with: item)
|
||
player.isMuted = muteInitially
|
||
isMuted = muteInitially
|
||
player.automaticallyWaitsToMinimizeStalling = true
|
||
player.allowsExternalPlayback = false
|
||
|
||
// 监听时长
|
||
let cmDuration = item.asset.duration.secondsNonNaN
|
||
if cmDuration.isFinite { duration = cmDuration }
|
||
|
||
// 时间观察者
|
||
addTimeObserver()
|
||
|
||
// 循环播放
|
||
if isLooping {
|
||
endObserver = NotificationCenter.default.addObserver(
|
||
forName: .AVPlayerItemDidPlayToEndTime,
|
||
object: item,
|
||
queue: .main
|
||
) { _ in
|
||
player.seek(to: .zero)
|
||
if autoPlay { player.play() }
|
||
}
|
||
}
|
||
|
||
// 自动播放
|
||
if autoPlay {
|
||
player.play()
|
||
isPlaying = true
|
||
scheduleAutoHideIfNeeded()
|
||
}
|
||
}
|
||
|
||
func cleanup() {
|
||
if let token = timeObserverToken {
|
||
player.removeTimeObserver(token)
|
||
timeObserverToken = nil
|
||
}
|
||
if let endObs = endObserver {
|
||
NotificationCenter.default.removeObserver(endObs)
|
||
endObserver = nil
|
||
}
|
||
autoHideWorkItem?.cancel()
|
||
autoHideWorkItem = nil
|
||
player.pause()
|
||
}
|
||
|
||
func addTimeObserver() {
|
||
// 每 0.5s 回调一次
|
||
let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in
|
||
guard !isScrubbing else { return }
|
||
currentTime = time.secondsNonNaN
|
||
if duration <= 0 {
|
||
if let cm = player.currentItem?.duration {
|
||
let total = cm.secondsNonNaN
|
||
if total.isFinite { duration = total }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Actions
|
||
private extension WakeVideoPlayer {
|
||
func togglePlay() {
|
||
if isPlaying {
|
||
player.pause()
|
||
isPlaying = false
|
||
showControls()
|
||
} else {
|
||
player.play()
|
||
isPlaying = true
|
||
scheduleAutoHideIfNeeded()
|
||
}
|
||
}
|
||
|
||
func toggleMute() {
|
||
isMuted.toggle()
|
||
player.isMuted = isMuted
|
||
showControls()
|
||
scheduleAutoHideIfNeeded()
|
||
}
|
||
|
||
func seek(to seconds: Double) {
|
||
let clamped = min(max(0, seconds), max(duration, 0))
|
||
let time = CMTime(seconds: clamped, preferredTimescale: 600)
|
||
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
|
||
if isPlaying { scheduleAutoHideIfNeeded() }
|
||
}
|
||
|
||
func toggleControls() {
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
isControlsVisible.toggle()
|
||
}
|
||
if isControlsVisible { scheduleAutoHideIfNeeded() }
|
||
}
|
||
|
||
func showControls() {
|
||
withAnimation(.easeInOut(duration: 0.2)) { isControlsVisible = true }
|
||
}
|
||
|
||
func scheduleAutoHideIfNeeded() {
|
||
autoHideWorkItem?.cancel()
|
||
guard showsControls && isPlaying else { return }
|
||
let work = DispatchWorkItem {
|
||
withAnimation(.easeOut(duration: 0.25)) { isControlsVisible = false }
|
||
}
|
||
autoHideWorkItem = work
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: work)
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
private extension WakeVideoPlayer {
|
||
func isHEVCHardwareDecodeSupported() -> Bool {
|
||
#if targetEnvironment(simulator)
|
||
return false
|
||
#else
|
||
return VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC)
|
||
#endif
|
||
}
|
||
|
||
func analyzeAsset(_ asset: AVAsset) async throws -> (hasVideo: Bool, hasHEVC: Bool) {
|
||
let tracks = try await asset.load(.tracks)
|
||
var hasVideo = false
|
||
var hasHEVC = false
|
||
for track in tracks {
|
||
// mediaType 可同步访问
|
||
if track.mediaType == .video {
|
||
hasVideo = true
|
||
let fds: [CMFormatDescription] = try await track.load(.formatDescriptions)
|
||
for desc in fds {
|
||
let subtype = CMFormatDescriptionGetMediaSubType(desc)
|
||
if subtype == kCMVideoCodecType_HEVC { hasHEVC = true }
|
||
}
|
||
}
|
||
}
|
||
return (hasVideo, hasHEVC)
|
||
}
|
||
|
||
func formatTime(_ seconds: Double) -> String {
|
||
guard seconds.isFinite && !seconds.isNaN else { return "00:00" }
|
||
let total = Int(seconds)
|
||
let h = total / 3600
|
||
let m = (total % 3600) / 60
|
||
let s = (total % 60)
|
||
if h > 0 {
|
||
return String(format: "%02d:%02d:%02d", h, m, s)
|
||
} else {
|
||
return String(format: "%02d:%02d", m, s)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Representable: 控制 videoGravity
|
||
private struct VideoPlayerRepresentable: UIViewControllerRepresentable {
|
||
let player: AVPlayer
|
||
let videoGravity: AVLayerVideoGravity
|
||
|
||
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||
let vc = AVPlayerViewController()
|
||
vc.player = player
|
||
vc.showsPlaybackControls = false
|
||
vc.videoGravity = videoGravity
|
||
return vc
|
||
}
|
||
|
||
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
||
uiViewController.player = player
|
||
uiViewController.videoGravity = videoGravity
|
||
}
|
||
}
|
||
|
||
// MARK: - Fullscreen Container
|
||
private struct FullscreenContainer: View {
|
||
let player: AVPlayer
|
||
@Binding var isPlaying: Bool
|
||
@Binding var isMuted: Bool
|
||
@Binding var duration: Double
|
||
@Binding var currentTime: Double
|
||
@Binding var isScrubbing: Bool
|
||
|
||
let onTogglePlay: () -> Void
|
||
let onSeek: (Double) -> Void
|
||
let onMute: () -> Void
|
||
let onDismiss: () -> Void
|
||
let videoGravity: AVLayerVideoGravity
|
||
|
||
@State private var isControlsVisible: Bool = true
|
||
@State private var autoHideWorkItem: DispatchWorkItem?
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
VideoPlayerRepresentable(player: player, videoGravity: videoGravity)
|
||
.ignoresSafeArea()
|
||
.background(Color.black)
|
||
.onTapGesture { toggleControls() }
|
||
|
||
if isControlsVisible {
|
||
VStack(spacing: 0) {
|
||
HStack {
|
||
Button(action: onDismiss) {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.font(.system(size: 24, weight: .semibold))
|
||
.foregroundColor(.white)
|
||
.shadow(color: Theme.Shadows.large, radius: 12, x: 0, y: 8)
|
||
}
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, Theme.Spacing.lg)
|
||
.padding(.top, Theme.Spacing.lg)
|
||
.padding(.bottom, Theme.Spacing.md)
|
||
.background(
|
||
LinearGradient(colors: [Color.black.opacity(0.55), .clear], startPoint: .top, endPoint: .bottom)
|
||
.ignoresSafeArea(edges: .top)
|
||
)
|
||
|
||
Spacer()
|
||
|
||
VStack(spacing: Theme.Spacing.sm) {
|
||
HStack {
|
||
Text(formatTime(currentTime))
|
||
.font(.caption)
|
||
.foregroundColor(.white.opacity(0.85))
|
||
.frame(width: 46, alignment: .leading)
|
||
|
||
Slider(value: Binding(
|
||
get: { currentTime },
|
||
set: { newVal in
|
||
currentTime = min(max(0, newVal), duration)
|
||
}
|
||
), in: 0...max(duration, 0.01), onEditingChanged: { editing in
|
||
isScrubbing = editing
|
||
if !editing { onSeek(currentTime) }
|
||
})
|
||
.tint(Theme.Colors.primary)
|
||
|
||
Text(formatTime(duration))
|
||
.font(.caption)
|
||
.foregroundColor(.white.opacity(0.85))
|
||
.frame(width: 46, alignment: .trailing)
|
||
}
|
||
|
||
HStack(spacing: Theme.Spacing.lg) {
|
||
Button(action: onTogglePlay) {
|
||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundColor(.white)
|
||
}
|
||
|
||
Button(action: onMute) {
|
||
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill")
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundColor(.white)
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
}
|
||
.padding(.horizontal, Theme.Spacing.lg)
|
||
.padding(.top, Theme.Spacing.sm)
|
||
.padding(.bottom, Theme.Spacing.lg + 4)
|
||
.background(
|
||
LinearGradient(colors: [Color.black.opacity(0.0), Color.black.opacity(0.7)], startPoint: .top, endPoint: .bottom)
|
||
.ignoresSafeArea(edges: .bottom)
|
||
)
|
||
}
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
.onAppear { scheduleAutoHideIfNeeded() }
|
||
.onChange(of: isPlaying) { _, _ in scheduleAutoHideIfNeeded() }
|
||
}
|
||
|
||
func toggleControls() {
|
||
withAnimation(.easeInOut(duration: 0.2)) {
|
||
isControlsVisible.toggle()
|
||
}
|
||
if isControlsVisible { scheduleAutoHideIfNeeded() }
|
||
}
|
||
|
||
func scheduleAutoHideIfNeeded() {
|
||
autoHideWorkItem?.cancel()
|
||
guard isPlaying else { return }
|
||
let work = DispatchWorkItem {
|
||
withAnimation(.easeOut(duration: 0.25)) { isControlsVisible = false }
|
||
}
|
||
autoHideWorkItem = work
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: work)
|
||
}
|
||
|
||
func formatTime(_ seconds: Double) -> String {
|
||
guard seconds.isFinite && !seconds.isNaN else { return "00:00" }
|
||
let total = Int(seconds)
|
||
let h = total / 3600
|
||
let m = (total % 3600) / 60
|
||
let s = (total % 60)
|
||
if h > 0 {
|
||
return String(format: "%02d:%02d:%02d", h, m, s)
|
||
} else {
|
||
return String(format: "%02d:%02d", m, s)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - CMTime helpers
|
||
private extension CMTime {
|
||
var secondsNonNaN: Double {
|
||
let s = CMTimeGetSeconds(self)
|
||
if s.isNaN || s.isInfinite { return 0 }
|
||
return s
|
||
}
|
||
}
|
||
|
||
// MARK: - Preview
|
||
#Preview("WakeVideoPlayer - Basic") {
|
||
VStack(spacing: 16) {
|
||
Text("WakeVideoPlayer 预览")
|
||
.font(.headline)
|
||
WakeVideoPlayer(
|
||
url: URL(string: "https://cdn.memorywake.com/users/7350439663116619888/files/7361241959983353857/7361241920703696897.mp4")!,
|
||
autoPlay: false,
|
||
isLooping: true,
|
||
showsControls: true,
|
||
allowFullscreen: true,
|
||
muteInitially: true,
|
||
videoGravity: .resizeAspectFill
|
||
)
|
||
.frame(height: 220)
|
||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large, style: .continuous))
|
||
.shadow(color: Theme.Shadows.cardShadow.color, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
|
||
.padding()
|
||
}
|
||
.background(Theme.Colors.background)
|
||
}
|
||
|
||
#Preview("WakeVideoPlayer - HLS (Primary)") {
|
||
VStack(spacing: 16) {
|
||
Text("WakeVideoPlayer HLS 主播放 预览")
|
||
.font(.headline)
|
||
WakeVideoPlayer(
|
||
url: URL(string: "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8")!,
|
||
autoPlay: false,
|
||
isLooping: true,
|
||
showsControls: true,
|
||
allowFullscreen: true,
|
||
muteInitially: true,
|
||
videoGravity: .resizeAspect
|
||
)
|
||
.frame(height: 220)
|
||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large, style: .continuous))
|
||
.shadow(color: Theme.Shadows.cardShadow.color, radius: Theme.Shadows.cardShadow.radius, x: Theme.Shadows.cardShadow.x, y: Theme.Shadows.cardShadow.y)
|
||
.padding()
|
||
}
|
||
.background(Theme.Colors.background)
|
||
}
|