Preload audio files when the user opens the app

This kicks off generation and download of audio files. Pending
files are put in a queue and re-attempted. In the UI if a user
hits the pending state they just get some UI now.
This commit is contained in:
Jackson Harper 2022-08-24 22:19:01 +08:00
parent 886d5fbe2c
commit 4837a63ecb
4 changed files with 94 additions and 35 deletions

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
@ -300,16 +360,12 @@ public class AudioSession: NSObject, ObservableObject, AVAudioPlayerDelegate {
}
}
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")
@ -328,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 {
@ -360,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) {