feat: 视频播放组件

This commit is contained in:
Junhui Chen 2025-09-12 11:21:25 +08:00
parent eb0f44287d
commit 6e1ac1fc75
5 changed files with 531 additions and 150 deletions

View File

@ -1,125 +0,0 @@
import SwiftUI
import UIKit
struct GIFView: UIViewRepresentable {
let name: String
var onTap: (() -> Void)? = nil
func makeUIView(context: Context) -> UIImageView {
let imageView = UIImageView()
// GIF
guard let url = Bundle.main.url(forResource: name, withExtension: "gif"),
let data = try? Data(contentsOf: url),
let image = UIImage.gif(data: data) else {
return imageView
}
imageView.image = image
imageView.contentMode = .scaleAspectFit
//
if onTap != nil {
imageView.isUserInteractionEnabled = true
let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap))
imageView.addGestureRecognizer(tapGesture)
}
return imageView
}
func updateUIView(_ uiView: UIImageView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject {
var parent: GIFView
init(_ parent: GIFView) {
self.parent = parent
}
@objc func handleTap() {
parent.onTap?()
}
}
}
// UIImageGIF
extension UIImage {
static func gif(data: Data) -> UIImage? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
print("无法创建CGImageSource")
return nil
}
let count = CGImageSourceGetCount(source)
var images = [UIImage]()
var duration: TimeInterval = 0
for i in 0..<count {
guard let cgImage = CGImageSourceCreateImageAtIndex(source, i, nil) else {
continue
}
duration += UIImage.gifDelayForImageAtIndex(source: source, index: i)
images.append(UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: .up))
}
if count == 1 {
return images.first
} else {
return UIImage.animatedImage(with: images, duration: duration)
}
}
static func gifDelayForImageAtIndex(source: CGImageSource, index: Int) -> TimeInterval {
var delay = 0.1
let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil)
let properties = cfProperties as? [String: Any] ?? [:]
let gifProperties = properties[kCGImagePropertyGIFDictionary as String] as? [String: Any] ?? [:]
if let delayTime = gifProperties[kCGImagePropertyGIFUnclampedDelayTime as String] as? Double {
delay = delayTime
} else if let delayTime = gifProperties[kCGImagePropertyGIFDelayTime as String] as? Double {
delay = delayTime
}
if delay < 0.011 {
delay = 0.1
}
return delay
}
}
// 使 -
struct GIFWithTapExample: View {
@State private var tapCount = 0
var body: some View {
VStack(spacing: 20) {
Text("点击GIF图片")
.font(.title)
GIFView(name: "Blind") {
//
Router.shared.navigate(to: .blindBox(mediaType: .video))
}
.frame(width: 300, height: 300)
.border(Color.blue) //
Text("点击次数: \(tapCount)")
.font(.subheadline)
}
}
}
struct GIFWithTapExample_Previews: PreviewProvider {
static var previews: some View {
GIFWithTapExample()
}
}

View File

@ -1,2 +1,52 @@
# SharedUI/Media # SharedUI/Media
媒体通用视图:`GIFView.swift``SVGImage.swift`/`SVGImageHtml.swift` 等。 媒体通用视图与组件。
## WakeVideoPlayer
一个遵循项目 Theme 风格的 SwiftUI 视频播放组件,基于 `AVKit` 封装。
支持:
- 播放 / 暂停
- 进度条拖动(支持拖动中不打断播放进度回调)
- 静音切换
- 全屏播放(`fullScreenCover`
- 自动隐藏控件(播放中 2.5s 无操作自动隐藏)
- 自动播放与循环播放
- 自定义填充模式(`videoGravity`
### 用法示例
```swift
import SwiftUI
struct DemoVideoCard: View {
var body: some View {
WakeVideoPlayer(
url: URL(string: "https://devstreaming-cdn.apple.com/videos/wwdc/2020/10653/4/17B5F5F3-4D9E-4BAE-8E8F-2C3C7A01F3F2/cmaf.m3u8")!,
autoPlay: false,
isLooping: true,
showsControls: true,
allowFullscreen: true,
muteInitially: false,
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()
}
}
```
### 初始化参数
- `url: URL` 必填。视频资源地址,支持网络或本地文件 URL。
- `autoPlay: Bool = true` 首次出现是否自动播放。
- `isLooping: Bool = false` 是否循环播放。
- `showsControls: Bool = true` 是否显示自定义控制层。
- `allowFullscreen: Bool = true` 是否允许进入全屏播放。
- `muteInitially: Bool = false` 初始是否静音。
- `videoGravity: AVLayerVideoGravity = .resizeAspectFill` 视频填充模式,如 `.resizeAspect` / `.resizeAspectFill`
### 注意事项
- 如果是新加入的文件,确保在 Xcode 中将 `WakeVideoPlayer.swift` 添加到对应 Target否则无法被编译。
- 远程流地址需确保允许跨域与 HTTPS示例使用 Apple 公共 HLS 资源。
- 如果需要画中画PiP、双击快退/快进、手势亮度/音量等高级功能,可在此基础上扩展。

View File

@ -1,16 +0,0 @@
import SwiftUI
// Deprecated: SVG runtime rendering removed. This view is a no-op placeholder to keep API compatibility.
struct SVGImage: View {
let svgName: String
var contentMode: ContentMode = .fit
var tintColor: Color?
var body: some View {
Color.clear
}
enum ContentMode {
case fit
case fill
}
}

View File

@ -1,8 +0,0 @@
import SwiftUI
// Deprecated: SVG runtime rendering removed. This view is a no-op placeholder.
struct SVGImageHtml: View {
let svgName: String
var body: some View {
Color.clear
}
}

View File

@ -0,0 +1,480 @@
//
// WakeVideoPlayer.swift
// wake
//
// Created by Cascade on 2025/9/12.
//
import SwiftUI
import AVKit
/// 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
// 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 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
) {
self.url = url
self.autoPlay = autoPlay
self.isLooping = isLooping
self.showsControls = showsControls
self.allowFullscreen = allowFullscreen
self.muteInitially = muteInitially
self.videoGravity = videoGravity
}
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)
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() {
// Player
let item = AVPlayerItem(url: url)
player.replaceCurrentItem(with: item)
player.isMuted = muteInitially
isMuted = muteInitially
//
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 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)
}