393 lines
14 KiB
Swift
393 lines
14 KiB
Swift
import SwiftUI
|
||
import AVKit
|
||
import os.log
|
||
|
||
struct BlindOutcomeView: View {
|
||
let media: MediaType
|
||
let time: String?
|
||
let description: String?
|
||
let isMember: 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) {
|
||
self.media = media
|
||
self.time = time
|
||
self.description = description
|
||
self.isMember = isMember
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
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)
|
||
|
||
Spacer()
|
||
.frame(height: 30)
|
||
|
||
// Media content
|
||
GeometryReader { geometry in
|
||
VStack(spacing: 16) {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.fill(Color.white)
|
||
.shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 2)
|
||
|
||
VStack(spacing: 0) {
|
||
switch media {
|
||
case .image(let uiImage):
|
||
Image(uiImage: uiImage)
|
||
.resizable()
|
||
.scaledToFit()
|
||
.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)
|
||
}
|
||
}
|
||
|
||
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(.system(size: 12))
|
||
.foregroundColor(Color.themeTextMessageMain)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.bottom, 12)
|
||
}
|
||
}
|
||
.padding(.top, 8)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.padding(.bottom, 20)
|
||
}
|
||
.padding(.horizontal)
|
||
|
||
Spacer()
|
||
|
||
// Button at bottom
|
||
VStack {
|
||
Spacer()
|
||
Button(action: {
|
||
if case .video = media {
|
||
withAnimation {
|
||
showIPListModal = true
|
||
}
|
||
} else {
|
||
Router.shared.navigate(to: .feedbackView)
|
||
}
|
||
}) {
|
||
Text("Continue")
|
||
.font(.headline)
|
||
.foregroundColor(.themeTextMessageMain)
|
||
.frame(maxWidth: .infinity)
|
||
.padding()
|
||
.background(Color.themePrimary)
|
||
.cornerRadius(26)
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
.padding(.bottom, 20)
|
||
}
|
||
}
|
||
.navigationBarHidden(true)
|
||
.navigationBarBackButtonHidden(true)
|
||
.statusBar(hidden: isFullscreen)
|
||
.overlay(
|
||
JoinModal(isPresented: $showIPListModal)
|
||
)
|
||
.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()
|
||
}
|
||
}
|
||
|
||
|
||
|
||
#if DEBUG
|
||
// MARK: - Previews
|
||
struct BlindOutcomeView_Previews: PreviewProvider {
|
||
private static func coloredImage(_ color: UIColor, size: CGSize = CGSize(width: 300, height: 300)) -> UIImage {
|
||
let format = UIGraphicsImageRendererFormat()
|
||
format.scale = 2
|
||
let renderer = UIGraphicsImageRenderer(size: size, format: format)
|
||
return renderer.image { ctx in
|
||
color.setFill()
|
||
ctx.fill(CGRect(origin: .zero, size: size))
|
||
}
|
||
}
|
||
|
||
static var previews: some View {
|
||
Group {
|
||
// 预览 1:含描述与时间,非会员
|
||
BlindOutcomeView(
|
||
media: .image(coloredImage(.systemPink)),
|
||
time: "00:23",
|
||
description: "这是一段示例描述,用于在预览中验证样式与布局。",
|
||
isMember: false
|
||
)
|
||
.previewDisplayName("Image • With Description • Guest")
|
||
|
||
// 预览 2:无描述无时间,会员
|
||
BlindOutcomeView(
|
||
media: .image(coloredImage(.systemTeal)),
|
||
time: nil,
|
||
description: nil,
|
||
isMember: true
|
||
)
|
||
.previewDisplayName("Image • Minimal • Member")
|
||
}
|
||
}
|
||
}
|
||
#endif
|
||
|