feat: 视频播放组件
This commit is contained in:
parent
eb0f44287d
commit
6e1ac1fc75
@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UIImage的扩展,用于处理GIF
|
||||
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()
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,52 @@
|
||||
# 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)、双击快退/快进、手势亮度/音量等高级功能,可在此基础上扩展。
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
480
wake/SharedUI/Media/WakeVideoPlayer.swift
Normal file
480
wake/SharedUI/Media/WakeVideoPlayer.swift
Normal 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user