Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 79 additions & 16 deletions BookPlayer.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions BookPlayer/Base.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ left";
"chapters_title" = "Chapters";
"chapters_item_description" = "Start: %@ - Duration: %@";
"reload_button" = "Reload";
"reload_chapters_title" = "Reload Chapters";
"reparse_chapters_found_title" = "Chapters Reloaded";
"reparse_chapters_found_description" = "Found %d chapters.";
"reparse_chapters_none_description" = "No additional chapters were found in this file.";
"reparse_chapters_download_description" = "Download this book before reloading its chapters.";
"restore_title" = "Restore";
"themes_caps_title" = "THEMES";
"plus_app_icons_title" = "App Icons";
Expand Down
32 changes: 32 additions & 0 deletions BookPlayer/Generated/AutoMockable.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,26 @@ class LibraryServiceProtocolMock: LibraryServiceProtocol {
loadChaptersIfNeededRelativePathAssetReceivedInvocations.append((relativePath: relativePath, asset: asset))
await loadChaptersIfNeededRelativePathAssetClosure?(relativePath, asset)
}
//MARK: - reloadChapters

var reloadChaptersRelativePathCallsCount = 0
var reloadChaptersRelativePathCalled: Bool {
return reloadChaptersRelativePathCallsCount > 0
}
var reloadChaptersRelativePathReceivedRelativePath: String?
var reloadChaptersRelativePathReceivedInvocations: [String] = []
var reloadChaptersRelativePathReturnValue: Int?
var reloadChaptersRelativePathClosure: ((String) async -> Int?)?
func reloadChapters(relativePath: String) async -> Int? {
reloadChaptersRelativePathCallsCount += 1
reloadChaptersRelativePathReceivedRelativePath = relativePath
reloadChaptersRelativePathReceivedInvocations.append(relativePath)
if let reloadChaptersRelativePathClosure = reloadChaptersRelativePathClosure {
return await reloadChaptersRelativePathClosure(relativePath)
} else {
return reloadChaptersRelativePathReturnValue
}
}
//MARK: - createFolder

var createFolderWithInsideThrowableError: Error?
Expand Down Expand Up @@ -1398,6 +1418,18 @@ class PlayerManagerProtocolMock: PlayerManagerProtocol {
jumpToChapterReceivedInvocations.append(chapter)
jumpToChapterClosure?(chapter)
}
//MARK: - reloadCurrentItem

var reloadCurrentItemCallsCount = 0
var reloadCurrentItemCalled: Bool {
return reloadCurrentItemCallsCount > 0
}
var reloadCurrentItemClosure: (() -> Void)?
@MainActor
func reloadCurrentItem() {
reloadCurrentItemCallsCount += 1
reloadCurrentItemClosure?()
}
//MARK: - markAsCompleted

var markAsCompletedCallsCount = 0
Expand Down
15 changes: 15 additions & 0 deletions BookPlayer/Player/PlayerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,21 @@ extension PlayerManager {
jumpTo(chapter.start + 0.1, recordBookmark: false)
}

@MainActor
func reloadCurrentItem() {
// Rebuild the in-memory item from storage so externally-changed data (e.g. re-parsed
// chapters) takes effect. Playback position is preserved — it's persisted in Core Data and
// re-seeded by time in `PlayableItem.init` — and the chapter-change subscription must be
// re-bound to the new instance, otherwise the end-of-chapter sleep timer silently breaks.
guard let relativePath = currentItem?.relativePath,
let libraryItem = libraryService.getSimpleItem(with: relativePath),
let updatedItem = try? playbackService.getPlayableItem(from: libraryItem) else {
return
}
currentItem = updatedItem
bindPlayableChapterSubscription(to: updatedItem, dropInitialReplay: true)
}

func initializeChapterTime(_ time: Double) {
guard let currentItem = self.currentItem else { return }

Expand Down
3 changes: 3 additions & 0 deletions BookPlayer/Player/PlayerManagerProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public protocol PlayerManagerProtocol: AnyObject {
func directSkip(_ interval: TimeInterval)
func jumpTo(_ time: Double, recordBookmark: Bool)
func jumpToChapter(_ chapter: PlayableChapter)
/// Rebuild `currentItem` from storage (and re-bind the chapter subscription) so externally
/// changed data — e.g. re-parsed chapters — takes effect. Preserves playback position.
@MainActor func reloadCurrentItem()
func markAsCompleted(_ flag: Bool)
func setSpeed(_ newValue: Float)
func setBoostVolume(_ newValue: Bool)
Expand Down
42 changes: 40 additions & 2 deletions BookPlayer/Player/Views/Chapters/ChaptersView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,45 @@ struct ChaptersView: View {
.navigationTitle("chapters_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("done_title") {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Label("voiceover_close_button", systemImage: "xmark")
}
.foregroundStyle(theme.linkColor)
}
if model.canReloadChapters {
ToolbarItem(placement: .confirmationAction) {
reloadButton
}
}
}
.bpAlert($model.currentAlert)
}
}
}

@ViewBuilder
private var reloadButton: some View {
Button {
Task { await model.reloadChapters() }
} label: {
// Keep the title laid out (just hidden) while loading so the spinner overlay doesn't
// change the toolbar item's width.
Text("reload_button")
.foregroundStyle(theme.linkColor)
.opacity(model.isReloadingChapters ? 0 : 1)
.overlay {
if model.isReloadingChapters {
ProgressView()
}
}
}
.disabled(model.isReloadingChapters)
.accessibilityLabel("reload_chapters_title")
}

@ViewBuilder
private func rowView(_ chapter: PlayableChapter, index: Int) -> some View {
let title =
Expand Down Expand Up @@ -83,16 +111,26 @@ struct ChaptersView: View {
}

extension ChaptersView {
@MainActor
class Model: ObservableObject {
@Published var chapters: [PlayableChapter]
@Published var currentChapter: PlayableChapter?
@Published var isReloadingChapters = false
@Published var currentAlert: BPAlertContent?

init(chapters: [PlayableChapter], currentChapter: PlayableChapter?) {
self.chapters = chapters
self.currentChapter = currentChapter
}

func handleChapterSelected(_ chapter: PlayableChapter) {}

/// Whether the "re-parse chapters" action applies to the current item.
var canReloadChapters: Bool { false }

/// Re-parse chapters from the file, replacing the list when more are found, and surface
/// the outcome via `currentAlert`.
func reloadChapters() async {}
}
}

Expand Down
48 changes: 47 additions & 1 deletion BookPlayer/Player/Views/Chapters/ChaptersViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import Foundation

final class ChaptersViewModel: ChaptersView.Model {
private let playerManager: PlayerManagerProtocol
private let libraryService: LibraryServiceProtocol

init(playerManager: PlayerManagerProtocol) {
init(playerManager: PlayerManagerProtocol, libraryService: LibraryServiceProtocol) {
self.playerManager = playerManager
self.libraryService = libraryService
super.init(
chapters: playerManager.currentItem?.chapters ?? [],
currentChapter: playerManager.currentItem?.currentChapter
Expand All @@ -24,4 +26,48 @@ final class ChaptersViewModel: ChaptersView.Model {
override func handleChapterSelected(_ chapter: PlayableChapter) {
self.playerManager.jumpToChapter(chapter)
}

/// Re-parsing only applies to single-file books; bound books and folders derive their
/// chapters from constituent files, so there's no embedded chapter track to re-read.
override var canReloadChapters: Bool {
playerManager.currentItem?.isBoundBook == false
}

@MainActor
override func reloadChapters() async {
guard let currentItem = playerManager.currentItem, currentItem.isBoundBook == false else {
return
}

let relativePath = currentItem.relativePath
let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
// The file must be downloaded first, through the usual library download flow.
currentAlert = Self.infoAlert(message: "reparse_chapters_download_description".localized)
return
}

isReloadingChapters = true
defer { isReloadingChapters = false }

guard let newCount = await libraryService.reloadChapters(relativePath: relativePath) else {
currentAlert = Self.infoAlert(message: "reparse_chapters_none_description".localized)
return
}

// Rebuild the player's item so the new chapters take effect everywhere (scrubber, now
// playing, end-of-chapter sleep timer), then refresh this screen's list from it.
playerManager.reloadCurrentItem()
chapters = playerManager.currentItem?.chapters ?? []
currentChapter = playerManager.currentItem?.currentChapter

currentAlert = Self.infoAlert(
title: "reparse_chapters_found_title".localized,
message: String.localizedStringWithFormat("reparse_chapters_found_description".localized, newCount)
)
}

private static func infoAlert(title: String? = nil, message: String) -> BPAlertContent {
BPAlertContent(title: title, message: message, style: .alert, actionItems: [.okAction])
}
}
5 changes: 4 additions & 1 deletion BookPlayer/Player/Views/PlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@ struct PlayerView: View {
.environmentObject(theme)
case .chapters:
ChaptersView{
ChaptersViewModel(playerManager: viewModel.playerManager)
ChaptersViewModel(
playerManager: viewModel.playerManager,
libraryService: viewModel.libraryService
)
}
.environmentObject(theme)
case .bookmark:
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "متبقّي %@";
"chapters_title" = "الفصول";
"chapters_item_description" = "البداية: %@ - المدة: %@";
"reload_button" = "إعادة التحميل";
"reload_chapters_title" = "إعادة تحميل الفصول";
"reparse_chapters_found_title" = "تمت إعادة تحميل الفصول";
"reparse_chapters_found_description" = "تم العثور على %d فصلًا.";
"reparse_chapters_none_description" = "لم يتم العثور على فصول إضافية في هذا الملف.";
"reparse_chapters_download_description" = "قم بتحميل هذا الكتاب قبل إعادة تحميل فصوله.";
"restore_title" = "استعادة";
"themes_caps_title" = "التنسيقات";
"plus_app_icons_title" = "أيقونات التطبيق";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ restant";
"chapters_title" = "Capítols";
"chapters_item_description" = "Inici: %@ - Durada: %@";
"reload_button" = "Torna a carregar";
"reload_chapters_title" = "Torna a carregar els capítols";
"reparse_chapters_found_title" = "Capítols tornats a carregar";
"reparse_chapters_found_description" = "S'han trobat %d capítols.";
"reparse_chapters_none_description" = "No s'ha trobat cap capítol addicional en aquest fitxer.";
"reparse_chapters_download_description" = "Descarrega aquest llibre abans de tornar a carregar-ne els capítols.";
"restore_title" = "Restaura";
"themes_caps_title" = "TEMES";
"plus_app_icons_title" = "Icones de l'aplicació";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/cs.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "Zbývá %@";
"chapters_title" = "Kapitoly";
"chapters_item_description" = "Začátek: %@ - Doba trvání: %@";
"reload_button" = "Načíst znovu";
"reload_chapters_title" = "Znovu načíst kapitoly";
"reparse_chapters_found_title" = "Kapitoly znovu načteny";
"reparse_chapters_found_description" = "Nalezeno %d kapitol.";
"reparse_chapters_none_description" = "V tomto souboru nebyly nalezeny žádné další kapitoly.";
"reparse_chapters_download_description" = "Před opětovným načtením kapitol nejprve stáhněte tuto knihu.";
"restore_title" = "Obnovit";
"themes_caps_title" = "TÉMATA";
"plus_app_icons_title" = "Ikony aplikací";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/da.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ tilbage";
"chapters_title" = "Kapitler";
"chapters_item_description" = "Start: %@ - Varighed: %@";
"reload_button" = "Genindlæs";
"reload_chapters_title" = "Genindlæs kapitler";
"reparse_chapters_found_title" = "Kapitler genindlæst";
"reparse_chapters_found_description" = "Fandt %d kapitler.";
"reparse_chapters_none_description" = "Der blev ikke fundet yderligere kapitler i denne fil.";
"reparse_chapters_download_description" = "Hent denne bog, før du genindlæser dens kapitler.";
"restore_title" = "Gendan";
"themes_caps_title" = "TEMAER";
"plus_app_icons_title" = "App-ikoner";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ übrig";
"chapters_title" = "Kapitel";
"chapters_item_description" = "Beginn: %@ – Dauer: %@";
"reload_button" = "Neu laden";
"reload_chapters_title" = "Kapitel neu laden";
"reparse_chapters_found_title" = "Kapitel neu geladen";
"reparse_chapters_found_description" = "%d Kapitel gefunden.";
"reparse_chapters_none_description" = "In dieser Datei wurden keine weiteren Kapitel gefunden.";
"reparse_chapters_download_description" = "Lade dieses Buch herunter, bevor du seine Kapitel neu lädst.";
"restore_title" = "Wiederherstellen";
"themes_caps_title" = "DESIGNS";
"plus_app_icons_title" = "App Icons";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/el.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "Απομένουν %@";
"chapters_title" = "Κεφάλαια";
"chapters_item_description" = "Έναρξη: %@ - Διάρκεια: %@";
"reload_button" = "Επαναφόρτωση";
"reload_chapters_title" = "Επαναφόρτωση κεφαλαίων";
"reparse_chapters_found_title" = "Τα κεφάλαια επαναφορτώθηκαν";
"reparse_chapters_found_description" = "Βρέθηκαν %d κεφάλαια.";
"reparse_chapters_none_description" = "Δεν βρέθηκαν επιπλέον κεφάλαια σε αυτό το αρχείο.";
"reparse_chapters_download_description" = "Κατεβάστε αυτό το βιβλίο πριν επαναφορτώσετε τα κεφάλαιά του.";
"restore_title" = "Επαναφέρω";
"themes_caps_title" = "ΘΕΜΑΤΑ";
"plus_app_icons_title" = "Εικονίδια εφαρμογών";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ left";
"chapters_title" = "Chapters";
"chapters_item_description" = "Start: %@ - Duration: %@";
"reload_button" = "Reload";
"reload_chapters_title" = "Reload Chapters";
"reparse_chapters_found_title" = "Chapters Reloaded";
"reparse_chapters_found_description" = "Found %d chapters.";
"reparse_chapters_none_description" = "No additional chapters were found in this file.";
"reparse_chapters_download_description" = "Download this book before reloading its chapters.";
"restore_title" = "Restore";
"themes_caps_title" = "THEMES";
"plus_app_icons_title" = "App Icons";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ restante";
"chapters_title" = "Capítulos";
"chapters_item_description" = "Inicio: %@ - Duración: %@";
"reload_button" = "Recargar";
"reload_chapters_title" = "Recargar capítulos";
"reparse_chapters_found_title" = "Capítulos recargados";
"reparse_chapters_found_description" = "Se encontraron %d capítulos.";
"reparse_chapters_none_description" = "No se encontraron capítulos adicionales en este archivo.";
"reparse_chapters_download_description" = "Descarga este libro antes de recargar sus capítulos.";
"restore_title" = "Restaurar";
"themes_caps_title" = "TEMAS";
"plus_app_icons_title" = "Iconos de la Aplicación";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/fi.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ jäljellä";
"chapters_title" = "Kappaleet";
"chapters_item_description" = "Aloitus: %@ - Kesto: %@";
"reload_button" = "Lataa uudelleen";
"reload_chapters_title" = "Lataa kappaleet uudelleen";
"reparse_chapters_found_title" = "Kappaleet ladattu uudelleen";
"reparse_chapters_found_description" = "Löytyi %d kappaletta.";
"reparse_chapters_none_description" = "Tästä tiedostosta ei löytynyt lisää kappaleita.";
"reparse_chapters_download_description" = "Lataa tämä kirja ennen kuin lataat sen kappaleet uudelleen.";
"restore_title" = "Palauta";
"themes_caps_title" = "TEEMAT";
"plus_app_icons_title" = "Appikuvakkeet";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ restant";
"chapters_title" = "Chapitres";
"chapters_item_description" = "Début : %@ - Durée : %@";
"reload_button" = "Recharger";
"reload_chapters_title" = "Recharger les chapitres";
"reparse_chapters_found_title" = "Chapitres rechargés";
"reparse_chapters_found_description" = "%d chapitres trouvés.";
"reparse_chapters_none_description" = "Aucun chapitre supplémentaire n’a été trouvé dans ce fichier.";
"reparse_chapters_download_description" = "Téléchargez ce livre avant de recharger ses chapitres.";
"restore_title" = "Restaurer";
"themes_caps_title" = "THÈMES";
"plus_app_icons_title" = "Icônes d'application";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/hu.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ van hátra";
"chapters_title" = "Fejezetek";
"chapters_item_description" = "Kezdés: %@ - Hossz: %@";
"reload_button" = "Újratöltés";
"reload_chapters_title" = "Fejezetek újratöltése";
"reparse_chapters_found_title" = "Fejezetek újratöltve";
"reparse_chapters_found_description" = "%d fejezet található.";
"reparse_chapters_none_description" = "Nem található további fejezet ebben a fájlban.";
"reparse_chapters_download_description" = "Töltsd le ezt a könyvet, mielőtt újratöltöd a fejezeteit.";
"restore_title" = "Visszaállítás";
"themes_caps_title" = "TÉMÁK";
"plus_app_icons_title" = "App ikonok";
Expand Down
6 changes: 6 additions & 0 deletions BookPlayer/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
"player_book_remaining_title" = "%@ rimanenti";
"chapters_title" = "Capitoli";
"chapters_item_description" = "Inizio: %@ - Durata: %@";
"reload_button" = "Ricarica";
"reload_chapters_title" = "Ricarica capitoli";
"reparse_chapters_found_title" = "Capitoli ricaricati";
"reparse_chapters_found_description" = "Trovati %d capitoli.";
"reparse_chapters_none_description" = "Nessun capitolo aggiuntivo trovato in questo file.";
"reparse_chapters_download_description" = "Scarica questo libro prima di ricaricarne i capitoli.";
"restore_title" = "Ripristina";
"themes_caps_title" = "TEMI";
"plus_app_icons_title" = "Icone dell'App";
Expand Down
Loading
Loading