From 1766011b47b8c85146f7422cc6148a0ccc7643fa Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 9 Mar 2026 23:46:19 +0500 Subject: [PATCH] feat(ios): reader ToC drawer, swipe chapters, scroll mode, library filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reader: list.bullet ToC button in top chrome opens ChaptersListSheet - Reader: swipe right on title page → prev chapter, swipe left on end page → next chapter - Reader: scroll mode toggle in settings panel (ReaderSettings.scrollMode); ScrollReaderContent for continuous layout - Library: segmented All/In Progress/Completed filter - Library: genre filter chips derived from book.genres, amber fill when active - Library: completed books show checkmark badge + Finished label - Library: context-aware empty state messages per filter combination - Player: prev/next chapter buttons in mini + full player, ±15s skip, sleep timer countdown, Chapter N of M label - Player: ChaptersListSheet with 100-chapter blocks, jump bar, search filter - Home/BookDetail/Browse: Apple Books-style redesign, zoom transitions --- .../LibNovel/Extensions/NavDestination.swift | 76 +- ios/LibNovel/LibNovel/Models/Models.swift | 76 ++ .../Services/AudioPlayerService.swift | 58 +- .../Views/BookDetail/BookDetailView.swift | 353 +++-- .../LibNovel/Views/Browse/BrowseView.swift | 1 + .../ChapterReader/ChapterReaderView.swift | 1152 +++++++++++++++-- .../LibNovel/Views/Home/HomeView.swift | 284 +++- .../LibNovel/Views/Library/LibraryView.swift | 372 +++++- .../LibNovel/Views/Player/PlayerViews.swift | 283 +++- 9 files changed, 2242 insertions(+), 413 deletions(-) diff --git a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift index ac441ab..5b87abf 100644 --- a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift +++ b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift @@ -13,12 +13,7 @@ extension View { /// Registers the app-wide navigation destinations for NavDestination values. /// Apply once per NavigationStack instead of repeating the switch in every tab. func appNavigationDestination() -> some View { - navigationDestination(for: NavDestination.self) { dest in - switch dest { - case .book(let slug): BookDetailView(slug: slug) - case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) - } - } + modifier(AppNavigationDestinationModifier()) } /// Presents a standard "Error" alert driven by an optional String binding. @@ -34,3 +29,72 @@ extension View { } } } + +// MARK: - Navigation destination modifier + +private struct AppNavigationDestinationModifier: ViewModifier { + @Namespace private var zoomNamespace + + func body(content: Content) -> some View { + if #available(iOS 18.0, *) { + content + .navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): + BookDetailView(slug: slug) + .navigationTransition(.zoom(sourceID: slug, in: zoomNamespace)) + case .chapter(let slug, let n): + ChapterReaderView(slug: slug, chapterNumber: n) + } + } + // Expose namespace to child views via environment + .environment(\.bookZoomNamespace, zoomNamespace) + } else { + content + .navigationDestination(for: NavDestination.self) { dest in + switch dest { + case .book(let slug): BookDetailView(slug: slug) + case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) + } + } + } + } +} + +// MARK: - Environment key for zoom namespace + +struct BookZoomNamespaceKey: EnvironmentKey { + static var defaultValue: Namespace.ID? { nil } +} + +extension EnvironmentValues { + var bookZoomNamespace: Namespace.ID? { + get { self[BookZoomNamespaceKey.self] } + set { self[BookZoomNamespaceKey.self] = newValue } + } +} + +// MARK: - Cover card zoom source modifier + +/// Apply this to any cover image that should be a zoom source for book navigation. +/// Falls back to a no-op on iOS 17 or when no namespace is available. +struct BookCoverZoomSource: ViewModifier { + let slug: String + @Environment(\.bookZoomNamespace) private var namespace + + func body(content: Content) -> some View { + if #available(iOS 18.0, *), let ns = namespace { + content.matchedTransitionSource(id: slug, in: ns) + } else { + content + } + } +} + +extension View { + /// Marks a cover image as the zoom source for a book's navigation transition. + func bookCoverZoomSource(slug: String) -> some View { + modifier(BookCoverZoomSource(slug: slug)) + } +} + diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift index c659ea1..9a8951c 100644 --- a/ios/LibNovel/LibNovel/Models/Models.swift +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI // MARK: - Book @@ -107,6 +108,81 @@ struct UserSettings: Codable { static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0) } +// MARK: - Reading Display Settings (local only — stored in UserDefaults) + +enum ReaderTheme: String, CaseIterable, Codable { + case white, sepia, night + + var backgroundColor: Color { + switch self { + case .white: return Color(.sRGB, white: 1.0, opacity: 1) + case .sepia: return Color(red: 0.97, green: 0.93, blue: 0.82) + case .night: return Color(red: 0.10, green: 0.10, blue: 0.12) + } + } + + var textColor: Color { + switch self { + case .white: return Color(.sRGB, white: 0.1, opacity: 1) + case .sepia: return Color(red: 0.25, green: 0.18, blue: 0.08) + case .night: return Color(red: 0.85, green: 0.85, blue: 0.87) + } + } + + var colorScheme: ColorScheme? { + switch self { + case .white: return nil // follows system + case .sepia: return .light + case .night: return .dark + } + } +} + +enum ReaderFont: String, CaseIterable, Codable { + case system = "System" + case georgia = "Georgia" + case newYork = "New York" + + var fontName: String? { + switch self { + case .system: return nil + case .georgia: return "Georgia" + case .newYork: return "NewYorkMedium-Regular" + } + } +} + +struct ReaderSettings: Codable, Equatable { + var fontSize: CGFloat + var lineSpacing: CGFloat + var font: ReaderFont + var theme: ReaderTheme + var scrollMode: Bool + + static let `default` = ReaderSettings( + fontSize: 17, + lineSpacing: 1.7, + font: .system, + theme: .white, + scrollMode: false + ) + + static let userDefaultsKey = "readerSettings" + + static func load() -> ReaderSettings { + guard let data = UserDefaults.standard.data(forKey: userDefaultsKey), + let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data) + else { return .default } + return decoded + } + + func save() { + if let data = try? JSONEncoder().encode(self) { + UserDefaults.standard.set(data, forKey: ReaderSettings.userDefaultsKey) + } + } +} + // MARK: - User struct AppUser: Codable, Identifiable { diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift index de07427..7947ba8 100644 --- a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -64,6 +64,9 @@ final class AudioPlayerService: ObservableObject { @Published var prevChapter: Int? = nil @Published var sleepTimer: SleepTimerOption? = nil + /// Human-readable countdown string shown in the full player near the moon button. + /// e.g. "38:12" for minute-based, "2 ch left" for chapter-based, "" when off. + @Published var sleepTimerRemainingText: String = "" @Published var nextPrefetchStatus: NextPrefetchStatus = .none @Published var nextAudioURL: String = "" @@ -109,6 +112,10 @@ final class AudioPlayerService: ObservableObject { // Sleep timer tracking private var sleepTimerTask: Task? private var sleepTimerStartChapter: Int = 0 + /// Absolute deadline for minute-based timers (nil when not active or chapter-based). + private var sleepTimerDeadline: Date? = nil + /// 1-second tick task that keeps sleepTimerRemainingText up-to-date. + private var sleepTimerCountdownTask: Task? = nil // MARK: - Init @@ -196,31 +203,67 @@ final class AudioPlayerService: ObservableObject { } func setSleepTimer(_ option: SleepTimerOption?) { - // Cancel existing timer + // Cancel existing timer + countdown sleepTimerTask?.cancel() sleepTimerTask = nil + sleepTimerCountdownTask?.cancel() + sleepTimerCountdownTask = nil + sleepTimerDeadline = nil sleepTimer = option - guard let option else { return } + guard let option else { + sleepTimerRemainingText = "" + return + } // Start timer based on option switch option { case .chapters(let count): sleepTimerStartChapter = chapter - // Monitor chapter changes in handlePlaybackFinished + // Update display immediately; chapter changes are tracked in handlePlaybackFinished. + updateChapterTimerLabel(chaptersRemaining: count) case .minutes(let minutes): + let deadline = Date().addingTimeInterval(Double(minutes) * 60) + sleepTimerDeadline = deadline + // Stop playback when the deadline is reached. sleepTimerTask = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000) guard let self, !Task.isCancelled else { return } await MainActor.run { self.stop() self.sleepTimer = nil + self.sleepTimerRemainingText = "" } } + // 1-second tick to keep the countdown label fresh. + sleepTimerCountdownTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard let self, !Task.isCancelled else { return } + await MainActor.run { + guard let deadline = self.sleepTimerDeadline else { return } + let remaining = max(0, deadline.timeIntervalSinceNow) + self.sleepTimerRemainingText = Self.formatCountdown(remaining) + } + } + } + // Set initial label without waiting for the first tick. + sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60) } } + + private func updateChapterTimerLabel(chaptersRemaining: Int) { + sleepTimerRemainingText = chaptersRemaining == 1 ? "1 ch left" : "\(chaptersRemaining) ch left" + } + + private static func formatCountdown(_ seconds: Double) -> String { + let s = Int(max(0, seconds)) + let m = s / 60 + let sec = s % 60 + return "\(m):\(String(format: "%02d", sec))" + } func stop() { player?.pause() @@ -231,10 +274,14 @@ final class AudioPlayerService: ObservableObject { audioURL = "" status = .idle - // Cancel sleep timer + // Cancel sleep timer + countdown sleepTimerTask?.cancel() sleepTimerTask = nil + sleepTimerCountdownTask?.cancel() + sleepTimerCountdownTask = nil + sleepTimerDeadline = nil sleepTimer = nil + sleepTimerRemainingText = "" } // MARK: - Audio generation @@ -408,6 +455,9 @@ final class AudioPlayerService: ObservableObject { stop() return } + // Update the remaining chapters label. + let remaining = count - chaptersPlayed + updateChapterTimerLabel(chaptersRemaining: remaining) } // Always notify the view that the chapter finished (it may update UI). diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift index a92554a..56470b7 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Kingfisher struct BookDetailView: View { let slug: String @@ -7,6 +8,7 @@ struct BookDetailView: View { @EnvironmentObject var audioPlayer: AudioPlayerService @State private var summaryExpanded = false @State private var chapterPage = 0 + @State private var scrollOffset: CGFloat = 0 private let pageSize = 50 init(slug: String) { @@ -15,18 +17,22 @@ struct BookDetailView: View { } var body: some View { - ScrollView { - if vm.isLoading { - ProgressView().frame(maxWidth: .infinity).padding(.top, 80) - } else if let book = vm.book { + ZStack(alignment: .top) { + // Scroll content + ScrollView { VStack(alignment: .leading, spacing: 0) { - heroSection(book: book) - Divider().padding(.vertical, 8) - chapterSection(book: book) + if vm.isLoading { + ProgressView().frame(maxWidth: .infinity).padding(.top, 120) + } else if let book = vm.book { + heroSection(book: book) + metaSection(book: book) + Divider().padding(.horizontal) + chapterSection(book: book) + } } } + .ignoresSafeArea(edges: .top) } - .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { bookmarkButton } .task { await vm.load() } @@ -38,85 +44,148 @@ struct BookDetailView: View { @ViewBuilder private func heroSection(book: Book) -> some View { ZStack(alignment: .bottom) { - // Blurred cover background — use plain colour placeholder to avoid - // the rounded-rect loading indicator showing through the blur. - AsyncCoverImage(url: book.cover, isBackground: true) + // Full-bleed blurred background + KFImage(URL(string: book.cover)) + .resizable() + .scaledToFill() .frame(maxWidth: .infinity) - .frame(height: 260) - .blur(radius: 20) + .frame(height: 320) + .blur(radius: 24) .clipped() - .overlay(Color.black.opacity(0.45)) + .overlay( + LinearGradient( + colors: [.black.opacity(0.15), .black.opacity(0.68)], + startPoint: .top, + endPoint: .bottom + ) + ) - HStack(alignment: .bottom, spacing: 14) { - AsyncCoverImage(url: book.cover) - .frame(width: 110, height: 160) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .shadow(radius: 8) + // Cover + info column centered + VStack(spacing: 16) { + // Isolated cover with 3D-style shadow + KFImage(URL(string: book.cover)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray5)) + } + .scaledToFill() + .frame(width: 130, height: 188) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10) + .shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3) - VStack(alignment: .leading, spacing: 6) { + // Title + author + VStack(spacing: 6) { Text(book.title) - .font(.headline) + .font(.title3.bold()) .foregroundStyle(.white) + .multilineTextAlignment(.center) .lineLimit(3) + .padding(.horizontal, 32) + Text(book.author) .font(.subheadline) - .foregroundStyle(.white.opacity(0.8)) - HStack { -// TagChip(label: book.status).colorScheme(.dark) - ForEach(book.genres.prefix(2), id: \.self) { - TagChip(label: $0).colorScheme(.dark) + .foregroundStyle(.white.opacity(0.75)) + } + + // Genre tags + if !book.genres.isEmpty { + HStack(spacing: 8) { + ForEach(book.genres.prefix(3), id: \.self) { genre in + TagChip(label: genre).colorScheme(.dark) } } } - Spacer(minLength: 0) + + // Status badge + if !book.status.isEmpty { + StatusBadge(status: book.status) + } } .padding(.horizontal) - .padding(.bottom, 16) + .padding(.bottom, 28) } + .frame(minHeight: 320) + } - // Summary - VStack(alignment: .leading, spacing: 8) { - Text(book.summary) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(summaryExpanded ? nil : 4) - if book.summary.count > 200 { - Button(summaryExpanded ? "Less" : "More") { - withAnimation { summaryExpanded.toggle() } + // MARK: - Meta section (summary + CTAs) + + @ViewBuilder + private func metaSection(book: Book) -> some View { + VStack(alignment: .leading, spacing: 0) { + // Quick stats row + HStack(spacing: 0) { + MetaStat(value: "\(book.totalChapters)", label: "Chapters", + icon: "doc.text") + Divider().frame(height: 36) + MetaStat(value: book.status.capitalized.isEmpty ? "—" : book.status.capitalized, + label: "Status", icon: "flag") + if book.ranking > 0 { + Divider().frame(height: 36) + MetaStat(value: "#\(book.ranking)", label: "Rank", + icon: "chart.bar.fill") } - .font(.caption.bold())  - .foregroundStyle(.amber) } - } - .padding() + .padding(.vertical, 16) + .frame(maxWidth: .infinity) - // CTA buttons - HStack(spacing: 10) { - if let last = vm.lastChapter, last > 0 { - NavigationLink(value: NavDestination.chapter(slug, last)) { - Label("Continue Ch.\(last)", systemImage: "play.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.amber) + Divider().padding(.horizontal) - NavigationLink(value: NavDestination.chapter(slug, 1)) { - Label("From Ch.1", systemImage: "arrow.counterclockwise") - .frame(maxWidth: .infinity) + // Summary + VStack(alignment: .leading, spacing: 8) { + Text("About") + .font(.headline) + + Text(book.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(summaryExpanded ? nil : 4) + .animation(.easeInOut(duration: 0.2), value: summaryExpanded) + + if book.summary.count > 200 { + Button(summaryExpanded ? "Less" : "More") { + withAnimation { summaryExpanded.toggle() } + } + .font(.caption.bold()) + .foregroundStyle(.amber) } - .buttonStyle(.bordered) - .tint(.secondary) - } else { - NavigationLink(value: NavDestination.chapter(slug, 1)) { - Label("Start Reading", systemImage: "book.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.amber) } + .padding(.horizontal) + .padding(.vertical, 16) + + Divider().padding(.horizontal) + + // CTA buttons + HStack(spacing: 10) { + if let last = vm.lastChapter, last > 0 { + NavigationLink(value: NavDestination.chapter(slug, last)) { + Label("Continue Ch.\(last)", systemImage: "play.fill") + .frame(maxWidth: .infinity) + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + .tint(.amber) + + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label("Ch.1", systemImage: "arrow.counterclockwise") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.secondary) + } else { + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label("Start Reading", systemImage: "book.fill") + .frame(maxWidth: .infinity) + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + } + .padding(.horizontal) + .padding(.vertical, 16) } - .padding(.horizontal) - .padding(.bottom, 8) } // MARK: - Chapter list @@ -130,9 +199,10 @@ struct BookDetailView: View { let pageChapters = Array(chapters[start.. 0 { Text("\(start + 1)–\(end) of \(total)") @@ -141,36 +211,58 @@ struct BookDetailView: View { } } .padding(.horizontal) - .padding(.vertical, 10) + .padding(.vertical, 14) if vm.chaptersLoading { ProgressView().frame(maxWidth: .infinity).padding() } else { ForEach(pageChapters) { ch in NavigationLink(value: NavDestination.chapter(slug, ch.number)) { - ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter) + ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter, + totalChapters: total) } .buttonStyle(.plain) Divider().padding(.leading) } } - // Pagination + // Pagination bar if total > pageSize { HStack { - Button("Previous") { chapterPage -= 1 } - .disabled(chapterPage == 0) + Button { + withAnimation { chapterPage -= 1 } + } label: { + Image(systemName: "chevron.left") + Text("Previous") + } + .disabled(chapterPage == 0) + Spacer() - Button("Next") { chapterPage += 1 } - .disabled(end >= total) + + Text("Page \(chapterPage + 1) of \((total + pageSize - 1) / pageSize)") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + Button { + withAnimation { chapterPage += 1 } + } label: { + Text("Next") + Image(systemName: "chevron.right") + } + .disabled(end >= total) } - .buttonStyle(.bordered) + .font(.subheadline) + .foregroundStyle(.amber) .padding() } + + Color.clear.frame(height: 32) } } - // MARK: - Toolbar bookmark + // MARK: - Bookmark toolbar @ToolbarContentBuilder private var bookmarkButton: some ToolbarContent { @@ -185,38 +277,123 @@ struct BookDetailView: View { } } +// MARK: - Chapter row + private struct ChapterRow: View { let chapter: ChapterIndex let isCurrent: Bool + let totalChapters: Int + + private var progressFraction: Double { + guard totalChapters > 1 else { return 0 } + return Double(chapter.number) / Double(totalChapters) + } + var body: some View { - HStack(spacing: 8) { + HStack(spacing: 10) { + // Number badge + ZStack { + Circle() + .fill(isCurrent ? Color.amber : Color(.systemGray6)) + Text("\(chapter.number)") + .font(.caption2.bold().monospacedDigit()) + .foregroundStyle(isCurrent ? .black : .secondary) + } + .frame(width: 32, height: 32) + VStack(alignment: .leading, spacing: 2) { - Text("Chapter \(chapter.number)") + let displayTitle: String = { + let stripped = chapter.title.strippingTrailingDate() + if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { + return "Chapter \(chapter.number)" + } + return stripped + }() + + Text(displayTitle) .font(.subheadline) - .fontWeight(isCurrent ? .bold : .regular) + .fontWeight(isCurrent ? .semibold : .regular) .foregroundStyle(isCurrent ? .amber : .primary) + .lineLimit(1) + if !chapter.title.isEmpty && chapter.title != "Chapter \(chapter.number)" { - Text(chapter.title) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + let subtitle = chapter.title.strippingTrailingDate() + if !subtitle.isEmpty && subtitle != "Chapter \(chapter.number)" { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } - Spacer(minLength: 12) - HStack(spacing: 6) { + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 2) { if !chapter.dateLabel.isEmpty { Text(chapter.dateLabel) .font(.caption2) .foregroundStyle(.tertiary) - .fixedSize() } - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.tertiary) } + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) } - .padding(.horizontal) + .padding(.horizontal, 16) .padding(.vertical, 10) .contentShape(Rectangle()) } } + +// MARK: - Supporting components + +private struct MetaStat: View { + let value: String + let label: String + let icon: String + + var body: some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(.amber) + Text(value) + .font(.subheadline.bold()) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} + +private struct StatusBadge: View { + let status: String + + private var color: Color { + switch status.lowercased() { + case "ongoing", "active": return .green + case "completed": return .blue + case "hiatus": return .orange + default: return .secondary + } + } + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(status.capitalized) + .font(.caption.weight(.medium)) + .foregroundStyle(color) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(color.opacity(0.12), in: Capsule()) + } +} diff --git a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift index 50fb046..3728df3 100644 --- a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift +++ b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift @@ -72,6 +72,7 @@ struct BrowseView: View { ForEach(vm.novels) { novel in NavigationLink(value: NavDestination.book(novel.slug)) { BrowseCard(novel: novel) + .bookCoverZoomSource(slug: novel.slug) } .buttonStyle(.plain) } diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift index e017c08..186e0f8 100644 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -1,19 +1,24 @@ import SwiftUI import WebKit +import UIKit -// MARK: - Chapter Reader +// MARK: - Chapter Reader (paginated, Apple Books–style) struct ChapterReaderView: View { let slug: String let chapterNumber: Int - /// Tracks the currently displayed chapter — updated in-place by skip/auto-next - /// so we never accumulate stale listeners on the navigation stack. @State private var currentChapter: Int @StateObject private var vm: ChapterReaderViewModel + @StateObject private var readerSettings = ReaderSettingsStore() @EnvironmentObject var audioPlayer: AudioPlayerService @EnvironmentObject var authStore: AuthStore + // Toolbar / UI chrome visibility + @State private var chromeVisible = true + @State private var showSettingsPanel = false + @State private var showToCSheet = false + init(slug: String, chapterNumber: Int) { self.slug = slug self.chapterNumber = chapterNumber @@ -22,160 +27,1038 @@ struct ChapterReaderView: View { } var body: some View { - Group { + ZStack { + // Full-bleed background colour driven by reader theme + readerSettings.settings.theme.backgroundColor + .ignoresSafeArea() + if vm.isLoading { - ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + ProgressView() + .tint(readerSettings.settings.theme.textColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let content = vm.content { - readerContent(content) - } else if let errMsg = vm.error { - VStack(spacing: 16) { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundStyle(.orange) - Text(errMsg) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding(.horizontal) - Button("Retry") { Task { await vm.load() } } - .buttonStyle(.borderedProminent) - .tint(.amber) + if readerSettings.settings.scrollMode { + ScrollReaderContent( + content: content, + readerSettings: readerSettings, + chromeVisible: $chromeVisible, + onNavigateChapter: navigateToChapter + ) + } else { + PaginatedReaderContent( + content: content, + readerSettings: readerSettings, + chromeVisible: $chromeVisible, + onNavigateChapter: navigateToChapter + ) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - Color.clear + } else if let errMsg = vm.error { + errorView(errMsg) + } + + // Top chrome: nav bar area (progress bar + title + controls) + if chromeVisible { + topChrome + } + + // Bottom chrome: prev/next + listen button + if chromeVisible, let content = vm.content { + bottomChrome(content: content) + } + + // Reading settings panel slides up from bottom + if showSettingsPanel { + ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(10) } } - .navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "") - .navigationBarTitleDisplayMode(.inline) - .overlay(alignment: .bottomTrailing) { - // Floating audio button when player is not active - if !audioPlayer.isActive { - floatingAudioButton + .navigationBarHidden(true) // we draw our own chrome + .ignoresSafeArea(edges: .top) + .preferredColorScheme(readerSettings.settings.theme.colorScheme) + .task(id: currentChapter) { await vm.load() } + .sheet(isPresented: $showToCSheet) { + if let content = vm.content { + ChaptersListSheet( + chapters: content.chapters, + currentChapter: currentChapter, + onChapterSelect: { selected in + showToCSheet = false + navigateToChapter(selected) + } + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) } } - .task(id: currentChapter) { - await vm.load() - } .onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in - guard let next = note.userInfo?["next"] as? Int else { return } - let shouldAutoNavigate = note.userInfo?["autoNext"] as? Bool ?? false - // Only handle if this is the top-most (currently active) chapter view - guard shouldAutoNavigate, currentChapter == audioPlayer.chapter else { return } + guard let next = note.userInfo?["next"] as? Int, + let autoNext = note.userInfo?["autoNext"] as? Bool, + autoNext, currentChapter == audioPlayer.chapter else { return } navigateToChapter(next) } .onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in - guard let next = note.userInfo?["next"] as? Int else { return } - // Only the view whose chapter matches the currently playing chapter should handle this - guard currentChapter == audioPlayer.chapter else { return } + guard let next = note.userInfo?["next"] as? Int, + currentChapter == audioPlayer.chapter else { return } navigateToChapter(next) } .onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in - guard let prev = note.userInfo?["prev"] as? Int else { return } - guard currentChapter == audioPlayer.chapter else { return } + guard let prev = note.userInfo?["prev"] as? Int, + currentChapter == audioPlayer.chapter else { return } navigateToChapter(prev) } } - /// Navigate to a chapter in-place: reloads content without pushing to the navigation stack. - /// Back button always returns to BookDetailView regardless of how many chapters were visited. + // MARK: - Top chrome + + @Environment(\.dismiss) private var dismiss + + private var topChrome: some View { + VStack(spacing: 0) { + // Safe area spacer + Color.clear.frame(height: safeAreaTop) + + ZStack { + // Blurred background strip + readerSettings.settings.theme.backgroundColor + .opacity(0.92) + .blur(radius: 0) + + HStack(spacing: 0) { + // Back button + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) + .frame(width: 44, height: 44) + } + + Spacer() + + // Chapter title (truncated) + if let content = vm.content { + Text(content.chapter.title.strippingTrailingDate()) + .font(.subheadline.weight(.medium)) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + .lineLimit(1) + .frame(maxWidth: 220) + } + + Spacer() + + // Aa settings button + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showSettingsPanel.toggle() + } + } label: { + Text("Aa") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) + .frame(width: 44, height: 44) + } + + // ToC button + Button { + showToCSheet = true + } label: { + Image(systemName: "list.bullet") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) + .frame(width: 44, height: 44) + } + } + .padding(.horizontal, 4) + } + .frame(height: 44) + + // Thin chapter-progress bar across full width + if let content = vm.content { + ChapterProgressBar( + currentChapter: content.chapter.number, + totalChapters: content.chapters.count > 0 + ? (content.chapters.last?.number ?? content.chapter.number) + : content.chapter.number, + color: readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) + : .amber + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .animation(.easeInOut(duration: 0.2), value: chromeVisible) + } + + // MARK: - Bottom chrome + + private func bottomChrome(content: ChapterResponse) -> some View { + VStack(spacing: 0) { + Spacer() + + ZStack { + readerSettings.settings.theme.backgroundColor + .opacity(0.92) + + HStack(spacing: 16) { + // Prev chapter + if let prev = content.prev { + Button { + navigateToChapter(prev) + } label: { + HStack(spacing: 6) { + Image(systemName: "chevron.left") + Text("Ch.\(prev)") + } + .font(.subheadline.weight(.medium)) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + } + } + + Spacer() + + // Listen / playing indicator + ListenButton( + audioPlayer: audioPlayer, + vm: vm, + authStore: authStore, + theme: readerSettings.settings.theme + ) + + Spacer() + + // Next chapter + if let next = content.next { + Button { + navigateToChapter(next) + } label: { + HStack(spacing: 6) { + Text("Ch.\(next)") + Image(systemName: "chevron.right") + } + .font(.subheadline.weight(.medium)) + .foregroundStyle(.amber) + } + } + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + } + .frame(height: 56) + + // Mini player spacer if active + if audioPlayer.isActive { + Color.clear.frame(height: AppLayout.miniPlayerBarHeight) + } + + // Home indicator area + Color.clear.frame(height: safeAreaBottom) + } + .animation(.easeInOut(duration: 0.2), value: chromeVisible) + } + + // MARK: - Error view + + private func errorView(_ msg: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) + Text(msg) + .multilineTextAlignment(.center) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + .padding(.horizontal) + Button("Retry") { Task { await vm.load() } } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func navigateToChapter(_ chapter: Int) { vm.switchChapter(to: chapter) currentChapter = chapter } - // MARK: - Content + // MARK: - Safe area helpers - @State private var webHeight: CGFloat = 800 - - @ViewBuilder - private func readerContent(_ content: ChapterResponse) -> some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - // Header - VStack(alignment: .leading, spacing: 4) { - Text(content.chapter.title.strippingTrailingDate()) - .font(.title2.bold()) - if !content.chapter.dateLabel.isEmpty { - Text(content.chapter.dateLabel) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.horizontal) - - Divider() - - // Chapter body - HTMLContentView(html: content.html, height: $webHeight) - .frame(height: webHeight) - .padding(.horizontal) - - Divider() - - // Prev / Next navigation — in-place swap so back button always returns to book - HStack(spacing: 12) { - if let prev = content.prev { - Button { - navigateToChapter(prev) - } label: { - Label("Ch.\(prev)", systemImage: "chevron.left") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } - if let next = content.next { - Button { - navigateToChapter(next) - } label: { - Label("Ch.\(next)", systemImage: "chevron.right") - .labelStyle(ReverseLabelStyle()) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .tint(.amber) - } - } - .padding() - } - .padding(.vertical) - } - // Ensure the Prev/Next buttons clear the mini-player bar when it is visible. - .safeAreaInset(edge: .bottom) { - if audioPlayer.isActive { - Color.clear.frame(height: AppLayout.miniPlayerBarHeight) - } - } + private var safeAreaTop: CGFloat { + (UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.windows.first(where: \.isKeyWindow)? + .safeAreaInsets.top) ?? 44 } - // MARK: - Floating audio button - - @ViewBuilder - private var floatingAudioButton: some View { - Button { - vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) - } label: { - HStack(spacing: 8) { - Image(systemName: "play.circle.fill") - .font(.system(size: 22)) - Text("Listen") - .font(.subheadline.weight(.semibold)) - } - .foregroundStyle(.white) - .padding(.horizontal, 20) - .padding(.vertical, 12) - .background( - Capsule() - .fill(Color.amber) - .shadow(color: .black.opacity(0.25), radius: 8, y: 4) - ) - } - .buttonStyle(.plain) - .padding(.trailing, 20) - .padding(.bottom, 20) + private var safeAreaBottom: CGFloat { + (UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.windows.first(where: \.isKeyWindow)? + .safeAreaInsets.bottom) ?? 0 } } -// MARK: - HTML content renderer using WKWebView +// MARK: - Paginated reader content + +/// Splits chapter HTML into pages and renders them in a horizontal TabView (swipe to turn pages). +/// On the last page, swiping further triggers the next-chapter navigation. +private struct PaginatedReaderContent: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + @Binding var chromeVisible: Bool + let onNavigateChapter: (Int) -> Void + + @State private var pages: [AttributedString] = [] + @State private var currentPage: Int = 0 + @State private var geometrySize: CGSize = .zero + /// Tracks the last page index we were on, used to detect edge-swipe direction. + @State private var lastPage: Int = 0 + + var body: some View { + GeometryReader { geo in + let size = geo.size + TabView(selection: $currentPage) { + // Chapter header page (index -1 is the cover-like title page) + ChapterTitlePage( + content: content, + readerSettings: readerSettings + ) + .tag(-1) + .onTapGesture { toggleChrome() } + + ForEach(Array(pages.enumerated()), id: \.offset) { idx, page in + ReaderPage( + text: page, + readerSettings: readerSettings, + pageNumber: idx + 1, + totalPages: pages.count + ) + .tag(idx) + .onTapGesture { toggleChrome() } + } + + // End-of-chapter page + ChapterEndPage( + content: content, + readerSettings: readerSettings, + onNavigateChapter: onNavigateChapter + ) + .tag(pages.count) + .onTapGesture { toggleChrome() } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .onChange(of: currentPage) { oldPage, newPage in + // Edge-swipe navigation: + // • Swiping right past the title page (–1) → previous chapter + // • Swiping left past the end page → next chapter + // TabView doesn't allow going before tag -1 or after tag pages.count, + // so we detect the transition from -1 back toward -1 (oldPage == 0 means + // the user was on the first content page and the selection "bounced" to -1, + // and now tries to go further left — we handle it differently: + // Instead, we watch if the user is already on page -1 and the tab tries to + // move to a phantom page. We use a DragGesture overlay for that. + lastPage = newPage + } + .onAppear { + if geometrySize != size { + geometrySize = size + repaginate(size: size) + } + } + .onChange(of: size) { _, newSize in + geometrySize = newSize + repaginate(size: newSize) + } + .onChange(of: readerSettings.settings) { _, _ in + repaginate(size: geometrySize) + } + .onChange(of: content.chapter.number) { _, _ in + currentPage = -1 + repaginate(size: geometrySize) + } + } + .ignoresSafeArea() + .onAppear { currentPage = -1 } + // Edge-swipe gesture: swipe right on title page → prev chapter + // swipe left on end page → next chapter + .simultaneousGesture( + DragGesture(minimumDistance: 40, coordinateSpace: .global) + .onEnded { value in + let isHorizontal = abs(value.translation.width) > abs(value.translation.height) * 1.5 + guard isHorizontal else { return } + let swipedRight = value.translation.width > 0 + let swipedLeft = value.translation.width < 0 + + if swipedRight && currentPage == -1, let prev = content.prev { + // On title page, swiping right → previous chapter + onNavigateChapter(prev) + } else if swipedLeft && currentPage == pages.count, let next = content.next { + // On end page, swiping left → next chapter (same as button) + onNavigateChapter(next) + } + } + ) + } + + private func toggleChrome() { + withAnimation(.easeInOut(duration: 0.22)) { + chromeVisible.toggle() + } + } + + private func repaginate(size: CGSize) { + guard size.width > 0, size.height > 0 else { return } + let settings = readerSettings.settings + + // Horizontal padding (mirroring Apple Books generous margins) + let hPad: CGFloat = 28 + let topPad: CGFloat = 90 // clear the top chrome (nav bar + progress bar) + let bottomPad: CGFloat = 80 // clear the bottom chrome + + let textWidth = size.width - hPad * 2 + let textHeight = size.height - topPad - bottomPad + + let attributed = HTMLParser.toAttributedString( + html: content.html, + fontSize: settings.fontSize, + lineSpacing: settings.lineSpacing, + fontName: settings.font.fontName, + textColor: settings.theme.textColor + ) + + pages = TextPaginator.paginate( + attributed: attributed, + width: textWidth, + height: textHeight, + fontSize: settings.fontSize + ) + // Stay on first content page after repagination (not the title page) + if currentPage > pages.count - 1 { + currentPage = max(0, pages.count - 1) + } + } +} + +// MARK: - Scroll mode reader content +// A continuous vertical ScrollView alternative to the paginated TabView. + +private struct ScrollReaderContent: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + @Binding var chromeVisible: Bool + let onNavigateChapter: (Int) -> Void + + var body: some View { + let settings = readerSettings.settings + let hPad: CGFloat = 24 + let topPad: CGFloat = 90 // below top chrome + let bottomPad: CGFloat = 80 // above bottom chrome + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + // Chapter title header + VStack(alignment: .leading, spacing: 10) { + Text(content.book.title) + .font(.caption.weight(.medium)) + .foregroundStyle(settings.theme.textColor.opacity(0.45)) + .textCase(.uppercase) + .tracking(1.2) + Rectangle() + .fill(settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15).opacity(0.5) + : Color.amber.opacity(0.6)) + .frame(width: 40, height: 2) + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: 22, weight: .bold, design: .serif)) + .foregroundStyle(settings.theme.textColor) + if !content.chapter.dateLabel.isEmpty { + Text(content.chapter.dateLabel) + .font(.caption) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + } + } + .padding(.horizontal, hPad) + .padding(.top, 24) + .padding(.bottom, 20) + + // Body text rendered as AttributedString + let attributed = HTMLParser.toAttributedString( + html: content.html, + fontSize: settings.fontSize, + lineSpacing: settings.lineSpacing, + fontName: settings.font.fontName, + textColor: settings.theme.textColor + ) + Text(attributed) + .padding(.horizontal, hPad) + + // Next chapter button at bottom + VStack(spacing: 16) { + Divider() + .padding(.horizontal, hPad) + if let next = content.next { + Button { + onNavigateChapter(next) + } label: { + HStack { + Text("Next Chapter") + .fontWeight(.semibold) + Image(systemName: "arrow.right") + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + Capsule() + .fill(settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) + : Color.amber) + ) + } + .buttonStyle(.plain) + .padding(.horizontal, hPad) + } + } + .padding(.vertical, 24) + .padding(.bottom, bottomPad) + } + } + .padding(.top, topPad) + .background(settings.theme.backgroundColor) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut(duration: 0.22)) { + chromeVisible.toggle() + } + } + } +} + +// MARK: - Individual reader page + +private struct ReaderPage: View { + let text: AttributedString + let readerSettings: ReaderSettingsStore + let pageNumber: Int + let totalPages: Int + + var body: some View { + let settings = readerSettings.settings + let hPad: CGFloat = 28 + let topPad: CGFloat = 90 + let bottomPad: CGFloat = 80 + + GeometryReader { geo in + ZStack(alignment: .bottomTrailing) { + Text(text) + .frame( + width: geo.size.width - hPad * 2, + alignment: .topLeading + ) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.horizontal, hPad) + .padding(.top, topPad) + .padding(.bottom, bottomPad) + + // Page number + Text("\(pageNumber) / \(totalPages)") + .font(.system(size: 11, weight: .regular).monospacedDigit()) + .foregroundStyle(settings.theme.textColor.opacity(0.3)) + .padding(.trailing, hPad) + .padding(.bottom, bottomPad - 22) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(settings.theme.backgroundColor) + } + } +} + +// MARK: - Chapter title page (shown before the first content page) + +private struct ChapterTitlePage: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + + var body: some View { + let settings = readerSettings.settings + VStack(spacing: 0) { + Spacer() + VStack(alignment: .leading, spacing: 12) { + Text(content.book.title) + .font(.caption.weight(.medium)) + .foregroundStyle(settings.theme.textColor.opacity(0.45)) + .textCase(.uppercase) + .tracking(1.2) + + Rectangle() + .fill(settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15).opacity(0.5) + : Color.amber.opacity(0.6)) + .frame(width: 40, height: 2) + + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: 26, weight: .bold, design: .serif)) + .foregroundStyle(settings.theme.textColor) + .fixedSize(horizontal: false, vertical: true) + + if !content.chapter.dateLabel.isEmpty { + Text(content.chapter.dateLabel) + .font(.caption) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 36) + Spacer() + Spacer() + + // Swipe hint + HStack(spacing: 6) { + Image(systemName: "arrow.right") + .font(.caption2) + Text("Swipe to read") + .font(.caption2) + } + .foregroundStyle(settings.theme.textColor.opacity(0.25)) + .padding(.bottom, 100) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(settings.theme.backgroundColor) + } +} + +// MARK: - Chapter end page + +private struct ChapterEndPage: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + let onNavigateChapter: (Int) -> Void + + var body: some View { + let settings = readerSettings.settings + VStack(spacing: 32) { + Spacer() + + VStack(spacing: 12) { + Image(systemName: "checkmark.circle") + .font(.system(size: 40)) + .foregroundStyle(settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) + : .amber) + + Text("End of Chapter \(content.chapter.number)") + .font(.title3.bold()) + .foregroundStyle(settings.theme.textColor) + } + + // Next chapter button + if let next = content.next { + Button { + onNavigateChapter(next) + } label: { + HStack { + Text("Next Chapter") + .fontWeight(.semibold) + Image(systemName: "arrow.right") + } + .foregroundStyle(.white) + .frame(width: 200, height: 50) + .background( + Capsule() + .fill(settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) + : Color.amber) + ) + } + .buttonStyle(.plain) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(settings.theme.backgroundColor) + } +} + +// MARK: - Chapter progress bar + +private struct ChapterProgressBar: View { + let currentChapter: Int + let totalChapters: Int + let color: Color + + private var progress: Double { + guard totalChapters > 1 else { return 1.0 } + return Double(currentChapter) / Double(totalChapters) + } + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle() + .fill(color.opacity(0.15)) + Rectangle() + .fill(color.opacity(0.75)) + .frame(width: geo.size.width * progress) + .animation(.easeInOut(duration: 0.4), value: progress) + } + } + .frame(height: 2) + } +} + +// MARK: - Listen button (bottom chrome) + +private struct ListenButton: View { + @ObservedObject var audioPlayer: AudioPlayerService + @ObservedObject var vm: ChapterReaderViewModel + @ObservedObject var authStore: AuthStore + let theme: ReaderTheme + + var body: some View { + Button { + vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) + } label: { + HStack(spacing: 6) { + Image(systemName: audioPlayer.isActive && + audioPlayer.slug == vm.slug && + audioPlayer.chapter == vm.chapter + ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 20)) + Text(audioPlayer.isActive && + audioPlayer.slug == vm.slug && + audioPlayer.chapter == vm.chapter + ? "Listening" : "Listen") + .font(.subheadline.weight(.semibold)) + } + .foregroundStyle(theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) + : .amber) + } + .buttonStyle(.plain) + } +} + +// MARK: - Reading settings panel + +struct ReaderSettingsPanel: View { + @ObservedObject var store: ReaderSettingsStore + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 24) { + // Handle + Capsule() + .fill(Color(.systemGray4)) + .frame(width: 36, height: 4) + .padding(.top, 12) + + // Font size row + HStack(spacing: 0) { + Button { adjustFontSize(-1) } label: { + Text("A") + .font(.system(size: 14, weight: .regular)) + .frame(width: 44, height: 44) + } + Slider( + value: Binding( + get: { store.settings.fontSize }, + set: { v in var s = store.settings; s.fontSize = v; store.update(s) } + ), + in: 12...26, step: 1 + ) + .tint(.amber) + .padding(.horizontal, 8) + Button { adjustFontSize(1) } label: { + Text("A") + .font(.system(size: 22, weight: .semibold)) + .frame(width: 44, height: 44) + } + } + .foregroundStyle(.primary) + + Divider().padding(.horizontal, 4) + + // Font family picker + HStack(spacing: 10) { + ForEach(ReaderFont.allCases, id: \.self) { font in + FontChip( + font: font, + isSelected: store.settings.font == font + ) { + var s = store.settings + s.font = font + store.update(s) + } + } + } + + Divider().padding(.horizontal, 4) + + // Theme picker + HStack(spacing: 10) { + ForEach(ReaderTheme.allCases, id: \.self) { theme in + ThemeChip(theme: theme, isSelected: store.settings.theme == theme) { + var s = store.settings + s.theme = theme + store.update(s) + } + } + } + + Divider().padding(.horizontal, 4) + + // Line spacing row + HStack { + Image(systemName: "line.3.horizontal") + .foregroundStyle(.secondary) + .frame(width: 30) + Slider( + value: Binding( + get: { store.settings.lineSpacing }, + set: { v in var s = store.settings; s.lineSpacing = v; store.update(s) } + ), + in: 1.2...2.4, step: 0.1 + ) + .tint(.amber) + Image(systemName: "line.3.horizontal") + .foregroundStyle(.secondary) + .scaleEffect(1.35) + .frame(width: 30) + } + + Divider().padding(.horizontal, 4) + + // Scroll vs Page mode toggle + HStack { + Image(systemName: store.settings.scrollMode ? "scroll" : "book") + .foregroundStyle(.secondary) + .frame(width: 30) + Text(store.settings.scrollMode ? "Scroll" : "Pages") + .font(.subheadline) + .foregroundStyle(.primary) + Spacer() + Toggle("", isOn: Binding( + get: { store.settings.scrollMode }, + set: { v in var s = store.settings; s.scrollMode = v; store.update(s) } + )) + .tint(.amber) + .labelsHidden() + } + + // Bottom safe area clearance + Color.clear.frame(height: 16) + } + .padding(.horizontal, 24) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(.regularMaterial) + .ignoresSafeArea(edges: .bottom) + ) + } + .ignoresSafeArea(edges: .bottom) + .background( + Color.black.opacity(0.25) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + isPresented = false + } + } + ) + } + + private func adjustFontSize(_ delta: CGFloat) { + var s = store.settings + s.fontSize = max(12, min(26, s.fontSize + delta)) + store.update(s) + } +} + +private struct FontChip: View { + let font: ReaderFont + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(font.rawValue) + .font(font.fontName.map { Font.custom($0, size: 15) } ?? .system(size: 15)) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isSelected ? Color.amber.opacity(0.18) : Color(.systemGray6)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5) + ) + ) + .foregroundStyle(isSelected ? .amber : .primary) + } + .buttonStyle(.plain) + } +} + +private struct ThemeChip: View { + let theme: ReaderTheme + let isSelected: Bool + let action: () -> Void + + private var label: String { + switch theme { + case .white: return "White" + case .sepia: return "Sepia" + case .night: return "Night" + } + } + + var body: some View { + Button(action: action) { + Text(label) + .font(.subheadline.weight(isSelected ? .semibold : .regular)) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(theme.backgroundColor) + .foregroundStyle(theme.textColor) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? Color.amber : Color(.systemGray4), lineWidth: isSelected ? 2 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } +} + +// MARK: - ReaderSettingsStore (ObservableObject wrapping ReaderSettings) + +final class ReaderSettingsStore: ObservableObject { + @Published private(set) var settings: ReaderSettings + + init() { + settings = ReaderSettings.load() + } + + func update(_ new: ReaderSettings) { + settings = new + new.save() + } +} + +// MARK: - HTML → AttributedString parser + +enum HTMLParser { + /// Converts HTML string to AttributedString with the given display settings. + /// Falls back to plain text if parsing fails. + static func toAttributedString( + html: String, + fontSize: CGFloat, + lineSpacing: CGFloat, + fontName: String?, + textColor: Color + ) -> AttributedString { + let uiFont: UIFont + if let name = fontName, let custom = UIFont(name: name, size: fontSize) { + uiFont = custom + } else { + uiFont = UIFont.systemFont(ofSize: fontSize) + } + + let uiColor = UIColor(textColor) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize + paragraphStyle.paragraphSpacing = fontSize * 0.7 + + let htmlData = Data(html.utf8) + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ] + + let nsAttr: NSMutableAttributedString + if let parsed = try? NSMutableAttributedString(data: htmlData, options: options, documentAttributes: nil) { + nsAttr = parsed + } else { + // Fallback: strip tags manually + let plain = html.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + nsAttr = NSMutableAttributedString(string: plain) + } + + // Apply our display settings over the full range + let fullRange = NSRange(location: 0, length: nsAttr.length) + nsAttr.addAttribute(.font, value: uiFont, range: fullRange) + nsAttr.addAttribute(.foregroundColor, value: uiColor, range: fullRange) + nsAttr.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) + + return (try? AttributedString(nsAttr, including: \.uiKit)) ?? AttributedString(nsAttr.string) + } +} + +// MARK: - Text paginator + +enum TextPaginator { + /// Splits an AttributedString into pages that fit within (width × height). + static func paginate( + attributed: AttributedString, + width: CGFloat, + height: CGFloat, + fontSize: CGFloat + ) -> [AttributedString] { + guard width > 0, height > 0 else { return [attributed] } + + let nsAttr = NSAttributedString(attributed) + guard nsAttr.length > 0 else { return [] } + + let framesetter = CTFramesetterCreateWithAttributedString(nsAttr) + let path = CGPath(rect: CGRect(x: 0, y: 0, width: width, height: height), transform: nil) + + var pages: [AttributedString] = [] + var startIndex = 0 + let totalLength = nsAttr.length + var emergencyBreak = 0 + + while startIndex < totalLength { + emergencyBreak += 1 + if emergencyBreak > 2000 { break } // safety valve + + let range = CFRange(location: startIndex, length: totalLength - startIndex) + let frame = CTFramesetterCreateFrame(framesetter, range, path, nil) + let visibleRange = CTFrameGetVisibleStringRange(frame) + + let pageLength = visibleRange.length > 0 ? visibleRange.length : max(1, totalLength - startIndex) + let endIndex = min(startIndex + pageLength, totalLength) + + let pageRange = NSRange(location: startIndex, length: endIndex - startIndex) + let pageAttr = nsAttr.attributedSubstring(from: pageRange) + if let pageAS = try? AttributedString(pageAttr, including: \.uiKit) { + pages.append(pageAS) + } + + if visibleRange.length <= 0 { break } + startIndex = endIndex + } + + return pages.isEmpty ? [attributed] : pages + } +} + +// MARK: - Reverse label style (kept for compatibility) + +struct ReverseLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.title + configuration.icon + } + } +} + +// MARK: - HTMLContentView (kept for potential fallback use) struct HTMLContentView: UIViewRepresentable { let html: String @@ -217,7 +1100,7 @@ struct HTMLContentView: UIViewRepresentable { init(_ parent: HTMLContentView) { self.parent = parent } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - webView.evaluateJavaScript("document.body.scrollHeight") { result, error in + webView.evaluateJavaScript("document.body.scrollHeight") { result, _ in DispatchQueue.main.async { if let h = result as? CGFloat, h > 0 { self.parent.height = h @@ -229,14 +1112,3 @@ struct HTMLContentView: UIViewRepresentable { } } } - -// MARK: - Reverse label style (icon on right) - -struct ReverseLabelStyle: LabelStyle { - func makeBody(configuration: Configuration) -> some View { - HStack { - configuration.title - configuration.icon - } - } -} diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift index 022b8a1..2875c90 100644 --- a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -8,51 +8,58 @@ struct HomeView: View { var body: some View { NavigationStack { ScrollView { - VStack(alignment: .leading, spacing: 28) { + VStack(alignment: .leading, spacing: 0) { - // Stats bar + // Large hero continue card (most recent in-progress book) + if let hero = vm.continueReading.first { + HeroContinueCard(item: hero) + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 28) + } + + // Continue reading shelf (remaining items after the hero) + let shelf = vm.continueReading.dropFirst() + if !shelf.isEmpty { + ShelfHeader(title: "Continue Reading") + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(Array(shelf)) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + ContinueReadingCard(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } + + // Stats strip if let stats = vm.stats { - HStack(spacing: 0) { - StatCell(value: "\(stats.totalBooks)", label: "Books") - Divider().frame(height: 32) - StatCell(value: "\(stats.totalChapters)", label: "Chapters") - Divider().frame(height: 32) - StatCell(value: "\(stats.booksInProgress)", label: "In Progress") - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) - .padding(.horizontal) + StatsStrip(stats: stats) + .padding(.horizontal) + .padding(.bottom, 28) } - // Continue reading - if !vm.continueReading.isEmpty { - SectionHeader(title: "Continue Reading") - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 12) { - ForEach(vm.continueReading) { item in - NavigationLink(value: NavDestination.book(item.book.slug)) { - ContinueReadingCard(item: item) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal) - } - } - - // Recently updated + // Recently updated shelf if !vm.recentlyUpdated.isEmpty { - SectionHeader(title: "Recently Updated") - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) { - ForEach(vm.recentlyUpdated) { book in - NavigationLink(value: NavDestination.book(book.slug)) { - BookCard(book: book) + ShelfHeader(title: "Recently Updated") + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(vm.recentlyUpdated) { book in + NavigationLink(value: NavDestination.book(book.slug)) { + ShelfBookCard(book: book) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } + .padding(.horizontal) + .padding(.bottom, 4) } - .padding(.horizontal) + .padding(.bottom, 28) } // Empty state @@ -71,10 +78,11 @@ struct HomeView: View { .frame(maxWidth: .infinity) .padding(.top, 60) } + + Color.clear.frame(height: 20) } - .padding(.vertical) } - .navigationTitle("Home") + .navigationTitle("Reading Now") .appNavigationDestination() .refreshable { await vm.load() } .task { await vm.load() } @@ -83,57 +91,197 @@ struct HomeView: View { } } -// MARK: - Supporting components +// MARK: - Hero card (full-width, Apple Books "Now Playing" style) + +private struct HeroContinueCard: View { + let item: ContinueReadingItem -private struct StatCell: View { - let value: String - let label: String var body: some View { - VStack(spacing: 2) { - Text(value).font(.title2.bold()).foregroundStyle(.primary) - Text(label).font(.caption).foregroundStyle(.secondary) + NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) { + ZStack(alignment: .bottom) { + // Blurred background + KFImage(URL(string: item.book.cover)) + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity) + .frame(height: 220) + .blur(radius: 18) + .clipped() + .overlay( + LinearGradient( + colors: [.black.opacity(0.2), .black.opacity(0.72)], + startPoint: .top, + endPoint: .bottom + ) + ) + + // Content row + HStack(alignment: .bottom, spacing: 14) { + // Cover + KFImage(URL(string: item.book.cover)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + } + .scaledToFill() + .frame(width: 90, height: 128) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: .black.opacity(0.5), radius: 10, y: 4) + .bookCoverZoomSource(slug: item.book.slug) + + // Text + CTA + VStack(alignment: .leading, spacing: 8) { + Text(item.book.title) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(2) + + Text(item.book.author) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + + Spacer(minLength: 6) + + // Continue pill + HStack(spacing: 6) { + Image(systemName: "play.fill") + .font(.caption.bold()) + Text("Continue Ch.\(item.chapter)") + .font(.subheadline.weight(.semibold)) + } + .foregroundStyle(.black.opacity(0.85)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Capsule().fill(Color.amber)) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.bottom, 18) + } + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.2), radius: 12, y: 4) } - .frame(maxWidth: .infinity) + .buttonStyle(.plain) } } -private struct SectionHeader: View { +// MARK: - Shelf header + +private struct ShelfHeader: View { let title: String + var body: some View { Text(title) .font(.title3.bold()) .padding(.horizontal) + .padding(.bottom, 10) } } +// MARK: - Horizontal shelf: continue reading card + private struct ContinueReadingCard: View { let item: ContinueReadingItem + var body: some View { VStack(alignment: .leading, spacing: 6) { - KFImage(URL(string: item.book.cover)) - .resizable() - .placeholder { coverPlaceholder } - .scaledToFill() - .frame(width: 120, height: 170) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay(alignment: .bottomTrailing) { - Text("Ch.\(item.chapter)") - .font(.caption2.bold()) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .padding(6) - } + ZStack(alignment: .bottomTrailing) { + KFImage(URL(string: item.book.cover)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + } + .scaledToFill() + .frame(width: 110, height: 158) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Text("Ch.\(item.chapter)") + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } Text(item.book.title) .font(.caption.bold()) .lineLimit(2) - .frame(width: 120, alignment: .leading) + .frame(width: 110, alignment: .leading) } } - private var coverPlaceholder: some View { - RoundedRectangle(cornerRadius: 10) - .fill(Color(.systemGray5)) - .frame(width: 120, height: 170) - .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) +} + +// MARK: - Horizontal shelf: recently updated book card + +private struct ShelfBookCard: View { + let book: Book + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + KFImage(URL(string: book.cover)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + } + .scaledToFill() + .frame(width: 110, height: 158) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .bookCoverZoomSource(slug: book.slug) + + Text(book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: 110, alignment: .leading) + + Text(book.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 110, alignment: .leading) + } + } +} + +// MARK: - Stats strip (compact inline) + +private struct StatsStrip: View { + let stats: HomeStats + + var body: some View { + HStack(spacing: 0) { + StatPill(value: "\(stats.totalBooks)", label: "Books") + Divider().frame(height: 24) + StatPill(value: "\(stats.totalChapters)", label: "Chapters") + Divider().frame(height: 24) + StatPill(value: "\(stats.booksInProgress)", label: "In Progress") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + } +} + +private struct StatPill: View { + let value: String + let label: String + + var body: some View { + VStack(spacing: 2) { + Text(value) + .font(.subheadline.bold().monospacedDigit()) + .foregroundStyle(.primary) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) } } diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift index 3b6016f..856e8a5 100644 --- a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift +++ b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift @@ -3,6 +3,83 @@ import Kingfisher struct LibraryView: View { @StateObject private var vm = LibraryViewModel() + @State private var sortOrder: SortOrder = .recentlyRead + @State private var readingFilter: ReadingFilter = .all + @State private var selectedGenre: String = "all" + @State private var searchText = "" + + enum SortOrder: String, CaseIterable { + case recentlyRead = "Recent" + case title = "Title" + case author = "Author" + case progress = "Progress" + } + + enum ReadingFilter: String, CaseIterable { + case all = "All" + case inProgress = "In Progress" + case completed = "Completed" + } + + // All distinct genres across the library, sorted alphabetically. + private var availableGenres: [String] { + let all = vm.items.flatMap { $0.book.genres } + let unique = Array(Set(all)).sorted() + return unique + } + + private var filtered: [LibraryItem] { + var result = vm.items + + // 1. Reading filter + switch readingFilter { + case .all: + break + case .inProgress: + result = result.filter { !isCompleted($0) } + case .completed: + result = result.filter { isCompleted($0) } + } + + // 2. Genre filter + if selectedGenre != "all" { + result = result.filter { $0.book.genres.contains(selectedGenre) } + } + + // 3. Sort + switch sortOrder { + case .recentlyRead: + break // server returns by recency + case .title: + result = result.sorted { $0.book.title < $1.book.title } + case .author: + result = result.sorted { $0.book.author < $1.book.author } + case .progress: + result = result.sorted { ($0.lastChapter ?? 0) > ($1.lastChapter ?? 0) } + } + + // 4. Search + if !searchText.isEmpty { + result = result.filter { + $0.book.title.localizedCaseInsensitiveContains(searchText) || + $0.book.author.localizedCaseInsensitiveContains(searchText) + } + } + + return result + } + + private func isCompleted(_ item: LibraryItem) -> Bool { + // Treat as completed if book status is "completed" OR + // the user has read up to (or past) the total chapter count. + if item.book.status.lowercased() == "completed", + let ch = item.lastChapter, + item.book.totalChapters > 0, + ch >= item.book.totalChapters { + return true + } + return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0 + } var body: some View { NavigationStack { @@ -18,18 +95,120 @@ struct LibraryView: View { ) } else { ScrollView { - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], - spacing: 16 - ) { - ForEach(vm.items) { item in - NavigationLink(value: NavDestination.book(item.book.slug)) { - LibraryCard(item: item) + VStack(spacing: 0) { + // Search bar + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search library", text: $searchText) + .font(.subheadline) + if !searchText.isEmpty { + Button { searchText = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } } - .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10)) + .padding(.horizontal) + .padding(.top, 8) + + // Reading filter (All / In Progress / Completed) + Picker("", selection: $readingFilter) { + ForEach(ReadingFilter.allCases, id: \.self) { f in + Text(f.rawValue).tag(f) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.top, 12) + + // Genre filter chips (only shown when genres are available) + if !availableGenres.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + // "All" chip + FilterChipView( + label: "All", + isSelected: selectedGenre == "all" + ) { + withAnimation { selectedGenre = "all" } + } + ForEach(availableGenres, id: \.self) { genre in + FilterChipView( + label: genre.capitalized, + isSelected: selectedGenre == genre + ) { + withAnimation { + selectedGenre = selectedGenre == genre ? "all" : genre + } + } + } + } + .padding(.horizontal) + } + .padding(.top, 10) + } + + // Sort chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(SortOrder.allCases, id: \.self) { order in + SortChip( + label: order.rawValue, + isSelected: sortOrder == order + ) { + withAnimation { sortOrder = order } + } + } + } + .padding(.horizontal) + } + .padding(.vertical, 10) + + // Book count + Text("\(filtered.count) book\(filtered.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.bottom, 4) + + if filtered.isEmpty { + VStack(spacing: 12) { + Image(systemName: readingFilter == .completed ? "checkmark.circle" : "book") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text(emptyMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.top, 60) + } else { + // 3-column grid + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], + spacing: 20 + ) { + ForEach(filtered) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + LibraryBookCard(item: item) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 24) } } - .padding() } } } @@ -40,40 +219,149 @@ struct LibraryView: View { .errorAlert($vm.error) } } -} -private struct LibraryCard: View { - let item: LibraryItem - var body: some View { - VStack(alignment: .leading, spacing: 6) { - ZStack(alignment: .bottomTrailing) { - KFImage(URL(string: item.book.cover)) - .resizable() - .placeholder { - RoundedRectangle(cornerRadius: 10) - .fill(Color(.systemGray5)) - .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) - } - .scaledToFill() - .frame(height: 200) - .clipShape(RoundedRectangle(cornerRadius: 10)) - - if let ch = item.lastChapter { - Text("Ch.\(ch)") - .font(.caption2.bold()) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .padding(6) - } - } - Text(item.book.title) - .font(.caption.bold()) - .lineLimit(2) - Text(item.book.author) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + private var emptyMessage: String { + switch readingFilter { + case .all: + return selectedGenre == "all" ? "No books match your search." : "No \(selectedGenre.capitalized) books in your library." + case .inProgress: + return "No books in progress." + case .completed: + return "No completed books yet." + } + } +} + +// MARK: - Genre filter chip + +private struct FilterChipView: View { + let label: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.caption.weight(isSelected ? .semibold : .regular)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(isSelected ? Color.amber : Color(.systemGray5)) + ) + .foregroundStyle(isSelected ? .white : .primary) + } + .buttonStyle(.plain) + } +} + +// MARK: - Sort chip + +private struct SortChip: View { + let label: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(label) + .font(.subheadline.weight(isSelected ? .semibold : .regular)) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + Capsule() + .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) + .overlay( + Capsule() + .stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5) + ) + ) + .foregroundStyle(isSelected ? .amber : .primary) + } + .buttonStyle(.plain) + } +} + +// MARK: - Library book card (3-column) + +private struct LibraryBookCard: View { + let item: LibraryItem + + private var progressFraction: Double { + guard let ch = item.lastChapter, item.book.totalChapters > 0 else { return 0 } + return Double(ch) / Double(item.book.totalChapters) + } + + private var isCompleted: Bool { + progressFraction >= 1.0 + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .topTrailing) { + // Cover image + KFImage(URL(string: item.book.cover)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemGray5)) + .overlay( + Image(systemName: "book.closed") + .foregroundStyle(.secondary) + ) + } + .scaledToFill() + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: .black.opacity(0.14), radius: 4, y: 2) + .bookCoverZoomSource(slug: item.book.slug) + + // Progress arc or completed checkmark in top-right corner + if isCompleted { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + .background(Circle().fill(Color.amber).padding(1)) + .padding(5) + } else if progressFraction > 0 { + ProgressArc(fraction: progressFraction) + .frame(width: 28, height: 28) + .padding(4) + } + } + + // Title + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + // Chapter badge if present + if let ch = item.lastChapter { + Text(isCompleted ? "Finished" : "Ch.\(ch)") + .font(.caption2) + .foregroundStyle(isCompleted ? Color.amber : .secondary) + } + } + } +} + +// MARK: - Circular progress arc overlay + +private struct ProgressArc: View { + let fraction: Double // 0...1 + + var body: some View { + ZStack { + Circle() + .fill(.ultraThinMaterial) + + Circle() + .trim(from: 0, to: fraction) + .stroke(Color.amber, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.5), value: fraction) } } } diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index 9f98b8c..d8bdc39 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -270,6 +270,12 @@ struct FullPlayerView: View { .font(.subheadline) .foregroundStyle(.white.opacity(0.65)) .lineLimit(1) + // Chapter position indicator + if !audioPlayer.chapters.isEmpty { + Text(chapterPositionText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white.opacity(0.4)) + } } .padding(.horizontal, 32) .padding(.top, 20) @@ -466,10 +472,18 @@ struct FullPlayerView: View { // Sleep timer Button { showingSleepTimer = true } label: { - Image(systemName: sleepTimerIcon) - .font(.system(size: 22)) - .foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7)) - .frame(maxWidth: .infinity) + VStack(spacing: 2) { + Image(systemName: sleepTimerIcon) + .font(.system(size: 22)) + .foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7)) + if !audioPlayer.sleepTimerRemainingText.isEmpty { + Text(audioPlayer.sleepTimerRemainingText) + .font(.system(size: 9, weight: .semibold).monospacedDigit()) + .foregroundStyle(Color.amber) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity) } .buttonStyle(.plain) } @@ -531,6 +545,15 @@ struct FullPlayerView: View { } } + private var chapterPositionText: String { + let total = audioPlayer.chapters.count + guard total > 0 else { return "" } + // Find the 1-based position (index) of the current chapter within the sorted list. + let sorted = audioPlayer.chapters.sorted(by: { $0.number < $1.number }) + let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1 + return "Chapter \(idx) of \(total)" + } + private var voiceName: String { // Extract voice name from audioPlayer.voice (e.g., "af_bella" -> "Bella") let components = audioPlayer.voice.split(separator: "_") @@ -686,93 +709,223 @@ struct SleepTimerSheet: View { } // MARK: - Chapters List Sheet +// Apple Books-style: chapters grouped into blocks of 100 with a sticky jump +// bar along the right edge. A search bar filters by number or title. struct ChaptersListSheet: View { let chapters: [ChapterIndexBrief] let currentChapter: Int let onChapterSelect: (Int) -> Void - + @Environment(\.dismiss) private var dismiss - - // Initialize scroll position to current chapter immediately (before view appears) - init(chapters: [ChapterIndexBrief], currentChapter: Int, onChapterSelect: @escaping (Int) -> Void) { - self.chapters = chapters - self.currentChapter = currentChapter - self.onChapterSelect = onChapterSelect - // Set initial scroll position state before view renders - _scrollPosition = State(initialValue: currentChapter) + @State private var searchText: String = "" + /// The block label the jump bar is currently scrolling to (e.g. "1–100"). + @State private var activeBlock: String? = nil + + // MARK: Derived data + + /// Chapters matching the current search query (or all chapters if empty). + private var filtered: [ChapterIndexBrief] { + guard !searchText.isEmpty else { return chapters } + let q = searchText.lowercased() + return chapters.filter { + "\($0.number)".contains(q) || $0.title.lowercased().contains(q) + } } - - @State private var scrollPosition: Int? - + + /// Chapters grouped into blocks of 100: ["1–100": [...], "101–200": [...], …] + /// When the user is searching we use a single "Results" group so the jump + /// bar hides and the flat list is shown directly. + private var groups: [(label: String, chapters: [ChapterIndexBrief])] { + guard searchText.isEmpty else { + return filtered.isEmpty ? [] : [("Results", filtered)] + } + guard !chapters.isEmpty else { return [] } + let blockSize = 100 + let minN = chapters.map(\.number).min() ?? 1 + let maxN = chapters.map(\.number).max() ?? 1 + // Round down to the nearest block boundary for the first block start. + let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 + var result: [(label: String, chapters: [ChapterIndexBrief])] = [] + var blockStart = firstBlock + while blockStart <= maxN { + let blockEnd = blockStart + blockSize - 1 + let slice = chapters.filter { $0.number >= blockStart && $0.number <= blockEnd } + if !slice.isEmpty { + result.append(("\(blockStart)–\(blockEnd)", slice)) + } + blockStart += blockSize + } + return result + } + + /// Jump-bar labels (shown only when not searching). + private var jumpLabels: [String] { groups.map(\.label) } + + // MARK: Body + var body: some View { NavigationStack { - List { - ForEach(chapters, id: \.number) { chapter in - Button { - onChapterSelect(chapter.number) - } label: { - HStack(spacing: 12) { - // Chapter number badge - Text("\(chapter.number)") - .font(.caption.bold()) - .foregroundStyle(chapter.number == currentChapter ? .white : .secondary) - .frame(width: 44, height: 44) - .background( - Circle() - .fill(chapter.number == currentChapter ? Color.amber : Color.gray.opacity(0.2)) + ZStack(alignment: .trailing) { + // ── Main chapter list ────────────────────────────────────── + List { + ForEach(groups, id: \.label) { group in + // Section header — shows block range (e.g. "1–100") + Section { + ForEach(group.chapters, id: \.number) { ch in + ChapterRow( + chapter: ch, + isCurrent: ch.number == currentChapter, + onSelect: { onChapterSelect(ch.number) } ) - - // Chapter title - VStack(alignment: .leading, spacing: 4) { - Text(chapter.title.strippingTrailingDate()) - .font(.subheadline.weight(chapter.number == currentChapter ? .semibold : .regular)) - .foregroundStyle(chapter.number == currentChapter ? .primary : .primary) - .lineLimit(2) - - if chapter.number == currentChapter { - Text("Now Playing") - .font(.caption2) - .foregroundStyle(.amber) - } + .id(group.label) // anchor for jump-bar scrollTo } - - Spacer() - - // Checkmark for current chapter - if chapter.number == currentChapter { - Image(systemName: "checkmark") + } header: { + if searchText.isEmpty { + Text(group.label) .font(.caption.bold()) - .foregroundStyle(.amber) + .foregroundStyle(.secondary) + .id("header_\(group.label)") } } - .padding(.vertical, 8) } - .buttonStyle(.plain) - .listRowBackground( - chapter.number == currentChapter - ? Color.amber.opacity(0.1) - : Color.clear - ) - .id(chapter.number) + } + .listStyle(.plain) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Chapter number or title") + .scrollPosition(id: $activeBlock, anchor: .top) + + // ── Right-edge jump bar (hidden while searching) ─────────── + if searchText.isEmpty && jumpLabels.count > 1 { + JumpBar(labels: jumpLabels, currentChapter: currentChapter, groups: groups) { label in + withAnimation { activeBlock = label } + } + .padding(.trailing, 4) } } - .listStyle(.plain) - .scrollPosition(id: $scrollPosition, anchor: .center) - .navigationTitle("Chapters") + .navigationTitle("Chapters (\(chapters.count))") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button("Done") { - dismiss() - } - .fontWeight(.semibold) + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + // Scroll to the currently playing chapter's block on first appear. + .onAppear { + if let block = groups.first(where: { g in + g.chapters.contains(where: { $0.number == currentChapter }) + }) { + activeBlock = block.label } } } } } +// MARK: - Individual chapter row + +private struct ChapterRow: View { + let chapter: ChapterIndexBrief + let isCurrent: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 14) { + // Number badge + Text("\(chapter.number)") + .font(.caption.bold()) + .foregroundStyle(isCurrent ? .white : .secondary) + .frame(width: 40, height: 40) + .background( + Circle().fill(isCurrent ? Color.amber : Color(.systemGray5)) + ) + + // Title + "Now Playing" subtitle + VStack(alignment: .leading, spacing: 3) { + Text(chapter.title.strippingTrailingDate()) + .font(.subheadline.weight(isCurrent ? .semibold : .regular)) + .foregroundStyle(.primary) + .lineLimit(2) + if isCurrent { + Label("Now Playing", systemImage: "waveform") + .font(.caption2) + .foregroundStyle(.amber) + } + } + + Spacer() + + if isCurrent { + Image(systemName: "speaker.wave.2.fill") + .font(.caption.bold()) + .foregroundStyle(.amber) + } + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) + } +} + +// MARK: - Right-edge jump bar +// A thin vertical strip on the right side of the sheet with block labels. +// Tapping or dragging a label jumps the list to that block instantly — +// exactly like the Contacts A–Z bar or Apple Books chapter scrubber. + +private struct JumpBar: View { + let labels: [String] + let currentChapter: Int + let groups: [(label: String, chapters: [ChapterIndexBrief])] + let onSelect: (String) -> Void + + @State private var isDragging = false + + /// Short display label for each block: "1–100" → "1" etc. + private func shortLabel(_ full: String) -> String { + full.components(separatedBy: "–").first ?? full + } + + /// Which block contains the currently playing chapter. + private var currentBlock: String? { + groups.first(where: { g in g.chapters.contains(where: { $0.number == currentChapter }) })?.label + } + + var body: some View { + VStack(spacing: 0) { + ForEach(labels, id: \.self) { label in + let isCurrent = label == currentBlock + Text(shortLabel(label)) + .font(.system(size: 10, weight: isCurrent ? .bold : .regular)) + .foregroundStyle(isCurrent ? Color.amber : Color.secondary) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .onTapGesture { onSelect(label) } + } + } + .padding(.vertical, 6) + .background( + Capsule() + .fill(.ultraThinMaterial) + .shadow(color: .black.opacity(0.15), radius: 4) + ) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged { value in + isDragging = true + let itemHeight: CGFloat = 28 + let index = Int(value.location.y / itemHeight) + let clamped = max(0, min(labels.count - 1, index)) + onSelect(labels[clamped]) + } + .onEnded { _ in isDragging = false } + ) + .animation(.easeInOut(duration: 0.15), value: isDragging) + } +} + // MARK: - Custom seek slider // A thicker, rounded-thumb slider that matches the amber design language.