From a818457f8cce2e587b01bdba8ad92900ca468493 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 8 Jul 2024 21:48:35 -0700 Subject: [PATCH] Fix gifv playing pausing audio from other apps Closes #505 --- Tusker.xcodeproj/project.pbxproj | 4 + Tusker/AudioSessionCoordinator.swift | 87 +++++++++++++++++++ .../VideoGalleryContentViewController.swift | 10 +-- Tusker/Views/Attachments/GifvController.swift | 14 ++- Tusker/Weak.swift | 8 +- 5 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 Tusker/AudioSessionCoordinator.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 00fecb11..fd3b14e7 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -361,6 +361,7 @@ D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; }; D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; }; + D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */; }; D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; }; D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; }; @@ -803,6 +804,7 @@ D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = ""; }; D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = ""; }; D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = ""; }; + D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionCoordinator.swift; sourceTree = ""; }; D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = ""; }; D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = ""; }; D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = ""; }; @@ -1607,6 +1609,7 @@ D691296D2BA75ACF005C58ED /* PrivacyInfo.xcprivacy */, D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */, D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, + D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */, D6D79F582A13293200AB2315 /* BackgroundManager.swift */, D69261262BB3BA610023152C /* Box.swift */, D61F75B6293C119700C0B37F /* Filterer.swift */, @@ -2147,6 +2150,7 @@ D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */, + D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */, D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */, D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */, D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */, diff --git a/Tusker/AudioSessionCoordinator.swift b/Tusker/AudioSessionCoordinator.swift new file mode 100644 index 00000000..f2cea657 --- /dev/null +++ b/Tusker/AudioSessionCoordinator.swift @@ -0,0 +1,87 @@ +// +// AudioSessionCoordinator.swift +// Tusker +// +// Created by Shadowfacts on 7/8/24. +// Copyright © 2024 Shadowfacts. All rights reserved. +// + +import Foundation +import AVFoundation + +final class AudioSessionCoordinator { + static let shared = AudioSessionCoordinator() + + private init() {} + + private let queue = DispatchQueue(label: "AudioSessionCoordinator", qos: .userInitiated) + + private var videoCount = 0 + private var gifvCount = 0 + + func beginPlayback(mode: Mode, completionHandler: (() -> Void)? = nil) -> Token { + let token = Token(mode: mode) + queue.async { + switch mode { + case .video: + self.videoCount += 1 + case .gifv: + self.gifvCount += 1 + } + self.update(completionHandler: completionHandler) + } + return token + } + + func endPlayback(token: Token, completionHandler: (() -> Void)? = nil) { + // the enqueued block can't retain token, since it may be being dealloc'd right now + let mode = token.mode + queue.async { + switch mode { + case .video: + self.videoCount -= 1 + case .gifv: + self.gifvCount -= 1 + } + self.update(completionHandler: completionHandler) + } + } + + private func update(completionHandler: (() -> Void)?) { + let currentCategory = AVAudioSession.sharedInstance().category + if videoCount > 0 { + try? AVAudioSession.sharedInstance().setCategory(.playback) + try? AVAudioSession.sharedInstance().setActive(true) + } else if gifvCount > 0 { + // if we're transitioning from video to gifv, fully deactivate first + // in order to let other (music) apps resume, then activate with the + // ambient category to "mix" with others + if currentCategory == .playback { + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + // only gifv modes requested, allow mixing with others + try? AVAudioSession.sharedInstance().setCategory(.ambient) + try? AVAudioSession.sharedInstance().setActive(true) + } else { + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + completionHandler?() + } + + final class Token { + let mode: Mode + + init(mode: Mode) { + self.mode = mode + } + + deinit { + AudioSessionCoordinator.shared.endPlayback(token: self) + } + } + + enum Mode { + case video + case gifv + } +} diff --git a/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift b/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift index 162b4885..8301787b 100644 --- a/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift +++ b/Tusker/Screens/Gallery/VideoGalleryContentViewController.swift @@ -29,6 +29,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon private var rateObservation: NSKeyValueObservation? private var isFirstAppearance = true private var hideControlsWorkItem: DispatchWorkItem? + private var audioSessionToken: AudioSessionCoordinator.Token? init(url: URL, caption: String?) { self.url = url @@ -172,10 +173,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon let wasFirstAppearance = isFirstAppearance isFirstAppearance = false - DispatchQueue.global(qos: .userInitiated).async { - try? AVAudioSession.sharedInstance().setCategory(.playback) - try? AVAudioSession.sharedInstance().setActive(true) - + audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) { if wasFirstAppearance { DispatchQueue.main.async { self.player.play() @@ -187,8 +185,8 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon func galleryContentWillDisappear() { player.pause() - DispatchQueue.global(qos: .userInitiated).async { - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + if let audioSessionToken { + AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken) } } diff --git a/Tusker/Views/Attachments/GifvController.swift b/Tusker/Views/Attachments/GifvController.swift index bc6737da..cc4b2849 100644 --- a/Tusker/Views/Attachments/GifvController.swift +++ b/Tusker/Views/Attachments/GifvController.swift @@ -17,6 +17,7 @@ class GifvController { let player: AVPlayer private var isGrayscale = false + private var audioSessionToken: AudioSessionCoordinator.Token? let presentationSizeSubject = PassthroughSubject() private var presentationSizeObservation: NSKeyValueObservation? @@ -29,7 +30,9 @@ class GifvController { self.isGrayscale = Preferences.shared.grayscaleImages player.isMuted = true - #if !os(visionOS) + #if os(visionOS) + player.preventsAutomaticBackgroundingDuringVideoPlayback = false + #else player.preventsDisplaySleepDuringVideoPlayback = false #endif @@ -41,12 +44,19 @@ class GifvController { func play() { if player.rate == 0 { - player.play() + audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .gifv) { + DispatchQueue.main.async { + self.player.play() + } + } } } func pause() { player.pause() + if let audioSessionToken { + AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken) + } } private func updatePresentationSizeObservation() { diff --git a/Tusker/Weak.swift b/Tusker/Weak.swift index f956a727..9ab36325 100644 --- a/Tusker/Weak.swift +++ b/Tusker/Weak.swift @@ -8,7 +8,7 @@ import Foundation -class WeakHolder { +final class WeakHolder { weak var object: T? init(_ object: T?) { @@ -16,7 +16,7 @@ class WeakHolder { } } -struct WeakArray: MutableCollection, RangeReplaceableCollection { +struct WeakArray: MutableCollection, RangeReplaceableCollection, RandomAccessCollection, BidirectionalCollection { private var array: [WeakHolder] var startIndex: Int { array.startIndex } @@ -47,6 +47,10 @@ struct WeakArray: MutableCollection, RangeReplaceableCollect return array.index(after: i) } + func index(before i: Int) -> Int { + return array.index(before: i) + } + mutating func replaceSubrange(_ subrange: Range, with newElements: C) where C : Collection, Self.Element == C.Element { array.replaceSubrange(subrange, with: newElements.map { WeakHolder($0) }) }