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)
+ }
}
}