feat: 结果页面优化
This commit is contained in:
parent
6e1ac1fc75
commit
ce3d858e16
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)主清单,内含多编码/多分辨率分流,兼容性更佳。
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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: {})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user