diff --git a/Tusker/Info.plist b/Tusker/Info.plist index c314d89d72..a3ce109887 100644 --- a/Tusker/Info.plist +++ b/Tusker/Info.plist @@ -81,6 +81,10 @@ + UIBackgroundModes + + audio + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/Tusker/Screens/Attachment Gallery/GalleryPlayerViewController.swift b/Tusker/Screens/Attachment Gallery/GalleryPlayerViewController.swift index c55ef87b29..f98abdcf6a 100644 --- a/Tusker/Screens/Attachment Gallery/GalleryPlayerViewController.swift +++ b/Tusker/Screens/Attachment Gallery/GalleryPlayerViewController.swift @@ -8,9 +8,18 @@ import UIKit import AVKit +import Pachyderm class GalleryPlayerViewController: AVPlayerViewController { + var attachment: Attachment! + + override func viewDidLoad() { + super.viewDidLoad() + + allowsPictureInPicturePlayback = true + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) diff --git a/Tusker/Screens/Attachment Gallery/GalleryViewController.swift b/Tusker/Screens/Attachment Gallery/GalleryViewController.swift index 4cdc80b975..dbcbf8ca56 100644 --- a/Tusker/Screens/Attachment Gallery/GalleryViewController.swift +++ b/Tusker/Screens/Attachment Gallery/GalleryViewController.swift @@ -12,11 +12,13 @@ import AVKit class GalleryViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, LargeImageAnimatableViewController { + weak var avPlayerViewControllerDelegate: AVPlayerViewControllerDelegate? + let attachments: [Attachment] let sourceViews: WeakArray let startIndex: Int - let pages: [UIViewController] + var pages: [UIViewController]! var currentIndex: Int { guard let vc = viewControllers?.first, @@ -72,6 +74,18 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc self.sourceViews = WeakArray(sourceViews) self.startIndex = startIndex + super.init(transitionStyle: .scroll, navigationOrientation: .horizontal) + + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.pages = attachments.enumerated().map { (index, attachment) in switch attachment.kind { case .image: @@ -82,6 +96,8 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc case .video, .audio: let vc = GalleryPlayerViewController() vc.player = AVPlayer(url: attachment.url) + vc.delegate = avPlayerViewControllerDelegate + vc.attachment = attachment return vc case .gifv: // Passing the source view to the LargeImageGifvContentView is a crappy workaround for not @@ -101,19 +117,7 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc } } - super.init(transitionStyle: .scroll, navigationOrientation: .horizontal) - setViewControllers([pages[startIndex]], direction: .forward, animated: false) - - modalPresentationStyle = .fullScreen - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() self.dataSource = self self.delegate = self diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift index f314ac9570..b5b11c0482 100644 --- a/Tusker/Views/Attachments/AttachmentView.swift +++ b/Tusker/Views/Attachments/AttachmentView.swift @@ -12,7 +12,7 @@ import Gifu import AVFoundation protocol AttachmentViewDelegate: class { - func attachmentViewGallery(startingAt index: Int) -> UIViewController? + func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? func attachmentViewPresent(_ vc: UIViewController, animated: Bool) } diff --git a/Tusker/Views/Status/BaseStatusTableViewCell.swift b/Tusker/Views/Status/BaseStatusTableViewCell.swift index 117ccca341..d3cfd2af12 100644 --- a/Tusker/Views/Status/BaseStatusTableViewCell.swift +++ b/Tusker/Views/Status/BaseStatusTableViewCell.swift @@ -9,6 +9,7 @@ import UIKit import Pachyderm import Combine +import AVKit protocol StatusTableViewCellDelegate: TuskerNavigationDelegate { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) @@ -70,6 +71,8 @@ class BaseStatusTableViewCell: UITableViewCell { private var statusUpdater: Cancellable? private var accountUpdater: Cancellable? + private var currentPictureInPictureVideoStatusID: String? + override func awakeFromNib() { super.awakeFromNib() @@ -360,15 +363,74 @@ class BaseStatusTableViewCell: UITableViewCell { } extension BaseStatusTableViewCell: AttachmentViewDelegate { - func attachmentViewGallery(startingAt index: Int) -> UIViewController? { + func attachmentViewGallery(startingAt index: Int) -> GalleryViewController? { guard let delegate = delegate, let status = mastodonController.persistentContainer.status(for: statusID) else { return nil } let sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:)) - return delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index) + let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index) + gallery.avPlayerViewControllerDelegate = self + return gallery } func attachmentViewPresent(_ vc: UIViewController, animated: Bool) { - delegate?.show(vc) + delegate?.present(vc, animated: animated) + } +} + +// todo: This is not ideal. It works when the original cell remains visible and when the cell is reused, but if the cell is dealloc'd +// resuming from PiP won't work because AVPlayerViewController.delegate is a weak reference. +extension BaseStatusTableViewCell: AVPlayerViewControllerDelegate { + func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + // We need to save the current statusID when PiP is initiated, because if the user restores from PiP after this cell has + // been reused, the current value of statusID will not be correct. + currentPictureInPictureVideoStatusID = statusID + } + + func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { + currentPictureInPictureVideoStatusID = nil + } + + func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool { + // Ideally, when PiP is automatically initiated by app closing the gallery should not be dismissed + // and when PiP is started because the user has tapped the button in the player controls the gallery + // gallery should be dismissed. Unfortunately, this doesn't seem to be possible. Instead, the gallery is + // always dismissed and is recreated when restoring the interface from PiP. + return true + } + + func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { + guard let delegate = delegate, + let playerViewController = playerViewController as? GalleryPlayerViewController, + let id = currentPictureInPictureVideoStatusID, + let status = mastodonController.persistentContainer.status(for: id), + let index = status.attachments.firstIndex(where: { $0.id == playerViewController.attachment?.id }) else { + // returning without invoking completionHandler will dismiss the PiP window + return + } + + // We create a new gallery view controller starting at the appropriate index and swap the + // already-playing VC into the appropriate index so it smoothly continues playing. + + let sourceViews: [UIImageView?] + if self.statusID == id { + sourceViews = status.attachments.map(attachmentsView.getAttachmentView(for:)) + } else { + sourceViews = status.attachments.map { (_) in nil } + } + let gallery = delegate.gallery(attachments: status.attachments, sourceViews: sourceViews, startIndex: index) + gallery.avPlayerViewControllerDelegate = self + + // ensure that all other page VCs are created + gallery.loadViewIfNeeded() + // replace the newly created player for the same attachment with the already-playing one + gallery.pages[index] = playerViewController + gallery.setViewControllers([playerViewController], direction: .forward, animated: false, completion: nil) + + // this isn't animated, otherwise the animation plays first and then the PiP window expands + // which looks even weirder than the black background appearing instantly and then the PiP window animating + delegate.present(gallery, animated: false) { + completionHandler(false) + } } }