Merge pull request #1133 from omnivore-app/fix/ios-audio-sessions

Improve iOS audio sessions, preload files on load
This commit is contained in:
Jackson Harper 2022-08-25 11:06:18 +08:00 committed by GitHub
commit d501580256
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 116 additions and 36 deletions

View file

@ -88,6 +88,26 @@ public struct MiniPlayer: View {
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
Button(
action: {
let shareActivity = UIActivityViewController(activityItems: [self.audioSession.localAudioUrl], applicationActivities: nil)
if let vc = UIApplication.shared.windows.first?.rootViewController {
shareActivity.popoverPresentationController?.sourceView = vc.view
// Setup share activity position on screen on bottom center
shareActivity.popoverPresentationController?.sourceRect = CGRect(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height, width: 0, height: 0)
shareActivity.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection.down
vc.present(shareActivity, animated: true, completion: nil)
}
},
label: {
Image(systemName: "square.and.arrow.up")
.font(.appCallout)
.tint(.appGrayText)
}
)
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .trailing)
Capsule()
.fill(.gray)
.frame(width: 60, height: 4)
@ -246,7 +266,7 @@ public struct MiniPlayer: View {
.padding(EdgeInsets(top: 0, leading: expanded ? 24 : 6, bottom: 0, trailing: expanded ? 24 : 6))
.background(
Color.systemBackground
.shadow(color: expanded ? .clear : .gray /* .opacity(0.33) */, radius: 8, x: 0, y: 4)
.shadow(color: expanded ? .clear : .gray.opacity(0.33), radius: 8, x: 0, y: 4)
.mask(Rectangle().padding(.top, -20))
)
.onTapGesture {

View file

@ -5,6 +5,7 @@ import Views
struct FeedCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
let item: LinkedItem
@ -33,7 +34,7 @@ struct FeedCardNavigationLink: View {
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioSession: audioSession) }
}
FeedCard(item: item) {
viewModel.selectedLinkItem = item.objectID
@ -44,6 +45,7 @@ struct FeedCardNavigationLink: View {
struct GridCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
@State private var scale = 1.0
@ -86,7 +88,7 @@ struct GridCardNavigationLink: View {
withAnimation { tapAction() }
})
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioSession: audioSession) }
}
}
.aspectRatio(1.8, contentMode: .fill)

View file

@ -11,11 +11,13 @@ import Views
struct HomeFeedContainerView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = false
@ObservedObject var viewModel: HomeFeedViewModel
func loadItems(isRefresh: Bool) {
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
Task { await viewModel.loadItems(dataService: dataService, audioSession: audioSession, isRefresh: isRefresh) }
}
var body: some View {
@ -339,6 +341,7 @@ import Views
struct HomeFeedGridView: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioSession: AudioSession
@State private var itemToRemove: LinkedItem?
@State private var confirmationShown = false
@ -361,7 +364,7 @@ import Views
}
func loadItems(isRefresh: Bool) {
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
Task { await viewModel.loadItems(dataService: dataService, audioSession: audioSession, isRefresh: isRefresh) }
}
var body: some View {

View file

@ -40,14 +40,14 @@ import Views
var searchIdx = 0
var receivedIdx = 0
func itemAppeared(item: LinkedItem, dataService: DataService) async {
func itemAppeared(item: LinkedItem, dataService: DataService, audioSession: AudioSession) async {
if isLoading { return }
let itemIndex = items.firstIndex(where: { $0.id == item.id })
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
// Check if user has scrolled to the last five items in the list
if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 {
await loadItems(dataService: dataService, isRefresh: false)
await loadItems(dataService: dataService, audioSession: audioSession, isRefresh: false)
}
}
@ -55,7 +55,7 @@ import Views
items.insert(item, at: 0)
}
func loadItems(dataService: DataService, isRefresh: Bool) async {
func loadItems(dataService: DataService, audioSession: AudioSession, isRefresh: Bool) async {
let syncStartTime = Date()
let thisSearchIdx = searchIdx
searchIdx += 1
@ -123,6 +123,7 @@ import Views
cursor = queryResult.cursor
if let username = dataService.currentViewer?.username {
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
await audioSession.preload(itemIDs: newItems.map(\.unwrappedID))
}
} else {
updateFetchController(dataService: dataService)

View file

@ -66,6 +66,54 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
downloadTask?.cancel()
}
public func preload(itemIDs: [String], retryCount: Int = 0) async {
var pendingList = [String]()
for pageId in itemIDs {
let permFile = pathForAudioFile(pageId: pageId)
if FileManager.default.fileExists(atPath: permFile.path) {
print("audio file already downloaded: ", permFile)
continue
}
// Attempt to fetch the file if not downloaded already
let result = try? await downloadAudioFile(pageId: pageId)
if result == nil {
print("audio file had error downloading: ", pageId)
pendingList.append(pageId)
}
if let result = result, result.pending {
print("audio file is pending download: ", pageId)
pendingList.append(pageId)
}
}
print("audio files pending download: ", pendingList)
if pendingList.isEmpty {
return
}
if retryCount > 5 {
print("reached max preload depth, stopping preloading")
return
}
let retryDelayInNanoSeconds = UInt64(retryCount * 2 * 1_000_000_000)
try? await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
await preload(itemIDs: pendingList, retryCount: retryCount + 1)
}
public var localAudioUrl: URL? {
if let pageId = item?.id {
return FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(pageId + ".mp3")
}
return nil
}
public var scrubState: PlayerScrubState = .reset {
didSet {
switch scrubState {
@ -103,6 +151,16 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
}
}
public func fileNameForAudioFile(_ pageId: String) -> String {
pageId + "-" + currentVoice + ".mp3"
}
public func pathForAudioFile(pageId: String) -> URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(fileNameForAudioFile(pageId))
}
public func startAudio() {
state = .loading
setupNotifications()
@ -110,19 +168,24 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
let pageId = item!.unwrappedID
downloadTask = Task {
do {
_ = try await downloadAudioFile(pageId: pageId)
if Task.isCancelled { return }
DispatchQueue.main.async {
self.startDownloadedAudioFile(pageId: pageId)
}
} catch {
// TODO: display a failure toast here
let result = try? await downloadAudioFile(pageId: pageId)
if Task.isCancelled { return }
if result == nil {
DispatchQueue.main.async {
NSNotification.operationSuccess(message: "Error generating audio.")
self.stop()
}
print("FAILED TO DOWNLOAD AUDIO URL")
print(error)
}
if let result = result, result.pending {
DispatchQueue.main.async {
NSNotification.operationSuccess(message: "Your audio is being generated.")
}
}
DispatchQueue.main.async {
self.startDownloadedAudioFile(pageId: pageId)
}
}
}
@ -136,10 +199,7 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
// TODO: Maybe check if app is active so it doesn't end up playing later?
let audioUrl = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(pageId + ".mp3")
let audioUrl = pathForAudioFile(pageId: pageId)
if !FileManager.default.fileExists(atPath: audioUrl.path) {
stop()
return
@ -294,21 +354,18 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
commandCenter.changePlaybackPositionCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
if let event = event as? MPChangePlaybackPositionCommandEvent {
self.player?.currentTime = event.positionTime
return .success
}
return .commandFailed
}
}
func downloadAudioFile(pageId: String) async throws -> URL? {
let audioUrl = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(pageId + ".mp3")
func downloadAudioFile(pageId: String) async throws -> (pending: Bool, url: URL?) {
let audioUrl = pathForAudioFile(pageId: pageId)
// if FileManager.default.fileExists(atPath: audioUrl.path) {
// // Prevent re-download
// // TODO: We aren't doing this very safely, we should be verifying a checksum
// return audioUrl
// }
if FileManager.default.fileExists(atPath: audioUrl.path) {
return (pending: false, url: audioUrl)
}
guard let url = URL(string: "/api/article/\(pageId)/mp3/\(currentVoice)", relativeTo: appEnvironment.serverBaseURL) else {
throw BasicError.message(messageText: "Invalid audio URL")
@ -327,10 +384,7 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
}
print("httpResponse: ", httpResponse)
if let httpResponse = result?.1 as? HTTPURLResponse, httpResponse.statusCode == 202 {
print("Tell the user the download has been queued")
DispatchQueue.main.async {
NSNotification.operationSuccess(message: "Your audio is being created.")
}
return (pending: true, nil)
}
guard let data = result?.0 else {
@ -359,7 +413,7 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
throw BasicError.message(messageText: errorMessage)
}
return audioUrl
return (pending: false, url: audioUrl)
}
public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully _: Bool) {