Refactor chapter data to allow for click to skip to next chapter
This commit is contained in:
@ -5,10 +5,42 @@ import Views
|
|||||||
import MarkdownUI
|
import MarkdownUI
|
||||||
import Utils
|
import Utils
|
||||||
|
|
||||||
|
|
||||||
|
func getChapterData(digest: DigestResult) -> [DigestChapterData] {
|
||||||
|
let speed = 1.0
|
||||||
|
var chapterData: [DigestChapterData] = []
|
||||||
|
var currentAudioIndex = 0
|
||||||
|
var currentWordCount = 0.0
|
||||||
|
|
||||||
|
for (index, speechFile) in digest.speechFiles.enumerated() {
|
||||||
|
let chapter = digest.chapters[index]
|
||||||
|
let duration = currentWordCount / SpeechDocument.averageWPM / speed * 60.0
|
||||||
|
|
||||||
|
chapterData.append(DigestChapterData(
|
||||||
|
time: formatTimeInterval(duration) ?? "00:00",
|
||||||
|
start: Int(currentAudioIndex),
|
||||||
|
end: currentAudioIndex + Int(speechFile.utterances.count)
|
||||||
|
))
|
||||||
|
currentAudioIndex += Int(speechFile.utterances.count)
|
||||||
|
currentWordCount += chapter.wordCount
|
||||||
|
}
|
||||||
|
return chapterData
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTimeInterval(_ time: TimeInterval) -> String? {
|
||||||
|
let componentFormatter = DateComponentsFormatter()
|
||||||
|
componentFormatter.unitsStyle = .positional
|
||||||
|
componentFormatter.allowedUnits = time >= 3600 ? [.second, .minute, .hour] : [.second, .minute]
|
||||||
|
componentFormatter.zeroFormattingBehavior = .pad
|
||||||
|
return componentFormatter.string(from: time)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public class FullScreenDigestViewModel: ObservableObject {
|
public class FullScreenDigestViewModel: ObservableObject {
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var digest: DigestResult?
|
@Published var digest: DigestResult?
|
||||||
|
@Published var chapterInfo: [(DigestChapter, DigestChapterData)]?
|
||||||
@AppStorage(UserDefaultKey.lastVisitedDigestId.rawValue) var lastVisitedDigestId = ""
|
@AppStorage(UserDefaultKey.lastVisitedDigestId.rawValue) var lastVisitedDigestId = ""
|
||||||
|
|
||||||
func load(dataService: DataService, audioController: AudioController) async {
|
func load(dataService: DataService, audioController: AudioController) async {
|
||||||
@ -24,7 +56,8 @@ public class FullScreenDigestViewModel: ObservableObject {
|
|||||||
if let playingDigest = audioController.itemAudioProperties as? DigestAudioItem, playingDigest.digest.id == digest.id {
|
if let playingDigest = audioController.itemAudioProperties as? DigestAudioItem, playingDigest.digest.id == digest.id {
|
||||||
// Don't think we need to do anything here
|
// Don't think we need to do anything here
|
||||||
} else {
|
} else {
|
||||||
audioController.play(itemAudioProperties: DigestAudioItem(digest: digest))
|
let chapters = getChapterData(digest: digest)
|
||||||
|
audioController.play(itemAudioProperties: DigestAudioItem(digest: digest, chapters: chapters))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@ -114,35 +147,6 @@ struct FullScreenDigestView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getChapterData(digest: DigestResult) -> [String:(time: String, start: Int, end: Int)] {
|
|
||||||
let speed = 1.0
|
|
||||||
var chapterData: [String:(time: String, start: Int, end: Int)] = [:]
|
|
||||||
var currentAudioIndex = 0
|
|
||||||
var currentWordCount = 0.0
|
|
||||||
|
|
||||||
for (index, speechFile) in digest.speechFiles.enumerated() {
|
|
||||||
let chapter = digest.chapters[index]
|
|
||||||
let duration = currentWordCount / SpeechDocument.averageWPM / speed * 60.0
|
|
||||||
|
|
||||||
chapterData[chapter.id] = (
|
|
||||||
time: formatTimeInterval(duration) ?? "00:00",
|
|
||||||
start: Int(currentAudioIndex),
|
|
||||||
end: currentAudioIndex + Int(speechFile.utterances.count)
|
|
||||||
)
|
|
||||||
currentAudioIndex += Int(speechFile.utterances.count)
|
|
||||||
currentWordCount += chapter.wordCount
|
|
||||||
}
|
|
||||||
return chapterData
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTimeInterval(_ time: TimeInterval) -> String? {
|
|
||||||
let componentFormatter = DateComponentsFormatter()
|
|
||||||
componentFormatter.unitsStyle = .positional
|
|
||||||
componentFormatter.allowedUnits = time >= 3600 ? [.second, .minute, .hour] : [.second, .minute]
|
|
||||||
componentFormatter.zeroFormattingBehavior = .pad
|
|
||||||
return componentFormatter.string(from: time)
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 17.0, *)
|
@available(iOS 17.0, *)
|
||||||
var itemBody: some View {
|
var itemBody: some View {
|
||||||
VStack {
|
VStack {
|
||||||
@ -182,30 +186,31 @@ struct FullScreenDigestView: View {
|
|||||||
.background(Color.themeLabelBackground.opacity(0.6))
|
.background(Color.themeLabelBackground.opacity(0.6))
|
||||||
.cornerRadius(5)
|
.cornerRadius(5)
|
||||||
|
|
||||||
if let digest = viewModel.digest {
|
if let chapters = viewModel.chapterInfo {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Chapters")
|
Text("Chapters")
|
||||||
.font(Font.system(size: 17, weight: .semibold))
|
.font(Font.system(size: 17, weight: .semibold))
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(0)
|
.padding(0)
|
||||||
let chapterData = getChapterData(digest: digest)
|
ForEach(chapters, id: \.0.id) { chaps in
|
||||||
ForEach(digest.chapters, id: \.id) { chapter in
|
let (chapter, chapterData) = chaps
|
||||||
if let startTime = chapterData[chapter.id]?.time, let skipIndex = chapterData[chapter.id]?.start {
|
let currentChapter = (audioController.currentAudioIndex >= chapterData.start && audioController.currentAudioIndex < chapterData.end)
|
||||||
let currentChapter = audioController.currentAudioIndex >= (chapterData[chapter.id]?.start ?? 0) &&
|
|
||||||
audioController.currentAudioIndex < (chapterData[chapter.id]?.end ?? 0)
|
ChapterView(
|
||||||
ChapterView(
|
startTime: chapterData.time,
|
||||||
startTime: startTime,
|
skipIndex: chapterData.start,
|
||||||
skipIndex: skipIndex,
|
chapter: chapter
|
||||||
chapter: chapter
|
)
|
||||||
)
|
.onTapGesture {
|
||||||
.onTapGesture {
|
audioController.seek(toIdx: chapterData.start)
|
||||||
audioController.seek(toIdx: skipIndex)
|
if audioController.state != .loading && !audioController.isPlaying {
|
||||||
|
audioController.unpause()
|
||||||
}
|
}
|
||||||
.background(
|
|
||||||
currentChapter ? Color.themeLabelBackground.opacity(0.6) : Color.clear
|
|
||||||
)
|
|
||||||
.cornerRadius(5)
|
|
||||||
}
|
}
|
||||||
|
.background(
|
||||||
|
currentChapter ? Color.themeLabelBackground.opacity(0.6) : Color.clear
|
||||||
|
)
|
||||||
|
.cornerRadius(5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
|
|||||||
@ -33,16 +33,20 @@ public struct DigestAudioItem: AudioItemProperties {
|
|||||||
public let digest: DigestResult
|
public let digest: DigestResult
|
||||||
public let itemID: String
|
public let itemID: String
|
||||||
public let title: String
|
public let title: String
|
||||||
|
public let chapters: [DigestChapterData]
|
||||||
|
|
||||||
public var byline: String?
|
public var byline: String?
|
||||||
public var imageURL: URL?
|
public var imageURL: URL?
|
||||||
public var language: String?
|
public var language: String?
|
||||||
public var startIndex: Int = 0
|
public var startIndex: Int = 0
|
||||||
public var startOffset: Double = 0.0
|
public var startOffset: Double = 0.0
|
||||||
|
|
||||||
public init(digest: DigestResult) {
|
public init(digest: DigestResult, chapters: [DigestChapterData]) {
|
||||||
self.digest = digest
|
self.digest = digest
|
||||||
self.itemID = digest.id
|
self.itemID = digest.id
|
||||||
self.title = digest.title
|
self.title = digest.title
|
||||||
|
self.chapters = chapters
|
||||||
|
|
||||||
self.startIndex = 0
|
self.startIndex = 0
|
||||||
self.startOffset = 0
|
self.startOffset = 0
|
||||||
|
|
||||||
@ -908,11 +912,30 @@ public struct DigestAudioItem: AudioItemProperties {
|
|||||||
return .commandFailed
|
return .commandFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let digest = self.itemAudioProperties as? DigestAudioItem {
|
||||||
|
commandCenter.nextTrackCommand.isEnabled = true
|
||||||
|
commandCenter.nextTrackCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
|
||||||
|
let next = self.currentAudioIndex + 1
|
||||||
|
if next < (self.document?.utterances.count ?? 0) {
|
||||||
|
self.seek(toIdx: self.currentAudioIndex + 1)
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
return .commandFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await downloadAndSetArtwork()
|
await downloadAndSetArtwork()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nextChapterIndex(digest: DigestResult, idx: Int) -> Int {
|
||||||
|
// for chapter in digest.chapters {
|
||||||
|
// if chapter.
|
||||||
|
// }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func isoLangForCurrentVoice() -> String {
|
func isoLangForCurrentVoice() -> String {
|
||||||
// currentVoicePair should not ever be nil but if it is we return an empty string
|
// currentVoicePair should not ever be nil but if it is we return an empty string
|
||||||
if let isoLang = currentVoicePair?.language {
|
if let isoLang = currentVoicePair?.language {
|
||||||
|
|||||||
@ -36,6 +36,18 @@ public struct DigestChapter: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct DigestChapterData {
|
||||||
|
public let time: String
|
||||||
|
public let start: Int
|
||||||
|
public let end: Int
|
||||||
|
|
||||||
|
public init(time: String, start: Int, end: Int) {
|
||||||
|
self.time = time
|
||||||
|
self.start = start
|
||||||
|
self.end = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct RefreshDigestResult: Codable {
|
public struct RefreshDigestResult: Codable {
|
||||||
public let jobId: String
|
public let jobId: String
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user