diff --git a/wake/SharedUI/Media/GIFView.swift b/wake/SharedUI/Media/GIFView.swift deleted file mode 100644 index acafd5f..0000000 --- a/wake/SharedUI/Media/GIFView.swift +++ /dev/null @@ -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.. 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() - } -} \ No newline at end of file diff --git a/wake/SharedUI/Media/README.md b/wake/SharedUI/Media/README.md index fa97f0e..ac7c46b 100644 --- a/wake/SharedUI/Media/README.md +++ b/wake/SharedUI/Media/README.md @@ -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)、双击快退/快进、手势亮度/音量等高级功能,可在此基础上扩展。 + diff --git a/wake/SharedUI/Media/SVGImage.swift b/wake/SharedUI/Media/SVGImage.swift deleted file mode 100644 index 7935768..0000000 --- a/wake/SharedUI/Media/SVGImage.swift +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/wake/SharedUI/Media/SVGImageHtml.swift b/wake/SharedUI/Media/SVGImageHtml.swift deleted file mode 100644 index 70ecf87..0000000 --- a/wake/SharedUI/Media/SVGImageHtml.swift +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/wake/SharedUI/Media/WakeVideoPlayer.swift b/wake/SharedUI/Media/WakeVideoPlayer.swift new file mode 100644 index 0000000..71d1d1d --- /dev/null +++ b/wake/SharedUI/Media/WakeVideoPlayer.swift @@ -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) +}