RTSDA-iOS/Views/VideoPlayerView.swift
RTSDA 00679f927c docs: Update README for v2.0 release and fix git remote URL
- Comprehensive README update documenting v2.0 architectural changes
- Updated git remote to ssh://rockvilleav@git.rockvilletollandsda.church:10443/RTSDA/RTSDA-iOS.git
- Documented unified ChurchService and 60% code reduction
- Added new features: Home Feed, responsive reading, enhanced UI
- Corrected license information (GPL v3 with church content copyright)
- Updated build instructions and technical stack details
2025-08-16 18:41:51 -04:00

298 lines
13 KiB
Swift

import SwiftUI
import AVKit
@preconcurrency import MediaPlayer
struct VideoPlayerView: View {
let url: URL
@Environment(\.dismiss) private var dismiss
@State private var isInPiPMode = false
@State private var isLoading = true
@State private var isInNativeFullscreen = false
var body: some View {
ZStack {
VideoPlayerViewController(url: url, isInPiPMode: $isInPiPMode, isLoading: $isLoading, isInNativeFullscreen: $isInNativeFullscreen)
.ignoresSafeArea()
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(isInPiPMode)
if isLoading {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.5)
.tint(.white)
}
// Close button - only show when NOT in native fullscreen mode
#if os(iOS)
if !isInNativeFullscreen {
VStack {
HStack {
Spacer()
Button(action: {
SharedVideoManager.shared.stopVideo()
}) {
ZStack {
// Background circle for better tap target
Circle()
.fill(Color.black.opacity(0.6))
.frame(width: 44, height: 44)
Image(systemName: "xmark.circle.fill")
.font(.system(size: UIDevice.current.userInterfaceIdiom == .pad ? 28 : 20))
.foregroundColor(.white)
}
}
.buttonStyle(.plain)
.frame(width: 44, height: 44)
.contentShape(Circle()) // Make the entire circle tappable
.padding(.top, UIDevice.current.userInterfaceIdiom == .pad ? 20 : 130) // Moved further below volume controls
.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 20 : 16) // Better aligned with volume button on iPhone
}
Spacer()
}
}
#endif
}
.onAppear {
print("🎬 DEBUG: VideoPlayerView onAppear")
setupAudio()
}
.onDisappear {
print("🎬 DEBUG: VideoPlayerView onDisappear")
}
}
private func setupAudio() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
print("🎬 DEBUG: Audio session configured for playback with movie mode")
} catch {
print("🎬 DEBUG: Failed to configure audio session: \(error)")
}
}
}
struct VideoPlayerViewController: UIViewControllerRepresentable {
let url: URL
@Binding var isInPiPMode: Bool
@Binding var isLoading: Bool
@Binding var isInNativeFullscreen: Bool
func makeUIViewController(context: Context) -> AVPlayerViewController {
print("🎬 DEBUG: makeUIViewController called with URL: \(url)")
let playerItem = AVPlayerItem(url: url)
let player = AVPlayer(playerItem: playerItem)
// Set metadata directly on the player item
setMetadataOnPlayerItem(playerItem)
let controller = AVPlayerViewController()
controller.player = player
controller.allowsPictureInPicturePlayback = true
controller.delegate = context.coordinator
// Disable fullscreen functionality
controller.entersFullScreenWhenPlaybackBegins = false
controller.exitsFullScreenWhenPlaybackEnds = false
print("🎬 DEBUG: Setting up player and controller")
context.coordinator.setPlayerController(controller)
// Add observers for buffering state and duration
player.addObserver(context.coordinator, forKeyPath: "timeControlStatus", options: [.old, .new], context: nil)
player.currentItem?.addObserver(context.coordinator, forKeyPath: "duration", options: [.old, .new], context: nil)
context.coordinator.hasObserver = true
print("🎬 DEBUG: Observer added successfully")
print("🎬 DEBUG: Starting playback")
player.play()
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(isInPiPMode: $isInPiPMode, isLoading: $isLoading, isInNativeFullscreen: $isInNativeFullscreen)
}
private func setMetadataOnPlayerItem(_ playerItem: AVPlayerItem) {
let videoManager = SharedVideoManager.shared
var metadata: [AVMetadataItem] = []
// Title
if let title = videoManager.currentTitle {
let titleItem = AVMutableMetadataItem()
titleItem.identifier = .commonIdentifierTitle
titleItem.value = title as NSString
titleItem.extendedLanguageTag = "und"
metadata.append(titleItem)
}
// Artist
let artistItem = AVMutableMetadataItem()
artistItem.identifier = .commonIdentifierArtist
artistItem.value = "Rockville Tolland SDA Church" as NSString
artistItem.extendedLanguageTag = "und"
metadata.append(artistItem)
// Artwork
if let artworkURLString = videoManager.currentArtworkURL,
let artworkURL = URL(string: artworkURLString) {
Task {
do {
let (data, _) = try await URLSession.shared.data(from: artworkURL)
if UIImage(data: data) != nil {
let artworkItem = AVMutableMetadataItem()
artworkItem.identifier = .commonIdentifierArtwork
artworkItem.value = data as NSData
artworkItem.dataType = kCMMetadataBaseDataType_JPEG as String
artworkItem.extendedLanguageTag = "und"
await MainActor.run {
var updatedMetadata = playerItem.externalMetadata
updatedMetadata.append(artworkItem)
playerItem.externalMetadata = updatedMetadata
}
}
} catch {
print("Failed to load artwork for metadata: \(error)")
}
}
}
playerItem.externalMetadata = metadata
}
class Coordinator: NSObject, AVPlayerViewControllerDelegate {
@Binding var isInPiPMode: Bool
@Binding var isLoading: Bool
@Binding var isInNativeFullscreen: Bool
internal var playerController: AVPlayerViewController?
private var wasPlayingBeforeDismiss = false
internal var hasObserver = false
init(isInPiPMode: Binding<Bool>, isLoading: Binding<Bool>, isInNativeFullscreen: Binding<Bool>) {
_isInPiPMode = isInPiPMode
_isLoading = isLoading
_isInNativeFullscreen = isInNativeFullscreen
print("🎬 DEBUG: Coordinator init")
super.init()
}
func setPlayerController(_ controller: AVPlayerViewController) {
print("🎬 DEBUG: setPlayerController called")
playerController = controller
// Register with SharedVideoManager so it can control PiP
SharedVideoManager.shared.currentPlayerController = controller
print("🎬 DEBUG: Player registered with SharedVideoManager")
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
print("🎬 DEBUG: observeValue called for keyPath: \(keyPath ?? "nil")")
if keyPath == "timeControlStatus",
let player = object as? AVPlayer {
let status = player.timeControlStatus
print("🎬 DEBUG: timeControlStatus = \(status.rawValue)")
DispatchQueue.main.async {
self.isLoading = player.timeControlStatus == .waitingToPlayAtSpecifiedRate
print("🎬 DEBUG: isLoading set to \(self.isLoading)")
}
} else if keyPath == "duration",
let playerItem = object as? AVPlayerItem {
let duration = playerItem.duration
if duration.isValid && !duration.isIndefinite {
let durationSeconds = CMTimeGetSeconds(duration)
print("🎬 DEBUG: Duration loaded: \(durationSeconds) seconds")
}
}
}
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
print("🎬 DEBUG: willBeginFullScreenPresentation")
DispatchQueue.main.async {
self.isInNativeFullscreen = true
}
if let player = playerController?.player {
wasPlayingBeforeDismiss = (player.rate > 0)
print("🎬 DEBUG: wasPlayingBeforeDismiss = \(wasPlayingBeforeDismiss)")
}
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
print("🎬 DEBUG: willEndFullScreenPresentation - exiting native fullscreen")
DispatchQueue.main.async {
self.isInNativeFullscreen = false
}
if wasPlayingBeforeDismiss, let player = playerController?.player {
print("🎬 DEBUG: Restoring playback rate to 1.0")
player.rate = 1.0
}
// Notify SharedVideoManager that we're exiting fullscreen
DispatchQueue.main.async {
SharedVideoManager.shared.showFullScreenPlayer = false
print("🎬 DEBUG: Set SharedVideoManager.showFullScreenPlayer = false")
}
}
func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
print("🎬 DEBUG: PiP WILL START")
DispatchQueue.main.async {
self.isInPiPMode = true
SharedVideoManager.shared.setPiPMode(true)
print("🎬 DEBUG: isInPiPMode set to true")
}
}
func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
print("🎬 DEBUG: PiP DID START")
}
func playerViewControllerWillStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
print("🎬 DEBUG: PiP WILL STOP")
}
func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
print("🎬 DEBUG: PiP DID STOP")
DispatchQueue.main.async {
self.isInPiPMode = false
SharedVideoManager.shared.setPiPMode(false)
print("🎬 DEBUG: isInPiPMode set to false")
}
}
func playerViewController(_ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: Error) {
print("🎬 DEBUG: PiP FAILED TO START: \(error)")
}
func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
print("🎬 DEBUG: RESTORE USER INTERFACE FOR PIP STOP - This is the 'Return to App' action")
print("🎬 DEBUG: Current isInPiPMode: \(isInPiPMode)")
print("🎬 DEBUG: playerController exists: \(playerController != nil)")
// This is the critical method for "Return to App"
DispatchQueue.main.async {
print("🎬 DEBUG: Calling completionHandler(true)")
completionHandler(true)
}
}
deinit {
print("🎬 DEBUG: Coordinator deinit")
if let player = playerController?.player, hasObserver {
print("🎬 DEBUG: Removing observers in deinit")
player.removeObserver(self, forKeyPath: "timeControlStatus")
player.currentItem?.removeObserver(self, forKeyPath: "duration")
hasObserver = false
}
}
}
}