
- 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
298 lines
13 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
} |