mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Clean up, remove old stubbed stuff
This commit is contained in:
parent
1ba0746cea
commit
f0be6a41a9
6 changed files with 186 additions and 182 deletions
|
|
@ -21,12 +21,10 @@ public class FullScreenDigestViewModel: ObservableObject {
|
|||
if let digest = try await dataService.getLatestDigest(timeoutInterval: 10) {
|
||||
self.digest = digest
|
||||
lastVisitedDigestId = digest.id
|
||||
|
||||
if let playingDigest = audioController.itemAudioProperties as? DigestAudioItem, playingDigest.digest.id == digest.id {
|
||||
// Don't think we need to do anything here
|
||||
} else {
|
||||
audioController.play(itemAudioProperties: DigestAudioItem(digest: digest))
|
||||
audioController.pause()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -72,7 +70,7 @@ struct FullScreenDigestView: View {
|
|||
}
|
||||
|
||||
var createdString: String {
|
||||
if let createdAt = viewModel.digest?.createdAt,
|
||||
if let createdAt = viewModel.digest?.createdAt,
|
||||
let date = DateFormatter.formatterISO8601.date(from: createdAt) {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
|
|
|
|||
|
|
@ -127,12 +127,11 @@ struct LibraryTabView: View {
|
|||
}.tag("inbox")
|
||||
|
||||
NavigationView {
|
||||
ProfileView()
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("profile")
|
||||
}
|
||||
|
||||
ProfileView()
|
||||
.navigationViewStyle(.stack)
|
||||
}.tag("profile")
|
||||
}
|
||||
|
||||
if audioController.itemAudioProperties != nil {
|
||||
MiniPlayerViewer()
|
||||
.onTapGesture {
|
||||
|
|
|
|||
|
|
@ -29,53 +29,55 @@
|
|||
case high
|
||||
}
|
||||
|
||||
public struct DigestAudioItem: AudioItemProperties {
|
||||
public let audioItemType = Models.AudioItemType.digest
|
||||
public let digest: DigestResult
|
||||
public let itemID: String
|
||||
public let title: String
|
||||
public var byline: String?
|
||||
public var imageURL: URL?
|
||||
public var language: String?
|
||||
public var startIndex: Int = 0
|
||||
public var startOffset: Double = 0.0
|
||||
|
||||
public init(digest: DigestResult) {
|
||||
self.digest = digest
|
||||
self.itemID = digest.id
|
||||
self.title = digest.title
|
||||
self.startIndex = 0
|
||||
self.startOffset = 0
|
||||
|
||||
self.imageURL = nil
|
||||
|
||||
if let first = digest.speechFiles.first {
|
||||
self.language = first.language
|
||||
self.byline = digest.byline
|
||||
}
|
||||
public struct DigestAudioItem: AudioItemProperties {
|
||||
public let audioItemType = Models.AudioItemType.digest
|
||||
public let digest: DigestResult
|
||||
public let itemID: String
|
||||
public let title: String
|
||||
public var byline: String?
|
||||
public var imageURL: URL?
|
||||
public var language: String?
|
||||
public var startIndex: Int = 0
|
||||
public var startOffset: Double = 0.0
|
||||
|
||||
public init(digest: DigestResult) {
|
||||
self.digest = digest
|
||||
self.itemID = digest.id
|
||||
self.title = digest.title
|
||||
self.startIndex = 0
|
||||
self.startOffset = 0
|
||||
|
||||
self.imageURL = nil
|
||||
|
||||
if let first = digest.speechFiles.first {
|
||||
self.language = first.language
|
||||
self.byline = digest.byline
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// swiftlint:disable all
|
||||
@MainActor
|
||||
public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||
@Published public var state: AudioControllerState = .stopped
|
||||
@Published public var currentAudioIndex: Int = 0
|
||||
@Published public var readText: String = ""
|
||||
@Published public var unreadText: String = ""
|
||||
@Published public var itemAudioProperties: AudioItemProperties?
|
||||
|
||||
|
||||
@Published public var timeElapsed: TimeInterval = 0
|
||||
@Published public var duration: TimeInterval = 0
|
||||
@Published public var timeElapsedString: String?
|
||||
@Published public var durationString: String?
|
||||
@Published public var voiceList: [VoiceItem]?
|
||||
@Published public var realisticVoiceList: [VoiceItem]?
|
||||
|
||||
|
||||
@Published public var textItems: [String]?
|
||||
|
||||
|
||||
@Published public var playbackError: Bool = false
|
||||
|
||||
|
||||
let dataService: DataService
|
||||
|
||||
|
||||
var timer: Timer?
|
||||
var player: AVQueuePlayer?
|
||||
var observer: Any?
|
||||
|
|
@ -83,30 +85,30 @@
|
|||
var synthesizer: SpeechSynthesizer?
|
||||
var durations: [Double]?
|
||||
var lastReadUpdate = 0.0
|
||||
|
||||
|
||||
var samplePlayer: AVPlayer?
|
||||
|
||||
|
||||
public init(dataService: DataService) {
|
||||
self.dataService = dataService
|
||||
|
||||
|
||||
super.init()
|
||||
self.voiceList = generateVoiceList()
|
||||
self.realisticVoiceList = generateRealisticVoiceList()
|
||||
self._currentLanguage = defaultLanguage
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
player = nil
|
||||
observer = nil
|
||||
}
|
||||
|
||||
|
||||
public func play(itemAudioProperties: AudioItemProperties) {
|
||||
stop()
|
||||
|
||||
|
||||
playbackError = false
|
||||
self.itemAudioProperties = itemAudioProperties
|
||||
startAudio(atIndex: itemAudioProperties.startIndex, andOffset: itemAudioProperties.startOffset)
|
||||
|
||||
|
||||
EventTracker.track(
|
||||
.audioSessionStart(
|
||||
linkID: itemAudioProperties.itemID,
|
||||
|
|
@ -116,7 +118,7 @@
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
public var offsets: [Double]? {
|
||||
if let durations = durations {
|
||||
var currentSum = 0.0
|
||||
|
|
@ -127,55 +129,55 @@
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
public func stop() {
|
||||
let stoppedId = itemAudioProperties?.itemID
|
||||
let stoppedTimeElapsed = timeElapsed
|
||||
|
||||
|
||||
savePositionInfo(force: true)
|
||||
|
||||
|
||||
player?.pause()
|
||||
timer?.invalidate()
|
||||
|
||||
|
||||
clearNowPlayingInfo()
|
||||
|
||||
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
player?.removeAllItems()
|
||||
|
||||
|
||||
document = nil
|
||||
textItems = nil
|
||||
|
||||
|
||||
timer = nil
|
||||
player = nil
|
||||
observer = nil
|
||||
synthesizer = nil
|
||||
lastReadUpdate = 0
|
||||
|
||||
|
||||
itemAudioProperties = nil
|
||||
state = .stopped
|
||||
timeElapsed = 0
|
||||
duration = 1
|
||||
durations = nil
|
||||
currentAudioIndex = 0
|
||||
|
||||
|
||||
if let stoppedId = stoppedId {
|
||||
EventTracker.track(
|
||||
.audioSessionEnd(linkID: stoppedId, timeElapsed: stoppedTimeElapsed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func stopWithError() {
|
||||
pause()
|
||||
playbackError = true
|
||||
|
||||
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
if let player = player {
|
||||
player.removeAllItems()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func generateVoiceList() -> [VoiceItem] {
|
||||
Voices.Pairs.flatMap { voicePair in
|
||||
[
|
||||
|
|
@ -184,7 +186,7 @@
|
|||
]
|
||||
}.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
|
||||
public func generateRealisticVoiceList() -> [VoiceItem] {
|
||||
Voices.UltraPairs.flatMap { voicePair in
|
||||
[
|
||||
|
|
@ -193,14 +195,14 @@
|
|||
]
|
||||
}.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
|
||||
public func preload(itemIDs: [String], retryCount _: Int = 0) async -> Bool {
|
||||
for itemID in itemIDs {
|
||||
_ = try? await downloadSpeechFile(itemID: itemID, priority: .low)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
public func downloadForOffline(itemID: String) async -> Bool {
|
||||
if let document = try? await getSpeechFile(itemID: itemID, priority: .low) {
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
|
||||
|
|
@ -216,7 +218,7 @@
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
public static func removeAudioFiles(itemID: String) {
|
||||
do {
|
||||
let audioDirectory = pathForAudioDirectory(itemID: itemID)
|
||||
|
|
@ -227,7 +229,7 @@
|
|||
print("Error removing audio files", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public var scrubState: PlayerScrubState = .reset {
|
||||
didSet {
|
||||
switch scrubState {
|
||||
|
|
@ -240,34 +242,34 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func updateDuration(forItem item: SpeechItem, newDuration: TimeInterval) {
|
||||
if let durations = self.durations, item.audioIdx < durations.count {
|
||||
self.durations?[item.audioIdx] = (newDuration / playbackRate)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func seek(toUtterance: Int) {
|
||||
player?.pause()
|
||||
|
||||
|
||||
player?.removeAllItems()
|
||||
synthesizeFrom(start: toUtterance, playWhenReady: state == .playing, atOffset: 0.0)
|
||||
scrubState = .reset
|
||||
fireTimer()
|
||||
}
|
||||
|
||||
|
||||
public func seek(to: TimeInterval) {
|
||||
let position = max(0, to)
|
||||
|
||||
|
||||
// Always reset this state when seeking so we trigger a re-saving of positional info
|
||||
lastReadUpdate = 0
|
||||
|
||||
|
||||
// If we are in reachedEnd state, and seek back, we need to move to
|
||||
// paused state
|
||||
if to < duration, state == .reachedEnd {
|
||||
state = .paused
|
||||
}
|
||||
|
||||
|
||||
// First find the item that this interval is within
|
||||
// Not the most effecient, but these lists should be less than 500 items
|
||||
var sum = 0.0
|
||||
|
|
@ -279,12 +281,12 @@
|
|||
}
|
||||
sum += duration
|
||||
}
|
||||
|
||||
|
||||
if let foundIdx = foundIdx {
|
||||
// Now figure out how far into this segment we need to seek to
|
||||
let before = durationBefore(playerIndex: foundIdx)
|
||||
let remainder = position - before
|
||||
|
||||
|
||||
// if the foundIdx happens to be the current item, we just set the position
|
||||
if let playerItem = player?.currentItem as? SpeechPlayerItem {
|
||||
if playerItem.speechItem.audioIdx == foundIdx {
|
||||
|
|
@ -294,7 +296,7 @@
|
|||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Move the playback to the found index, we also seek by the remainder amount
|
||||
// before moving we pause the player so playback doesnt jump to a previous spot
|
||||
player?.pause()
|
||||
|
|
@ -309,7 +311,7 @@
|
|||
synthesizeFrom(start: durations.count - 1, playWhenReady: state == .playing, atOffset: last)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
scrubState = .reset
|
||||
fireTimer()
|
||||
}
|
||||
|
|
@ -317,7 +319,7 @@
|
|||
public func seek(toIdx: Int) {
|
||||
let before = durationBefore(playerIndex: toIdx)
|
||||
let remainder = 0.0
|
||||
|
||||
|
||||
// if the foundIdx happens to be the current item, we just set the position
|
||||
if let playerItem = player?.currentItem as? SpeechPlayerItem {
|
||||
if playerItem.speechItem.audioIdx == toIdx {
|
||||
|
|
@ -327,23 +329,23 @@
|
|||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Move the playback to the found index, we also seek by the remainder amount
|
||||
// before moving we pause the player so playback doesnt jump to a previous spot
|
||||
player?.pause()
|
||||
player?.removeAllItems()
|
||||
synthesizeFrom(start: toIdx, playWhenReady: state == .playing, atOffset: remainder)
|
||||
|
||||
|
||||
scrubState = .reset
|
||||
fireTimer()
|
||||
}
|
||||
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechDefaultLanguage.rawValue) public var defaultLanguage = "en" {
|
||||
didSet {
|
||||
currentLanguage = defaultLanguage
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechPlaybackRate.rawValue) public var playbackRate = 1.0 {
|
||||
didSet {
|
||||
updateDurations(oldPlayback: oldValue, newPlayback: playbackRate)
|
||||
|
|
@ -351,25 +353,25 @@
|
|||
fireTimer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechPreloadEnabled.rawValue) public var preloadEnabled = false
|
||||
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechUseUltraRealisticVoices.rawValue) public var useUltraRealisticVoices = false
|
||||
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechUltraRealisticFeatureKey.rawValue) public var ultraRealisticFeatureKey: String = ""
|
||||
@AppStorage(UserDefaultKey.textToSpeechUltraRealisticFeatureRequested.rawValue) public var ultraRealisticFeatureRequested: Bool = false
|
||||
|
||||
|
||||
var speechAuthHeader: String? {
|
||||
if Voices.isUltraRealisticVoice(currentVoice), !ultraRealisticFeatureKey.isEmpty {
|
||||
return ultraRealisticFeatureKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
public var currentVoiceLanguage: VoiceLanguage {
|
||||
Voices.Languages.first(where: { $0.key == currentLanguage }) ?? Voices.English
|
||||
}
|
||||
|
||||
|
||||
private var _currentLanguage: String?
|
||||
public var currentLanguage: String {
|
||||
get {
|
||||
|
|
@ -383,30 +385,30 @@
|
|||
}
|
||||
set {
|
||||
_currentLanguage = newValue
|
||||
|
||||
|
||||
let newVoice = getPreferredVoice(forLanguage: newValue)
|
||||
currentVoice = newVoice
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var _currentVoice: String?
|
||||
public var currentVoice: String {
|
||||
get {
|
||||
if let currentVoice = _currentVoice {
|
||||
return currentVoice
|
||||
}
|
||||
|
||||
|
||||
if let currentVoice = UserDefaults.standard.string(forKey: "\(currentLanguage)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)") {
|
||||
return currentVoice
|
||||
}
|
||||
|
||||
|
||||
return currentVoiceLanguage.defaultVoice
|
||||
}
|
||||
set {
|
||||
_currentVoice = newValue
|
||||
voiceList = generateVoiceList()
|
||||
realisticVoiceList = generateRealisticVoiceList()
|
||||
|
||||
|
||||
var currentIdx = 0
|
||||
var currentOffset = 0.0
|
||||
if let player = self.player, let item = self.player?.currentItem as? SpeechPlayerItem {
|
||||
|
|
@ -415,11 +417,11 @@
|
|||
}
|
||||
player?.removeAllItems()
|
||||
playbackError = false
|
||||
|
||||
|
||||
downloadAndPlayFrom(currentIdx, currentOffset)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public var currentVoicePair: VoicePair? {
|
||||
let voice = currentVoice
|
||||
if Voices.isUltraRealisticVoice(currentVoice) {
|
||||
|
|
@ -430,14 +432,14 @@
|
|||
}
|
||||
return Voices.Pairs.first(where: { $0.firstKey == voice || $0.secondKey == voice })
|
||||
}
|
||||
|
||||
|
||||
struct TextNode: Codable {
|
||||
let to: String
|
||||
let from: String
|
||||
let heading: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
|
||||
func setTextItems() {
|
||||
if let document = self.document {
|
||||
textItems = document.utterances.map { utterance in
|
||||
|
|
@ -452,7 +454,7 @@
|
|||
textItems = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func updateReadText() {
|
||||
if let item = player?.currentItem as? SpeechPlayerItem, let speechMarks = item.speechMarks {
|
||||
var currentItemOffset = 0
|
||||
|
|
@ -471,17 +473,17 @@
|
|||
currentItemOffset = (last.start ?? 0) + (last.length ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Sometimes we get negatives
|
||||
currentItemOffset = max(currentItemOffset, 0)
|
||||
|
||||
|
||||
let idx = currentAudioIndex // item.speechItem.audioIdx
|
||||
if idx < document?.utterances.count ?? 0 {
|
||||
let currentItem = document?.utterances[idx].text ?? ""
|
||||
let currentReadIndex = currentItem.index(currentItem.startIndex, offsetBy: min(currentItemOffset, currentItem.count))
|
||||
let lastItem = String(currentItem[..<currentReadIndex])
|
||||
let lastItemAfter = String(currentItem[currentReadIndex...])
|
||||
|
||||
|
||||
readText = lastItem
|
||||
unreadText = lastItemAfter
|
||||
}
|
||||
|
|
@ -489,34 +491,34 @@
|
|||
readText = ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func getPreferredVoice(forLanguage language: String) -> String {
|
||||
UserDefaults.standard.string(forKey: "\(language)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)") ?? currentVoiceLanguage.defaultVoice
|
||||
}
|
||||
|
||||
|
||||
public func setPreferredVoice(_ voice: String, forLanguage language: String) {
|
||||
UserDefaults.standard.set(voice, forKey: "\(language)-\(UserDefaultKey.textToSpeechPreferredVoice.rawValue)")
|
||||
}
|
||||
|
||||
|
||||
private func downloadAndPlayFrom(_ currentIdx: Int, _ currentOffset: Double) {
|
||||
let desiredState = state
|
||||
|
||||
|
||||
pause()
|
||||
document = nil
|
||||
synthesizer = nil
|
||||
|
||||
|
||||
if let itemID = itemAudioProperties?.itemID {
|
||||
Task {
|
||||
let document = try? await self.getSpeechFile(itemID: itemID, priority: .high)
|
||||
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let document = document {
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: self.dataService.appEnvironment, networker: self.dataService.networker, document: document, speechAuthHeader: self.speechAuthHeader)
|
||||
|
||||
|
||||
self.setTextItems()
|
||||
self.durations = synthesizer.estimatedDurations(forSpeed: self.playbackRate)
|
||||
self.synthesizer = synthesizer
|
||||
|
||||
|
||||
self.state = desiredState
|
||||
self.synthesizeFrom(start: currentIdx, playWhenReady: self.state == .playing, atOffset: currentOffset)
|
||||
} else {
|
||||
|
|
@ -527,7 +529,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public var secondaryVoice: String {
|
||||
if let pair = currentVoicePair {
|
||||
if pair.firstKey == currentVoice {
|
||||
|
|
@ -539,14 +541,14 @@
|
|||
}
|
||||
return "en-US-CoraNeural"
|
||||
}
|
||||
|
||||
|
||||
func previewVoiceURL(_ voice: String) -> URL? {
|
||||
URL(string: "https://storage.googleapis.com/omnivore_preview_bucket/tts-voice-previews/\(voice).mp3")
|
||||
}
|
||||
|
||||
|
||||
public func playVoiceSample(voice: String) {
|
||||
pause()
|
||||
|
||||
|
||||
if let url = previewVoiceURL(voice) {
|
||||
samplePlayer = AVPlayer(playerItem: AVPlayerItem(url: url))
|
||||
if let samplePlayer = samplePlayer {
|
||||
|
|
@ -556,7 +558,7 @@
|
|||
NSNotification.operationFailed(message: "Error playing voice sample.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func isPlayingSample(voice: String) -> Bool {
|
||||
if let samplePlayer = self.samplePlayer, let url = previewVoiceURL(voice) {
|
||||
if let urlAsset = samplePlayer.currentItem?.asset as? AVURLAsset {
|
||||
|
|
@ -566,31 +568,31 @@
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
public func stopVoiceSample() {
|
||||
if let samplePlayer = self.samplePlayer {
|
||||
samplePlayer.pause()
|
||||
self.samplePlayer = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func updateDurations(oldPlayback: Double, newPlayback: Double) {
|
||||
if let oldDurations = durations {
|
||||
durations = oldDurations.map { $0 * oldPlayback / newPlayback }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public var isLoading: Bool {
|
||||
if state == .reachedEnd {
|
||||
return false
|
||||
}
|
||||
return (state == .loading || player?.currentItem == nil || player?.currentItem?.status == .unknown)
|
||||
}
|
||||
|
||||
|
||||
public var isPlaying: Bool {
|
||||
state == .playing
|
||||
}
|
||||
|
||||
|
||||
public func isLoadingItem(_ audioItem: AudioItemProperties?) -> Bool {
|
||||
if state == .reachedEnd {
|
||||
return false
|
||||
|
|
@ -600,41 +602,41 @@
|
|||
}
|
||||
return itemAudioProperties?.itemID == audioItem?.itemID && isLoading
|
||||
}
|
||||
|
||||
|
||||
public func isPlayingItem(itemID: String) -> Bool {
|
||||
itemAudioProperties?.itemID == itemID && isPlaying
|
||||
}
|
||||
|
||||
|
||||
public func skipForward(seconds: Double) {
|
||||
seek(to: timeElapsed + seconds)
|
||||
}
|
||||
|
||||
|
||||
public func skipBackwards(seconds: Double) {
|
||||
seek(to: timeElapsed - seconds)
|
||||
}
|
||||
|
||||
|
||||
public func fileNameForAudioFile(_ itemID: String) -> String {
|
||||
itemID + "-" + currentVoice + ".mp3"
|
||||
}
|
||||
|
||||
|
||||
public static func pathForAudioDirectory(itemID: String) -> URL {
|
||||
URL.om_documentsDirectory
|
||||
.appendingPathComponent("audio-\(itemID)/")
|
||||
}
|
||||
|
||||
|
||||
public func pathForSpeechFile(itemID: String) -> URL {
|
||||
Self.pathForAudioDirectory(itemID: itemID)
|
||||
.appendingPathComponent("speech-\(currentVoice).json")
|
||||
}
|
||||
|
||||
|
||||
public func startAudio(atIndex index: Int, andOffset offset: Double) {
|
||||
state = .loading
|
||||
setupNotifications()
|
||||
|
||||
|
||||
if let itemID = itemAudioProperties?.itemID {
|
||||
Task {
|
||||
let document = try? await getSpeechFile(itemID: itemID, priority: .high)
|
||||
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.setTextItems()
|
||||
if let document = document {
|
||||
|
|
@ -650,7 +652,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// swiftlint:disable all
|
||||
private func startStreamingAudio(itemID _: String, document: SpeechDocument, atIndex index: Int, andOffset offset: Double) {
|
||||
do {
|
||||
|
|
@ -660,7 +662,7 @@
|
|||
// try? FileManager.default.removeItem(atPath: audioUrl.path)
|
||||
state = .stopped
|
||||
}
|
||||
|
||||
|
||||
player = AVQueuePlayer(items: [])
|
||||
if let player = player {
|
||||
observer = player.observe(\.currentItem, options: [.new]) { _, _ in
|
||||
|
|
@ -668,21 +670,21 @@
|
|||
self.updateReadText()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
|
||||
durations = synthesizer.estimatedDurations(forSpeed: playbackRate)
|
||||
self.synthesizer = synthesizer
|
||||
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
synthesizeFrom(start: index, playWhenReady: true, atOffset: offset)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
func synthesizeFrom(start: Int, playWhenReady: Bool, atOffset: Double = 0.0) {
|
||||
if let synthesizer = self.synthesizer, let items = self.synthesizer?.createPlayerItems(from: start) {
|
||||
let prefetchQueue = OperationQueue()
|
||||
prefetchQueue.maxConcurrentOperationCount = 5
|
||||
|
||||
|
||||
for speechItem in items {
|
||||
let isLast = speechItem.audioIdx == synthesizer.document.utterances.count - 1
|
||||
let playerItem = SpeechPlayerItem(session: self, prefetchQueue: prefetchQueue, speechItem: speechItem) {
|
||||
|
|
@ -709,7 +711,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func pause() {
|
||||
if let player = player {
|
||||
player.pause()
|
||||
|
|
@ -717,7 +719,7 @@
|
|||
savePositionInfo(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func unpause() {
|
||||
stopVoiceSample()
|
||||
if let player = player {
|
||||
|
|
@ -725,7 +727,7 @@
|
|||
state = .playing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func formatTimeInterval(_ time: TimeInterval) -> String? {
|
||||
let componentFormatter = DateComponentsFormatter()
|
||||
componentFormatter.unitsStyle = .positional
|
||||
|
|
@ -733,14 +735,14 @@
|
|||
componentFormatter.zeroFormattingBehavior = .pad
|
||||
return componentFormatter.string(from: time)
|
||||
}
|
||||
|
||||
|
||||
// What we need is an array of all items in a document, either Utterances if unloaded or AVPlayerItems
|
||||
// if they have been loaded, then for each one we can calculate a duration
|
||||
func durationBefore(playerIndex: Int) -> TimeInterval {
|
||||
let result = durations?.prefix(playerIndex).reduce(0, +) ?? 0
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
func startTimer() {
|
||||
if timer == nil {
|
||||
lastReadUpdate = 0
|
||||
|
|
@ -748,22 +750,22 @@
|
|||
timer?.fire()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Every second, get the current playing time of the player and refresh the status of the player progressslider
|
||||
@objc func fireTimer() {
|
||||
if let player = player {
|
||||
if player.error != nil || player.currentItem?.error != nil {
|
||||
stopWithError()
|
||||
}
|
||||
|
||||
|
||||
if let durations = durations {
|
||||
duration = durations.reduce(0, +)
|
||||
durationString = formatTimeInterval(duration)
|
||||
}
|
||||
|
||||
|
||||
updateReadText()
|
||||
}
|
||||
|
||||
|
||||
if let player = player {
|
||||
switch scrubState {
|
||||
case .reset:
|
||||
|
|
@ -776,10 +778,10 @@
|
|||
currentAudioIndex = playerItem.speechItem.audioIdx + 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
timeElapsed = durationBefore(playerIndex: playerItem.speechItem.audioIdx) + itemElapsed
|
||||
timeElapsedString = formatTimeInterval(timeElapsed)
|
||||
|
||||
|
||||
if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo {
|
||||
nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration)
|
||||
nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed)
|
||||
|
|
@ -798,38 +800,38 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
savePositionInfo()
|
||||
}
|
||||
|
||||
|
||||
func savePositionInfo(force: Bool = false) {
|
||||
if force || (timeElapsed - 10 > lastReadUpdate) {
|
||||
let percentProgress = timeElapsed / duration
|
||||
let speechIndex = (player?.currentItem as? SpeechPlayerItem)?.speechItem.audioIdx ?? 0
|
||||
let anchorIndex = Int((player?.currentItem as? SpeechPlayerItem)?.speechItem.htmlIdx ?? "") ?? 0
|
||||
|
||||
|
||||
if let itemID = itemAudioProperties?.itemID {
|
||||
dataService.updateLinkReadingProgress(itemID: itemID, readingProgress: percentProgress, anchorIndex: anchorIndex, force: true)
|
||||
}
|
||||
|
||||
|
||||
if let itemID = itemAudioProperties?.itemID, let player = player, let currentItem = player.currentItem {
|
||||
let currentOffset = CMTimeGetSeconds(currentItem.currentTime())
|
||||
print("updating listening info: ", speechIndex, currentOffset, timeElapsed)
|
||||
|
||||
|
||||
dataService.updateLinkListeningProgress(itemID: itemID,
|
||||
listenIndex: speechIndex,
|
||||
listenOffset: currentOffset,
|
||||
listenTime: timeElapsed)
|
||||
}
|
||||
|
||||
|
||||
lastReadUpdate = timeElapsed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func clearNowPlayingInfo() {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [:]
|
||||
}
|
||||
|
||||
|
||||
func downloadAndSetArtwork() async {
|
||||
if let pageId = itemAudioProperties?.itemID, let imageURL = itemAudioProperties?.imageURL {
|
||||
if let result = try? await URLSession.shared.data(from: imageURL) {
|
||||
|
|
@ -849,10 +851,10 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func setupRemoteControl() {
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
|
||||
|
||||
if let itemAudioProperties = itemAudioProperties {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
|
||||
MPMediaItemPropertyTitle: NSString(string: itemAudioProperties.title),
|
||||
|
|
@ -861,21 +863,21 @@
|
|||
MPNowPlayingInfoPropertyElapsedPlaybackTime: NSNumber(value: timeElapsed)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.playCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in
|
||||
self.unpause()
|
||||
return .success
|
||||
}
|
||||
|
||||
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.addTarget { _ -> MPRemoteCommandHandlerStatus in
|
||||
self.pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [15, 30, 60]
|
||||
commandCenter.skipForwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
|
||||
|
|
@ -885,7 +887,7 @@
|
|||
}
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [15, 30, 60]
|
||||
commandCenter.skipBackwardCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
|
||||
|
|
@ -895,7 +897,7 @@
|
|||
}
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
|
||||
if let event = event as? MPChangePlaybackPositionCommandEvent {
|
||||
|
|
@ -904,12 +906,12 @@
|
|||
}
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
|
||||
Task {
|
||||
await downloadAndSetArtwork()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func isoLangForCurrentVoice() -> String {
|
||||
// currentVoicePair should not ever be nil but if it is we return an empty string
|
||||
if let isoLang = currentVoicePair?.language {
|
||||
|
|
@ -928,11 +930,11 @@
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func downloadLibraryItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
|
||||
let decoder = JSONDecoder()
|
||||
let speechFileUrl = pathForSpeechFile(itemID: itemID)
|
||||
|
||||
|
||||
if FileManager.default.fileExists(atPath: speechFileUrl.path) {
|
||||
let data = try Data(contentsOf: speechFileUrl)
|
||||
document = try decoder.decode(SpeechDocument.self, from: data)
|
||||
|
|
@ -941,30 +943,30 @@
|
|||
return document
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let path = "/api/article/\(itemID)/speech?voice=\(currentVoice)&secondaryVoice=\(secondaryVoice)&priority=\(priority)\(isoLangForCurrentVoice())"
|
||||
guard let url = URL(string: path, relativeTo: dataService.appEnvironment.serverBaseURL) else {
|
||||
throw BasicError.message(messageText: "Invalid audio URL")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
for (header, value) in dataService.networker.defaultHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
|
||||
|
||||
let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
|
||||
}
|
||||
|
||||
|
||||
guard let data = result?.0 else {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no data received.")
|
||||
}
|
||||
|
||||
|
||||
let str = String(decoding: data, as: UTF8.self)
|
||||
print("result speech file: ", str)
|
||||
|
||||
|
||||
if let document = try? JSONDecoder().decode(SpeechDocument.self, from: data) {
|
||||
do {
|
||||
try? FileManager.default.createDirectory(at: document.audioDirectory, withIntermediateDirectories: true)
|
||||
|
|
@ -974,15 +976,15 @@
|
|||
print("error writing file", error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func combineSpeechFiles(from digest: DigestResult) -> ([Utterance], Double) {
|
||||
let allUtterances = digest.speechFiles.flatMap { $0.utterances }
|
||||
var updatedUtterances: [Utterance] = []
|
||||
var currentWordOffset = 0.0
|
||||
|
||||
|
||||
for (index, utterance) in allUtterances.enumerated() {
|
||||
let newUtterance = Utterance(
|
||||
idx: String(index + 1),
|
||||
|
|
@ -997,7 +999,7 @@
|
|||
|
||||
return (updatedUtterances, currentWordOffset)
|
||||
}
|
||||
|
||||
|
||||
func downloadDigestItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
|
||||
if let digestItem = itemAudioProperties as? DigestAudioItem, let firstFile = digestItem.digest.speechFiles.first {
|
||||
let (utterances, wordCount) = combineSpeechFiles(from: digestItem.digest)
|
||||
|
|
@ -1012,15 +1014,15 @@
|
|||
try? FileManager.default.createDirectory(at: document.audioDirectory, withIntermediateDirectories: true)
|
||||
return document
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func getSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
|
||||
document = try await downloadSpeechFile(itemID: itemID, priority: priority)
|
||||
return document
|
||||
}
|
||||
|
||||
|
||||
func setupNotifications() {
|
||||
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
|
||||
NotificationCenter.default.addObserver(self,
|
||||
|
|
@ -1028,7 +1030,7 @@
|
|||
name: AVAudioSession.interruptionNotification,
|
||||
object: AVAudioSession.sharedInstance())
|
||||
}
|
||||
|
||||
|
||||
@objc func handleInterruption(notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
|
|
@ -1036,7 +1038,7 @@
|
|||
else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Switch over the interruption type.
|
||||
switch type {
|
||||
case .began:
|
||||
|
|
@ -1052,6 +1054,6 @@
|
|||
default: ()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public extension DataService {
|
|||
|
||||
if let linkedItemID = linkedItemID {
|
||||
Task {
|
||||
AudioController.removeAudioFiles(itemID: linkedItemID)
|
||||
await AudioController.removeAudioFiles(itemID: linkedItemID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,7 +340,6 @@ public final class OmnivoreWebView: WKWebView {
|
|||
Task {
|
||||
let selection = try? await self.evaluateJavaScript("window.getSelection().toString()")
|
||||
if let selection = selection as? String, let explainHandler = explainHandler {
|
||||
print("Explaining \(selection)")
|
||||
explainHandler(selection)
|
||||
} else {
|
||||
showInReaderSnackbar("Error getting text to explain")
|
||||
|
|
|
|||
|
|
@ -221,12 +221,18 @@ const createUserProfile = async (
|
|||
})
|
||||
|
||||
const contextualTemplate = PromptTemplate.fromTemplate(
|
||||
digestDefinition.zeroShot.userPreferencesProfilePrompt
|
||||
`Explain the following text within the context of the provided article text
|
||||
|
||||
Text: {text}
|
||||
|
||||
Article: {content}
|
||||
`
|
||||
)
|
||||
|
||||
const chain = contextualTemplate.pipe(llm)
|
||||
const result = await chain.invoke({
|
||||
titles: preferences.map((item) => `* ${item.title}`).join('\n'),
|
||||
text: '',
|
||||
content: '',
|
||||
})
|
||||
|
||||
return result
|
||||
|
|
|
|||
Loading…
Reference in a new issue