feat: 结果页面优化
This commit is contained in:
parent
6e1ac1fc75
commit
ce3d858e16
@ -8,7 +8,7 @@ enum AppRoute: Hashable {
|
|||||||
case feedbackDetail(type: FeedbackView.FeedbackType)
|
case feedbackDetail(type: FeedbackView.FeedbackType)
|
||||||
case mediaUpload
|
case mediaUpload
|
||||||
case blindBox(mediaType: BlindBoxMediaType, blindBoxId: String? = nil)
|
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 memories
|
||||||
case subscribe
|
case subscribe
|
||||||
case userInfo
|
case userInfo
|
||||||
@ -33,8 +33,20 @@ enum AppRoute: Hashable {
|
|||||||
MediaUploadView()
|
MediaUploadView()
|
||||||
case .blindBox(let mediaType, let blindBoxId):
|
case .blindBox(let mediaType, let blindBoxId):
|
||||||
BlindBoxView(mediaType: mediaType, blindBoxId: blindBoxId)
|
BlindBoxView(mediaType: mediaType, blindBoxId: blindBoxId)
|
||||||
case .blindOutcome(let media, let time, let description, let isMember):
|
case .blindOutcome(let media, let title, let description, let isMember, let goToFeedback):
|
||||||
BlindOutcomeView(media: media, time: time, description: description, isMember: isMember)
|
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:
|
case .memories:
|
||||||
MemoriesView()
|
MemoriesView()
|
||||||
case .subscribe:
|
case .subscribe:
|
||||||
|
|||||||
@ -283,9 +283,10 @@ struct BlindBoxView: View {
|
|||||||
Router.shared.navigate(
|
Router.shared.navigate(
|
||||||
to: .blindOutcome(
|
to: .blindOutcome(
|
||||||
media: .video(url, nil),
|
media: .video(url, nil),
|
||||||
time: viewModel.blindGenerate?.name ?? "Your box",
|
title: viewModel.blindGenerate?.name ?? "Your box",
|
||||||
description: viewModel.blindGenerate?.description ?? "",
|
description: viewModel.blindGenerate?.description ?? "",
|
||||||
isMember: viewModel.isMember
|
isMember: viewModel.isMember,
|
||||||
|
goToFeedback: false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@ -303,9 +304,10 @@ struct BlindBoxView: View {
|
|||||||
Router.shared.navigate(
|
Router.shared.navigate(
|
||||||
to: .blindOutcome(
|
to: .blindOutcome(
|
||||||
media: .image(image),
|
media: .image(image),
|
||||||
time: viewModel.blindGenerate?.name ?? "Your box",
|
title: viewModel.blindGenerate?.name ?? "Your box",
|
||||||
description: viewModel.blindGenerate?.description ?? "",
|
description: viewModel.blindGenerate?.description ?? "",
|
||||||
isMember: viewModel.isMember
|
isMember: viewModel.isMember,
|
||||||
|
goToFeedback: true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVKit
|
|
||||||
import os.log
|
import os.log
|
||||||
|
|
||||||
struct BlindOutcomeView: View {
|
struct BlindOutcomeView: View {
|
||||||
let media: MediaType
|
let media: MediaType
|
||||||
let time: String?
|
let title: String?
|
||||||
let description: String?
|
let description: String?
|
||||||
let isMember: Bool
|
let isMember: Bool
|
||||||
|
let onContinue: () -> Void
|
||||||
|
let showJoinModal: Bool
|
||||||
|
|
||||||
// Removed presentationMode; use Router.shared.pop() for back navigation
|
// 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 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.media = media
|
||||||
self.time = time
|
self.title = title
|
||||||
self.description = description
|
self.description = description
|
||||||
self.isMember = isMember
|
self.isMember = isMember
|
||||||
|
self.onContinue = onContinue
|
||||||
|
self.showJoinModal = showJoinModal
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -26,40 +26,17 @@ struct BlindOutcomeView: View {
|
|||||||
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
Color.themeTextWhiteSecondary.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// 自定义导航栏
|
// 通用导航栏
|
||||||
HStack {
|
// NaviHeader(
|
||||||
Button(action: {
|
// title: "Blind Box",
|
||||||
Router.shared.pop()
|
// onBackTap: { Router.shared.pop() },
|
||||||
}) {
|
// showBackButton: true,
|
||||||
HStack(spacing: 4) {
|
// titleStyle: .title,
|
||||||
Image(systemName: "chevron.left")
|
// backgroundColor: Color.themeTextWhiteSecondary
|
||||||
.font(.headline)
|
// )
|
||||||
}
|
// .zIndex(1)
|
||||||
.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)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
.frame(height: 30)
|
.frame(height: Theme.Spacing.lg)
|
||||||
|
|
||||||
// Media content
|
// Media content
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@ -77,47 +54,35 @@ struct BlindOutcomeView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.onTapGesture {
|
// 图片不启用全屏切换
|
||||||
withAnimation {
|
|
||||||
isFullscreen.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case .video(let url, _):
|
case .video(let url, _):
|
||||||
VideoPlayerView(url: url, isPlaying: $isPlaying, player: $player)
|
WakeVideoPlayer(
|
||||||
.frame(width: UIScreen.main.bounds.width - 40)
|
url: url,
|
||||||
.background(Color.clear)
|
autoPlay: true,
|
||||||
.cornerRadius(10)
|
isLooping: true,
|
||||||
.clipped()
|
showsControls: true,
|
||||||
.onAppear {
|
allowFullscreen: true,
|
||||||
isPlaying = true
|
muteInitially: false,
|
||||||
}
|
videoGravity: .resizeAspect
|
||||||
.onDisappear {
|
)
|
||||||
isPlaying = false
|
.frame(width: UIScreen.main.bounds.width - 40)
|
||||||
player?.pause()
|
.background(Color.clear)
|
||||||
}
|
.cornerRadius(10)
|
||||||
.onTapGesture {
|
.clipped()
|
||||||
withAnimation {
|
|
||||||
showControls.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fullScreenCover(isPresented: $isFullscreen) {
|
|
||||||
FullscreenMediaView(media: media, isPresented: $isFullscreen, isPlaying: $isPlaying, player: player)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let description = description, !description.isEmpty {
|
if let description = description, !description.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Description")
|
// Text("Description")
|
||||||
.font(Typography.font(for: .body, family: .quicksandBold))
|
// .font(Typography.font(for: .body, family: .quicksandBold))
|
||||||
.foregroundColor(.themeTextMessageMain)
|
// .foregroundColor(.themeTextMessageMain)
|
||||||
Text(description)
|
Text(description)
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(Color.themeTextMessageMain)
|
.foregroundColor(Color.themeTextMessageMain)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(Theme.Spacing.lg)
|
||||||
.padding(.bottom, 12)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@ -134,12 +99,12 @@ struct BlindOutcomeView: View {
|
|||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if case .video = media {
|
if showJoinModal {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
showIPListModal = true
|
showIPListModal = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Router.shared.navigate(to: .feedbackView)
|
onContinue()
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Text("Continue")
|
Text("Continue")
|
||||||
@ -155,199 +120,11 @@ struct BlindOutcomeView: View {
|
|||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
// .navigationBarHidden(true)
|
||||||
.navigationBarBackButtonHidden(true)
|
// .navigationBarBackButtonHidden(true)
|
||||||
.statusBar(hidden: isFullscreen)
|
|
||||||
.overlay(
|
.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:含描述与时间,非会员
|
// 预览 1:含描述与时间,非会员
|
||||||
BlindOutcomeView(
|
BlindOutcomeView(
|
||||||
media: .image(remoteImage("https://cdn.memorywake.com/files/7350515957925810176/original_1752499572813_screenshot-20250514-170854.png")),
|
media: .image(remoteImage("https://cdn.memorywake.com/files/7350515957925810176/original_1752499572813_screenshot-20250514-170854.png")),
|
||||||
time: "00:23",
|
title: "00:23",
|
||||||
description: "这是一段示例描述,用于在预览中验证样式与布局。",
|
description: "这是一段示例描述,用于在预览中验证样式与布局。",
|
||||||
isMember: false
|
isMember: false,
|
||||||
|
onContinue: {}
|
||||||
)
|
)
|
||||||
.previewDisplayName("Image • With Description • Guest")
|
.previewDisplayName("Image • With Description • Guest")
|
||||||
|
|
||||||
// 预览 2:无描述无时间,会员
|
// 预览 2:无描述无时间,会员
|
||||||
BlindOutcomeView(
|
BlindOutcomeView(
|
||||||
media: .image(remoteImage("https://cdn.memorywake.com/files/7350515957925810176/original_1752499572813_screenshot-20250514-170854.png")),
|
media: .image(remoteImage("https://cdn.memorywake.com/files/7350515957925810176/original_1752499572813_screenshot-20250514-170854.png")),
|
||||||
time: nil,
|
title: nil,
|
||||||
description: nil,
|
description: nil,
|
||||||
isMember: true
|
isMember: true,
|
||||||
|
onContinue: {}
|
||||||
)
|
)
|
||||||
.previewDisplayName("Image • Minimal • Member")
|
.previewDisplayName("Image • Minimal • Member")
|
||||||
|
|
||||||
// 预览 3:视频示例
|
// 预览 3:视频示例
|
||||||
BlindOutcomeView(
|
BlindOutcomeView(
|
||||||
media: .video(URL(string: "https://cdn.memorywake.com/users/7350439663116619888/files/7361241959983353857/7361241920703696897.mp4")!, nil),
|
media: .video(URL(string: "https://cdn.memorywake.com/users/7350439663116619888/files/7361241959983353857/7361241920703696897.mp4")!, nil),
|
||||||
time: "00:23",
|
title: "00:23",
|
||||||
description: "视频预览示例",
|
description: "视频预览示例",
|
||||||
isMember: false
|
isMember: false,
|
||||||
|
onContinue: {}
|
||||||
)
|
)
|
||||||
.previewDisplayName("Video • With Description • Guest")
|
.previewDisplayName("Video • With Description • Guest")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,9 +44,33 @@ struct DemoVideoCard: View {
|
|||||||
- `allowFullscreen: Bool = true` 是否允许进入全屏播放。
|
- `allowFullscreen: Bool = true` 是否允许进入全屏播放。
|
||||||
- `muteInitially: Bool = false` 初始是否静音。
|
- `muteInitially: Bool = false` 初始是否静音。
|
||||||
- `videoGravity: AVLayerVideoGravity = .resizeAspectFill` 视频填充模式,如 `.resizeAspect` / `.resizeAspectFill`。
|
- `videoGravity: AVLayerVideoGravity = .resizeAspectFill` 视频填充模式,如 `.resizeAspect` / `.resizeAspectFill`。
|
||||||
|
- `fallbackURL: URL? = nil` 备用码流地址(建议提供 H.264/HLS)。当检测到资源为 HEVC 且当前环境不支持硬解码(如模拟器)时,自动使用该地址播放。
|
||||||
|
|
||||||
### 注意事项
|
### 注意事项
|
||||||
- 如果是新加入的文件,确保在 Xcode 中将 `WakeVideoPlayer.swift` 添加到对应 Target,否则无法被编译。
|
- 如果是新加入的文件,确保在 Xcode 中将 `WakeVideoPlayer.swift` 添加到对应 Target,否则无法被编译。
|
||||||
- 远程流地址需确保允许跨域与 HTTPS,示例使用 Apple 公共 HLS 资源。
|
- 远程流地址需确保允许跨域与 HTTPS,示例使用 Apple 公共 HLS 资源。
|
||||||
- 如果需要画中画(PiP)、双击快退/快进、手势亮度/音量等高级功能,可在此基础上扩展。
|
- 如果需要画中画(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)主清单,内含多编码/多分辨率分流,兼容性更佳。
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVKit
|
import AVKit
|
||||||
|
import VideoToolbox
|
||||||
|
|
||||||
/// 一个遵循项目 Theme 风格的 SwiftUI 视频播放组件。
|
/// 一个遵循项目 Theme 风格的 SwiftUI 视频播放组件。
|
||||||
/// 支持:播放/暂停、进度条、静音、全屏、自动隐藏控件、自动播放与循环。
|
/// 支持:播放/暂停、进度条、静音、全屏、自动隐藏控件、自动播放与循环。
|
||||||
@ -19,6 +20,7 @@ public struct WakeVideoPlayer: View {
|
|||||||
private let allowFullscreen: Bool
|
private let allowFullscreen: Bool
|
||||||
private let muteInitially: Bool
|
private let muteInitially: Bool
|
||||||
private let videoGravity: AVLayerVideoGravity
|
private let videoGravity: AVLayerVideoGravity
|
||||||
|
private let fallbackURL: URL?
|
||||||
|
|
||||||
// MARK: - Internal State
|
// MARK: - Internal State
|
||||||
@State private var player: AVPlayer = AVPlayer()
|
@State private var player: AVPlayer = AVPlayer()
|
||||||
@ -29,6 +31,7 @@ public struct WakeVideoPlayer: View {
|
|||||||
@State private var isScrubbing: Bool = false
|
@State private var isScrubbing: Bool = false
|
||||||
@State private var isControlsVisible: Bool = true
|
@State private var isControlsVisible: Bool = true
|
||||||
@State private var isFullscreen: Bool = false
|
@State private var isFullscreen: Bool = false
|
||||||
|
@State private var warningMessage: String?
|
||||||
|
|
||||||
@State private var timeObserverToken: Any?
|
@State private var timeObserverToken: Any?
|
||||||
@State private var endObserver: Any?
|
@State private var endObserver: Any?
|
||||||
@ -43,7 +46,8 @@ public struct WakeVideoPlayer: View {
|
|||||||
showsControls: Bool = true,
|
showsControls: Bool = true,
|
||||||
allowFullscreen: Bool = true,
|
allowFullscreen: Bool = true,
|
||||||
muteInitially: Bool = false,
|
muteInitially: Bool = false,
|
||||||
videoGravity: AVLayerVideoGravity = .resizeAspectFill
|
videoGravity: AVLayerVideoGravity = .resizeAspectFill,
|
||||||
|
fallbackURL: URL? = nil
|
||||||
) {
|
) {
|
||||||
self.url = url
|
self.url = url
|
||||||
self.autoPlay = autoPlay
|
self.autoPlay = autoPlay
|
||||||
@ -52,6 +56,7 @@ public struct WakeVideoPlayer: View {
|
|||||||
self.allowFullscreen = allowFullscreen
|
self.allowFullscreen = allowFullscreen
|
||||||
self.muteInitially = muteInitially
|
self.muteInitially = muteInitially
|
||||||
self.videoGravity = videoGravity
|
self.videoGravity = videoGravity
|
||||||
|
self.fallbackURL = fallbackURL
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
@ -95,6 +100,23 @@ private extension WakeVideoPlayer {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.allowsHitTesting(false)
|
.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()
|
Spacer()
|
||||||
|
|
||||||
// Center play/pause button(大按钮便于点按)
|
// Center play/pause button(大按钮便于点按)
|
||||||
@ -166,17 +188,50 @@ private extension WakeVideoPlayer {
|
|||||||
// MARK: - Lifecycle & Player Setup
|
// MARK: - Lifecycle & Player Setup
|
||||||
private extension WakeVideoPlayer {
|
private extension WakeVideoPlayer {
|
||||||
func setup() {
|
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.replaceCurrentItem(with: item)
|
||||||
player.isMuted = muteInitially
|
player.isMuted = muteInitially
|
||||||
isMuted = muteInitially
|
isMuted = muteInitially
|
||||||
|
player.automaticallyWaitsToMinimizeStalling = true
|
||||||
|
player.allowsExternalPlayback = false
|
||||||
|
|
||||||
// 监听时长
|
// 监听时长
|
||||||
let cmDuration = item.asset.duration.secondsNonNaN
|
let cmDuration = item.asset.duration.secondsNonNaN
|
||||||
if cmDuration.isFinite {
|
if cmDuration.isFinite { duration = cmDuration }
|
||||||
duration = cmDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
// 时间观察者
|
// 时间观察者
|
||||||
addTimeObserver()
|
addTimeObserver()
|
||||||
@ -283,6 +338,32 @@ private extension WakeVideoPlayer {
|
|||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
private extension WakeVideoPlayer {
|
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 {
|
func formatTime(_ seconds: Double) -> String {
|
||||||
guard seconds.isFinite && !seconds.isNaN else { return "00:00" }
|
guard seconds.isFinite && !seconds.isNaN else { return "00:00" }
|
||||||
let total = Int(seconds)
|
let total = Int(seconds)
|
||||||
@ -414,7 +495,7 @@ private struct FullscreenContainer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { scheduleAutoHideIfNeeded() }
|
.onAppear { scheduleAutoHideIfNeeded() }
|
||||||
.onChange(of: isPlaying) { _ in scheduleAutoHideIfNeeded() }
|
.onChange(of: isPlaying) { _, _ in scheduleAutoHideIfNeeded() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleControls() {
|
func toggleControls() {
|
||||||
@ -478,3 +559,24 @@ private extension CMTime {
|
|||||||
}
|
}
|
||||||
.background(Theme.Colors.background)
|
.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)
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct JoinModal: View {
|
struct JoinModal: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
@ -26,7 +27,7 @@ struct JoinModal: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
Button(action: {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
Router.shared.navigate(to: .blindBox(mediaType: .all))
|
onClose()
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
@ -246,6 +247,6 @@ private struct JoinListMark: View {
|
|||||||
|
|
||||||
struct JoinModal_Previews: PreviewProvider {
|
struct JoinModal_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
JoinModal(isPresented: .constant(true))
|
JoinModal(isPresented: .constant(true), onClose: {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user