From ce3d858e165d3dbdf9894e5aef8f237e18f22998 Mon Sep 17 00:00:00 2001 From: Junhui Chen Date: Fri, 12 Sep 2025 14:15:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=93=E6=9E=9C=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wake/Core/Navigation/Router.swift | 18 +- .../Features/BlindBox/View/BlindBoxView.swift | 10 +- .../Features/BlindBox/View/BlindOutCome.swift | 320 +++--------------- wake/SharedUI/Media/README.md | 24 ++ wake/SharedUI/Media/WakeVideoPlayer.swift | 116 ++++++- wake/View/Subscribe/JoinModal.swift | 5 +- 6 files changed, 207 insertions(+), 286 deletions(-) diff --git a/wake/Core/Navigation/Router.swift b/wake/Core/Navigation/Router.swift index 39ee741..0bcf82c 100644 --- a/wake/Core/Navigation/Router.swift +++ b/wake/Core/Navigation/Router.swift @@ -8,7 +8,7 @@ enum AppRoute: Hashable { case feedbackDetail(type: FeedbackView.FeedbackType) case mediaUpload case blindBox(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) - case blindOutcome(media: MediaType, time: String? = nil, description: String? = nil, isMember: Bool) + case blindOutcome(media: MediaType, title: String? = nil, description: String? = nil, isMember: Bool, goToFeedback: Bool = false) case memories case subscribe case userInfo @@ -33,8 +33,20 @@ enum AppRoute: Hashable { MediaUploadView() case .blindBox(let mediaType, let blindBoxId): BlindBoxView(mediaType: mediaType, blindBoxId: blindBoxId) - case .blindOutcome(let media, let time, let description, let isMember): - BlindOutcomeView(media: media, time: time, description: description, isMember: isMember) + case .blindOutcome(let media, let title, let description, let isMember, let goToFeedback): + BlindOutcomeView( + media: media, + title: title, + description: description, + isMember: isMember, + onContinue: { + if goToFeedback { + Router.shared.navigate(to: .feedbackView) + } else { + Router.shared.navigate(to: .blindBox(mediaType: .all)) + } + } + ) case .memories: MemoriesView() case .subscribe: diff --git a/wake/Features/BlindBox/View/BlindBoxView.swift b/wake/Features/BlindBox/View/BlindBoxView.swift index 58d4a28..145ce50 100644 --- a/wake/Features/BlindBox/View/BlindBoxView.swift +++ b/wake/Features/BlindBox/View/BlindBoxView.swift @@ -283,9 +283,10 @@ struct BlindBoxView: View { Router.shared.navigate( to: .blindOutcome( media: .video(url, nil), - time: viewModel.blindGenerate?.name ?? "Your box", + title: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", - isMember: viewModel.isMember + isMember: viewModel.isMember, + goToFeedback: false ) ) return @@ -303,9 +304,10 @@ struct BlindBoxView: View { Router.shared.navigate( to: .blindOutcome( media: .image(image), - time: viewModel.blindGenerate?.name ?? "Your box", + title: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", - isMember: viewModel.isMember + isMember: viewModel.isMember, + goToFeedback: true ) ) return diff --git a/wake/Features/BlindBox/View/BlindOutCome.swift b/wake/Features/BlindBox/View/BlindOutCome.swift index 354618d..6586f47 100644 --- a/wake/Features/BlindBox/View/BlindOutCome.swift +++ b/wake/Features/BlindBox/View/BlindOutCome.swift @@ -1,24 +1,24 @@ import SwiftUI -import AVKit import os.log struct BlindOutcomeView: View { let media: MediaType - let time: String? + let title: String? let description: String? let isMember: Bool + let onContinue: () -> Void + let showJoinModal: Bool + // Removed presentationMode; use Router.shared.pop() for back navigation - @State private var isFullscreen = false - @State private var isPlaying = false - @State private var showControls = true @State private var showIPListModal = false - @State private var player: AVPlayer? - init(media: MediaType, time: String? = nil, description: String? = nil, isMember: Bool = false) { + init(media: MediaType, title: String? = nil, description: String? = nil, isMember: Bool = false, onContinue: @escaping () -> Void, showJoinModal: Bool = false) { self.media = media - self.time = time + self.title = title self.description = description self.isMember = isMember + self.onContinue = onContinue + self.showJoinModal = showJoinModal } var body: some View { @@ -26,40 +26,17 @@ struct BlindOutcomeView: View { Color.themeTextWhiteSecondary.ignoresSafeArea() VStack(spacing: 0) { - // 自定义导航栏 - HStack { - Button(action: { - Router.shared.pop() - }) { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .font(.headline) - } - .foregroundColor(Color.themeTextMessageMain) - } - .padding(.leading, 16) - - Spacer() - - Text("Blind Box") - .font(.headline) - .foregroundColor(Color.themeTextMessageMain) - - Spacer() - - HStack(spacing: 4) { - Image(systemName: "chevron.left") - .opacity(0) - } - .padding(.trailing, 16) - } - .padding(.vertical, 12) - .background(Color.themeTextWhiteSecondary) - .zIndex(1) - + // 通用导航栏 + // NaviHeader( + // title: "Blind Box", + // onBackTap: { Router.shared.pop() }, + // showBackButton: true, + // titleStyle: .title, + // backgroundColor: Color.themeTextWhiteSecondary + // ) + // .zIndex(1) Spacer() - .frame(height: 30) - + .frame(height: Theme.Spacing.lg) // Media content GeometryReader { geometry in VStack(spacing: 16) { @@ -77,47 +54,35 @@ struct BlindOutcomeView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .cornerRadius(10) .padding(4) - .onTapGesture { - withAnimation { - isFullscreen.toggle() - } - } + // 图片不启用全屏切换 case .video(let url, _): - VideoPlayerView(url: url, isPlaying: $isPlaying, player: $player) - .frame(width: UIScreen.main.bounds.width - 40) - .background(Color.clear) - .cornerRadius(10) - .clipped() - .onAppear { - isPlaying = true - } - .onDisappear { - isPlaying = false - player?.pause() - } - .onTapGesture { - withAnimation { - showControls.toggle() - } - } - .fullScreenCover(isPresented: $isFullscreen) { - FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: player) - } + WakeVideoPlayer( + url: url, + autoPlay: true, + isLooping: true, + showsControls: true, + allowFullscreen: true, + muteInitially: false, + videoGravity: .resizeAspect + ) + .frame(width: UIScreen.main.bounds.width - 40) + .background(Color.clear) + .cornerRadius(10) + .clipped() } if let description = description, !description.isEmpty { VStack(alignment: .leading, spacing: 2) { - Text("Description") - .font(Typography.font(for: .body, family: .quicksandBold)) - .foregroundColor(.themeTextMessageMain) + // Text("Description") + // .font(Typography.font(for: .body, family: .quicksandBold)) + // .foregroundColor(.themeTextMessageMain) Text(description) .font(.system(size: 12)) .foregroundColor(Color.themeTextMessageMain) .fixedSize(horizontal: false, vertical: true) } - .padding(.horizontal, 12) - .padding(.bottom, 12) + .padding(Theme.Spacing.lg) } } .padding(.top, 8) @@ -134,12 +99,12 @@ struct BlindOutcomeView: View { VStack { Spacer() Button(action: { - if case .video = media { + if showJoinModal { withAnimation { showIPListModal = true } } else { - Router.shared.navigate(to: .feedbackView) + onContinue() } }) { Text("Continue") @@ -155,199 +120,11 @@ struct BlindOutcomeView: View { .padding(.bottom, 20) } } - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) - .statusBar(hidden: isFullscreen) + // .navigationBarHidden(true) + // .navigationBarBackButtonHidden(true) .overlay( - JoinModal(isPresented: $showIPListModal) + JoinModal(isPresented: $showIPListModal, onClose: { onContinue() }) ) - .onDisappear { - player?.pause() - player = nil - } - } -} - -// MARK: - Fullscreen Media View -private struct FullscreenMediaView: View { - let media: MediaType - @Binding var isPresented: Bool - @Binding var isPlaying: Bool - @State private var showControls = true - private let player: AVPlayer? - - init(media: MediaType, isPresented: Binding, isPlaying: Binding, player: AVPlayer?) { - self.media = media - self._isPresented = isPresented - self._isPlaying = isPlaying - self.player = player - } - - var body: some View { - ZStack { - Color.black.edgesIgnoringSafeArea(.all) - - ZStack { - switch media { - case .image(let uiImage): - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture { - withAnimation { - showControls.toggle() - } - } - - case .video(_, _): - if let player = player { - CustomVideoPlayer(player: player) - .onAppear { - player.play() - isPlaying = true - } - .onDisappear { - player.pause() - isPlaying = false - } - } - } - } - - VStack { - HStack { - Button(action: { isPresented = false }) { - Image(systemName: "xmark") - .font(.title2) - .foregroundColor(.white) - .padding() - .background(Color.black.opacity(0.5)) - .clipShape(Circle()) - } - .padding() - Spacer() - } - Spacer() - } - } - .onDisappear { - player?.pause() - } - } -} - -// MARK: - Video Player View -struct VideoPlayerView: UIViewRepresentable { - let url: URL - @Binding var isPlaying: Bool - @Binding var player: AVPlayer? - - func makeUIView(context: Context) -> PlayerView { - let view = PlayerView() - let player = view.setupPlayer(url: url) - self.player = player - return view - } - - func updateUIView(_ uiView: PlayerView, context: Context) { - if isPlaying { - uiView.play() - } else { - uiView.pause() - } - } -} - -// MARK: - Custom Video Player -@available(iOS 14.0, *) -struct CustomVideoPlayer: UIViewControllerRepresentable { - let player: AVPlayer - - func makeUIViewController(context: Context) -> AVPlayerViewController { - let controller = AVPlayerViewController() - controller.player = player - controller.showsPlaybackControls = false - controller.videoGravity = .resizeAspect - return controller - } - - func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { - uiViewController.player = player - } -} - -// MARK: - Player View -class PlayerView: UIView { - private var player: AVPlayer? - private var playerLayer: AVPlayerLayer? - private var playerItem: AVPlayerItem? - private var playerItemObserver: NSKeyValueObservation? - - @discardableResult - func setupPlayer(url: URL) -> AVPlayer { - cleanup() - - let asset = AVAsset(url: url) - let playerItem = AVPlayerItem(asset: asset) - self.playerItem = playerItem - - player = AVPlayer(playerItem: playerItem) - - let playerLayer = AVPlayerLayer(player: player) - playerLayer.videoGravity = .resizeAspect - layer.addSublayer(playerLayer) - self.playerLayer = playerLayer - - playerLayer.frame = bounds - - NotificationCenter.default.addObserver( - self, - selector: #selector(playerItemDidReachEnd), - name: .AVPlayerItemDidPlayToEndTime, - object: playerItem - ) - - return player! - } - - func play() { - player?.play() - } - - func pause() { - player?.pause() - } - - private func cleanup() { - if let playerItem = playerItem { - NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem) - } - - player?.pause() - player?.replaceCurrentItem(with: nil) - player = nil - - playerLayer?.removeFromSuperlayer() - playerLayer = nil - - playerItem?.cancelPendingSeeks() - playerItem?.asset.cancelLoading() - playerItem = nil - } - - @objc private func playerItemDidReachEnd() { - player?.seek(to: .zero) - player?.play() - } - - override func layoutSubviews() { - super.layoutSubviews() - playerLayer?.frame = bounds - } - - deinit { - cleanup() } } @@ -380,27 +157,30 @@ struct BlindOutcomeView_Previews: PreviewProvider { // 预览 1:含描述与时间,非会员 BlindOutcomeView( media: .image(remoteImage("https://cdn.memorywake.com/files/7350515957925810176/original_1752499572813_screenshot-20250514-170854.png")), - time: "00:23", + title: "00:23", description: "这是一段示例描述,用于在预览中验证样式与布局。", - isMember: false + isMember: false, + onContinue: {} ) .previewDisplayName("Image • With Description • Guest") // 预览 2:无描述无时间,会员 BlindOutcomeView( media: .image(remoteImage("https://cdn.memorywake.com/files/7350515957925810176/original_1752499572813_screenshot-20250514-170854.png")), - time: nil, + title: nil, description: nil, - isMember: true + isMember: true, + onContinue: {} ) .previewDisplayName("Image • Minimal • Member") // 预览 3:视频示例 BlindOutcomeView( media: .video(URL(string: "https://cdn.memorywake.com/users/7350439663116619888/files/7361241959983353857/7361241920703696897.mp4")!, nil), - time: "00:23", + title: "00:23", description: "视频预览示例", - isMember: false + isMember: false, + onContinue: {} ) .previewDisplayName("Video • With Description • Guest") } diff --git a/wake/SharedUI/Media/README.md b/wake/SharedUI/Media/README.md index ac7c46b..e17dd83 100644 --- a/wake/SharedUI/Media/README.md +++ b/wake/SharedUI/Media/README.md @@ -44,9 +44,33 @@ struct DemoVideoCard: View { - `allowFullscreen: Bool = true` 是否允许进入全屏播放。 - `muteInitially: Bool = false` 初始是否静音。 - `videoGravity: AVLayerVideoGravity = .resizeAspectFill` 视频填充模式,如 `.resizeAspect` / `.resizeAspectFill`。 +- `fallbackURL: URL? = nil` 备用码流地址(建议提供 H.264/HLS)。当检测到资源为 HEVC 且当前环境不支持硬解码(如模拟器)时,自动使用该地址播放。 ### 注意事项 - 如果是新加入的文件,确保在 Xcode 中将 `WakeVideoPlayer.swift` 添加到对应 Target,否则无法被编译。 - 远程流地址需确保允许跨域与 HTTPS,示例使用 Apple 公共 HLS 资源。 - 如果需要画中画(PiP)、双击快退/快进、手势亮度/音量等高级功能,可在此基础上扩展。 +### HEVC/H.265 支持说明与降级策略 +- 模拟器通常不支持 HEVC 硬解码,表现为“只有声音、无画面”。真机(A9 及以上设备)通常支持。 +- 组件会在加载时异步分析资源轨道编码;若检测到 HEVC 且当前环境不支持硬解码,则: + - 若提供了 `fallbackURL`(建议为 H.264 或多码率 HLS),将自动切换播放该备用源; + - 若未提供 `fallbackURL`,会显示顶部黄色提示,建议在真机测试或提供备用码流。 + +示例: +```swift +WakeVideoPlayer( + url: URL(string: "https://example.com/video_h265.mp4")!, + fallbackURL: URL(string: "https://example.com/video_h264.m3u8")!, + autoPlay: true, + isLooping: false, + showsControls: true, + allowFullscreen: true, + muteInitially: false, + videoGravity: .resizeAspect +) +.frame(height: 220) +``` + +建议优先使用 HLS(.m3u8)主清单,内含多编码/多分辨率分流,兼容性更佳。 + diff --git a/wake/SharedUI/Media/WakeVideoPlayer.swift b/wake/SharedUI/Media/WakeVideoPlayer.swift index 71d1d1d..d24ca56 100644 --- a/wake/SharedUI/Media/WakeVideoPlayer.swift +++ b/wake/SharedUI/Media/WakeVideoPlayer.swift @@ -7,6 +7,7 @@ import SwiftUI import AVKit +import VideoToolbox /// 一个遵循项目 Theme 风格的 SwiftUI 视频播放组件。 /// 支持:播放/暂停、进度条、静音、全屏、自动隐藏控件、自动播放与循环。 @@ -19,6 +20,7 @@ public struct WakeVideoPlayer: View { 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() @@ -29,6 +31,7 @@ public struct WakeVideoPlayer: View { @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? @@ -43,7 +46,8 @@ public struct WakeVideoPlayer: View { showsControls: Bool = true, allowFullscreen: Bool = true, muteInitially: Bool = false, - videoGravity: AVLayerVideoGravity = .resizeAspectFill + videoGravity: AVLayerVideoGravity = .resizeAspectFill, + fallbackURL: URL? = nil ) { self.url = url self.autoPlay = autoPlay @@ -52,6 +56,7 @@ public struct WakeVideoPlayer: View { self.allowFullscreen = allowFullscreen self.muteInitially = muteInitially self.videoGravity = videoGravity + self.fallbackURL = fallbackURL } public var body: some View { @@ -95,6 +100,23 @@ private extension WakeVideoPlayer { .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(大按钮便于点按) @@ -166,17 +188,50 @@ private extension WakeVideoPlayer { // MARK: - Lifecycle & Player Setup private extension WakeVideoPlayer { func setup() { - // 配置 Player - let item = AVPlayerItem(url: url) + // 异步分析编码并选择源 + 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 - } + if cmDuration.isFinite { duration = cmDuration } // 时间观察者 addTimeObserver() @@ -283,6 +338,32 @@ private extension WakeVideoPlayer { // 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) @@ -414,7 +495,7 @@ private struct FullscreenContainer: View { } } .onAppear { scheduleAutoHideIfNeeded() } - .onChange(of: isPlaying) { _ in scheduleAutoHideIfNeeded() } + .onChange(of: isPlaying) { _, _ in scheduleAutoHideIfNeeded() } } func toggleControls() { @@ -478,3 +559,24 @@ private extension CMTime { } .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) +} diff --git a/wake/View/Subscribe/JoinModal.swift b/wake/View/Subscribe/JoinModal.swift index 9201191..0ccf453 100644 --- a/wake/View/Subscribe/JoinModal.swift +++ b/wake/View/Subscribe/JoinModal.swift @@ -2,6 +2,7 @@ import SwiftUI struct JoinModal: View { @Binding var isPresented: Bool + let onClose: () -> Void var body: some View { ZStack(alignment: .bottom) { @@ -26,7 +27,7 @@ struct JoinModal: View { Spacer() Button(action: { withAnimation { - Router.shared.navigate(to: .blindBox(mediaType: .all)) + onClose() } }) { Image(systemName: "xmark") @@ -246,6 +247,6 @@ private struct JoinListMark: View { struct JoinModal_Previews: PreviewProvider { static var previews: some View { - JoinModal(isPresented: .constant(true)) + JoinModal(isPresented: .constant(true), onClose: {}) } }