wake-ios/wake/SharedUI/Media/WakeVideoPlayer.swift

583 lines
20 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.

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