feat: 结果页面优化

This commit is contained in:
Junhui Chen 2025-09-12 14:15:35 +08:00
parent 6e1ac1fc75
commit ce3d858e16
6 changed files with 207 additions and 286 deletions

View File

@ -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:

View File

@ -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

View File

@ -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<Bool>, isPlaying: Binding<Bool>, 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")
}

View File

@ -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主清单内含多编码/多分辨率分流,兼容性更佳。

View File

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

View File

@ -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: {})
}
}