I have a ScrollView which contains a series of views which contain videos. The setup is as follows:
I add the following to my ScrollView:
@ViewBuilder private var items: some View {
if let products = viewModel.allSortedExclusiveProducts {
VStack(spacing: 0) {
ForEach(products, id: \.id) { product in
ExclusiveProductListingItemView(viewModel: ExclusiveProductListingItemViewModel(product: product), scrollOffset: $scrollOffset)
}
}
}
}
Each ExclusiveProductListingItemView
looks like this:
private func videoListing(videoURL: URL) -> some View {
ZStack(alignment: .bottom) {
makeVideo(url: videoURL)
.aspectRatio(contentMode: .fit)
.frame(width: UIScreen.main.bounds.width)
.frame(maxHeight: .infinity)
itemText
}
}
private func makeVideo(url: URL) -> some View {
VideoPlayerViewRepresentable(shouldPlayVideo: .constant(true), didError: .constant(false), didFinishPlayingVideo: .constant(false), moveTo: .constant(0.0), videoDidFinishLoading: .constant(true), isLoading: .constant(false), videoURL: url, localVideoFile: nil, backgroundColour: .black, videoType: .standard)
.environmentObject(VideoPlayerEnvironmentObject())
}
And the VideoPlayerViewRepresentable
is as follows:
struct VideoPlayerViewRepresentable: UIViewControllerRepresentable {
@EnvironmentObject private var videoPlayerEnvironmentObject: VideoPlayerEnvironmentObject
@Binding var shouldPlayVideo: Bool
@Binding var didError: Bool
@Binding var didFinishPlayingVideo: Bool
@Binding var moveTo: Double
@Binding var videoDidFinishLoading: Bool
@Binding var isLoading: Bool
@State private var currentPlaybackTime: CMTime = .zero
let videoURL: URL?
let localVideoFile: String?
let backgroundColour: UIColor
let videoType: VideoType
var cancellables = Set<AnyCancellable>()
private let cacheManager = VideoCacheManager.shared
var isFullScreenGallery: Bool = false
private let playerItemPublisher = NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime)
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
uiViewController.player?.removeObserver(coordinator, forKeyPath: #keyPath(AVPlayer.currentItem.status), context: nil)
if !coordinator.isFullScreenGallery {
uiViewController.player?.replaceCurrentItem(with: nil)
}
coordinator.cancellable?.cancel()
coordinator.playerDurationObservation?.invalidate()
}
private func handleVideoPlayback(context: Context, player: AVPlayer?, playerViewController: AVPlayerViewController, seekTo: CMTime?) {
if shouldPlayVideo {
player?.currentItem?.seek(to: seekTo ?? CMTime.zero, completionHandler: nil)
playerViewController.player?.play()
}
context.coordinator.cancellable = playerItemPublisher
.sink { notification in
if let playerItem: AVPlayerItem = notification.object as? AVPlayerItem {
DispatchQueue.main.async {
if shouldPlayVideo {
didFinishPlayingVideo = true
playerItem.seek(to: CMTime.zero, completionHandler: nil)
playerViewController.player?.play()
}
}
}
}
}
func makeUIViewController(context: Context) -> AVPlayerViewController {
let player = videoPlayerEnvironmentObject.player(for: videoURL)
let playerViewController = AVPlayerViewController()
playerViewController.view.backgroundColor = backgroundColour
playerViewController.videoGravity = .resizeAspectFill
context.coordinator.isFullScreenGallery = isFullScreenGallery
context.coordinator.isFullScreenGallery = false
playerViewController.showsPlaybackControls = isFullScreenGallery && videoType != .interactive
playerViewController.player = player
// Check if cache is available
if let videoURL {
cacheManager.getFileWith(stringUrl: videoURL.absoluteString, autoPlay: videoType == .standard, player: player) { result in
switch result {
case .success(let cachedURL):
// Update the player with the cached URL
let cachedPlayer = AVPlayer(url: cachedURL)
cachedPlayer.addObserver(context.coordinator, forKeyPath: #keyPath(AVPlayer.currentItem.status), options: [.new, .old], context: nil)
let currentPlayerTime = player?.currentTime()
playerViewController.player = cachedPlayer
// Start playing the video from the cache if necessary
if shouldPlayVideo {
// Update the current playback time
DispatchQueue.main.async {
handleVideoPlayback(context: context, player: player, playerViewController: playerViewController, seekTo: currentPlayerTime)
}
}
case .failure:
didError = true
}
}
} else if let localVideoFile, let localVideoURL = Bundle.main.url(forResource: localVideoFile, withExtension: "mp4") {
let localPlayer = AVPlayer(url: localVideoURL)
localPlayer.addObserver(context.coordinator, forKeyPath: #keyPath(AVPlayer.currentItem.status), options: [.new, .old], context: nil)
playerViewController.player = localPlayer
localPlayer.play()
}
return playerViewController
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
guard videoType == .interactive else {
if shouldPlayVideo {
uiViewController.player?.play()
} else {
uiViewController.player?.pause()
}
return
}
guard let duration = uiViewController.player?.currentItem?.duration,
duration != .indefinite else {
return
}
let timestampToSeekTo = CMTimeMultiplyByFloat64(duration, multiplier: Float64(moveTo))
uiViewController.player?.seek(to: timestampToSeekTo, toleranceBefore: .zero, toleranceAfter: .zero)
DispatchQueue.main.async {
if uiViewController.player?.timeControlStatus == .playing || uiViewController.player?.timeControlStatus == .paused {
videoDidFinishLoading = true
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(didError: $didError, isLoading: $isLoading)
}
final class Coordinator: NSObject, AVPlayerViewControllerDelegate {
@Binding private var didError: Bool
@Binding var isLoading: Bool
var cancellable: AnyCancellable?
var isFullScreenGallery: Bool = false
var playerDurationObservation: NSKeyValueObservation?
var playerErrorObservation: NSKeyValueObservation?
var currentPlaybackTime: CMTime = .zero
init(didError: Binding<Bool>, isLoading: Binding<Bool>) {
self._didError = didError
self._isLoading = isLoading
}
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
playerViewController.showsPlaybackControls = true
}
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
coordinator.animate(alongsideTransition: nil) { _ in
playerViewController.player?.play()
}
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == #keyPath(AVPlayer.currentItem.status) {
let status: AVPlayerItem.Status
if let currentStatus = (change?[NSKeyValueChangeKey.newKey] as? NSNumber)?.intValue {
status = AVPlayerItem.Status(rawValue: currentStatus) ?? .unknown
if status == .readyToPlay {
isLoading = false
}
} else {
status = .unknown
}
if status == .failed {
didError = true
isLoading = false
}
}
}
}
}
Now, I need the videoListing
views to take up the full width of the screen, and for the height to adjust dynamically. I thought that by adding:
.aspectRatio(contentMode: .fit)
.frame(width: UIScreen.main.bounds.width)
.frame(maxHeight: .infinity)
this would take care of this. However, whilst the videos are full width, the height is not right at all. I am missing a large portion of the top and bottom of each video. The have the right aspect ratio, but the top and bottom are cut off.
The only way I seem to be able to resolve this is by setting an explicit height, but I can't do this as I do not know what height the videos will be in advance.