Skip to content

Commit 7919337

Browse files
authored
Merge pull request #1560 from TortugaPower/feat/reload-chapters
Add reload chapters support to chapter list
2 parents d06cb85 + 478311e commit 7919337

45 files changed

Lines changed: 797 additions & 29 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

BookPlayer.xcodeproj/project.pbxproj

Lines changed: 79 additions & 16 deletions
Large diffs are not rendered by default.

BookPlayer/Base.lproj/Localizable.strings

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@
102102
"player_book_remaining_title" = "%@ left";
103103
"chapters_title" = "Chapters";
104104
"chapters_item_description" = "Start: %@ - Duration: %@";
105+
"reload_button" = "Reload";
106+
"reload_chapters_title" = "Reload Chapters";
107+
"reparse_chapters_found_title" = "Chapters Reloaded";
108+
"reparse_chapters_found_description" = "Found %d chapters.";
109+
"reparse_chapters_none_description" = "No additional chapters were found in this file.";
110+
"reparse_chapters_download_description" = "Download this book before reloading its chapters.";
105111
"restore_title" = "Restore";
106112
"themes_caps_title" = "THEMES";
107113
"plus_app_icons_title" = "App Icons";

BookPlayer/Generated/AutoMockable.generated.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,26 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol {
513513
loadChaptersIfNeededRelativePathAssetReceivedInvocations.append((relativePath: relativePath, asset: asset))
514514
await loadChaptersIfNeededRelativePathAssetClosure?(relativePath, asset)
515515
}
516+
//MARK: - reloadChapters
517+
518+
var reloadChaptersRelativePathCallsCount = 0
519+
var reloadChaptersRelativePathCalled: Bool {
520+
return reloadChaptersRelativePathCallsCount > 0
521+
}
522+
var reloadChaptersRelativePathReceivedRelativePath: String?
523+
var reloadChaptersRelativePathReceivedInvocations: [String] = []
524+
var reloadChaptersRelativePathReturnValue: Int?
525+
var reloadChaptersRelativePathClosure: ((String) async -> Int?)?
526+
func reloadChapters(relativePath: String) async -> Int? {
527+
reloadChaptersRelativePathCallsCount += 1
528+
reloadChaptersRelativePathReceivedRelativePath = relativePath
529+
reloadChaptersRelativePathReceivedInvocations.append(relativePath)
530+
if let reloadChaptersRelativePathClosure = reloadChaptersRelativePathClosure {
531+
return await reloadChaptersRelativePathClosure(relativePath)
532+
} else {
533+
return reloadChaptersRelativePathReturnValue
534+
}
535+
}
516536
//MARK: - createFolder
517537

518538
var createFolderWithInsideThrowableError: Error?
@@ -1398,6 +1418,18 @@ class PlayerManagerProtocolMock: PlayerManagerProtocol {
13981418
jumpToChapterReceivedInvocations.append(chapter)
13991419
jumpToChapterClosure?(chapter)
14001420
}
1421+
//MARK: - reloadCurrentItem
1422+
1423+
var reloadCurrentItemCallsCount = 0
1424+
var reloadCurrentItemCalled: Bool {
1425+
return reloadCurrentItemCallsCount > 0
1426+
}
1427+
var reloadCurrentItemClosure: (() -> Void)?
1428+
@MainActor
1429+
func reloadCurrentItem() {
1430+
reloadCurrentItemCallsCount += 1
1431+
reloadCurrentItemClosure?()
1432+
}
14011433
//MARK: - markAsCompleted
14021434

14031435
var markAsCompletedCallsCount = 0

BookPlayer/Player/PlayerManager.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,21 @@ extension PlayerManager {
714714
jumpTo(chapter.start + 0.1, recordBookmark: false)
715715
}
716716

717+
@MainActor
718+
func reloadCurrentItem() {
719+
// Rebuild the in-memory item from storage so externally-changed data (e.g. re-parsed
720+
// chapters) takes effect. Playback position is preserved — it's persisted in Core Data and
721+
// re-seeded by time in `PlayableItem.init` — and the chapter-change subscription must be
722+
// re-bound to the new instance, otherwise the end-of-chapter sleep timer silently breaks.
723+
guard let relativePath = currentItem?.relativePath,
724+
let libraryItem = libraryService.getSimpleItem(with: relativePath),
725+
let updatedItem = try? playbackService.getPlayableItem(from: libraryItem) else {
726+
return
727+
}
728+
currentItem = updatedItem
729+
bindPlayableChapterSubscription(to: updatedItem, dropInitialReplay: true)
730+
}
731+
717732
func initializeChapterTime(_ time: Double) {
718733
guard let currentItem = self.currentItem else { return }
719734

BookPlayer/Player/PlayerManagerProtocol.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ public protocol PlayerManagerProtocol: AnyObject {
4040
func directSkip(_ interval: TimeInterval)
4141
func jumpTo(_ time: Double, recordBookmark: Bool)
4242
func jumpToChapter(_ chapter: PlayableChapter)
43+
/// Rebuild `currentItem` from storage (and re-bind the chapter subscription) so externally
44+
/// changed data — e.g. re-parsed chapters — takes effect. Preserves playback position.
45+
@MainActor func reloadCurrentItem()
4346
func markAsCompleted(_ flag: Bool)
4447
func setSpeed(_ newValue: Float)
4548
func setBoostVolume(_ newValue: Bool)

BookPlayer/Player/Views/Chapters/ChaptersView.swift

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,45 @@ struct ChaptersView: View {
3636
.navigationTitle("chapters_title")
3737
.navigationBarTitleDisplayMode(.inline)
3838
.toolbar {
39-
ToolbarItem(placement: .confirmationAction) {
40-
Button("done_title") {
39+
ToolbarItem(placement: .cancellationAction) {
40+
Button {
4141
dismiss()
42+
} label: {
43+
Label("voiceover_close_button", systemImage: "xmark")
4244
}
4345
.foregroundStyle(theme.linkColor)
4446
}
47+
if model.canReloadChapters {
48+
ToolbarItem(placement: .confirmationAction) {
49+
reloadButton
50+
}
51+
}
4552
}
53+
.bpAlert($model.currentAlert)
4654
}
4755
}
4856
}
4957

58+
@ViewBuilder
59+
private var reloadButton: some View {
60+
Button {
61+
Task { await model.reloadChapters() }
62+
} label: {
63+
// Keep the title laid out (just hidden) while loading so the spinner overlay doesn't
64+
// change the toolbar item's width.
65+
Text("reload_button")
66+
.foregroundStyle(theme.linkColor)
67+
.opacity(model.isReloadingChapters ? 0 : 1)
68+
.overlay {
69+
if model.isReloadingChapters {
70+
ProgressView()
71+
}
72+
}
73+
}
74+
.disabled(model.isReloadingChapters)
75+
.accessibilityLabel("reload_chapters_title")
76+
}
77+
5078
@ViewBuilder
5179
private func rowView(_ chapter: PlayableChapter, index: Int) -> some View {
5280
let title =
@@ -83,16 +111,26 @@ struct ChaptersView: View {
83111
}
84112

85113
extension ChaptersView {
114+
@MainActor
86115
class Model: ObservableObject {
87116
@Published var chapters: [PlayableChapter]
88117
@Published var currentChapter: PlayableChapter?
118+
@Published var isReloadingChapters = false
119+
@Published var currentAlert: BPAlertContent?
89120

90121
init(chapters: [PlayableChapter], currentChapter: PlayableChapter?) {
91122
self.chapters = chapters
92123
self.currentChapter = currentChapter
93124
}
94125

95126
func handleChapterSelected(_ chapter: PlayableChapter) {}
127+
128+
/// Whether the "re-parse chapters" action applies to the current item.
129+
var canReloadChapters: Bool { false }
130+
131+
/// Re-parse chapters from the file, replacing the list when more are found, and surface
132+
/// the outcome via `currentAlert`.
133+
func reloadChapters() async {}
96134
}
97135
}
98136

BookPlayer/Player/Views/Chapters/ChaptersViewModel.swift

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import Foundation
1212

1313
final class ChaptersViewModel: ChaptersView.Model {
1414
private let playerManager: PlayerManagerProtocol
15+
private let libraryService: LibraryServiceProtocol
1516

16-
init(playerManager: PlayerManagerProtocol) {
17+
init(playerManager: PlayerManagerProtocol, libraryService: LibraryServiceProtocol) {
1718
self.playerManager = playerManager
19+
self.libraryService = libraryService
1820
super.init(
1921
chapters: playerManager.currentItem?.chapters ?? [],
2022
currentChapter: playerManager.currentItem?.currentChapter
@@ -24,4 +26,48 @@ final class ChaptersViewModel: ChaptersView.Model {
2426
override func handleChapterSelected(_ chapter: PlayableChapter) {
2527
self.playerManager.jumpToChapter(chapter)
2628
}
29+
30+
/// Re-parsing only applies to single-file books; bound books and folders derive their
31+
/// chapters from constituent files, so there's no embedded chapter track to re-read.
32+
override var canReloadChapters: Bool {
33+
playerManager.currentItem?.isBoundBook == false
34+
}
35+
36+
@MainActor
37+
override func reloadChapters() async {
38+
guard let currentItem = playerManager.currentItem, currentItem.isBoundBook == false else {
39+
return
40+
}
41+
42+
let relativePath = currentItem.relativePath
43+
let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath)
44+
guard FileManager.default.fileExists(atPath: fileURL.path) else {
45+
// The file must be downloaded first, through the usual library download flow.
46+
currentAlert = Self.infoAlert(message: "reparse_chapters_download_description".localized)
47+
return
48+
}
49+
50+
isReloadingChapters = true
51+
defer { isReloadingChapters = false }
52+
53+
guard let newCount = await libraryService.reloadChapters(relativePath: relativePath) else {
54+
currentAlert = Self.infoAlert(message: "reparse_chapters_none_description".localized)
55+
return
56+
}
57+
58+
// Rebuild the player's item so the new chapters take effect everywhere (scrubber, now
59+
// playing, end-of-chapter sleep timer), then refresh this screen's list from it.
60+
playerManager.reloadCurrentItem()
61+
chapters = playerManager.currentItem?.chapters ?? []
62+
currentChapter = playerManager.currentItem?.currentChapter
63+
64+
currentAlert = Self.infoAlert(
65+
title: "reparse_chapters_found_title".localized,
66+
message: String.localizedStringWithFormat("reparse_chapters_found_description".localized, newCount)
67+
)
68+
}
69+
70+
private static func infoAlert(title: String? = nil, message: String) -> BPAlertContent {
71+
BPAlertContent(title: title, message: message, style: .alert, actionItems: [.okAction])
72+
}
2773
}

BookPlayer/Player/Views/PlayerView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,10 @@ struct PlayerView: View {
166166
.environmentObject(theme)
167167
case .chapters:
168168
ChaptersView{
169-
ChaptersViewModel(playerManager: viewModel.playerManager)
169+
ChaptersViewModel(
170+
playerManager: viewModel.playerManager,
171+
libraryService: viewModel.libraryService
172+
)
170173
}
171174
.environmentObject(theme)
172175
case .bookmark:

BookPlayer/ar.lproj/Localizable.strings

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@
102102
"player_book_remaining_title" = "متبقّي %@";
103103
"chapters_title" = "الفصول";
104104
"chapters_item_description" = "البداية: %@ - المدة: %@";
105+
"reload_button" = "إعادة التحميل";
106+
"reload_chapters_title" = "إعادة تحميل الفصول";
107+
"reparse_chapters_found_title" = "تمت إعادة تحميل الفصول";
108+
"reparse_chapters_found_description" = "تم العثور على %d فصلًا.";
109+
"reparse_chapters_none_description" = "لم يتم العثور على فصول إضافية في هذا الملف.";
110+
"reparse_chapters_download_description" = "قم بتحميل هذا الكتاب قبل إعادة تحميل فصوله.";
105111
"restore_title" = "استعادة";
106112
"themes_caps_title" = "التنسيقات";
107113
"plus_app_icons_title" = "أيقونات التطبيق";

BookPlayer/ca.lproj/Localizable.strings

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@
102102
"player_book_remaining_title" = "%@ restant";
103103
"chapters_title" = "Capítols";
104104
"chapters_item_description" = "Inici: %@ - Durada: %@";
105+
"reload_button" = "Torna a carregar";
106+
"reload_chapters_title" = "Torna a carregar els capítols";
107+
"reparse_chapters_found_title" = "Capítols tornats a carregar";
108+
"reparse_chapters_found_description" = "S'han trobat %d capítols.";
109+
"reparse_chapters_none_description" = "No s'ha trobat cap capítol addicional en aquest fitxer.";
110+
"reparse_chapters_download_description" = "Descarrega aquest llibre abans de tornar a carregar-ne els capítols.";
105111
"restore_title" = "Restaura";
106112
"themes_caps_title" = "TEMES";
107113
"plus_app_icons_title" = "Icones de l'aplicació";

0 commit comments

Comments
 (0)