diff --git a/.opencode/skills/ios-ux/SKILL.md b/.opencode/skills/ios-ux/SKILL.md new file mode 100644 index 0000000..1d222ac --- /dev/null +++ b/.opencode/skills/ios-ux/SKILL.md @@ -0,0 +1,156 @@ +--- +name: ios-ux +description: iOS/SwiftUI UI & UX review and implementation guidelines for LibNovel. Enforces Apple HIG, iOS 17+ APIs, spring animations, haptics, accessibility, performance, and offline handling. Load this skill for any iOS view work. +compatibility: opencode +--- + +# iOS UI/UX Skill — LibNovel + +Load this skill whenever working on SwiftUI views in `ios/`. It defines design standards, review process for screenshots, and implementation rules. + +--- + +## Screenshot Review Process + +When the user provides a screenshot of the app: + +1. **Analyze first** — identify specific UI/UX issues across these categories: + - Visual hierarchy and spacing + - Typography (size, weight, contrast) + - Color and material usage + - Animation and interactivity gaps + - Accessibility problems + - Deprecated or non-native patterns +2. **Present a numbered list** of suggested improvements with brief rationale for each. +3. **Ask for confirmation** before writing any code: "Should I apply all of these, or only specific ones?" +4. Apply only what the user confirms. + +--- + +## Design System + +### Colors & Materials +- **Accent**: `Color.amber` (project-defined). Use for active state, selection indicators, progress fills, and CTAs. +- **Backgrounds**: Prefer `.regularMaterial`, `.ultraThinMaterial`, or `.thinMaterial` over hard-coded `Color.black.opacity(x)` or `Color(.systemBackground)`. +- **Dark overlays** (e.g. full-screen players): Use `KFImage` blurred background + `Color.black.opacity(0.5–0.6)` overlay. Never use a flat solid black background. +- **Semantic colors**: Use `.primary`, `.secondary`, `.tertiary` foreground styles. Avoid hard-coded `Color.white` except on dark material contexts (full-screen player). +- **No hardcoded color literals** — use `Color+App.swift` extensions or system semantic colors. + +### Typography +- Use the SF Pro system font via `.font(.title)`, `.font(.body)`, etc. — never hardcode font names except for intentional stylistic accents (e.g. "Snell Roundhand" for voice watermark). +- Apply `.fontWeight()` and `.fontDesign()` modifiers rather than custom font families. +- Support Dynamic Type — never hardcode a fixed font size as the sole option without a `.minimumScaleFactor` or system font size modifier. +- Hierarchy: title3.bold for primary labels, subheadline for secondary, caption/caption2 for metadata. + +### Spacing & Layout +- Minimum touch target: **44×44 pt**. Use `.frame(minWidth: 44, minHeight: 44)` or `.contentShape(Rectangle())` on small icons. +- Prefer 16–20 pt horizontal padding on full-width containers; 12 pt for compact inner elements. +- Use `VStack(spacing:)` and `HStack(spacing:)` explicitly — never rely on default spacing for production UI. +- Corner radii: 12–14 pt for cards/chips, 10 pt for small badges, 20–24 pt for large cover art. + +--- + +## Animation Rules + +### Spring Animations (default for all interactive transitions) +- Use `.spring(response:dampingFraction:)` for state-driven layout changes, selection feedback, and appear/disappear transitions. +- Recommended defaults: + - Interactive elements: `response: 0.3, dampingFraction: 0.7` + - Entrance animations: `response: 0.45–0.5, dampingFraction: 0.7` + - Quick snappy feedback: `response: 0.2, dampingFraction: 0.6` +- Reserve `.easeInOut` only for non-interactive, ambient animations (e.g. opacity pulses, generating overlays). + +### SF Symbol Transitions +- Always use `contentTransition(.symbolEffect(.replace.downUp))` when a symbol name changes based on state (play/pause, checkmark/circle, etc.). +- Use `.symbolEffect(.variableColor.cumulative)` for continuous animations (waveform, loading indicators). +- Use `.symbolEffect(.bounce)` for one-shot entrance emphasis (e.g. completion checkmark appearing). +- Use `.symbolEffect(.pulse)` for error/warning states that need attention. + +### Repeating Animations +- Use `phaseAnimator` for any looping animation that previously used manual `@State` + `withAnimation` chains. +- Do not use `Timer` publishers for UI animation — prefer `phaseAnimator` or `TimelineView`. + +--- + +## Haptic Feedback + +Add `UIImpactFeedbackGenerator` to every user-initiated interactive control: +- `.light` — toggle switches, selection chips, secondary actions, slider drag start. +- `.medium` — primary transport buttons (play/pause, chapter skip), significant confirmations. +- `.heavy` — destructive actions (only if no confirmation dialog). + +Pattern: +```swift +Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + // action +} label: { ... } +``` + +Do **not** add haptics to: +- Programmatic state changes not directly triggered by a tap. +- Buttons inside `List` rows that already use swipe actions. +- Scroll events. + +--- + +## iOS 17+ API Usage + +Flag and replace any of the following deprecated patterns: + +| Deprecated | Replace with | +|---|---| +| `NavigationView` | `NavigationStack` | +| `@StateObject` / `ObservableObject` (new types only) | `@Observable` macro | +| `DispatchQueue.main.async` | `await MainActor.run` or `@MainActor` | +| Manual `@State` animation chains for repeating loops | `phaseAnimator` | +| `.animation(_:)` without `value:` | `.animation(_:value:)` | +| `AnyView` wrapping for conditional content | `@ViewBuilder` + `Group` | + +Do **not** refactor existing `ObservableObject` types to `@Observable` unless explicitly asked — only apply `@Observable` to new types. + +--- + +## Accessibility + +Every view must: +- Support VoiceOver: add `.accessibilityLabel()` to icon-only buttons and image views. +- Support Dynamic Type: test that text doesn't truncate at xxxLarge without a layout adjustment. +- Meet contrast ratio: text on tinted backgrounds must be legible — avoid `.opacity(0.25)` or lower for any user-readable text. +- Touch targets ≥ 44pt (see Spacing above). +- Interactive controls must have `.accessibilityAddTraits(.isButton)` if not using `Button`. +- Do not rely solely on color to convey state — pair color with icon or label. + +--- + +## Performance + +- **Isolate high-frequency observers**: Any view that observes a `PlaybackProgress` (timer-tick updates) must be a separate sub-view that `@ObservedObject`-observes only the progress object — not the parent view. This prevents the entire parent from re-rendering every 0.5 seconds. +- **Avoid `id()` overuse**: Only use `.id()` to force view recreation when necessary (e.g. background image on track change). Prefer `onChange(of:)` for side effects. +- **Lazy containers**: Use `LazyVStack` / `LazyHStack` inside `ScrollView` for lists of 20+ items. `List` is inherently lazy and does not need this. +- **Image loading**: Always use `KFImage` (Kingfisher) with `.placeholder` for remote images. Never use `AsyncImage` for cover art — it has no disk cache. +- **Avoid `AnyView`**: It breaks structural identity and hurts diffing. Use `@ViewBuilder` or `Group { }` instead. + +--- + +## Offline & Error States + +Every view that makes network calls must: +1. Wrap the body in a `VStack` with `OfflineBanner` at the top, gated on `networkMonitor.isConnected`. +2. Suppress network errors silently when offline via `ErrorAlertModifier` — do not show an alert when the device is offline. +3. Gate `.task` / `.onAppear` network calls: `guard networkMonitor.isConnected else { return }`. +4. Show a non-blocking inline empty state (not a full-screen error) for failed loads when online. + +--- + +## Component Checklist (before submitting any view change) + +- [ ] All interactive elements ≥ 44pt touch target +- [ ] SF Symbol state changes use `contentTransition(.symbolEffect(...))` +- [ ] State-driven layout transitions use `.spring(response:dampingFraction:)` +- [ ] Tappable controls have haptic feedback +- [ ] No `NavigationView`, no `DispatchQueue.main.async`, no `.animation(_:)` without `value:` +- [ ] High-frequency observers are isolated sub-views +- [ ] Offline state handled with `OfflineBanner` + `NetworkMonitor` +- [ ] VoiceOver labels on icon-only buttons +- [ ] No hardcoded `Color.black` / `Color.white` / `Color(.systemBackground)` where a material applies diff --git a/AGENTS.md b/AGENTS.md index 0ef6d7d..55c814a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,3 +169,14 @@ Kokoro and Browserless are **external services** — not in docker-compose. - **To add a new API endpoint**: add handler in the appropriate `handlers_*.go` file, register in `server.go` `ListenAndServe()` - **Storage changes**: update `Store` interface in `store.go`, implement on `HybridStore` (hybrid.go) and `PocketBaseStore`/`MinioClient` as needed; update mock in `orchestrator_test.go` - **Skip**: `scraper/bin/` (compiled binary), MinIO/PocketBase data volumes + +## iOS App + +See `ios/AGENTS.md` for full iOS/SwiftUI conventions. + +## Documentation Tools + +This project has two MCP-backed documentation tools available. Use them proactively: + +- **`context7`** — Live Apple SwiftUI/Swift docs, Go stdlib, SvelteKit, and any other library docs. Use before implementing anything non-trivial in Swift/SwiftUI. Example: `use context7 to look up NavigationStack`. +- **`gh_grep`** — Search real-world code on GitHub for implementation patterns. Example: `use gh_grep to find examples of background URLSession in Swift`. diff --git a/ios/AGENTS.md b/ios/AGENTS.md new file mode 100644 index 0000000..5992ee8 --- /dev/null +++ b/ios/AGENTS.md @@ -0,0 +1,87 @@ +# LibNovel iOS App + +SwiftUI app targeting iOS 17+. Consumes the Go scraper HTTP API for books, chapters, and audio. Uses MinIO presigned URLs for media playback and downloads. + +## Project Structure + +``` +ios/LibNovel/LibNovel/ +├── App/ # LibNovelApp.swift, ContentView.swift, RootTabView.swift +├── Models/ # Models.swift (all domain types) +├── Networking/ # APIClient.swift (URLSession-based HTTP client) +├── Services/ # AudioPlayerService, AudioDownloadService, AuthStore, +│ # BookVoicePreferences, NetworkMonitor +├── ViewModels/ # One per view/feature (HomeViewModel, BrowseViewModel, etc.) +├── Views/ +│ ├── Auth/ # AuthView +│ ├── BookDetail/ # BookDetailView, CommentsView +│ ├── Browse/ # BrowseView (infinite scroll shelves) +│ ├── ChapterReader/ # ChapterReaderView, DownloadAudioButton +│ ├── Common/ # CommonViews (shared reusable components) +│ ├── Components/ # OfflineBanner +│ ├── Downloads/ # DownloadsView, DownloadQueueButton +│ ├── Home/ # HomeView +│ ├── Library/ # LibraryView (2-col grid, filters) +│ ├── Player/ # PlayerViews (floating FAB, compact, full-screen) +│ ├── Profile/ # ProfileView, VoiceSelectionView, UserProfileView, etc. +│ └── Search/ # SearchView +└── Extensions/ # NavDestination.swift, String+App.swift, Color+App.swift +``` + +## iOS / Swift Conventions + +- **Deployment target**: iOS 17.0 — use iOS 17+ APIs freely. +- **Observable pattern**: The codebase currently uses `@StateObject` / `ObservableObject` / `@Published`. When adding new types, prefer the **`@Observable` macro** (iOS 17+) over `ObservableObject`. Do not refactor existing types unless explicitly asked. +- **Navigation**: Use `NavigationStack` (not `NavigationView`). Use `.navigationDestination(for:)` for type-safe routing. +- **Concurrency**: Use `async/await` and structured concurrency. Avoid callback-based APIs and `DispatchQueue.main.async` — prefer `@MainActor` or `await MainActor.run`. +- **State management**: Prefer `@State` + `@Binding` for local UI state. Use environment objects for app-wide services (authStore, audioPlayer, downloadService, networkMonitor). +- **SwiftData**: Not currently used. Do not introduce SwiftData without discussion. +- **SF Symbols**: Use `Image(systemName:)` for icons. No emoji in UI unless already present. + +## Key Patterns + +- **Download keys**: Use `::` as separator (e.g., `"slug::chapter-1::voice"`), never `-`. Slugs contain hyphens. +- **Voice fallback chain**: book override → global default → `"af_bella"`. See `BookVoicePreferences.voiceWithFallback()`. +- **Offline handling**: Wrap view bodies in `VStack` with `OfflineBanner` at top. Use `NetworkMonitor` (environment object) to gate network calls. Suppress network errors silently when offline via `ErrorAlertModifier`. +- **Audio playback priority**: local file → MinIO presigned URL → trigger TTS generation. +- **Progress display**: Show decimal % when < 10% (e.g., "3.4%"), rounded when >= 10% (e.g., "47%"). +- **Cover images**: Always proxy via `/api/cover/{domain}/{slug}` — never link directly to source. + +## Networking + +`APIClient.swift` wraps all Go scraper API calls. When adding new endpoints: + +1. Add a method to `APIClient`. +2. Keep error handling consistent — throw typed errors, let ViewModels catch and set `errorMessage`. +3. All requests are relative to `SCRAPER_API_URL` (configured at build time via xcconfig or environment). + +## Using Documentation Tools + +When writing or reviewing SwiftUI/Swift code: + +- Use `context7` to look up current Apple SwiftUI/Swift documentation before implementing anything non-trivial. Apple's APIs evolve fast — do not rely on training data alone. +- Use `gh_grep` to find real-world Swift patterns when unsure how something is typically implemented. + +Example prompts: +- "How does `.searchable` work in iOS 17? use context7" +- "Show me examples of `@Observable` with async tasks. use context7" +- "How do other apps implement background URLSession downloads in Swift? use gh_grep" + +## UI/UX Skill + +For any iOS view work, always load the `ios-ux` skill at the start of the task: + +``` +skill({ name: "ios-ux" }) +``` + +This skill defines the full design system, animation rules, haptic feedback policy, accessibility checklist, performance guidelines, and offline handling requirements. It also governs how to handle screenshot-based reviews (analyze → suggest → confirm before applying). + +## What to Avoid + +- `NavigationView` — deprecated, use `NavigationStack` +- `ObservableObject` / `@Published` for new types — prefer `@Observable` +- `DispatchQueue.main.async` — prefer `@MainActor` +- Force unwrapping optionals +- Hardcoded color literals — use `Color+App.swift` extensions or semantic colors +- Adding new dependencies (SPM packages) without discussion diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj index a4bced2..781ac45 100644 --- a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj +++ b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj @@ -8,8 +8,13 @@ /* Begin PBXBuildFile section */ 032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */; }; + 07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */; }; 08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */; }; 0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762E378B9BC2161A7AA2CC36 /* Models.swift */; }; + 0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */; }; + 192F82518CB8763775E33B38 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79133D9FA697D1909C8D3973 /* SearchView.swift */; }; + 1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */; }; + 1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */; }; 2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */; }; 2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; }; 3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; }; @@ -18,24 +23,29 @@ 4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; }; 58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; }; 5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; }; + 5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */; }; + 62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */; }; 64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; }; + 65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */; }; 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; }; + 774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175390266E8C6CF1437A229 /* DownloadsView.swift */; }; 7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; }; 7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; }; + 880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */; }; + 8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */; }; + 9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */; }; 94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; }; 9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; }; - A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */; }; - A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */; }; + 9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F247DE25991F4DB98DF717AA /* UserProfileView.swift */; }; + A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B9AFE90719BDBC718F0621 /* CommentsView.swift */; }; A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; }; - AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */; }; - BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */; }; BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; }; - C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */; }; C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; }; CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; }; - D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */; }; + DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */; }; E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; }; E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; }; + ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */; }; EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; }; F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; }; F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; }; @@ -54,39 +64,49 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = ""; }; + 16B9AFE90719BDBC718F0621 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = ""; }; + 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadQueueButton.swift; sourceTree = ""; }; 1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = ""; }; + 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; - 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = ""; }; + 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAudioButton.swift; sourceTree = ""; }; 39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = ""; }; 3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = ""; }; 5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = ""; }; + 79133D9FA697D1909C8D3973 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = ""; }; 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + 8175390266E8C6CF1437A229 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = ""; }; 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = ""; }; 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = ""; }; 8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = ""; }; 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = ""; }; 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = ""; }; 9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = ""; }; - AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; - B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsView.swift; sourceTree = ""; }; - B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = ""; }; + A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = ""; }; + AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverViewModel.swift; sourceTree = ""; }; + AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMenuSheet.swift; sourceTree = ""; }; B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = ""; }; B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; - BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = ""; }; C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; - C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; - D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCropView.swift; sourceTree = ""; }; + CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionViewModel.swift; sourceTree = ""; }; D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = ""; }; DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = ""; }; + F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineBanner.swift; sourceTree = ""; }; F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = ""; }; + F247DE25991F4DB98DF717AA /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; FEC6F837FF2E902E334ED72E /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -119,11 +139,13 @@ 8E8AAA58A33084ADB8AEA80C /* Browse */, 4EAB87A1ED4943A311F26F84 /* ChapterReader */, 5D5809803A3D74FAE19DB218 /* Common */, + 9180FAFE96724B8AACFA9859 /* Components */, + 3881CBFE9730C6422BE6F03D /* Downloads */, 811FC0F6B9C209D6EC8543BD /* Home */, FA994FD601E79EC811D822A4 /* Library */, 89F2CB14192E7D7565A588E0 /* Player */, 3DB66C5703A4CCAFFA1B7AFE /* Profile */, - E6A2B4C07F1D38E0A95B3C72 /* Search */, + 474BE4FC0353C2DD8D8425D1 /* Search */, ); path = Views; sourceTree = ""; @@ -136,13 +158,23 @@ path = Auth; sourceTree = ""; }; + 3881CBFE9730C6422BE6F03D /* Downloads */ = { + isa = PBXGroup; + children = ( + 16ECDDD02E6A2F8562111538 /* DownloadQueueButton.swift */, + 8175390266E8C6CF1437A229 /* DownloadsView.swift */, + ); + path = Downloads; + sourceTree = ""; + }; 3DB66C5703A4CCAFFA1B7AFE /* Profile */ = { isa = PBXGroup; children = ( + AAD554706F61FE3DC061189F /* AccountMenuSheet.swift */, + 775B5C22D6215D7A7C412E13 /* AvatarCropView.swift */, C0B17D50389C6C98FC78BDBC /* ProfileView.swift */, - D4F1B8A26E3C97D0F52A4B71 /* AvatarCropView.swift */, - B3E9D2C85A4F02E1F63B5A49 /* AccountMenuSheet.swift */, - AA11BB22CC33DD44EE55FF67 /* UserProfileView.swift */, + F247DE25991F4DB98DF717AA /* UserProfileView.swift */, + A75E148A48D47A5B37CA7FB3 /* VoiceSelectionView.swift */, ); path = Profile; sourceTree = ""; @@ -155,10 +187,19 @@ path = Networking; sourceTree = ""; }; + 474BE4FC0353C2DD8D8425D1 /* Search */ = { + isa = PBXGroup; + children = ( + 79133D9FA697D1909C8D3973 /* SearchView.swift */, + ); + path = Search; + sourceTree = ""; + }; 4EAB87A1ED4943A311F26F84 /* ChapterReader */ = { isa = PBXGroup; children = ( 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */, + 35942111986E54CC0E83A391 /* DownloadAudioButton.swift */, ); path = ChapterReader; sourceTree = ""; @@ -220,6 +261,14 @@ path = Browse; sourceTree = ""; }; + 9180FAFE96724B8AACFA9859 /* Components */ = { + isa = PBXGroup; + children = ( + F082F99F2EE05BD98C9EF2AA /* OfflineBanner.swift */, + ); + path = Components; + sourceTree = ""; + }; 9AF55E5D62F980C72431782A = { isa = PBXGroup; children = ( @@ -253,10 +302,12 @@ 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */, 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */, 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */, + AA9111BF29C75E8D60FCEDF6 /* DiscoverViewModel.swift */, 3AB2E843D93461074A89A171 /* HomeViewModel.swift */, FC338B05EA6DB22900712000 /* LibraryViewModel.swift */, 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */, - BB22CC33DD44EE55FF66AA12 /* UserProfileViewModel.swift */, + 10777FC4816A7067AF9C4797 /* UserProfileViewModel.swift */, + CB2489CA141D5E19373D0936 /* VoiceSelectionViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -264,20 +315,15 @@ DA6F6F625578875F3E74F1D3 /* Services */ = { isa = PBXGroup; children = ( + 94730324A6BD9D6A772286BB /* AudioDownloadService.swift */, DB13E89E50529E3081533A66 /* AudioPlayerService.swift */, F219788AE5ACBD6F240674F5 /* AuthStore.swift */, + 1C0022D98CDAD0B11840AAAC /* BookVoicePreferences.swift */, + 1FA1B6D9FF31780095F5ACA8 /* NetworkMonitor.swift */, ); path = Services; sourceTree = ""; }; - E6A2B4C07F1D38E0A95B3C72 /* Search */ = { - isa = PBXGroup; - children = ( - C4F0A3B75E2D19C0E85A7B61 /* SearchView.swift */, - ); - path = Search; - sourceTree = ""; - }; FA994FD601E79EC811D822A4 /* Library */ = { isa = PBXGroup; children = ( @@ -290,7 +336,7 @@ isa = PBXGroup; children = ( 39DE056C37FBC5EED8771821 /* BookDetailView.swift */, - B2E8D1C74A3F91D0E5C72A38 /* CommentsView.swift */, + 16B9AFE90719BDBC718F0621 /* CommentsView.swift */, ); path = BookDetail; sourceTree = ""; @@ -363,7 +409,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 2630; + LastUpgradeCheck = 1600; }; buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */; developmentRegion = en; @@ -413,19 +459,27 @@ buildActionMask = 2147483647; files = ( FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */, + ED54860A709FED5A8CBF4EEB /* AccountMenuSheet.swift in Sources */, + 65CA672C02F367F72F18F8B8 /* AudioDownloadService.swift in Sources */, A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */, 5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */, 9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */, + 0B40E3DCE82EBEA7C4ECF148 /* AvatarCropView.swift in Sources */, 749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */, - A1C3F2B84D9E72A1BC054F17 /* CommentsView.swift in Sources */, 7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */, + 1945DD2D0DF497FE66FAAF90 /* BookVoicePreferences.swift in Sources */, 08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */, 2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */, FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */, 2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */, E2572692178FD17145FDAF77 /* Color+App.swift in Sources */, + A7485E99B9ACBCBCCD1EB7B2 /* CommentsView.swift in Sources */, F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */, 94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */, + 07FC69FB9DF3F6073564E489 /* DiscoverViewModel.swift in Sources */, + DFA7EB1B0BD53F68FE1335C8 /* DownloadAudioButton.swift in Sources */, + 880D411C936F7BA92AF83383 /* DownloadQueueButton.swift in Sources */, + 774CFCDA8A13311DF85FF051 /* DownloadsView.swift in Sources */, EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */, C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */, 032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */, @@ -433,16 +487,18 @@ 3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */, 0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */, F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */, + 5F7409635F6563E44C836390 /* NetworkMonitor.swift in Sources */, + 62B42DB777F53856C57CB6AF /* OfflineBanner.swift in Sources */, BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */, 64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */, - C3D7A2E15F8B04C9AB163D50 /* AvatarCropView.swift in Sources */, - A2F1C3B84E9D71A0BC164F28 /* AccountMenuSheet.swift in Sources */, 58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */, - AA11BB22CC33DD44EE55FF66 /* UserProfileView.swift in Sources */, - BB22CC33DD44EE55FF66AA11 /* UserProfileViewModel.swift in Sources */, 367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */, - D5E2A1C96F3B08D0F74C6B50 /* SearchView.swift in Sources */, + 192F82518CB8763775E33B38 /* SearchView.swift in Sources */, 41FB51553F1F1AEBFEA91C0A /* String+App.swift in Sources */, + 9C19B17E746FE6A834E53AF3 /* UserProfileView.swift in Sources */, + 9407F80F454D0248D5C779A6 /* UserProfileViewModel.swift in Sources */, + 8B02625CA1B93118B63E9C9D /* VoiceSelectionView.swift in Sources */, + 1964D61094D4731227384F3A /* VoiceSelectionViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -497,17 +553,14 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = GHZXC6FVMU; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = LibNovel/Resources/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; @@ -549,12 +602,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1000; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_PREVIEWS = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -577,7 +629,6 @@ ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.10; @@ -588,25 +639,18 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = GHZXC6FVMU; + DEVELOPMENT_TEAM = GHZXC6FVMU; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = LibNovel/Resources/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovel; PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f"; - PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "LibNovel Distribution"; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -646,12 +690,11 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1000; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_PREVIEWS = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -667,7 +710,6 @@ MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; diff --git a/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme b/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme index 575ed1b..f271d0d 100644 --- a/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme +++ b/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme @@ -1,10 +1,11 @@ + LastUpgradeVersion = "1600" + version = "1.7"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> + + + + + + diff --git a/ios/LibNovel/LibNovel/App/LibNovelApp.swift b/ios/LibNovel/LibNovel/App/LibNovelApp.swift index 135a5b6..4f7c203 100644 --- a/ios/LibNovel/LibNovel/App/LibNovelApp.swift +++ b/ios/LibNovel/LibNovel/App/LibNovelApp.swift @@ -4,12 +4,16 @@ import SwiftUI struct LibNovelApp: App { @StateObject private var authStore = AuthStore() @StateObject private var audioPlayer = AudioPlayerService() + @StateObject private var downloadService = AudioDownloadService.shared + @StateObject private var networkMonitor = NetworkMonitor() var body: some Scene { WindowGroup { ContentView() .environmentObject(authStore) .environmentObject(audioPlayer) + .environmentObject(downloadService) + .environmentObject(networkMonitor) } } } diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift index e6cd8ee..65e4a09 100644 --- a/ios/LibNovel/LibNovel/App/RootTabView.swift +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -8,7 +8,7 @@ struct RootTabView: View { @State private var selectedTab: Tab = .home @State private var showFullPlayer: Bool = false - @State private var showCompactControls: Bool = false + @State private var readerIsActive: Bool = false /// Live drag offset while the user is dragging the full player down. @State private var fullPlayerDragOffset: CGFloat = 0 @@ -37,27 +37,17 @@ struct RootTabView: View { .tag(Tab.search) } - // Floating circular player button (hidden while full player is open) - if audioPlayer.isActive && !showFullPlayer { - ZStack { - // Compact controls overlay (bottom sheet) - if showCompactControls { - CompactPlayerControls(isPresented: $showCompactControls) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - - // Floating button (always on top) - FloatingPlayerButton( - showFullPlayer: $showFullPlayer, - showControls: $showCompactControls - ) - } - .transition(.scale.combined(with: .opacity)) - .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) + // Mini player bar — sits above the tab bar, hidden while full player is open + // or while the chapter reader is active (it has its own audio chrome). + if audioPlayer.isActive && !showFullPlayer && !readerIsActive { + MiniPlayerBar(showFullPlayer: $showFullPlayer) + // Lift above the tab bar (approx 49 pt on all devices) + .padding(.bottom, 49) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) } - // Full player — slides up from the bottom as a custom overlay (not a sheet) - // so it feels physically connected to the mini player bar. + // Full player — slides up from the bottom as a custom overlay. if showFullPlayer { FullPlayerView(onDismiss: { withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { @@ -70,7 +60,6 @@ struct RootTabView: View { DragGesture(minimumDistance: 10) .onChanged { value in if value.translation.height > 0 { - // Rubberband slightly so it doesn't feel locked fullPlayerDragOffset = value.translation.height } } @@ -94,6 +83,8 @@ struct RootTabView: View { } } .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: showCompactControls) + .onPreferenceChange(HideMiniPlayerKey.self) { hide in + readerIsActive = hide + } } } diff --git a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift index 93d9eb6..f334016 100644 --- a/ios/LibNovel/LibNovel/Extensions/NavDestination.swift +++ b/ios/LibNovel/LibNovel/Extensions/NavDestination.swift @@ -6,6 +6,7 @@ enum NavDestination: Hashable { case book(String) // slug case chapter(String, Int) // slug + chapter number case userProfile(String) // username + case browseCategory(sort: String, genre: String, status: String, title: String) // Browse with filters } // MARK: - View extensions for shared navigation + error alert patterns @@ -19,15 +20,58 @@ extension View { /// Presents a standard "Error" alert driven by an optional String binding. /// Dismissing the alert sets the binding back to nil. + /// Silently suppresses network errors when offline (banner shows instead). func errorAlert(_ error: Binding) -> some View { - alert("Error", isPresented: Binding( - get: { error.wrappedValue != nil }, - set: { if !$0 { error.wrappedValue = nil } } - )) { - Button("OK") { error.wrappedValue = nil } - } message: { - Text(error.wrappedValue ?? "") + self.modifier(ErrorAlertModifier(error: error)) + } +} + +// MARK: - Error Alert Modifier + +private struct ErrorAlertModifier: ViewModifier { + @Binding var error: String? + @EnvironmentObject var networkMonitor: NetworkMonitor + + private var shouldShowAlert: Bool { + guard let errorMessage = error else { return false } + + // If offline, suppress common network error messages + if !networkMonitor.isConnected { + let networkKeywords = [ + "internet", + "offline", + "network", + "connection", + "unreachable", + "timed out", + "no data" + ] + + let lowercased = errorMessage.lowercased() + let isNetworkError = networkKeywords.contains { lowercased.contains($0) } + + if isNetworkError { + // Clear the error silently + DispatchQueue.main.async { + self.error = nil + } + return false + } } + + return true + } + + func body(content: Content) -> some View { + content + .alert("Error", isPresented: Binding( + get: { shouldShowAlert }, + set: { if !$0 { error = nil } } + )) { + Button("OK") { error = nil } + } message: { + Text(error ?? "") + } } } @@ -48,6 +92,8 @@ private struct AppNavigationDestinationModifier: ViewModifier { ChapterReaderView(slug: slug, chapterNumber: n) case .userProfile(let username): UserProfileView(username: username) + case .browseCategory(let sort, let genre, let status, let title): + BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) } } // Expose namespace to child views via environment @@ -59,6 +105,8 @@ private struct AppNavigationDestinationModifier: ViewModifier { case .book(let slug): BookDetailView(slug: slug) case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n) case .userProfile(let username): UserProfileView(username: username) + case .browseCategory(let sort, let genre, let status, let title): + BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) } } } @@ -78,6 +126,22 @@ extension EnvironmentValues { } } +// MARK: - Preference key: suppress mini player overlay (used by ChapterReaderView) + +struct HideMiniPlayerKey: PreferenceKey { + static var defaultValue = false + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = value || nextValue() + } +} + +extension View { + /// Signal to the root overlay that the mini player should be hidden. + func hideMiniPlayer() -> some View { + preference(key: HideMiniPlayerKey.self, value: true) + } +} + // MARK: - Cover card zoom source modifier /// Apply this to any cover image that should be a zoom source for book navigation. diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift index e35a027..58956dc 100644 --- a/ios/LibNovel/LibNovel/Networking/APIClient.swift +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -127,7 +127,7 @@ actor APIClient { func logout() async throws { let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST") - await setAuthCookie(nil) + setAuthCookie(nil) } // MARK: - Home diff --git a/ios/LibNovel/LibNovel/Services/AudioDownloadService.swift b/ios/LibNovel/LibNovel/Services/AudioDownloadService.swift new file mode 100644 index 0000000..c98f25b --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/AudioDownloadService.swift @@ -0,0 +1,318 @@ +import Foundation +import Combine + +// MARK: - AudioDownloadService +// Manages offline TTS audio downloads with progress tracking and persistent storage. +// Downloads are saved to the app's Documents directory, organized by slug/chapter/voice. + +@MainActor +final class AudioDownloadService: NSObject, ObservableObject { + static let shared = AudioDownloadService() + + // MARK: - Published State + + @Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice" + @Published var downloadedChapters: Set = [] // key: "slug::chapter::voice" + + // MARK: - Private + + private var session: URLSession! + private var activeTasks: [String: URLSessionDownloadTask] = [:] + private let fileManager = FileManager.default + private let metadataKey = "downloadedChaptersMetadata" + + // MARK: - Init + + private override init() { + super.init() + let config = URLSessionConfiguration.background(withIdentifier: "cc.kalekber.libnovel.audio-downloads") + config.isDiscretionary = false + config.sessionSendsLaunchEvents = true + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + loadMetadata() + } + + // MARK: - Public API + + /// Check if a chapter's audio is downloaded offline + func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + return downloadedChapters.contains(key) + } + + /// Get the local file URL for a downloaded chapter (nil if not downloaded) + func localURL(slug: String, chapter: Int, voice: String) -> URL? { + guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil } + return audioFileURL(slug: slug, chapter: chapter, voice: voice) + } + + /// Start downloading a chapter's audio + func download(slug: String, chapter: Int, voice: String) async throws { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + + print("📥 AudioDownload: Starting download - slug: \(slug), chapter: \(chapter), voice: \(voice)") + + // Already downloaded or in progress + if downloadedChapters.contains(key) { + print("⚠️ AudioDownload: Already downloaded - key: \(key)") + return + } + if activeTasks[key] != nil { + print("⚠️ AudioDownload: Already in progress - key: \(key)") + return + } + + // Get presigned URL from API + print("🔗 AudioDownload: Fetching presigned URL...") + let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) + guard let url = URL(string: urlString) else { + print("❌ AudioDownload: Invalid URL - \(urlString)") + throw URLError(.badURL) + } + + print("🔗 AudioDownload: Presigned URL obtained: \(url.absoluteString)") + + // Create download task + let task = session.downloadTask(with: url) + task.taskDescription = key // Use taskDescription to identify the download + activeTasks[key] = task + + // Initialize progress tracking + downloads[key] = DownloadProgress( + slug: slug, + chapter: chapter, + voice: voice, + progress: 0, + totalBytes: 0, + downloadedBytes: 0, + status: .downloading + ) + + print("🚀 AudioDownload: Starting download task - key: \(key)") + task.resume() + } + + /// Cancel an ongoing download + func cancelDownload(slug: String, chapter: Int, voice: String) { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + activeTasks[key]?.cancel() + activeTasks.removeValue(forKey: key) + downloads.removeValue(forKey: key) + } + + /// Delete a downloaded chapter + func deleteDownload(slug: String, chapter: Int, voice: String) throws { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) + + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } + + downloadedChapters.remove(key) + downloads.removeValue(forKey: key) + saveMetadata() + } + + /// Get total storage used by downloads (in bytes) + func getTotalStorageUsed() -> Int64 { + guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + return 0 + } + + let audioDir = documentsURL.appendingPathComponent("audio") + guard let enumerator = fileManager.enumerator(at: audioDir, includingPropertiesForKeys: [.fileSizeKey]) else { + return 0 + } + + var totalSize: Int64 = 0 + for case let fileURL as URL in enumerator { + if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + totalSize += Int64(fileSize) + } + } + return totalSize + } + + /// Delete all downloads + func deleteAllDownloads() throws { + guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + return + } + + let audioDir = documentsURL.appendingPathComponent("audio") + if fileManager.fileExists(atPath: audioDir.path) { + try fileManager.removeItem(at: audioDir) + } + + downloadedChapters.removeAll() + downloads.removeAll() + activeTasks.values.forEach { $0.cancel() } + activeTasks.removeAll() + saveMetadata() + } + + /// Get list of all book slugs that have offline downloads + func getOfflineBookSlugs() -> [String] { + let slugs = downloadedChapters.compactMap { key -> String? in + let components = key.split(separator: "::") + guard components.count == 3 else { return nil } + return String(components[0]) + } + return Array(Set(slugs)).sorted() + } + + /// Get count of downloaded chapters for a specific book + func getDownloadedChapterCount(for slug: String) -> Int { + return downloadedChapters.filter { key in + let components = key.split(separator: "::") + guard components.count == 3 else { return false } + return String(components[0]) == slug + }.count + } + + // MARK: - Private Helpers + + /// Build the canonical download key used for both in-memory tracking and UserDefaults. + /// Uses `::` as separator so slugs that contain `-` are unambiguous. + func makeKey(slug: String, chapter: Int, voice: String) -> String { + "\(slug)::\(chapter)::\(voice)" + } + + nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL { + guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + fatalError("Could not access documents directory") + } + + return documentsURL + .appendingPathComponent("audio") + .appendingPathComponent(slug) + .appendingPathComponent("\(chapter)-\(voice).mp3") + } + + private func loadMetadata() { + if let data = UserDefaults.standard.data(forKey: metadataKey), + let decoded = try? JSONDecoder().decode(Set.self, from: data) { + downloadedChapters = decoded + } + } + + private func saveMetadata() { + if let encoded = try? JSONEncoder().encode(downloadedChapters) { + UserDefaults.standard.set(encoded, forKey: metadataKey) + } + } +} + +// MARK: - URLSessionDownloadDelegate + +extension AudioDownloadService: URLSessionDownloadDelegate { + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + guard let key = downloadTask.taskDescription else { + print("⚠️ AudioDownload: No task description") + return + } + + print("✅ AudioDownload: Finished downloading - key: \(key)") + + let components = key.split(separator: "::") + guard components.count == 3, + let chapter = Int(components[1]) else { + print("⚠️ AudioDownload: Invalid key format: \(key)") + return + } + + let slug = String(components[0]) + let voice = String(components[2]) + + let destinationURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) + + print("📁 AudioDownload: Moving from \(location.path) to \(destinationURL.path)") + + do { + // Create directory if needed + let directory = destinationURL.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + + // Move file from temp location to permanent storage + if fileManager.fileExists(atPath: destinationURL.path) { + print("📁 AudioDownload: Removing existing file at destination") + try fileManager.removeItem(at: destinationURL) + } + try fileManager.moveItem(at: location, to: destinationURL) + + print("✅ AudioDownload: File moved successfully") + + Task { @MainActor in + print("✅ AudioDownload: Marking as completed - key: \(key)") + self.downloadedChapters.insert(key) + self.downloads.removeValue(forKey: key) // Remove from active downloads + self.activeTasks.removeValue(forKey: key) + self.saveMetadata() + print("✅ AudioDownload: Metadata saved, downloadedChapters count: \(self.downloadedChapters.count)") + } + } catch { + print("❌ AudioDownload: Failed to move file - \(error.localizedDescription)") + Task { @MainActor in + self.downloads[key]?.status = .failed(error.localizedDescription) + self.activeTasks.removeValue(forKey: key) + } + } + } + + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + guard let key = downloadTask.taskDescription else { return } + + let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0 + + if Int(progress * 100) % 10 == 0 { // Log every 10% + print("📊 AudioDownload: Progress for \(key): \(Int(progress * 100))% (\(totalBytesWritten)/\(totalBytesExpectedToWrite) bytes)") + } + + Task { @MainActor in + if var progressData = self.downloads[key] { + progressData.downloadedBytes = totalBytesWritten + progressData.totalBytes = totalBytesExpectedToWrite + progressData.progress = progress + self.downloads[key] = progressData + } + } + } + + nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let key = task.taskDescription else { return } + + if let error = error { + let nsError = error as NSError + if nsError.code != NSURLErrorCancelled { + print("❌ AudioDownload: Task completed with error - key: \(key), error: \(error.localizedDescription)") + Task { @MainActor in + self.downloads[key]?.status = .failed(error.localizedDescription) + self.activeTasks.removeValue(forKey: key) + } + } else { + print("⚠️ AudioDownload: Task cancelled - key: \(key)") + } + } else { + print("✅ AudioDownload: Task completed without error - key: \(key)") + } + } +} + +// MARK: - Supporting Types + +struct DownloadProgress: Equatable { + let slug: String + let chapter: Int + let voice: String + var progress: Double + var totalBytes: Int64 + var downloadedBytes: Int64 + var status: DownloadStatus +} + +enum DownloadStatus: Equatable { + case downloading + case completed + case failed(String) +} diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift index 7af1fde..d30fd4c 100644 --- a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -275,6 +275,17 @@ final class AudioPlayerService: ObservableObject { private func generateAudio() async { guard !slug.isEmpty, chapter > 0 else { return } + + // Check if audio is downloaded locally first + if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) { + audioURL = localURL.absoluteString + status = .ready + generationProgress = 100 + await playURL(localURL.absoluteString) + await prefetchNext() + return + } + do { // Fast path: audio already in MinIO — get a presigned URL and play immediately. if let presignedURL = try? await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) { diff --git a/ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift b/ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift new file mode 100644 index 0000000..2d6b500 --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/BookVoicePreferences.swift @@ -0,0 +1,73 @@ +import Foundation + +// MARK: - Book Voice Preferences Service +// Manages per-book voice overrides with global fallback + +@MainActor +final class BookVoicePreferences: ObservableObject { + static let shared = BookVoicePreferences() + + @Published private(set) var bookVoices: [String: String] = [:] // slug -> voice + + private let userDefaults = UserDefaults.standard + private let storageKey = "bookVoicePreferences" + + private init() { + loadPreferences() + } + + // MARK: - Public API + + /// Get the voice for a specific book (returns nil if no override set) + func voice(for slug: String) -> String? { + return bookVoices[slug] + } + + /// Get the voice for a book with fallback to global user voice + func voiceWithFallback(for slug: String, globalVoice: String) -> String { + return bookVoices[slug] ?? globalVoice + } + + /// Set a voice override for a specific book + func setVoice(_ voice: String, for slug: String) { + print("📚 BookVoicePreferences: Setting voice '\(voice)' for book '\(slug)'") + bookVoices[slug] = voice + savePreferences() + } + + /// Remove voice override for a book (will use global voice) + func removeVoice(for slug: String) { + print("📚 BookVoicePreferences: Removing voice override for book '\(slug)'") + bookVoices.removeValue(forKey: slug) + savePreferences() + } + + /// Check if a book has a voice override + func hasOverride(for slug: String) -> Bool { + return bookVoices[slug] != nil + } + + /// Clear all book voice overrides + func clearAll() { + print("📚 BookVoicePreferences: Clearing all book voice overrides") + bookVoices.removeAll() + savePreferences() + } + + // MARK: - Persistence + + private func loadPreferences() { + if let data = userDefaults.data(forKey: storageKey), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) { + bookVoices = decoded + print("📚 BookVoicePreferences: Loaded \(bookVoices.count) book voice overrides") + } + } + + private func savePreferences() { + if let encoded = try? JSONEncoder().encode(bookVoices) { + userDefaults.set(encoded, forKey: storageKey) + print("📚 BookVoicePreferences: Saved \(bookVoices.count) book voice overrides") + } + } +} diff --git a/ios/LibNovel/LibNovel/Services/NetworkMonitor.swift b/ios/LibNovel/LibNovel/Services/NetworkMonitor.swift new file mode 100644 index 0000000..26bdfbf --- /dev/null +++ b/ios/LibNovel/LibNovel/Services/NetworkMonitor.swift @@ -0,0 +1,54 @@ +import Foundation +import Network + +// MARK: - Network Monitor +// Monitors network connectivity and provides offline state across the app + +@MainActor +final class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + @Published var isConnected: Bool = true + @Published var connectionType: NWInterface.InterfaceType? + + private let monitor: NWPathMonitor + private let queue = DispatchQueue(label: "NetworkMonitor") + + init() { + monitor = NWPathMonitor() + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + self?.isConnected = path.status == .satisfied + self?.connectionType = path.availableInterfaces.first?.type + + if path.status == .satisfied { + print("🌐 Network: Connected (\(path.availableInterfaces.first?.type.debugDescription ?? "unknown"))") + } else { + print("📴 Network: Offline") + } + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } +} + +extension NWInterface.InterfaceType { + var debugDescription: String { + switch self { + case .wifi: return "Wi-Fi" + case .cellular: return "Cellular" + case .wiredEthernet: return "Ethernet" + case .loopback: return "Loopback" + case .other: return "Other" + @unknown default: return "Unknown" + } + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift index 599c050..cf0b39a 100644 --- a/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/ChapterReaderViewModel.swift @@ -52,13 +52,17 @@ final class ChapterReaderViewModel: ObservableObject { } else { let nextChapter: Int? = content.next let prevChapter: Int? = content.prev + + // Use per-book voice override, fallback to global voice + let voice = BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: settings.voice) + audioPlayer.load( slug: slug, chapter: chapter, chapterTitle: content.chapter.title, bookTitle: content.book.title, coverURL: content.book.cover, - voice: settings.voice, + voice: voice, speed: settings.speed, chapters: content.chapters, nextChapter: nextChapter, diff --git a/ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift new file mode 100644 index 0000000..b128aa6 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/DiscoverViewModel.swift @@ -0,0 +1,78 @@ +import Foundation + +@MainActor +final class DiscoverViewModel: ObservableObject { + @Published var trending: [BrowseNovel] = [] + @Published var topRated: [BrowseNovel] = [] + @Published var recentlyUpdated: [BrowseNovel] = [] + @Published var newReleases: [BrowseNovel] = [] + @Published var genreShelves: [GenreShelf] = [] + @Published var isLoading = false + @Published var error: String? + + struct GenreShelf: Identifiable { + let id: String + let name: String + let genre: String + var novels: [BrowseNovel] = [] + } + + // Popular genres to show as shelves + private let featuredGenres = [ + ("fantasy", "Fantasy"), + ("romance", "Romance"), + ("action", "Action"), + ("sci-fi", "Sci-Fi"), + ("mystery", "Mystery") + ] + + func load() async { + guard !isLoading else { return } + isLoading = true + error = nil + + async let trendingTask = loadShelf(sort: "popular", limit: 20) + async let topRatedTask = loadShelf(sort: "rating", limit: 20) + async let recentlyUpdatedTask = loadShelf(sort: "updated", limit: 20) + async let newReleasesTask = loadShelf(sort: "new", limit: 20) + + do { + trending = try await trendingTask + topRated = try await topRatedTask + recentlyUpdated = try await recentlyUpdatedTask + newReleases = try await newReleasesTask + + // Load genre shelves + await loadGenreShelves() + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + + isLoading = false + } + + private func loadShelf(sort: String, genre: String = "all", status: String = "all", limit: Int = 20) async throws -> [BrowseNovel] { + let result = try await APIClient.shared.browse(page: 1, genre: genre, sort: sort, status: status) + return Array(result.novels.prefix(limit)) + } + + private func loadGenreShelves() async { + var shelves: [GenreShelf] = [] + + for (genre, name) in featuredGenres { + do { + let novels = try await loadShelf(sort: "popular", genre: genre, limit: 15) + if !novels.isEmpty { + shelves.append(GenreShelf(id: genre, name: name, genre: genre, novels: novels)) + } + } catch { + // Skip failed genres silently + continue + } + } + + genreShelves = shelves + } +} diff --git a/ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift new file mode 100644 index 0000000..785c722 --- /dev/null +++ b/ios/LibNovel/LibNovel/ViewModels/VoiceSelectionViewModel.swift @@ -0,0 +1,127 @@ +import Foundation +import AVFoundation + +@MainActor +class VoiceSelectionViewModel: ObservableObject { + @Published var voices: [String] = [] + @Published var isLoading = false + @Published var error: String? + @Published var playingVoice: String? + + private var audioPlayer: AVPlayer? + // Store the opaque token returned by the block-based addObserver so we can + // actually remove it later. removeObserver(self, ...) does nothing when the + // block-based API was used — the token is the observer, not `self`. + private var endObserverToken: NSObjectProtocol? + + // Voice label formatting (matches web UI logic) + func voiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + + let prefix = String(parts[0]) + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + + var info = "" + switch prefix { + case "af": info = "US F" + case "am": info = "US M" + case "bf": info = "UK F" + case "bm": info = "UK M" + default: info = prefix.uppercased() + } + + return "\(name) (\(info))" + } + + func voiceId(_ voice: String) -> String { voice } + + // Load available voices from API + func loadVoices() async { + isLoading = true + error = nil + defer { isLoading = false } + + do { + let fetchedVoices = try await APIClient.shared.voices() + voices = fetchedVoices.isEmpty ? fallbackVoices() : fetchedVoices + } catch { + self.error = "Failed to load voices: \(error.localizedDescription)" + voices = fallbackVoices() + } + } + + // Play voice sample + func playSample(_ voice: String) async { + if playingVoice == voice { + stopSample() + return + } + + stopSample() + playingVoice = voice + + do { + let presignedURL = try await APIClient.shared.presignVoiceSample(voice: voice) + guard let url = URL(string: presignedURL) else { + throw NSError(domain: "VoiceSelection", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]) + } + + let playerItem = AVPlayerItem(url: url) + audioPlayer = AVPlayer(playerItem: playerItem) + + // Block-based addObserver returns a token — store it so we can remove it. + endObserverToken = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: playerItem, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.stopSample() + } + } + + audioPlayer?.play() + } catch { + // Sample might not be generated yet — silently ignore. + print("Voice sample not available for \(voice): \(error)") + playingVoice = nil + } + } + + // Stop currently playing sample + func stopSample() { + audioPlayer?.pause() + audioPlayer = nil + playingVoice = nil + if let token = endObserverToken { + NotificationCenter.default.removeObserver(token) + endObserverToken = nil + } + } + + private func fallbackVoices() -> [String] { + ["af_bella", "af_sarah", "af_nicole", + "am_adam", "am_michael", + "bf_emma", "bf_isabella", + "bm_george", "bm_lewis", + "af_sky"] + } + + // deinit: must NOT dispatch a Task capturing self. + // A Task strongly retains self, which causes "deallocated with non-zero retain + // count 2" → SIGABRT. Instead capture just the two values we need (player and + // token) and clean up without touching self at all. + nonisolated deinit { + // Capture locals — self is going away, do not reference it after this point. + // audioPlayer and endObserverToken are actor-isolated, but we can read their + // stored value directly in deinit because deinit is the last exclusive owner. + // Suppress the "actor-isolated" warning with an unowned reference pattern: + // Swift SE-0371 allows nonisolated deinit to access stored properties directly. + audioPlayer?.pause() + if let token = endObserverToken { + NotificationCenter.default.removeObserver(token) + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift index ef54f44..17df86b 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -15,22 +15,26 @@ struct BookDetailView: View { } var body: some View { - ZStack(alignment: .top) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - 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) - chaptersRow(book: book) - Divider().padding(.horizontal) - CommentsView(slug: slug) + VStack(spacing: 0) { + OfflineBanner() + + ZStack(alignment: .top) { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + 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) + chaptersRow(book: book) + Divider().padding(.horizontal) + CommentsView(slug: slug) + } } } + .ignoresSafeArea(edges: .top) } - .ignoresSafeArea(edges: .top) } .navigationBarTitleDisplayMode(.inline) .appNavigationDestination() @@ -252,6 +256,9 @@ struct BookDetailView: View { } // MARK: - Chapters list sheet +// Apple Books-style: chapters grouped into blocks of 100 with a right-edge jump bar. +// A .searchable bar filters by number or title; an "offline only" toggle shows downloaded chapters. +// Per-row download status (arc ring, labels, swipe actions) mirrors ChaptersListSheet in PlayerViews. struct BookChaptersSheet: View { let slug: String @@ -260,118 +267,182 @@ struct BookChaptersSheet: View { let totalChapters: Int @Environment(\.dismiss) private var dismiss - @State private var searchText = "" - @State private var scrollToCurrentOnAppear = true + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var audioPlayer: AudioPlayerService + + @State private var searchText: String = "" + @State private var filterOfflineOnly = false + @State private var showingDownloadAll = false + /// The block label the jump bar is currently scrolling to (e.g. "1–100"). + @State private var activeBlock: String? = nil + + // MARK: Derived data + + private var downloadedCount: Int { + chapters.filter { ch in + downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice) + }.count + } + + private var downloadingCount: Int { + downloadService.downloads.filter { key, _ in + key.hasPrefix("\(slug)::") + }.count + } + + private var defaultVoice: String { + BookVoicePreferences.shared.voiceWithFallback(for: slug, globalVoice: audioPlayer.voice) + } private var filtered: [ChapterIndex] { - guard !searchText.isEmpty else { return chapters } - let q = searchText.lowercased() - return chapters.filter { - "chapter \($0.number)".contains(q) || - $0.title.lowercased().contains(q) + var result = chapters + + if filterOfflineOnly { + result = result.filter { ch in + downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: defaultVoice) + } } + + if !searchText.isEmpty { + let q = searchText.lowercased() + result = result.filter { + "\($0.number)".contains(q) || + $0.title.lowercased().contains(q) || + "chapter \($0.number)".contains(q) + } + } + + return result } + /// Chapters grouped into blocks of 100 with range labels "1–100", "101–200", etc. + /// When searching or filtering the jump bar is hidden and a flat "Results" group is used. + private var groups: [(label: String, chapters: [ChapterIndex])] { + guard searchText.isEmpty && !filterOfflineOnly else { + return filtered.isEmpty ? [] : [("Results", filtered)] + } + guard !filtered.isEmpty else { return [] } + let blockSize = 100 + let minN = filtered.map(\.number).min() ?? 1 + let maxN = filtered.map(\.number).max() ?? 1 + let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 + var result: [(label: String, chapters: [ChapterIndex])] = [] + var blockStart = firstBlock + while blockStart <= maxN { + let blockEnd = blockStart + blockSize - 1 + let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } + if !slice.isEmpty { + result.append(("\(blockStart)–\(blockEnd)", slice)) + } + blockStart += blockSize + } + return result + } + + private var jumpLabels: [String] { groups.map(\.label) } + + // MARK: Body + var body: some View { NavigationStack { - VStack(spacing: 0) { - // Search bar - HStack(spacing: 8) { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("Search chapters…", text: $searchText) - .autocorrectionDisabled() - if !searchText.isEmpty { - Button { searchText = "" } label: { - Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) - } - } - } - .padding(10) - .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - .padding(.vertical, 10) + ZStack(alignment: .trailing) { + // ── Main chapter list ────────────────────────────────────── + List { + // Offline downloads summary (shown when at least one chapter is downloaded) + if downloadedCount > 0 || downloadingCount > 0 { + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Offline Downloads") + .font(.headline) + Text("\(downloadedCount) of \(chapters.count) chapters") + .font(.subheadline) + .foregroundStyle(.secondary) + } - Divider() + Spacer() - // Jump-to-current banner (shown when user has progress and not searching) - if let last = lastChapter, last > 0, searchText.isEmpty { - Button { - scrollToCurrentOnAppear = true - } label: { - HStack(spacing: 8) { - Image(systemName: "arrow.down.circle.fill") - .foregroundStyle(.amber) - Text("Jump to Ch.\(last)") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.amber) - Spacer() - let pct = totalChapters > 0 - ? Int(Double(last) / Double(totalChapters) * 100) - : 0 - Text("\(pct)% read") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - } - .buttonStyle(.plain) - - Divider() - } - - if chapters.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if filtered.isEmpty { - VStack(spacing: 10) { - Image(systemName: "magnifyingglass") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("No chapters match \"\(searchText)\"") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollViewReader { proxy in - List { - ForEach(filtered) { ch in - NavigationLink(value: NavDestination.chapter(slug, ch.number)) { - ChapterRow( - chapter: ch, - isCurrent: ch.number == lastChapter, - totalChapters: chapters.count - ) + Button { + showingDownloadAll = true + } label: { + Label("Manage", systemImage: "arrow.down.circle") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.bordered) + .tint(.blue) } - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 16)) - .id(ch.number) - } - } - .listStyle(.plain) - .appNavigationDestination() - .onAppear { - if scrollToCurrentOnAppear, let last = lastChapter, last > 0 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - withAnimation { - proxy.scrollTo(last, anchor: .center) + + if downloadingCount > 0 { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")") + .font(.caption) + .foregroundStyle(.secondary) } } - scrollToCurrentOnAppear = false + + Toggle("Show offline only", isOn: $filterOfflineOnly) + .font(.subheadline) + .tint(.amber) } + .padding(.vertical, 8) } - .onChange(of: scrollToCurrentOnAppear) { _, jump in - if jump, let last = lastChapter, last > 0 { - withAnimation { - proxy.scrollTo(last, anchor: .center) - } - scrollToCurrentOnAppear = false + } + + ForEach(groups, id: \.label) { group in + Section { + ForEach(group.chapters, id: \.number) { ch in + BookChapterRow( + chapter: ch, + slug: slug, + isCurrent: ch.number == lastChapter, + voice: defaultVoice + ) + .id(group.label) + } + } header: { + if searchText.isEmpty && !filterOfflineOnly { + Text(group.label) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .id("header_\(group.label)") } } } + + if chapters.isEmpty { + Section { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .listRowBackground(Color.clear) + } + } + } + .listStyle(.plain) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Chapter number or title" + ) + .scrollPosition(id: $activeBlock, anchor: .top) + .appNavigationDestination() + + // ── Right-edge jump bar ──────────────────────────────────── + if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 { + BookChaptersJumpBar( + labels: jumpLabels, + currentChapter: lastChapter ?? 0, + groups: groups + ) { label in + withAnimation { activeBlock = label } + } + .padding(.trailing, 4) } } - .navigationTitle("Chapters") + .navigationTitle("Chapters (\(filtered.count))") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -379,63 +450,209 @@ struct BookChaptersSheet: View { .fontWeight(.semibold) } } + // Sheet to manage bulk downloads for this book + .sheet(isPresented: $showingDownloadAll) { + DownloadManagementSheet( + chapters: chapters.map { ChapterIndexBrief(number: $0.number, title: $0.title) }, + slug: slug, + voice: Binding( + get: { defaultVoice }, + set: { _ in } // voice changes handled inside DownloadManagementSheet + ) + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + // Scroll to the current chapter's block on first appear + .onAppear { + if let block = groups.first(where: { g in + g.chapters.contains(where: { $0.number == (lastChapter ?? 0) }) + }) { + activeBlock = block.label + } + } } .presentationDetents([.large]) .presentationDragIndicator(.visible) } } -// MARK: - Chapter row (reused by sheet) +// MARK: - Individual chapter row with download status + NavigationLink -private struct ChapterRow: View { +private struct BookChapterRow: View { let chapter: ChapterIndex + let slug: String let isCurrent: Bool - let totalChapters: Int + let voice: String + + @EnvironmentObject var downloadService: AudioDownloadService + + private var isDownloaded: Bool { + downloadService.isDownloaded(slug: slug, chapter: chapter.number, voice: voice) + } + + private var downloadProgress: DownloadProgress? { + let key = downloadService.makeKey(slug: slug, chapter: chapter.number, voice: voice) + return downloadService.downloads[key] + } + + private var isDownloading: Bool { downloadProgress != nil } + + private var displayTitle: String { + let stripped = chapter.title.strippingTrailingDate() + if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { + return "Chapter \(chapter.number)" + } + return stripped + } var body: some View { - HStack(spacing: 12) { - // Number badge - ZStack { - Circle() - .fill(isCurrent ? Color.amber : Color(.systemGray6)) - Text("\(chapter.number)") - .font(.caption2.bold().monospacedDigit()) - .foregroundStyle(isCurrent ? .black : .secondary) - .minimumScaleFactor(0.6) - } - .frame(width: 32, height: 32) + NavigationLink(value: NavDestination.chapter(slug, chapter.number)) { + HStack(spacing: 14) { + // Number badge with optional download-progress arc ring + ZStack { + Circle() + .fill(isCurrent ? Color.amber : Color(.systemGray5)) + .frame(width: 40, height: 40) - VStack(alignment: .leading, spacing: 2) { - let displayTitle: String = { - let stripped = chapter.title.strippingTrailingDate() - if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { - return "Chapter \(chapter.number)" + Text("\(chapter.number)") + .font(.caption.bold().monospacedDigit()) + .foregroundStyle(isCurrent ? .white : .secondary) + .minimumScaleFactor(0.6) + .frame(width: 40, height: 40) + + // In-progress download arc + if isDownloading, let progress = downloadProgress { + Circle() + .trim(from: 0, to: progress.progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 44, height: 44) + .animation(.easeInOut(duration: 0.3), value: progress.progress) } - return stripped - }() - - Text(displayTitle) - .font(.subheadline) - .fontWeight(isCurrent ? .semibold : .regular) - .foregroundStyle(isCurrent ? .amber : .primary) - .lineLimit(1) - - if !chapter.dateLabel.isEmpty { - Text(chapter.dateLabel) - .font(.caption2) - .foregroundStyle(.tertiary) } + + // Title + status subtitle + VStack(alignment: .leading, spacing: 3) { + Text(displayTitle) + .font(.subheadline.weight(isCurrent ? .semibold : .regular)) + .foregroundStyle(isCurrent ? .amber : .primary) + .lineLimit(1) + + HStack(spacing: 8) { + if isCurrent { + Label("Reading", systemImage: "bookmark.fill") + .font(.caption2) + .foregroundStyle(.amber) + } + + if isDownloading, let progress = downloadProgress { + Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundStyle(.blue) + } else if isDownloaded { + Label("Downloaded", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + } else if !chapter.dateLabel.isEmpty { + Text(chapter.dateLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + + Spacer(minLength: 4) } - - Spacer(minLength: 8) - - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.tertiary) + .padding(.vertical, 6) + .contentShape(Rectangle()) } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .contentShape(Rectangle()) + .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) + // Trailing swipe: Download / Cancel / Delete + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isDownloaded { + Button(role: .destructive) { + Task { + try? downloadService.deleteDownload( + slug: slug, chapter: chapter.number, voice: voice + ) + } + } label: { + Label("Delete", systemImage: "trash") + } + } else if isDownloading { + Button(role: .destructive) { + downloadService.cancelDownload( + slug: slug, chapter: chapter.number, voice: voice + ) + } label: { + Label("Cancel", systemImage: "xmark") + } + } else { + Button { + Task { + try? await downloadService.download( + slug: slug, chapter: chapter.number, voice: voice + ) + } + } label: { + Label("Download", systemImage: "arrow.down.circle") + } + .tint(.blue) + } + } + } +} + +// MARK: - Right-edge jump bar for BookChaptersSheet +// Mirrors the JumpBar in PlayerViews.swift but operates on ChapterIndex groups. + +private struct BookChaptersJumpBar: View { + let labels: [String] + let currentChapter: Int + let groups: [(label: String, chapters: [ChapterIndex])] + let onSelect: (String) -> Void + + @State private var isDragging = false + + private func shortLabel(_ full: String) -> String { + full.components(separatedBy: "–").first ?? full + } + + 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) } } diff --git a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift index 3189545..3f96a86 100644 --- a/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift +++ b/ios/LibNovel/LibNovel/Views/Browse/BrowseView.swift @@ -1,152 +1,522 @@ import SwiftUI -struct BrowseView: View { - @StateObject private var vm = BrowseViewModel() - @State private var showFilters = false +// MARK: - Discover View (Browse) +// Serendipity-focused browsing with curated shelves. +// No search bar — use the dedicated Search tab for that. +struct BrowseView: View { + @StateObject private var vm = DiscoverViewModel() + @State private var showGenreSheet = false + var body: some View { NavigationStack { VStack(spacing: 0) { - // Search bar - HStack { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField("Search novels...", text: $vm.searchQuery) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.search) - .onSubmit { Task { await vm.search() } } - if !vm.searchQuery.isEmpty { - Button { vm.clearSearch() } label: { - Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) - } - } - } - .padding(10) - .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - .padding(.vertical, 8) - - // Filter chips row - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ChipButton(label: "Sort: \(vm.sort.capitalized)", isSelected: vm.sort != "popular", style: .outlined) { - showFilters = true - } - ChipButton(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isSelected: vm.genre != "all", style: .outlined) { - showFilters = true - } - ChipButton(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isSelected: vm.status != "all", style: .outlined) { - showFilters = true - } - } - .padding(.horizontal) - } - .padding(.bottom, 4) - - Divider() - - // Results - if vm.isLoading && vm.novels.isEmpty { - ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) - } else if vm.novels.isEmpty && !vm.isLoading { - VStack(spacing: 16) { - if let errMsg = vm.error { + OfflineBanner() + + Group { + if vm.isLoading && vm.trending.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMsg = vm.error, vm.trending.isEmpty { + VStack(spacing: 16) { Image(systemName: "wifi.slash") .font(.largeTitle) .foregroundStyle(.secondary) - Text(errMsg) + Text(errorMsg) .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal) - Button("Retry") { Task { await vm.loadFirstPage() } } + Button("Retry") { Task { await vm.load() } } .buttonStyle(.borderedProminent) .tint(.amber) - } else { - EmptyStateView(icon: "magnifyingglass", title: "No results", message: "Try a different search or filter.") } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) { - ForEach(vm.novels) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - BrowseCard(novel: novel) - .bookCoverZoomSource(slug: novel.slug) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 32) { + // Trending shelf + if !vm.trending.isEmpty { + DiscoverShelf( + title: "Trending Now", + novels: vm.trending, + destination: .browseCategory( + sort: "popular", + genre: "all", + status: "all", + title: "Trending Now" + ) + ) } - .buttonStyle(.plain) - } - - // Infinite scroll trigger - if vm.hasNext { - ProgressView() - .frame(maxWidth: .infinity) - .padding() - .onAppear { Task { await vm.loadNextPage() } } + + // Top Rated shelf + if !vm.topRated.isEmpty { + DiscoverShelf( + title: "Top Rated", + novels: vm.topRated, + destination: .browseCategory( + sort: "rating", + genre: "all", + status: "all", + title: "Top Rated" + ) + ) + } + + // Recently Updated shelf + if !vm.recentlyUpdated.isEmpty { + DiscoverShelf( + title: "Recently Updated", + novels: vm.recentlyUpdated, + destination: .browseCategory( + sort: "updated", + genre: "all", + status: "all", + title: "Recently Updated" + ) + ) + } + + // New Releases shelf + if !vm.newReleases.isEmpty { + DiscoverShelf( + title: "New Releases", + novels: vm.newReleases, + destination: .browseCategory( + sort: "new", + genre: "all", + status: "all", + title: "New Releases" + ) + ) + } + + // Categories button — replaces individual genre shelves + CategoriesRow(onTap: { showGenreSheet = true }) + .padding(.horizontal) + + Color.clear.frame(height: 100) } + .padding(.top, 8) } - .padding() + .refreshable { await vm.load() } } - .refreshable { await vm.loadFirstPage() } } - } - .navigationTitle("Discover") - .appNavigationDestination() - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - AvatarToolbarButton() + .navigationTitle("Discover") + .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + DownloadQueueButton() + AvatarToolbarButton() + } + } } + .task { await vm.load() } } - .sheet(isPresented: $showFilters) { - BrowseFiltersView(vm: vm) - } - .task { await vm.loadFirstPage() } - .onChange(of: vm.sort) { _, _ in Task { await vm.loadFirstPage() } } - .onChange(of: vm.genre) { _, _ in Task { await vm.loadFirstPage() } } - .onChange(of: vm.status) { _, _ in Task { await vm.loadFirstPage() } } + } + .sheet(isPresented: $showGenreSheet) { + GenrePickerSheet() } } } -// MARK: - Browse card +// MARK: - Categories row (Apple Books–style single button) + +private struct CategoriesRow: View { + let onTap: () -> Void -private struct BrowseCard: View { - let novel: BrowseNovel var body: some View { - VStack(alignment: .leading, spacing: 6) { + Button(action: onTap) { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color.amber.opacity(0.15)) + .frame(width: 44, height: 44) + Image(systemName: "square.grid.2x2") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(Color.amber) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Browse by Genre") + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + Text("Action, Fantasy, Romance & more") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(14) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + } +} + +// MARK: - Genre picker sheet + +private struct GenrePickerSheet: View { + @Environment(\.dismiss) private var dismiss + + private let genres: [(label: String, genre: String, icon: String)] = [ + ("Action", "action", "bolt.fill"), + ("Fantasy", "fantasy", "wand.and.stars"), + ("Romance", "romance", "heart.fill"), + ("Sci-Fi", "sci-fi", "sparkles"), + ("Mystery", "mystery", "magnifyingglass"), + ("Horror", "horror", "moon.fill"), + ("Comedy", "comedy", "face.smiling"), + ("Adventure", "adventure", "map.fill"), + ("Martial Arts", "martial arts", "figure.martial.arts"), + ("Cultivation", "cultivation", "leaf.fill"), + ("Historical", "historical", "building.columns.fill"), + ("Slice of Life", "slice of life", "sun.max.fill"), + ] + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], + spacing: 12 + ) { + // "All" tile + NavigationLink(value: NavDestination.browseCategory( + sort: "popular", genre: "all", status: "all", title: "All Novels" + )) { + GenreTile(label: "All Novels", icon: "books.vertical.fill") + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { dismiss() }) + + ForEach(genres, id: \.genre) { item in + NavigationLink(value: NavDestination.browseCategory( + sort: "popular", + genre: item.genre, + status: "all", + title: item.label + )) { + GenreTile(label: item.label, icon: item.icon) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { dismiss() }) + } + } + .padding(16) + .padding(.bottom, 20) + } + .navigationTitle("Genres") + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(20) + } +} + +private struct GenreTile: View { + let label: String + let icon: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color.amber) + .frame(width: 24) + Text(label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// MARK: - Discover Shelf (horizontal scrolling) + +private struct DiscoverShelf: View { + let title: String + let novels: [BrowseNovel] + let destination: NavDestination + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header with "See All" button + HStack(spacing: 10) { + // Amber accent bar — matches ShelfHeader style used on Home and UserProfile + RoundedRectangle(cornerRadius: 2) + .fill(Color.amber) + .frame(width: 3, height: 18) + Text(title) + .font(.title3.bold()) + Spacer() + NavigationLink(value: destination) { + HStack(spacing: 4) { + Text("See All") + .font(.subheadline) + Image(systemName: "chevron.right") + .font(.caption.bold()) + } + .foregroundStyle(.amber) + } + .buttonStyle(.plain) + } + .padding(.horizontal) + + // Horizontal scroll — leading padding aligns cards with header + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + DiscoverShelfCard(novel: novel) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.vertical, 4) // let shadows breathe + } + } + } +} + +// MARK: - Shelf card (card-style) + +private struct DiscoverShelfCard: View { + let novel: BrowseNovel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { ZStack(alignment: .topLeading) { AsyncCoverImage(url: novel.cover) - .frame(height: 200) + .frame(width: 120, height: 173) // 2:3 ratio .clipShape(RoundedRectangle(cornerRadius: 10)) + .bookCoverZoomSource(slug: novel.slug) + if !novel.rank.isEmpty { Text(novel.rank) .font(.caption2.bold()) - .padding(.horizontal, 6).padding(.vertical, 3) + .padding(.horizontal, 6) + .padding(.vertical, 3) .background(.ultraThinMaterial, in: Capsule()) .padding(6) } } - Text(novel.title) - .font(.caption.bold()).lineLimit(2) - if !novel.chapters.isEmpty { - Text(novel.chapters).font(.caption2).foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: 120, alignment: .leading) + .multilineTextAlignment(.leading) + + if !novel.chapters.isEmpty { + Text(novel.chapters) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 120, alignment: .leading) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + .frame(width: 136) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) + } +} + +// MARK: - Browse Category View (full grid for "See All") + +struct BrowseCategoryView: View { + let sort: String + let genre: String + let status: String + let title: String + + @StateObject private var vm: BrowseViewModel + @State private var showFilters = false + + init(sort: String, genre: String, status: String, title: String) { + self.sort = sort + self.genre = genre + self.status = status + self.title = title + + let viewModel = BrowseViewModel() + viewModel.sort = sort + viewModel.genre = genre + viewModel.status = status + _vm = StateObject(wrappedValue: viewModel) + } + + var body: some View { + Group { + if vm.isLoading && vm.novels.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMsg = vm.error, vm.novels.isEmpty { + VStack(spacing: 16) { + Image(systemName: "wifi.slash") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text(errorMsg) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal) + Button("Retry") { Task { await vm.loadFirstPage() } } + .buttonStyle(.borderedProminent) + .tint(.amber) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ], + spacing: 14 + ) { + ForEach(vm.novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + BrowseCategoryCard(novel: novel) + } + .buttonStyle(.plain) + .onAppear { + // Infinite scroll + if novel.id == vm.novels.last?.id { + Task { await vm.loadNextPage() } + } + } + } + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 100) + + if vm.isLoading && !vm.novels.isEmpty { + ProgressView() + .padding() + } + } + .refreshable { await vm.loadFirstPage() } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showFilters = true + } label: { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(.amber) + } + } + } + .sheet(isPresented: $showFilters) { + BrowseFiltersView(vm: vm) + } + .task { + if vm.novels.isEmpty { + await vm.loadFirstPage() } } } } -// MARK: - Filters sheet +private struct BrowseCategoryCard: View { + let novel: BrowseNovel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topLeading) { + AsyncCoverImage(url: novel.cover) + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .bookCoverZoomSource(slug: novel.slug) + + if !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + } + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if !novel.chapters.isEmpty { + Text(novel.chapters) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) + } +} + +// MARK: - Filters sheet (kept for future "See All" views) struct BrowseFiltersView: View { @ObservedObject var vm: BrowseViewModel @Environment(\.dismiss) private var dismiss - + let sortOptions = ["popular", "new", "updated", "rating", "rank"] let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery", "horror", "comedy", "drama", "adventure", "martial arts", "cultivation", "magic", "supernatural", "historical", "slice of life"] let statusOptions = ["all", "ongoing", "completed"] - + var body: some View { NavigationStack { Form { diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift index c25217c..ed18f9d 100644 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -2,7 +2,7 @@ import SwiftUI import WebKit import UIKit -// MARK: - Chapter Reader (paginated, Apple Books–style) +// MARK: - Chapter Reader (Apple Books–style, modern) struct ChapterReaderView: View { let slug: String @@ -14,7 +14,6 @@ struct ChapterReaderView: View { @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 @@ -28,7 +27,7 @@ struct ChapterReaderView: View { var body: some View { ZStack { - // Full-bleed background colour driven by reader theme + // Full-bleed background readerSettings.settings.theme.backgroundColor .ignoresSafeArea() @@ -56,27 +55,32 @@ struct ChapterReaderView: View { errorView(errMsg) } - // Top chrome: nav bar area (progress bar + title + controls) + // Overlaid chrome (top + bottom) — must NOT ignore safe area so buttons + // stay above the home indicator and below the status bar. if chromeVisible { - topChrome + VStack(spacing: 0) { + topChrome + Spacer() + if let content = vm.content { + bottomChrome(content: content) + } + } + .transition(.opacity.animation(.easeInOut(duration: 0.2))) + .ignoresSafeArea(edges: .top) // top chrome extends behind status bar only } - - // Bottom chrome: prev/next + listen button - if chromeVisible, let content = vm.content { - bottomChrome(content: content) - } - } - .navigationBarHidden(true) // we draw our own chrome - .toolbar(.hidden, for: .tabBar) // hide tab bar in reader (Apple Books style) - .ignoresSafeArea(edges: .top) + .ignoresSafeArea(edges: .all) + .navigationBarHidden(true) + .toolbar(.hidden, for: .tabBar) .preferredColorScheme(readerSettings.settings.theme.colorScheme) + .hideMiniPlayer() .task(id: currentChapter) { await vm.load() } .sheet(isPresented: $showSettingsPanel) { ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel) - .presentationDetents([.height(480)]) + .presentationDetents([.height(460)]) .presentationDragIndicator(.visible) - .presentationCornerRadius(20) + .presentationCornerRadius(24) + .presentationBackground(.regularMaterial) } .sheet(isPresented: $showToCSheet) { if let content = vm.content { @@ -115,167 +119,161 @@ struct ChapterReaderView: View { @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) + ZStack(alignment: .bottom) { + Rectangle() + .fill(.ultraThinMaterial) + .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) + .ignoresSafeArea(edges: .top) + VStack(spacing: 0) { HStack(spacing: 0) { - // Left: 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)) + // Back + Button { dismiss() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor) .frame(width: 44, height: 44) + .contentShape(Rectangle()) } Spacer() - // Center: Chapter title (truncated) + // Single-line chapter title if let content = vm.content { Text(content.chapter.title.strippingTrailingDate()) - .font(.subheadline.weight(.medium)) - .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) .lineLimit(1) .frame(maxWidth: 200) } Spacer() - // 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) - } - - // X dismiss button (rightmost) - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) - .frame(width: 44, height: 44) + // ToC + Aa + HStack(spacing: 0) { + Button { showToCSheet = true } label: { + Image(systemName: "list.bullet") + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + 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) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } } } .padding(.horizontal, 4) - } - .frame(height: 44) + .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 - ) + // Progress bar + if let content = vm.content { + ChapterProgressBar( + currentChapter: content.chapter.number, + totalChapters: content.chapters.last?.number ?? content.chapter.number, + color: accentColor + ) + } } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .animation(.easeInOut(duration: 0.2), value: chromeVisible) + .fixedSize(horizontal: false, vertical: true) } // MARK: - Bottom chrome private func bottomChrome(content: ChapterResponse) -> some View { - VStack(spacing: 0) { - Spacer() + HStack(alignment: .center, spacing: 12) { - ZStack { - readerSettings.settings.theme.backgroundColor - .opacity(0.95) - - HStack(spacing: 0) { - // Prev chapter pill - if let prev = content.prev { - Button { - navigateToChapter(prev) - } label: { - HStack(spacing: 5) { - Image(systemName: "chevron.left") - .font(.system(size: 11, weight: .bold)) - Text("Ch.\(prev)") - .font(.caption.weight(.semibold)) - } - .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.55)) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background( - Capsule() - .fill(readerSettings.settings.theme.textColor.opacity(0.07)) - ) - } - .buttonStyle(.plain) - } else { - // Placeholder to keep center stable - Color.clear.frame(width: 60, height: 32) - } - - Spacer() - - // Listen / playing indicator - ListenButton( - audioPlayer: audioPlayer, - vm: vm, - authStore: authStore, - theme: readerSettings.settings.theme - ) - - Spacer() - - // Next chapter pill - if let next = content.next { - Button { - navigateToChapter(next) - } label: { - HStack(spacing: 5) { - Text("Ch.\(next)") - .font(.caption.weight(.semibold)) - Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .bold)) - } - .foregroundStyle(.amber) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background( - Capsule() - .fill(Color.amber.opacity(0.12)) - ) - } - .buttonStyle(.plain) - } else { - Color.clear.frame(width: 60, height: 32) + // ── Prev chapter ──────────────────────────────────────────────── + if let prev = content.prev { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + navigateToChapter(prev) + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .bold)) + Text("Ch.\(prev)") + .font(.caption.weight(.semibold)) } + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + .frame(minWidth: 64) + .padding(.vertical, 10) + .contentShape(Rectangle()) } - .padding(.horizontal, 20) - .padding(.vertical, 10) + .buttonStyle(.plain) + } else { + Color.clear.frame(width: 64, height: 40) } - .frame(height: 60) - // Home indicator area (no mini player spacer — tab bar and mini player are hidden in reader) - Color.clear.frame(height: safeAreaBottom) + Spacer(minLength: 0) + + // ── Download ──────────────────────────────────────────────────── + DownloadAudioButton( + slug: slug, + chapter: currentChapter, + voice: audioPlayer.voice, + theme: readerSettings.settings.theme + ) + + // ── Listen / Pause pill ───────────────────────────────────────── + ListenButton( + audioPlayer: audioPlayer, + vm: vm, + authStore: authStore, + theme: readerSettings.settings.theme + ) + + Spacer(minLength: 0) + + // ── Next chapter ──────────────────────────────────────────────── + if let next = content.next { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + navigateToChapter(next) + } label: { + HStack(spacing: 4) { + Text("Ch.\(next)") + .font(.caption.weight(.semibold)) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + } + .foregroundStyle(.white) + .frame(minWidth: 64) + .padding(.vertical, 10) + .background(Capsule().fill(accentColor)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } else { + Color.clear.frame(width: 64, height: 40) + } } - .animation(.easeInOut(duration: 0.2), value: chromeVisible) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) + .ignoresSafeArea(edges: .bottom) + ) } - // MARK: - Error view + // MARK: - Helpers + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) + : .amber + } private func errorView(_ msg: String) -> some View { VStack(spacing: 16) { @@ -297,28 +295,13 @@ struct ChapterReaderView: View { vm.switchChapter(to: chapter) currentChapter = chapter } - - // MARK: - Safe area helpers - - private var safeAreaTop: CGFloat { - (UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first?.windows.first(where: \.isKeyWindow)? - .safeAreaInsets.top) ?? 44 - } - - private var safeAreaBottom: CGFloat { - (UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first?.windows.first(where: \.isKeyWindow)? - .safeAreaInsets.bottom) ?? 0 - } } // 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. +/// Edge-swipe on title page (left→right) navigates to previous chapter; +/// edge-swipe on end page (right→left) navigates to next chapter. private struct PaginatedReaderContent: View { let content: ChapterResponse let readerSettings: ReaderSettingsStore @@ -328,14 +311,16 @@ private struct PaginatedReaderContent: View { @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 + // Height reserved for top and bottom chrome (approximation — avoids layout passes) + private let topReserve: CGFloat = 80 // nav bar + progress bar + safe area + private let bottomReserve: CGFloat = 64 // single unified toolbar + safe area + 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 @@ -354,7 +339,6 @@ private struct PaginatedReaderContent: View { .onTapGesture { toggleChrome() } } - // End-of-chapter page ChapterEndPage( content: content, readerSettings: readerSettings, @@ -364,18 +348,7 @@ private struct PaginatedReaderContent: View { .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 - } + .onChange(of: currentPage) { _, newPage in lastPage = newPage } .onAppear { if geometrySize != size { geometrySize = size @@ -396,8 +369,6 @@ private struct PaginatedReaderContent: View { } .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 @@ -405,12 +376,9 @@ private struct PaginatedReaderContent: View { 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) } } @@ -418,22 +386,15 @@ private struct PaginatedReaderContent: View { } private func toggleChrome() { - withAnimation(.easeInOut(duration: 0.22)) { - chromeVisible.toggle() - } + 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 textWidth = size.width - hPad * 2 + let textHeight = size.height - topReserve - bottomReserve let attributed = HTMLParser.toAttributedString( html: content.html, @@ -442,14 +403,12 @@ private struct PaginatedReaderContent: View { 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) } @@ -457,7 +416,6 @@ private struct PaginatedReaderContent: View { } // MARK: - Scroll mode reader content -// A continuous vertical ScrollView alternative to the paginated TabView. private struct ScrollReaderContent: View { let content: ChapterResponse @@ -468,23 +426,21 @@ private struct ScrollReaderContent: View { 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 + let accentColor: Color = settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { - // Chapter title header + // Chapter header VStack(alignment: .leading, spacing: 10) { Text(content.book.title) - .font(.caption.weight(.medium)) + .font(.system(size: 11, 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) + .fill(accentColor.opacity(0.6)) + .frame(width: 36, height: 2) Text(content.chapter.title.strippingTrailingDate()) .font(.system(size: 22, weight: .bold, design: .serif)) .foregroundStyle(settings.theme.textColor) @@ -495,10 +451,10 @@ private struct ScrollReaderContent: View { } } .padding(.horizontal, hPad) - .padding(.top, 24) + .padding(.top, 20) .padding(.bottom, 20) - // Body text rendered as AttributedString + // Body let attributed = HTMLParser.toAttributedString( html: content.html, fontSize: settings.fontSize, @@ -509,14 +465,11 @@ private struct ScrollReaderContent: View { Text(attributed) .padding(.horizontal, hPad) - // Next chapter button at bottom + // Next chapter footer VStack(spacing: 16) { - Divider() - .padding(.horizontal, hPad) + Divider().padding(.horizontal, hPad) if let next = content.next { - Button { - onNavigateChapter(next) - } label: { + Button { onNavigateChapter(next) } label: { HStack { Text("Next Chapter") .fontWeight(.semibold) @@ -525,28 +478,22 @@ private struct ScrollReaderContent: View { .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) - ) + .background(Capsule().fill(accentColor)) } .buttonStyle(.plain) .padding(.horizontal, hPad) } } .padding(.vertical, 24) - .padding(.bottom, bottomPad) + .padding(.bottom, 80) } } - .padding(.top, topPad) + // Offset content below the top chrome without padding (safeAreaInset) + .safeAreaInset(edge: .top) { Color.clear.frame(height: 52) } .background(settings.theme.backgroundColor) .ignoresSafeArea() .onTapGesture { - withAnimation(.easeInOut(duration: 0.22)) { - chromeVisible.toggle() - } + withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } } } } @@ -562,27 +509,25 @@ private struct ReaderPage: View { var body: some View { let settings = readerSettings.settings let hPad: CGFloat = 28 - let topPad: CGFloat = 90 - let bottomPad: CGFloat = 80 + let topPad: CGFloat = 80 // visual breathing room below top chrome + let bottomPad: CGFloat = 56 // visual breathing room above bottom chrome GeometryReader { geo in - ZStack(alignment: .bottomTrailing) { + ZStack(alignment: .bottom) { Text(text) - .frame( - width: geo.size.width - hPad * 2, - alignment: .topLeading - ) + .frame(width: geo.size.width - hPad * 2, alignment: .topLeading) .frame(maxHeight: .infinity, alignment: .top) .padding(.horizontal, hPad) .padding(.top, topPad) .padding(.bottom, bottomPad) + .frame(maxWidth: .infinity) - // Page number - Text("\(pageNumber) / \(totalPages)") + // Page indicator: "3 of 47" centered at bottom + Text("\(pageNumber) of \(totalPages)") .font(.system(size: 11, weight: .regular).monospacedDigit()) .foregroundStyle(settings.theme.textColor.opacity(0.3)) - .padding(.trailing, hPad) - .padding(.bottom, bottomPad - 22) + .padding(.bottom, bottomPad - 24) + .frame(maxWidth: .infinity, alignment: .center) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(settings.theme.backgroundColor) @@ -590,15 +535,12 @@ private struct ReaderPage: View { } } -// MARK: - Chapter title page (shown before the first content page) +// MARK: - Chapter title page private struct ChapterTitlePage: View { let content: ChapterResponse let readerSettings: ReaderSettingsStore - @State private var arrowOpacity: Double = 0.25 - @State private var arrowOffset: CGFloat = 0 - private var totalChapters: Int { content.chapters.last?.number ?? content.chapter.number } @@ -608,78 +550,85 @@ private struct ChapterTitlePage: View { return Int((Double(content.chapter.number) / Double(totalChapters)) * 100) } + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + 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) + GeometryReader { geo in + VStack(alignment: .leading, spacing: 0) { + Spacer() - 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) + VStack(alignment: .leading, spacing: 14) { + // Book name pill + Text(content.book.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(settings.theme.textColor.opacity(0.45)) + .textCase(.uppercase) + .tracking(1.4) + .lineLimit(2) - Text(content.chapter.title.strippingTrailingDate()) - .font(.system(size: 26, weight: .bold, design: .serif)) - .foregroundStyle(settings.theme.textColor) - .fixedSize(horizontal: false, vertical: true) + // Accent rule + Rectangle() + .fill(accentColor) + .frame(width: 36, height: 2) + .clipShape(Capsule()) - HStack(spacing: 8) { - if !content.chapter.dateLabel.isEmpty { - Text(content.chapter.dateLabel) - .font(.caption) - .foregroundStyle(settings.theme.textColor.opacity(0.4)) - } - if totalChapters > 1 { + // Chapter title — large serif + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: min(32, geo.size.width / 10.5), weight: .bold, design: .serif)) + .foregroundStyle(settings.theme.textColor) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(4) + + // Meta row + HStack(spacing: 8) { if !content.chapter.dateLabel.isEmpty { - Text("·") + Text(content.chapter.dateLabel) .font(.caption) - .foregroundStyle(settings.theme.textColor.opacity(0.25)) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + } + if totalChapters > 1 { + if !content.chapter.dateLabel.isEmpty { + Circle() + .fill(settings.theme.textColor.opacity(0.25)) + .frame(width: 3, height: 3) + } + Text("\(progressPercent)% through") + .font(.caption.weight(.medium)) + .foregroundStyle(accentColor.opacity(0.85)) } - Text("\(progressPercent)% through") - .font(.caption.weight(.medium)) - .foregroundStyle( - settings.theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15).opacity(0.75) - : Color.amber.opacity(0.75) - ) } } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 36) - Spacer() - Spacer() + .padding(.horizontal, 36) - // Animated swipe hint - HStack(spacing: 5) { - Image(systemName: "arrow.right") - .font(.caption2) - .offset(x: arrowOffset) - Text("Swipe to read") - .font(.caption2) - } - .foregroundStyle(settings.theme.textColor.opacity(arrowOpacity)) - .padding(.bottom, 100) - .onAppear { - withAnimation(.easeOut(duration: 0.5).delay(0.5)) { - arrowOpacity = 0.6 - arrowOffset = 5 + Spacer() + Spacer() + + // Swipe hint — uses phaseAnimator for continuous subtle motion + HStack(spacing: 6) { + Image(systemName: "arrow.right") + .font(.caption2.weight(.semibold)) + Text("Swipe to read") + .font(.caption2) } - withAnimation(.easeIn(duration: 0.7).delay(1.4)) { - arrowOpacity = 0.18 - arrowOffset = 0 + .foregroundStyle(settings.theme.textColor.opacity(0.5)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 96) + .phaseAnimator([false, true]) { content, phase in + content + .offset(x: phase ? 4 : -2) + .opacity(phase ? 0.55 : 0.15) + } animation: { phase in + .easeInOut(duration: 0.9) } + .onAppear {} } + .frame(maxWidth: .infinity) + .background(settings.theme.backgroundColor) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(settings.theme.backgroundColor) } } @@ -690,49 +639,85 @@ private struct ChapterEndPage: View { let readerSettings: ReaderSettingsStore let onNavigateChapter: (Int) -> Void + @State private var appeared = false + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + 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) + VStack(spacing: 20) { + // Layered ring + checkmark + ZStack { + Circle() + .fill(accentColor.opacity(0.07)) + .frame(width: 96, height: 96) + Circle() + .fill(accentColor.opacity(0.14)) + .frame(width: 72, height: 72) + Image(systemName: "checkmark") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(accentColor) + .symbolEffect(.bounce, value: appeared) + } + .scaleEffect(appeared ? 1 : 0.7) + .opacity(appeared ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.65).delay(0.05), value: appeared) - Text("End of Chapter \(content.chapter.number)") - .font(.title3.bold()) - .foregroundStyle(settings.theme.textColor) + VStack(spacing: 6) { + Text("Chapter \(content.chapter.number)") + .font(.caption.weight(.semibold)) + .foregroundStyle(accentColor) + .textCase(.uppercase) + .tracking(1.2) + + Text("Complete") + .font(.title2.bold()) + .foregroundStyle(settings.theme.textColor) + + if content.next == nil { + Text("You've reached the latest chapter") + .font(.subheadline) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + .animation(.easeOut(duration: 0.35).delay(0.15), value: appeared) } - // Next chapter button if let next = content.next { - Button { - onNavigateChapter(next) - } label: { - HStack { - Text("Next Chapter") + Button { onNavigateChapter(next) } label: { + HStack(spacing: 8) { + Text("Chapter \(next)") .fontWeight(.semibold) Image(systemName: "arrow.right") + .font(.system(size: 14, weight: .semibold)) } .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) - ) + .frame(height: 52) + .frame(maxWidth: 240) + .background(Capsule().fill(accentColor)) } .buttonStyle(.plain) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .animation(.easeOut(duration: 0.35).delay(0.25), value: appeared) } Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(settings.theme.backgroundColor) + .onAppear { appeared = true } + .onDisappear { appeared = false } } } @@ -751,15 +736,20 @@ private struct ChapterProgressBar: View { var body: some View { GeometryReader { geo in ZStack(alignment: .leading) { + Rectangle().fill(color.opacity(0.10)) Rectangle() - .fill(color.opacity(0.15)) - Rectangle() - .fill(color.opacity(0.75)) + .fill( + LinearGradient( + colors: [color.opacity(0.7), color], + startPoint: .leading, + endPoint: .trailing + ) + ) .frame(width: geo.size.width * progress) - .animation(.easeInOut(duration: 0.4), value: progress) + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: progress) } } - .frame(height: 1) + .frame(height: 3) } } @@ -771,27 +761,47 @@ private struct ListenButton: View { @ObservedObject var authStore: AuthStore let theme: ReaderTheme + private var isActive: Bool { + audioPlayer.isActive && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter + } + + private var isGenerating: Bool { + audioPlayer.status == .generating && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter + } + + private var accentColor: Color { + theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + 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") + HStack(spacing: 7) { + if isGenerating { + ProgressView() + .scaleEffect(0.75) + .tint(isActive ? .white : accentColor) + } else { + Image(systemName: isActive ? "waveform" : "headphones") + .font(.system(size: 15, weight: .semibold)) + .symbolEffect(.variableColor.cumulative, isActive: isActive) + } + Text(isGenerating ? "Generating…" : (isActive ? "Listening" : "Listen")) .font(.subheadline.weight(.semibold)) } - .foregroundStyle(theme == .sepia - ? Color(red: 0.65, green: 0.45, blue: 0.15) - : .amber) + .foregroundStyle(isActive ? .white : accentColor) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background( + Capsule() + .fill(isActive ? accentColor : accentColor.opacity(0.13)) + ) + .contentShape(Capsule()) } .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isActive) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isGenerating) } } @@ -802,115 +812,160 @@ struct ReaderSettingsPanel: View { @Binding var isPresented: Bool var body: some View { - ScrollView { - VStack(spacing: 24) { - // Font size row + VStack(spacing: 0) { + // Handle + Capsule() + .fill(Color(.systemGray4)) + .frame(width: 36, height: 5) + .padding(.top, 10) + .padding(.bottom, 18) - // 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) + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 22) { - 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) + // ── Font size ────────────────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Font Size") + HStack(spacing: 0) { + Button { adjustFontSize(-1) } label: { + Text("A") + .font(.system(size: 13, weight: .regular)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + 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: 21, weight: .semibold)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.primary) } } - } - Divider().padding(.horizontal, 4) + settingsDivider - // 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) + // ── Font family ──────────────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Font") + HStack(spacing: 8) { + 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) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } } } + + settingsDivider + + // ── Theme ────────────────────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Theme") + HStack(spacing: 8) { + 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) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + } + } + + settingsDivider + + // ── Line spacing ─────────────────────────────────────── + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Line Spacing") + HStack(spacing: 8) { + Image(systemName: "text.alignleft") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .frame(width: 28) + 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: "text.alignleft") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + .frame(width: 28) + } + } + + settingsDivider + + // ── Scroll vs Pages ──────────────────────────────────── + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(store.settings.scrollMode ? "Scroll" : "Pages") + .font(.subheadline.weight(.medium)) + Text(store.settings.scrollMode + ? "Continuous vertical scroll" + : "Swipe horizontally between pages") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: Binding( + get: { store.settings.scrollMode }, + set: { v in + var s = store.settings; s.scrollMode = v; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + )) + .tint(.amber) + .labelsHidden() + } + + Color.clear.frame(height: 8) } - - 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) - .padding(.top, 8) + .padding(.horizontal, 20) + } } } + private var settingsDivider: some View { + Divider().padding(.horizontal, 4) + } + private func adjustFontSize(_ delta: CGFloat) { var s = store.settings s.fontSize = max(12, min(26, s.fontSize + delta)) store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } +} + +private struct SectionLabel: View { + let title: String + init(_ title: String) { self.title = title } + + var body: some View { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.8) } } @@ -924,18 +979,20 @@ private struct FontChip: View { Text(font.rawValue) .font(font.fontName.map { Font.custom($0, size: 15) } ?? .system(size: 15)) .frame(maxWidth: .infinity) - .frame(height: 44) + .frame(height: 46) .background( - RoundedRectangle(cornerRadius: 10) - .fill(isSelected ? Color.amber.opacity(0.18) : Color(.systemGray6)) + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5) + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5) ) ) .foregroundStyle(isSelected ? .amber : .primary) + .scaleEffect(isSelected ? 1.03 : 1.0) } .buttonStyle(.plain) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) } } @@ -957,27 +1014,28 @@ private struct ThemeChip: View { Text(label) .font(.subheadline.weight(isSelected ? .semibold : .regular)) .frame(maxWidth: .infinity) - .frame(height: 44) + .frame(height: 46) .background(theme.backgroundColor) .foregroundStyle(theme.textColor) .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(isSelected ? Color.amber : Color(.systemGray4), lineWidth: isSelected ? 2 : 1) + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.amber : Color(.systemGray4), + lineWidth: isSelected ? 2 : 1) ) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .scaleEffect(isSelected ? 1.03 : 1.0) } .buttonStyle(.plain) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) } } -// MARK: - ReaderSettingsStore (ObservableObject wrapping ReaderSettings) +// MARK: - ReaderSettingsStore final class ReaderSettingsStore: ObservableObject { @Published private(set) var settings: ReaderSettings - init() { - settings = ReaderSettings.load() - } + init() { settings = ReaderSettings.load() } func update(_ new: ReaderSettings) { settings = new @@ -988,28 +1046,16 @@ final class ReaderSettingsStore: ObservableObject { // MARK: - HTML → AttributedString parser enum HTMLParser { - /// Strips the duplicated chapter-header block that novelfire embeds at the - /// top of the HTML body. The pattern looks like: - /// "538 Chapter 538: Title.1 day ago\nChapter 538: Title\n" - /// We detect it by looking for a leading paragraph whose text starts with - /// a digit followed by " Chapter \d+:" and strip up through the end of - /// the next paragraph if it also starts with "Chapter \d+:". + /// Strips the duplicated chapter-header block novelfire embeds at the top of the HTML body. static func stripLeadingChapterHeader(from html: String) -> String { - // Work on the plain-text representation of the first ~400 chars to avoid - // running full HTML parse twice; we strip matching

or leading text nodes. - // Strategy: strip any leading

tags whose inner text matches the pattern. var result = html - - // Repeat up to 3 times in case there are multiple such paragraphs. for _ in 0..<3 { - // Match an opening

tag, capture everything up to

let pattern = #"^(\s*]*>)(.*?)(

)"# guard let regex = try? NSRegularExpression( pattern: pattern, options: [.dotMatchesLineSeparators, .caseInsensitive] ) else { break } - let nsResult = result as NSString guard let match = regex.firstMatch( in: result, range: NSRange(result.startIndex..., in: result) @@ -1020,24 +1066,20 @@ enum HTMLParser { let swiftRange = Range(innerRange, in: result) else { break } let inner = String(result[swiftRange]) - // Strip HTML tags from inner to get plain text for pattern check - let plain = inner.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + let plain = inner + .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) - // Match: "538 Chapter 538: ..." or "Chapter 538: ..." let isHeaderLine = plain.range( of: #"^\d*\s*[Cc]hapter\s+\d+"#, options: .regularExpression ) != nil - guard isHeaderLine else { break } - // Remove the entire matched

block let fullMatchRange = match.range(at: 0) guard let swiftFullRange = Range(fullMatchRange, in: result) else { break } result.removeSubrange(swiftFullRange) } - return result } @@ -1056,7 +1098,6 @@ enum HTMLParser { } let uiColor = UIColor(textColor) - let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize paragraphStyle.paragraphSpacing = fontSize * 0.7 @@ -1088,7 +1129,6 @@ enum HTMLParser { // MARK: - Text paginator enum TextPaginator { - /// Splits an AttributedString into pages that fit within (width × height). static func paginate( attributed: AttributedString, width: CGFloat, @@ -1110,7 +1150,7 @@ enum TextPaginator { while startIndex < totalLength { emergencyBreak += 1 - if emergencyBreak > 2000 { break } // safety valve + if emergencyBreak > 2000 { break } let range = CFRange(location: startIndex, length: totalLength - startIndex) let frame = CTFramesetterCreateFrame(framesetter, range, path, nil) diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/DownloadAudioButton.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/DownloadAudioButton.swift new file mode 100644 index 0000000..9a609fd --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/DownloadAudioButton.swift @@ -0,0 +1,156 @@ +import SwiftUI + +// MARK: - Download Audio Button +// Shows download status and allows users to download/delete offline audio. +// Uses symbolEffect + spring animations for a modern, tactile feel. + +struct DownloadAudioButton: View { + let slug: String + let chapter: Int + let voice: String + let theme: ReaderTheme + + @StateObject private var downloadService = AudioDownloadService.shared + @State private var showDownloadMenu = false + @State private var bounceDownload = false + + private var downloadKey: String { + AudioDownloadService.shared.makeKey(slug: slug, chapter: chapter, voice: voice) + } + + private var isDownloaded: Bool { + downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice) + } + + private var downloadProgress: DownloadProgress? { + downloadService.downloads[downloadKey] + } + + private var accentColor: Color { + theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + Button { + showDownloadMenu = true + } label: { + ZStack { + // Background pill + Circle() + .fill(backgroundFillColor) + .frame(width: 44, height: 44) + + stateIcon + } + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isDownloaded) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: downloadProgress?.status.isDownloading) + .confirmationDialog("Audio Download", isPresented: $showDownloadMenu) { + if isDownloaded { + Button("Delete Download", role: .destructive) { + Task { + try? await downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice) + } + } + } else if let progress = downloadProgress, case .downloading = progress.status { + Button("Cancel Download", role: .destructive) { + downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice) + } + } else { + Button("Download for Offline") { + Task { + try? await downloadService.download(slug: slug, chapter: chapter, voice: voice) + } + withAnimation(.spring(response: 0.4, dampingFraction: 0.5)) { bounceDownload.toggle() } + } + } + Button("Cancel", role: .cancel) {} + } message: { + if isDownloaded { + Text("This chapter's audio is downloaded for offline listening.") + } else if let progress = downloadProgress, case .downloading = progress.status { + Text("Downloading… \(Int(progress.progress * 100))%") + } else { + Text("Download this chapter's audio to listen offline without internet connection.") + } + } + } + + // MARK: - Background + + private var backgroundFillColor: Color { + if isDownloaded { + return Color.green.opacity(0.15) + } else if let progress = downloadProgress, case .downloading = progress.status { + return accentColor.opacity(0.1) + } else if let progress = downloadProgress, case .failed = progress.status { + return Color.red.opacity(0.12) + } else { + return theme.textColor.opacity(0.07) + } + } + + // MARK: - Icon + + @ViewBuilder + private var stateIcon: some View { + if isDownloaded { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(.green) + .symbolEffect(.bounce, value: isDownloaded) + .transition(.scale.combined(with: .opacity)) + + } else if let progress = downloadProgress { + switch progress.status { + case .downloading: + ZStack { + // Track ring + Circle() + .stroke(accentColor.opacity(0.18), lineWidth: 2.5) + // Progress arc + Circle() + .trim(from: 0, to: progress.progress) + .stroke( + accentColor, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress.progress) + // Down arrow + Image(systemName: "arrow.down") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(accentColor) + } + .frame(width: 26, height: 26) + .transition(.scale.combined(with: .opacity)) + + case .failed: + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(.red) + .symbolEffect(.pulse) + .transition(.scale.combined(with: .opacity)) + + case .completed: + EmptyView() + } + + } else { + // Idle — not yet downloaded + Image(systemName: "arrow.down.circle") + .font(.system(size: 22)) + .foregroundStyle(theme.textColor.opacity(0.55)) + .symbolEffect(.bounce, value: bounceDownload) + .transition(.scale.combined(with: .opacity)) + } + } +} + +private extension DownloadStatus { + var isDownloading: Bool { + if case .downloading = self { return true } + return false + } +} diff --git a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift index 7fd4f94..264a0f1 100644 --- a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift +++ b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift @@ -141,3 +141,24 @@ struct ChipButton: View { EmptyView() } } + +// MARK: - Shelf header (amber accent bar + title) +// Used by HomeView, UserProfileView, BrowseView's DiscoverShelf, and any future shelf screen. +// Call sites that need trailing content (e.g. a "See All" NavigationLink) wrap this in an HStack. + +struct ShelfHeader: View { + let title: String + + var body: some View { + HStack(spacing: 10) { + // 3-pt amber accent bar — the brand visual anchor for all shelf titles + RoundedRectangle(cornerRadius: 2) + .fill(Color.amber) + .frame(width: 3, height: 18) + Text(title) + .font(.title3.bold()) + } + .padding(.horizontal) + .padding(.bottom, 10) + } +} diff --git a/ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift b/ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift new file mode 100644 index 0000000..b440baa --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Components/OfflineBanner.swift @@ -0,0 +1,32 @@ +import SwiftUI + +// MARK: - Offline Banner +// Subtle banner shown at top of screen when network is unavailable + +struct OfflineBanner: View { + @EnvironmentObject var networkMonitor: NetworkMonitor + + var body: some View { + if !networkMonitor.isConnected { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .font(.caption) + Text("You're offline") + .font(.subheadline.weight(.medium)) + Spacer() + Text("Showing cached content") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.15)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.orange.opacity(0.3)) + .frame(height: 1) + } + .transition(.move(edge: .top).combined(with: .opacity)) + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift b/ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift new file mode 100644 index 0000000..cbb3c4a --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Downloads/DownloadQueueButton.swift @@ -0,0 +1,340 @@ +import SwiftUI + +// MARK: - Download Queue Toolbar Button +// Compact toolbar button that shows active download status and opens queue management sheet. +// Shows: +// - Download icon with badge count when downloads are active +// - Progress ring around icon +// - Taps opens DownloadQueueSheet for management + +struct DownloadQueueButton: View { + @StateObject private var downloadService = AudioDownloadService.shared + @State private var showQueue = false + + private var activeDownloads: [DownloadProgress] { + downloadService.downloads.values.filter { $0.status == .downloading } + } + + private var hasActiveDownloads: Bool { + !activeDownloads.isEmpty + } + + private var averageProgress: Double { + guard !activeDownloads.isEmpty else { return 0 } + let total = activeDownloads.reduce(0.0) { $0 + $1.progress } + return total / Double(activeDownloads.count) + } + + var body: some View { + Button { + showQueue = true + } label: { + ZStack { + // Progress ring (only shown when downloading) + if hasActiveDownloads { + Circle() + .stroke(Color.amber.opacity(0.3), lineWidth: 2) + .frame(width: 30, height: 30) + + Circle() + .trim(from: 0, to: averageProgress) + .stroke(Color.amber, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .frame(width: 30, height: 30) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.3), value: averageProgress) + } + + // Download icon + Image(systemName: hasActiveDownloads ? "arrow.down.circle.fill" : "arrow.down.circle") + .font(.system(size: 22)) + .foregroundStyle(hasActiveDownloads ? .amber : .secondary) + .symbolRenderingMode(.hierarchical) + + // Badge count (top-right corner) + if activeDownloads.count > 0 { + VStack { + HStack { + Spacer() + Text("\(activeDownloads.count)") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + .padding(3) + .frame(minWidth: 16) + .background(Circle().fill(Color.red)) + .offset(x: 6, y: -6) + } + Spacer() + } + .frame(width: 30, height: 30) + } + } + } + .opacity(hasActiveDownloads || downloadService.downloadedChapters.count > 0 ? 1 : 0.6) + .sheet(isPresented: $showQueue) { + DownloadQueueSheet() + } + } +} + +// MARK: - Download Queue Management Sheet +// Bottom sheet showing active downloads and quick management options + +struct DownloadQueueSheet: View { + @StateObject private var downloadService = AudioDownloadService.shared + @Environment(\.dismiss) private var dismiss + + private var activeDownloads: [(key: String, value: DownloadProgress)] { + downloadService.downloads + .filter { $0.value.status == .downloading } + .sorted { $0.key < $1.key } + } + + private var failedDownloads: [(key: String, value: DownloadProgress)] { + downloadService.downloads.compactMap { key, value in + if case .failed = value.status { + return (key, value) + } + return nil + } + .sorted { $0.key < $1.key } + } + + private var totalDownloaded: Int { + downloadService.downloadedChapters.count + } + + var body: some View { + NavigationStack { + Group { + if activeDownloads.isEmpty && failedDownloads.isEmpty && totalDownloaded == 0 { + emptyState + } else { + List { + // Active downloads section + if !activeDownloads.isEmpty { + Section { + ForEach(activeDownloads, id: \.key) { key, progress in + ActiveDownloadRow(progress: progress) + } + } header: { + HStack { + Text("Downloading") + Spacer() + Text("\(activeDownloads.count)") + .foregroundStyle(.secondary) + } + } + } + + // Failed downloads section + if !failedDownloads.isEmpty { + Section("Failed") { + ForEach(failedDownloads, id: \.key) { key, progress in + FailedDownloadRow(progress: progress, key: key) + } + } + } + + // Quick stats section + Section { + NavigationLink { + DownloadsView() + } label: { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Downloaded Chapters") + Spacer() + Text("\(totalDownloaded)") + .foregroundStyle(.secondary) + } + } + + HStack { + Image(systemName: "internaldrive") + .foregroundStyle(.amber) + Text("Storage Used") + Spacer() + Text(storageUsedFormatted) + .foregroundStyle(.secondary) + } + } + + // Cancel all option (only show if there are active downloads) + if !activeDownloads.isEmpty { + Section { + Button(role: .destructive) { + activeDownloads.forEach { key, progress in + downloadService.cancelDownload( + slug: progress.slug, + chapter: progress.chapter, + voice: progress.voice + ) + } + } label: { + HStack { + Spacer() + Text("Cancel All Downloads") + .font(.subheadline.bold()) + Spacer() + } + } + } + } + } + } + } + .navigationTitle("Download Queue") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + .foregroundStyle(.amber) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + // MARK: - Empty State + + @ViewBuilder + private var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "arrow.down.circle") + .font(.system(size: 56)) + .foregroundStyle(.secondary.opacity(0.5)) + Text("No Active Downloads") + .font(.title2.bold()) + .foregroundStyle(.primary) + Text("Audio chapters you download will appear here") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Helpers + + private var storageUsedFormatted: String { + let bytes = downloadService.getTotalStorageUsed() + return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } +} + +// MARK: - Active Download Row + +private struct ActiveDownloadRow: View { + let progress: DownloadProgress + @StateObject private var downloadService = AudioDownloadService.shared + + var body: some View { + HStack(spacing: 12) { + // Book/Chapter info + VStack(alignment: .leading, spacing: 4) { + Text(formatSlug(progress.slug)) + .font(.subheadline.bold()) + .lineLimit(1) + Text("Chapter \(progress.chapter)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // Progress indicator + VStack(alignment: .trailing, spacing: 4) { + Text("\(Int(progress.progress * 100))%") + .font(.caption.bold()) + .foregroundStyle(.amber) + .monospacedDigit() + + ProgressView(value: progress.progress) + .frame(width: 60) + .tint(.amber) + } + + // Cancel button + Button { + downloadService.cancelDownload( + slug: progress.slug, + chapter: progress.chapter, + voice: progress.voice + ) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + } + + private func formatSlug(_ slug: String) -> String { + // Convert slug to readable title (e.g., "my-book-title" -> "My Book Title") + slug.split(separator: "-") + .map { $0.capitalized } + .joined(separator: " ") + } +} + +// MARK: - Failed Download Row + +private struct FailedDownloadRow: View { + let progress: DownloadProgress + let key: String + @StateObject private var downloadService = AudioDownloadService.shared + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + + VStack(alignment: .leading, spacing: 4) { + Text(formatSlug(progress.slug)) + .font(.subheadline.bold()) + .lineLimit(1) + Text("Chapter \(progress.chapter)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // Retry button + Button { + Task { + // Remove failed status + downloadService.downloads.removeValue(forKey: key) + // Retry download + try? await downloadService.download( + slug: progress.slug, + chapter: progress.chapter, + voice: progress.voice + ) + } + } label: { + Text("Retry") + .font(.caption.bold()) + .foregroundStyle(.amber) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.amber.opacity(0.15), in: Capsule()) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + } + + private func formatSlug(_ slug: String) -> String { + slug.split(separator: "-") + .map { $0.capitalized } + .joined(separator: " ") + } +} diff --git a/ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift b/ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift new file mode 100644 index 0000000..219c447 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Downloads/DownloadsView.swift @@ -0,0 +1,216 @@ +import SwiftUI + +// MARK: - Downloads Management View +// Shows all downloaded audio chapters and allows deletion + +struct DownloadsView: View { + @StateObject private var downloadService = AudioDownloadService.shared + @Environment(\.dismiss) private var dismiss + + private var sortedDownloads: [(key: String, value: DownloadProgress)] { + downloadService.downloads.sorted { $0.key < $1.key } + } + + private var totalStorageFormatted: String { + let bytes = downloadService.getTotalStorageUsed() + return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + var body: some View { + NavigationStack { + Group { + if downloadService.downloadedChapters.isEmpty && downloadService.downloads.isEmpty { + // Empty state + VStack(spacing: 16) { + Image(systemName: "arrow.down.circle") + .font(.system(size: 56)) + .foregroundStyle(.secondary.opacity(0.5)) + Text("No Downloads") + .font(.title2.bold()) + .foregroundStyle(.primary) + Text("Downloaded audio chapters will appear here for offline listening") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + // Storage info section + Section { + HStack { + Image(systemName: "internaldrive") + .foregroundStyle(.amber) + Text("Total Storage Used") + Spacer() + Text(totalStorageFormatted) + .foregroundStyle(.secondary) + } + } + + // Active downloads + if !downloadService.downloads.isEmpty { + Section("Active Downloads") { + ForEach(sortedDownloads, id: \.key) { key, progress in + DownloadRow(progress: progress, key: key) + } + } + } + + // Downloaded chapters + if !downloadService.downloadedChapters.isEmpty { + Section("Downloaded (\(downloadService.downloadedChapters.count))") { + ForEach(Array(downloadService.downloadedChapters.sorted()), id: \.self) { key in + DownloadedChapterRow(key: key) + } + } + } + + // Delete all button + if !downloadService.downloadedChapters.isEmpty { + Section { + Button(role: .destructive) { + try? downloadService.deleteAllDownloads() + } label: { + HStack { + Spacer() + Text("Delete All Downloads") + .font(.subheadline.bold()) + Spacer() + } + } + } + } + } + } + } + .navigationTitle("Downloads") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .foregroundStyle(.amber) + } + } + } + } +} + +// MARK: - Download Row (in progress) + +private struct DownloadRow: View { + let progress: DownloadProgress + let key: String + @StateObject private var downloadService = AudioDownloadService.shared + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(progress.chapter)") + .font(.subheadline.bold()) + Text(progress.slug) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if progress.status == .downloading { + VStack(alignment: .trailing, spacing: 4) { + Text("\(Int(progress.progress * 100))%") + .font(.caption) + .foregroundStyle(.secondary) + ProgressView(value: progress.progress) + .frame(width: 60) + } + + Button { + downloadService.cancelDownload(slug: progress.slug, chapter: progress.chapter, voice: progress.voice) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } else if case .failed(let error) = progress.status { + VStack(alignment: .trailing) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text("Failed") + .font(.caption2) + .foregroundStyle(.red) + } + } + } + } +} + +// MARK: - Downloaded Chapter Row + +private struct DownloadedChapterRow: View { + let key: String + @StateObject private var downloadService = AudioDownloadService.shared + + private var components: (slug: String, chapter: String, voice: String) { + let parts = key.split(separator: "-") + if parts.count >= 3 { + return (String(parts[0]), String(parts[1]), parts[2...].joined(separator: "-")) + } + return ("", "", "") + } + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(components.chapter)") + .font(.subheadline.bold()) + HStack(spacing: 4) { + Text(components.slug) + .font(.caption) + .foregroundStyle(.secondary) + Text("•") + .font(.caption) + .foregroundStyle(.secondary) + Text(formatVoice(components.voice)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + let parts = components + if let chapter = Int(parts.chapter) { + try? downloadService.deleteDownload(slug: parts.slug, chapter: chapter, voice: parts.voice) + } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + + private func formatVoice(_ voice: String) -> String { + // Format voice name (e.g., "af_bella" -> "Bella (US F)") + let parts = voice.split(separator: "_") + guard parts.count == 2 else { return voice } + + let prefix = String(parts[0]) + let name = String(parts[1]).capitalized + + let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : "" + let accent = prefix.hasPrefix("af") ? "US" : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" : "" + + if !gender.isEmpty && !accent.isEmpty { + return "\(name) (\(accent) \(gender))" + } else if !gender.isEmpty { + return "\(name) (\(gender))" + } else { + return name + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift index b255a0c..35fdaff 100644 --- a/ios/LibNovel/LibNovel/Views/Home/HomeView.swift +++ b/ios/LibNovel/LibNovel/Views/Home/HomeView.swift @@ -3,13 +3,25 @@ import SwiftUI struct HomeView: View { @StateObject private var vm = HomeViewModel() @EnvironmentObject var authStore: AuthStore + @StateObject private var downloadService = AudioDownloadService.shared + + private var offlineBooks: [Book] { + let offlineSlugs = downloadService.getOfflineBookSlugs() + // Filter continue reading items that have offline downloads + return vm.continueReading + .filter { offlineSlugs.contains($0.book.slug) } + .map { $0.book } + } var body: some View { NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 0) { + VStack(spacing: 0) { + OfflineBanner() + + ScrollView { + VStack(alignment: .leading, spacing: 0) { - // Continue reading — all in-progress books as a horizontal shelf (Apple Books style) + // Continue reading — all in-progress books as a horizontal shelf (Apple Books style) if !vm.continueReading.isEmpty { ShelfHeader(title: "Continue Reading") .padding(.top, 8) @@ -38,6 +50,47 @@ struct HomeView: View { } .padding(.bottom, 28) } + + // Offline books — books with downloaded chapters + if !offlineBooks.isEmpty { + HStack { + ShelfHeader(title: "Downloaded for Offline") + Spacer() + Image(systemName: "wifi.slash") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.trailing, 16) + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 14) { + ForEach(offlineBooks) { book in + NavigationLink(value: NavDestination.book(book.slug)) { + VStack(alignment: .leading, spacing: 8) { + ShelfBookCard(book: book) + HStack(spacing: 4) { + Image(systemName: "arrow.down.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + Text("\(downloadService.getDownloadedChapterCount(for: book.slug)) chapters") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 4) + } + } + .buttonStyle(.plain) + .contextMenu { + ShareLink(item: shareURL(for: book)) { + Label("Share", systemImage: "square.and.arrow.up") + } + } + } + } + .padding(.horizontal) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } // Stats strip if let stats = vm.stats { @@ -119,9 +172,15 @@ struct HomeView: View { .errorAlert($vm.error) .toolbar { ToolbarItem(placement: .topBarTrailing) { - AvatarToolbarButton() + HStack(spacing: 8) { + DownloadQueueButton() + Divider() + .frame(height: 18) + AvatarToolbarButton() + } } } + } } } @@ -150,19 +209,6 @@ struct HomeView: 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 (Apple Books style) private struct ContinueReadingCard: View { @@ -172,29 +218,51 @@ private struct ContinueReadingCard: View { guard item.book.totalChapters > 0 else { return 0 } return min(1.0, Double(item.chapter) / Double(item.book.totalChapters)) } + + private var progressText: String { + let percentage = progressFraction * 100 + + // For books with many chapters, show decimal precision when less than 10% + if percentage < 10 && percentage > 0 { + return String(format: "%.1f%% complete", percentage) + } + + // Otherwise, round to nearest integer (min 1% if any progress exists) + let rounded = max(1, Int(round(percentage))) + return "\(rounded)% complete" + } var body: some View { VStack(alignment: .leading, spacing: 8) { // Cover - ZStack(alignment: .bottomLeading) { + ZStack(alignment: .bottom) { AsyncCoverImage(url: item.book.cover) .frame(width: 130, height: 188) .clipShape(RoundedRectangle(cornerRadius: 10)) - .shadow(color: .black.opacity(0.18), radius: 6, y: 3) + .shadow(color: .black.opacity(0.22), radius: 8, y: 4) .bookCoverZoomSource(slug: item.book.slug) - // "Continue" pill badge at bottom-left + // Gradient scrim so badge is always readable + LinearGradient( + colors: [Color.black.opacity(0), Color.black.opacity(0.55)], + startPoint: .center, + endPoint: .bottom + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(height: 60) + + // "Continue" pill badge — centered at bottom over the scrim HStack(spacing: 4) { Image(systemName: "play.fill") .font(.system(size: 8, weight: .bold)) Text("Ch.\(item.chapter)") .font(.system(size: 10, weight: .bold)) } - .foregroundStyle(.black.opacity(0.85)) - .padding(.horizontal, 8) + .foregroundStyle(.white) + .padding(.horizontal, 9) .padding(.vertical, 5) .background(Capsule().fill(Color.amber)) - .padding(8) + .padding(.bottom, 10) } // Title @@ -210,15 +278,15 @@ private struct ContinueReadingCard: View { Capsule() .fill(Color.secondary.opacity(0.2)) Capsule() - .fill(Color.amber.opacity(0.85)) + .fill(Color.amber.opacity(0.9)) .frame(width: max(4, geo.size.width * progressFraction)) } } .frame(width: 130, height: 3) - // Percent label — floor at 1% so early chapters don't display "0%" - Text("\(max(1, Int(progressFraction * 100)))% complete") - .font(.caption2) + // Progress label with smart rounding + Text(progressText) + .font(.caption) .foregroundStyle(.secondary) } .frame(width: 130) @@ -232,11 +300,22 @@ private struct ShelfBookCard: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - AsyncCoverImage(url: book.cover) - .frame(width: 110, height: 158) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(color: .black.opacity(0.12), radius: 4, y: 2) - .bookCoverZoomSource(slug: book.slug) + ZStack(alignment: .topTrailing) { + AsyncCoverImage(url: book.cover) + .frame(width: 110, height: 158) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .bookCoverZoomSource(slug: book.slug) + + // Chapter count badge + Text("\(book.totalChapters) ch") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.black.opacity(0.55))) + .padding(6) + } Text(book.title) .font(.caption.bold()) @@ -308,10 +387,10 @@ private struct StatPill: View { let label: String var body: some View { - VStack(spacing: 4) { + VStack(spacing: 5) { Image(systemName: icon) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(Color.amber.opacity(0.8)) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.amber) Text(value) .font(.subheadline.bold().monospacedDigit()) .foregroundStyle(.primary) diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift index ff4fd89..a5921d2 100644 --- a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift +++ b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift @@ -6,31 +6,30 @@ struct LibraryView: View { @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: @@ -40,12 +39,12 @@ struct LibraryView: View { 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: @@ -57,18 +56,10 @@ struct LibraryView: View { 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. @@ -80,180 +71,6 @@ struct LibraryView: View { } return item.book.status.lowercased() == "completed" && (item.lastChapter ?? 0) > 0 } - - var body: some View { - NavigationStack { - Group { - if vm.isLoading && vm.items.isEmpty { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if vm.items.isEmpty { - EmptyStateView( - icon: "bookmark", - title: "No saved books", - message: "Books you save or start reading will appear here." - ) - } else { - ScrollView { - 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) - } - } - } - .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 - ChipButton( - label: "All", - isSelected: selectedGenre == "all", - style: .filled - ) { - withAnimation { selectedGenre = "all" } - } - ForEach(availableGenres, id: \.self) { genre in - ChipButton( - label: genre.capitalized, - isSelected: selectedGenre == genre, - style: .filled - ) { - 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 - ChipButton( - label: order.rawValue, - isSelected: sortOrder == order, - style: .outlined - ) { - 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 { - // 2-column grid (matches Discover) - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ], - spacing: 16 - ) { - ForEach(filtered) { item in - NavigationLink(value: NavDestination.book(item.book.slug)) { - LibraryBookCard(item: item) - } - .buttonStyle(.plain) - .contextMenu { - BookContextMenu( - book: item.book, - isFinished: isCompleted(item), - onMarkFinished: { - Task { - await markAsFinished(item.book) - } - }, - onRemove: { - Task { - await removeFromLibrary(item.book.slug) - } - } - ) - } - } - } - .padding(.horizontal) - .padding(.top, 8) - .padding(.bottom, 24) - } - } - } - } - } - .navigationTitle("Library") - .appNavigationDestination() - .refreshable { await vm.load() } - .task { await vm.load() } - .errorAlert($vm.error) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - AvatarToolbarButton() - } - } - } - } - - 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." - } - } private func markAsFinished(_ book: Book) async { do { @@ -272,133 +89,303 @@ struct LibraryView: View { vm.error = error.localizedDescription } } -} - -// 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") + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + Group { + if vm.isLoading && vm.items.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.items.isEmpty { + EmptyStateView( + icon: "bookmark", + title: "No saved books", + message: "Books you save or start reading will appear here." + ) + } else { + ScrollView { + VStack(spacing: 0) { + // 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, 16) + + // Genre filter chips (only shown when genres are available) + if !availableGenres.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + // "All" chip + ChipButton( + label: "All", + isSelected: selectedGenre == "all", + style: .filled + ) { + withAnimation { selectedGenre = "all" } + } + ForEach(availableGenres, id: \.self) { genre in + ChipButton( + label: genre.capitalized, + isSelected: selectedGenre == genre, + style: .filled + ) { + 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 + ChipButton( + label: order.rawValue, + isSelected: sortOrder == order, + style: .outlined + ) { + 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 { + // 2-column grid (matches Discover) + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ], + spacing: 14 + ) { + ForEach(filtered) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + LibraryBookCard(item: item) + } + .buttonStyle(.plain) + .contextMenu { + BookContextMenu( + book: item.book, + isFinished: isCompleted(item), + onMarkFinished: { + Task { + await markAsFinished(item.book) + } + }, + onRemove: { + Task { + await removeFromLibrary(item.book.slug) + } + } + ) + } + } + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 100) + } + } + } + } + } + .navigationTitle("Library") + .appNavigationDestination() + .refreshable { await vm.load() } + .task { await vm.load() } + .errorAlert($vm.error) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + DownloadQueueButton() + AvatarToolbarButton() + } } - .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(.subheadline.bold()) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - - // Chapter badge if present - if let ch = item.lastChapter { - Text(isCompleted ? "Finished" : "Ch.\(ch)") - .font(.caption) - .foregroundStyle(isCompleted ? Color.amber : .secondary) + } + + var emptyMessage: String { + switch readingFilter { + case .all: + return selectedGenre == "all" ? "No books in your library." : "No \(selectedGenre.capitalized) books in your library." + case .inProgress: + return "No books in progress." + case .completed: + return "No completed books yet." } } } -} - -// 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) - } - } -} - -// MARK: - Book context menu - -private struct BookContextMenu: View { - let book: Book - let isFinished: Bool - let onMarkFinished: () -> Void - let onRemove: () -> Void - var body: some View { - Group { - // Share book - ShareLink(item: shareURL) { - Label("Share", systemImage: "square.and.arrow.up") + // 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: 0) { + ZStack(alignment: .topTrailing) { + // Cover image + KFImage(URL(string: item.book.cover)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray5)) + .overlay( + Image(systemName: "book.closed") + .foregroundStyle(.secondary) + ) + } + .scaledToFill() + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .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(6) + } else if progressFraction > 0 { + ProgressArc(fraction: progressFraction) + .frame(width: 28, height: 28) + .padding(5) + } + } + + // Title + chapter badge + VStack(alignment: .leading, spacing: 3) { + Text(item.book.title) + .font(.subheadline.bold()) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + + if let ch = item.lastChapter { + Text(isCompleted ? "Finished" : "Ch.\(ch)") + .font(.caption) + .foregroundStyle(isCompleted ? Color.amber : .secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) } - - Divider() - - // Mark as finished (only show if not already finished) - if !isFinished { - Button { - onMarkFinished() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) + } + } + + // 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) + } + } + } + + // MARK: - Book context menu + + private struct BookContextMenu: View { + let book: Book + let isFinished: Bool + let onMarkFinished: () -> Void + let onRemove: () -> Void + + var body: some View { + Group { + // Share book + ShareLink(item: shareURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + + Divider() + + // Mark as finished (only show if not already finished) + if !isFinished { + Button { + onMarkFinished() + } label: { + Label("Mark as Finished", systemImage: "checkmark.circle") + } + } + + Divider() + + // Remove from library (destructive) + Button(role: .destructive) { + onRemove() } label: { - Label("Mark as Finished", systemImage: "checkmark.circle") + Label("Remove from Library", systemImage: "trash") } } - - Divider() - - // Remove from library (destructive) - Button(role: .destructive) { - onRemove() - } label: { - Label("Remove from Library", systemImage: "trash") - } + } + + private var shareURL: URL { + // Share the book detail page URL + let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + return URL(string: "\(baseURL)/books/\(book.slug)")! } } - - private var shareURL: URL { - // Share the book detail page URL - let baseURL = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String - ?? "https://v2.libnovel.kalekber.cc" - return URL(string: "\(baseURL)/books/\(book.slug)")! - } } diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index d6a6889..06ba600 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -2,470 +2,138 @@ import SwiftUI import Kingfisher // used directly for blurred background in FullPlayerView import AVKit // for AVRoutePickerView (AirPlay) -// MARK: - Floating circular player button (modern FAB design) +// MARK: - Mini player bar (Spotify-style, fixed above tab bar) +// Replaces the old FloatingPlayerButton + CompactPlayerControls. +// Swipe up → full player. Swipe down → stop. Tap cover/track info → full player. +// All transport buttons are plain Buttons with no competing gesture recognisers. -// MARK: - Floating circular player button (modern FAB design) - -struct FloatingPlayerButton: View { - @Binding var showFullPlayer: Bool - @Binding var showControls: Bool - @EnvironmentObject var audioPlayer: AudioPlayerService - - /// Persistent position stored in UserDefaults - @AppStorage("floatingPlayerX") private var savedX: Double = -1 - @AppStorage("floatingPlayerY") private var savedY: Double = -1 - - @State private var position: CGPoint = .zero - @State private var dragOffset: CGSize = .zero - - private let buttonSize: CGFloat = 64 - - private var progressFraction: CGFloat { - guard audioPlayer.duration > 0 else { return 0 } - return CGFloat(audioPlayer.currentTime / audioPlayer.duration) - } - - var body: some View { - GeometryReader { geo in - ZStack { - // Circular cover with glassmorphic background - ZStack { - // Progress ring - Circle() - .trim(from: 0, to: progressFraction) - .stroke(Color.amber, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.easeInOut(duration: 0.3), value: audioPlayer.currentTime) - - // Cover image - AsyncCoverImage(url: audioPlayer.coverURL) - .frame(width: buttonSize - 8, height: buttonSize - 8) - .clipShape(Circle()) - - // Play/pause icon overlay (small, centered) - if audioPlayer.status == .ready { - ZStack { - Circle() - .fill(.ultraThinMaterial) - .frame(width: 28, height: 28) - - Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) - .offset(x: audioPlayer.isPlaying ? 0 : 1) - } - } else if audioPlayer.status == .generating { - ZStack { - Circle() - .fill(.ultraThinMaterial) - .frame(width: 28, height: 28) - ProgressView() - .tint(.white) - .scaleEffect(0.7) - } - } - } - .frame(width: buttonSize, height: buttonSize) - .background( - Circle() - .fill(.ultraThinMaterial) - .overlay( - Circle() - .fill(Color.black.opacity(0.2)) - ) - ) - .shadow(color: .black.opacity(0.3), radius: 12, y: 4) - .position( - x: position.x + dragOffset.width, - y: position.y + dragOffset.height - ) - .gesture( - DragGesture() - .onChanged { value in - dragOffset = value.translation - } - .onEnded { value in - // Update persistent position - let newX = position.x + value.translation.width - let newY = position.y + value.translation.height - - // Clamp to screen bounds with padding - let padding: CGFloat = buttonSize / 2 + 8 - position.x = max(padding, min(geo.size.width - padding, newX)) - position.y = max(padding, min(geo.size.height - padding, newY)) - - dragOffset = .zero - - // Save to UserDefaults - savedX = position.x - savedY = position.y - } - ) - .onTapGesture { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - showControls.toggle() - } - } - .onLongPressGesture(minimumDuration: 0.5) { - showFullPlayer = true - } - } - .onAppear { - // Initialize position from saved or default to bottom-right - if savedX < 0 || savedY < 0 { - position = CGPoint( - x: geo.size.width - buttonSize / 2 - 20, - y: geo.size.height - buttonSize / 2 - 100 - ) - } else { - position = CGPoint(x: savedX, y: savedY) - } - } - } - } -} - -// MARK: - Compact player controls overlay - -struct CompactPlayerControls: View { - @Binding var isPresented: Bool - @EnvironmentObject var audioPlayer: AudioPlayerService - - private var progressFraction: CGFloat { - guard audioPlayer.duration > 0 else { return 0 } - return CGFloat(audioPlayer.currentTime / audioPlayer.duration) - } - - var body: some View { - VStack(spacing: 0) { - Spacer() - - VStack(spacing: 16) { - // Drag handle - Capsule() - .fill(Color.white.opacity(0.3)) - .frame(width: 36, height: 4) - .padding(.top, 12) - - // Track info - VStack(spacing: 6) { - Text(chapterLabel) - .font(.headline) - .lineLimit(1) - Text(audioPlayer.bookTitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .padding(.horizontal, 20) - - // Progress bar with time labels - VStack(spacing: 8) { - HStack(spacing: 12) { - Text(formatTime(audioPlayer.currentTime)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - - GeometryReader { geo in - ZStack(alignment: .leading) { - // Track background - RoundedRectangle(cornerRadius: 2) - .fill(.white.opacity(0.2)) - .frame(height: 4) - - // Progress fill - RoundedRectangle(cornerRadius: 2) - .fill(Color.amber) - .frame(width: geo.size.width * progressFraction, height: 4) - } - } - .frame(height: 4) - - Text(formatTime(audioPlayer.duration)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 20) - } - - // Control buttons - HStack(spacing: 32) { - // Previous chapter - Button { - if let prev = audioPlayer.prevChapter { - NotificationCenter.default.post( - name: .skipToPrevChapter, - object: nil, - userInfo: ["prev": prev] - ) - } - } label: { - Image(systemName: "backward.end.fill") - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 50, height: 50) - } - .disabled(audioPlayer.prevChapter == nil) - .opacity(audioPlayer.prevChapter == nil ? 0.4 : 1.0) - - // Play/pause - Button { - audioPlayer.togglePlayPause() - } label: { - ZStack { - Circle() - .fill(Color.amber) - .frame(width: 60, height: 60) - - Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 24, weight: .bold)) - .foregroundStyle(.black) - .offset(x: audioPlayer.isPlaying ? 0 : 2) - } - } - .disabled(audioPlayer.status != .ready) - - // Next chapter - Button { - if let next = audioPlayer.nextChapter { - NotificationCenter.default.post( - name: .skipToNextChapter, - object: nil, - userInfo: ["next": next] - ) - } - } label: { - Image(systemName: "forward.end.fill") - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 50, height: 50) - } - .disabled(audioPlayer.nextChapter == nil) - .opacity(audioPlayer.nextChapter == nil ? 0.4 : 1.0) - } - .padding(.vertical, 8) - - // Bottom safe area padding - Color.clear.frame(height: 20) - } - .background( - .ultraThinMaterial, - in: RoundedRectangle(cornerRadius: 24, style: .continuous) - ) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(Color.black.opacity(0.3)) - ) - .padding(.horizontal, 16) - .padding(.bottom, 16) - .shadow(color: .black.opacity(0.4), radius: 20, y: -8) - } - .ignoresSafeArea() - .onTapGesture { - // Tap outside to dismiss - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - isPresented = false - } - } - } - - private var chapterLabel: String { - let raw = audioPlayer.chapterTitle.isEmpty - ? "Chapter \(audioPlayer.chapter)" - : audioPlayer.chapterTitle - return raw.strippingTrailingDate() - } - - private func formatTime(_ seconds: Double) -> String { - guard seconds.isFinite && seconds >= 0 else { return "0:00" } - let mins = Int(seconds) / 60 - let secs = Int(seconds) % 60 - return String(format: "%d:%02d", mins, secs) - } -} - -// MARK: - Legacy mini player (kept for reference, will be removed) - -struct MiniPlayerView_Legacy: View { +struct MiniPlayerBar: View { @Binding var showFullPlayer: Bool @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService - /// Live drag offset while the user is swiping up/down (negative = moving up). + /// Live vertical drag offset while the user swipes up/down. @State private var dragOffset: CGFloat = 0 - var body: some View { - ZStack { - // Static progress bar as background (full bleed behind content) - MiniPlayerProgressBar(progress: audioPlayer.progress) + private var isCurrentChapterDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } - // Content layer - HStack(spacing: 16) { - // Cover thumbnail with rounded corners + var body: some View { + VStack(spacing: 0) { + // ── Thin amber progress strip at the very top ──────────────── + MiniBarProgress(progress: audioPlayer.progress) + + // ── Main content row ───────────────────────────────────────── + HStack(spacing: 12) { + // Cover art — tap opens full player Button { showFullPlayer = true } label: { AsyncCoverImage(url: audioPlayer.coverURL) - .frame(width: 56, height: 56) - .clipShape(RoundedRectangle(cornerRadius: 40)) + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .shadow(color: .black.opacity(0.18), radius: 6, y: 2) } .buttonStyle(.plain) - // Track info - VStack(alignment: .leading, spacing: 4) { - Text(chapterLabel) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) + // Track info — tap opens full player + Button { showFullPlayer = true } label: { + VStack(alignment: .leading, spacing: 2) { + Text(chapterLabel) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + HStack(spacing: 4) { + Text(audioPlayer.bookTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + if isCurrentChapterDownloaded { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 9)) + .foregroundStyle(.green) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + // ── Transport controls ─────────────────────────────────── + + // Previous chapter + Button { + if let prev = audioPlayer.prevChapter { + NotificationCenter.default.post( + name: .skipToPrevChapter, + object: nil, + userInfo: ["prev": prev] + ) + } + } label: { + Image(systemName: "backward.end.fill") + .font(.system(size: 19, weight: .semibold)) .foregroundStyle(.primary) - Text(audioPlayer.bookTitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) } - .frame(maxWidth: .infinity, alignment: .leading) - .onTapGesture { showFullPlayer = true } + .buttonStyle(.plain) + .disabled(audioPlayer.prevChapter == nil) + .opacity(audioPlayer.prevChapter == nil ? 0.3 : 1) - Spacer(minLength: 8) - - // Control buttons - compact group - HStack(spacing: 12) { - // Previous chapter button - if audioPlayer.status == .ready { - Button { - if let prev = audioPlayer.prevChapter { - NotificationCenter.default.post( - name: .skipToPrevChapter, - object: nil, - userInfo: ["prev": prev] - ) - } - } label: { - Image(systemName: "backward.end.fill") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(audioPlayer.prevChapter == nil) - .opacity(audioPlayer.prevChapter == nil ? 0.4 : 1.0) - } - - // Status indicator or play/pause control - Group { - switch audioPlayer.status { - case .generating: - ProgressView() - .tint(.white) - .scaleEffect(1.0) - .frame(width: 44, height: 44) - case .ready: - MiniPlayerPlayPauseButton( - progress: audioPlayer.progress, - onToggle: { audioPlayer.togglePlayPause() } - ) - case .error: - Image(systemName: "exclamationmark.circle.fill") - .font(.system(size: 24)) - .foregroundStyle(.red) - .frame(width: 44, height: 44) - default: - EmptyView() - } - } - - // Next chapter button - if audioPlayer.status == .ready { - Button { - if let next = audioPlayer.nextChapter { - NotificationCenter.default.post( - name: .skipToNextChapter, - object: nil, - userInfo: ["next": next] - ) - } - } label: { - ZStack { - Image(systemName: "forward.end.fill") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) - - // Show small loading indicator if next chapter is being prefetched - if audioPlayer.nextPrefetchStatus == .prefetching { - VStack { - Spacer() - HStack { - Spacer() - ProgressView() - .scaleEffect(0.5) - .tint(.amber) - .padding(2) - .background(Circle().fill(.black.opacity(0.6))) - } - } - } - } - .frame(width: 40, height: 40) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(audioPlayer.nextChapter == nil) - .opacity(audioPlayer.nextChapter == nil ? 0.4 : 1.0) - } + // Play / pause (isolated observer — only re-renders this button) + MiniBarPlayPause(progress: audioPlayer.progress) { + audioPlayer.togglePlayPause() + UIImpactFeedbackGenerator(style: .light).impactOccurred() } + .disabled(audioPlayer.status == .generating) + + // Next chapter + Button { + if let next = audioPlayer.nextChapter { + NotificationCenter.default.post( + name: .skipToNextChapter, + object: nil, + userInfo: ["next": next] + ) + } + } label: { + Image(systemName: "forward.end.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(audioPlayer.nextChapter == nil) + .opacity(audioPlayer.nextChapter == nil ? 0.3 : 1) } - .padding(.horizontal, 20) - .padding(.vertical, 12) + .padding(.horizontal, 16) + .padding(.vertical, 10) } - .background( - // Dark rounded background (pill-shaped with full circular ends) - RoundedRectangle(cornerRadius: 40) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 40) - .fill(Color.black.opacity(0.3)) - ) - ) - .frame(height: 20) - .shadow(color: .black.opacity(0.3), radius: 12, y: 4) - // Follow finger in both directions while dragging vertically + .background(.regularMaterial) .offset(y: dragOffset) - // Visual feedback: fade out and scale down slightly when dragging down to dismiss - .opacity(dragOffset > 0 ? max(0.3, 1.0 - (dragOffset / 200)) : 1.0) - .scaleEffect(dragOffset > 0 ? max(0.95, 1.0 - (dragOffset / 800)) : 1.0) - .simultaneousGesture( - DragGesture(minimumDistance: 10, coordinateSpace: .local) + .opacity(dragOffset > 0 ? max(0.3, 1 - dragOffset / 200) : 1) + .gesture( + DragGesture(minimumDistance: 8, coordinateSpace: .local) .onChanged { value in - // Only handle vertical drags (not horizontal seeks) - if abs(value.translation.height) > abs(value.translation.width) { - if value.translation.height < 0 { - // Upward swipe: rubberband resistance (opens full player) - dragOffset = value.translation.height * 0.4 - } else { - // Downward swipe: less resistance for easier dismiss - dragOffset = value.translation.height * 0.8 - } - } + let dy = value.translation.height + dragOffset = dy < 0 ? dy * 0.25 : dy * 0.7 } .onEnded { value in + let dy = value.translation.height let velocity = value.predictedEndTranslation.height - value.translation.height - if value.translation.height < -40 || velocity < -200 { - // Swipe up: open full player - withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { - dragOffset = 0 - } + if dy < -30 || velocity < -150 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { dragOffset = 0 } showFullPlayer = true - } else if value.translation.height > 60 || velocity > 200 { - // Swipe down: dismiss with animation - withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { - dragOffset = 300 // Slide out completely - } - // Stop audio after animation starts - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - audioPlayer.stop() - } + } else if dy > 60 || velocity > 200 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { dragOffset = 200 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { audioPlayer.stop() } } else { - // Not enough distance: spring back - withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { - dragOffset = 0 - } + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { dragOffset = 0 } } } ) @@ -479,15 +147,77 @@ struct MiniPlayerView_Legacy: View { } } +// MARK: - Isolated progress strip (observes PlaybackProgress directly) + +private struct MiniBarProgress: View { + @ObservedObject var progress: PlaybackProgress + + var body: some View { + GeometryReader { geo in + let fraction = progress.duration > 0 + ? CGFloat(progress.currentTime / progress.duration) + : 0 + Rectangle() + .fill(Color.amber) + .frame(width: geo.size.width * max(0, min(1, fraction)), height: 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 2) + } +} + +// MARK: - Isolated play/pause for mini bar (observes PlaybackProgress directly) + +private struct MiniBarPlayPause: View { + @ObservedObject var progress: PlaybackProgress + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .buttonStyle(.plain) + } +} + +// CompactPlayerControls removed — replaced by MiniPlayerBar above. + // MARK: - Full player sheet struct FullPlayerView: View { @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var authStore: AuthStore /// Called when the view wants to close itself (Done button or drag-to-dismiss). var onDismiss: () -> Void = {} - + @State private var showingChaptersList = false @State private var showingSleepTimer = false + @State private var showingVoiceSelector = false + @StateObject private var voiceVM = VoiceSelectionViewModel() + @State private var coverAppeared = false + + private var isCurrentChapterDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + + private var currentDownloadProgress: DownloadProgress? { + let key = downloadService.makeKey( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + return downloadService.downloads[key] + } var body: some View { GeometryReader { geo in @@ -498,44 +228,47 @@ struct FullPlayerView: View { .scaledToFill() .frame(width: geo.size.width, height: geo.size.height) .clipped() - .blur(radius: 50, opaque: true) - .overlay(Color.black.opacity(0.5)) + .blur(radius: 55, opaque: true) + .overlay(Color.black.opacity(0.55)) .ignoresSafeArea() + .id(audioPlayer.coverURL) // re-render background on track change // ── Content ──────────────────────────────────────────────── VStack(spacing: 0) { // Drag handle Capsule() - .fill(Color.white.opacity(0.3)) + .fill(Color.white.opacity(0.25)) .frame(width: 36, height: 4) .padding(.top, 14) // ── Cover art ────────────────────────────────────────── - // Scales to fill ~55 % of screen height minus chrome let coverSize = min(geo.size.width - 56, geo.size.height * 0.42) ZStack { KFImage(URL(string: audioPlayer.coverURL)) .resizable() .placeholder { - RoundedRectangle(cornerRadius: 20) + RoundedRectangle(cornerRadius: 22) .fill(.white.opacity(0.08)) .overlay( Image(systemName: "book.closed") .font(.system(size: 56)) - .foregroundStyle(.white.opacity(0.3)) + .foregroundStyle(.white.opacity(0.25)) ) } .scaledToFill() .frame(width: coverSize, height: coverSize) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .shadow(color: .black.opacity(0.6), radius: 32, y: 16) - // Dim cover while generating + .clipShape(RoundedRectangle(cornerRadius: 22)) + .shadow(color: .black.opacity(0.55), radius: 36, y: 18) .overlay( - RoundedRectangle(cornerRadius: 20) - .fill(Color.black.opacity(audioPlayer.status == .generating ? 0.45 : 0)) + RoundedRectangle(cornerRadius: 22) + .fill(Color.black.opacity(audioPlayer.status == .generating ? 0.5 : 0)) + .animation(.easeInOut(duration: 0.3), value: audioPlayer.status == .generating) ) + // Subtle scale pulse while playing + .scaleEffect(audioPlayer.progress.isPlaying && !coverAppeared ? 1.0 : (audioPlayer.progress.isPlaying ? 1.02 : 0.97)) + .animation(.spring(response: 0.45, dampingFraction: 0.7), value: audioPlayer.progress.isPlaying) - // Generating spinner centred over cover + // Generating overlay if audioPlayer.status == .generating { VStack(spacing: 10) { ProgressView() @@ -543,17 +276,18 @@ struct FullPlayerView: View { .scaleEffect(1.4) Text("Generating audio…") .font(.caption.weight(.medium)) - .foregroundStyle(.white.opacity(0.75)) + .foregroundStyle(.white.opacity(0.8)) } + .transition(.opacity) } - // Voice watermark — bottom-left corner of cover + // Voice watermark — bottom-left corner VStack { Spacer() HStack { Text(voiceName) - .font(.custom("Snell Roundhand", size: 18)) - .foregroundStyle(.white.opacity(0.55)) + .font(.custom("Snell Roundhand", size: 17)) + .foregroundStyle(.white.opacity(0.5)) .shadow(color: .black.opacity(0.5), radius: 2) .padding(12) Spacer() @@ -562,11 +296,22 @@ struct FullPlayerView: View { .frame(width: coverSize, height: coverSize) } .frame(width: coverSize, height: coverSize) - .padding(.top, 20) + .padding(.top, 18) + .onAppear { + withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1)) { + coverAppeared = true + } + } + .onChange(of: audioPlayer.slug) { _, _ in + coverAppeared = false + withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.05)) { + coverAppeared = true + } + } // ── Title block ──────────────────────────────────────── HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 4) { Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate()) @@ -575,24 +320,58 @@ struct FullPlayerView: View { .lineLimit(2) Text(audioPlayer.bookTitle) .font(.subheadline) - .foregroundStyle(.white.opacity(0.6)) + .foregroundStyle(.white.opacity(0.55)) .lineLimit(1) - if !audioPlayer.chapters.isEmpty { - Text(chapterPositionText) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.white.opacity(0.35)) - .padding(.top, 1) + + HStack(spacing: 8) { + if !audioPlayer.chapters.isEmpty { + Text(chapterPositionText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white.opacity(0.3)) + } + + // Download badge + if let progress = currentDownloadProgress { + Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundStyle(.blue) + } else if isCurrentChapterDownloaded { + Label("Offline", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + } } + .padding(.top, 1) } .frame(maxWidth: .infinity, alignment: .leading) - // Auto-next toggle (heart-like button on right of title) + // Quick download + if !isCurrentChapterDownloaded && currentDownloadProgress == nil { + Button { + Task { + try? await downloadService.download( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Image(systemName: "arrow.down.circle") + .font(.system(size: 24)) + .foregroundStyle(.white.opacity(0.65)) + } + .buttonStyle(.plain) + } + + // Auto-next toggle Button { audioPlayer.autoNext.toggle() + UIImpactFeedbackGenerator(style: .light).impactOccurred() } label: { Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle") .font(.system(size: 28)) - .foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.45)) + .foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.4)) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) @@ -610,39 +389,39 @@ struct FullPlayerView: View { .allowsHitTesting(audioPlayer.status != .generating) // ── Transport controls ───────────────────────────────── - // Layout: [skip-15] [prev-ch] [PLAY/PAUSE] [next-ch] [skip+15] - // Outer skip buttons are smaller; prev/next are medium; center is large circle HStack(spacing: 0) { - // ← skip 15 s PlayerSecondaryButton( systemName: "gobackward.15", size: 24, disabled: audioPlayer.status == .generating - ) { audioPlayer.skip(by: -15) } + ) { + audioPlayer.skip(by: -15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } - // ← previous chapter PlayerChapterSkipButton( systemName: "backward.end.fill", size: 30, disabled: audioPlayer.prevChapter == nil ) { if let prev = audioPlayer.prevChapter { - onDismiss() NotificationCenter.default.post( name: .skipToPrevChapter, object: nil, userInfo: ["prev": prev] ) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() } } - // Play / pause — large circle PlayerPlayPauseButton( progress: audioPlayer.progress, isGenerating: audioPlayer.status == .generating, - onToggle: { audioPlayer.togglePlayPause() } + onToggle: { + audioPlayer.togglePlayPause() + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } ) - // → next chapter PlayerChapterSkipButton( systemName: "forward.end.fill", size: 30, @@ -650,27 +429,28 @@ struct FullPlayerView: View { prefetching: audioPlayer.nextPrefetchStatus == .prefetching ) { if let next = audioPlayer.nextChapter { - onDismiss() NotificationCenter.default.post( name: .skipToNextChapter, object: nil, userInfo: ["next": next] ) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() } } - // → skip 15 s PlayerSecondaryButton( systemName: "goforward.15", size: 24, disabled: audioPlayer.status == .generating - ) { audioPlayer.skip(by: 15) } + ) { + audioPlayer.skip(by: 15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } } .padding(.horizontal, 16) .padding(.top, 16) .padding(.bottom, 8) // ── Bottom toolbar ───────────────────────────────────── - // AirPlay | Speed | Chevron-down | List | Moon HStack(spacing: 0) { // AirPlay AirPlayButton() @@ -692,18 +472,41 @@ struct FullPlayerView: View { } } label: { Text("\(audioPlayer.speed, specifier: "%.2g")×") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white.opacity(0.7)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(0.65)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule().fill(.white.opacity(0.12)) + ) .frame(maxWidth: .infinity) .frame(height: 44) } .buttonStyle(.plain) + // Voice Selector + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showingVoiceSelector.toggle() + } + if !showingVoiceSelector { + voiceVM.stopSample() + } + } label: { + Image(systemName: showingVoiceSelector ? "mic.fill" : "mic") + .font(.system(size: 20)) + .foregroundStyle(showingVoiceSelector ? Color.amber : .white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + // Collapse Button { onDismiss() } label: { Image(systemName: "chevron.down") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white.opacity(0.7)) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white.opacity(0.65)) .frame(maxWidth: .infinity) .frame(height: 44) } @@ -713,7 +516,7 @@ struct FullPlayerView: View { Button { showingChaptersList = true } label: { Image(systemName: "list.bullet") .font(.system(size: 20)) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(.white.opacity(0.65)) .frame(maxWidth: .infinity) .frame(height: 44) } @@ -724,7 +527,8 @@ struct FullPlayerView: View { VStack(spacing: 1) { Image(systemName: sleepTimerIcon) .font(.system(size: 20)) - .foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.7)) + .foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.65)) + .contentTransition(.symbolEffect(.replace)) if !audioPlayer.sleepTimerRemainingText.isEmpty { Text(audioPlayer.sleepTimerRemainingText) .font(.system(size: 9, weight: .semibold).monospacedDigit()) @@ -738,7 +542,33 @@ struct FullPlayerView: View { .buttonStyle(.plain) } .padding(.horizontal, 12) - .padding(.bottom, 8) + .padding(.bottom, showingVoiceSelector ? 0 : 8) + + // ── Voice selection panel (expandable) ──────────────── + if showingVoiceSelector { + VoiceSelectorPanel( + voices: voiceVM.voices, + selectedVoice: audioPlayer.voice, + playingVoice: voiceVM.playingVoice, + voiceVM: voiceVM, + onSelectVoice: { voice in + voiceVM.stopSample() + audioPlayer.voice = voice + BookVoicePreferences.shared.setVoice(voice, for: audioPlayer.slug) + Task { + var settings = authStore.settings + settings.voice = voice + await authStore.saveSettings(settings) + } + } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .task { + if voiceVM.voices.isEmpty { + await voiceVM.loadVoices() + } + } + } } .ignoresSafeArea(edges: .bottom) } @@ -751,7 +581,6 @@ struct FullPlayerView: View { onChapterSelect: { selectedChapter in showingChaptersList = false guard selectedChapter != audioPlayer.chapter else { return } - let currentAudioChapter = audioPlayer.chapter let chapterTitle = audioPlayer.chapters .first(where: { $0.number == selectedChapter })?.title ?? "" @@ -772,14 +601,6 @@ struct FullPlayerView: View { nextChapter: nextChapter, prevChapter: prevChapter ) - - let notifName: Notification.Name = selectedChapter > currentAudioChapter - ? .skipToNextChapter : .skipToPrevChapter - let key = selectedChapter > currentAudioChapter ? "next" : "prev" - NotificationCenter.default.post( - name: notifName, object: nil, - userInfo: [key: selectedChapter] - ) } ) .presentationDetents([.medium, .large]) @@ -906,89 +727,152 @@ struct AirPlayButton: UIViewControllerRepresentable { struct SleepTimerSheet: View { @ObservedObject var audioPlayer: AudioPlayerService @Environment(\.dismiss) private var dismiss - + var body: some View { NavigationStack { - List { - Section { - Button { - audioPlayer.setSleepTimer(nil) - dismiss() - } label: { - HStack { - Text("Off") - .foregroundStyle(.primary) - Spacer() - if audioPlayer.sleepTimer == nil { - Image(systemName: "checkmark") - .foregroundStyle(.amber) - } - } - } - } header: { - Text("Chapter-based") - } - - Section { - ForEach([1, 2, 3, 4], id: \.self) { count in - Button { - audioPlayer.setSleepTimer(.chapters(count)) + ScrollView { + VStack(spacing: 20) { + // ── Off card ────────────────────────────────────────── + TimerCard { + TimerOptionRow( + label: "Off", + systemImage: "moon.zzz", + isSelected: audioPlayer.sleepTimer == nil + ) { + audioPlayer.setSleepTimer(nil) dismiss() - } label: { - HStack { - Text("\(count) \(count == 1 ? "chapter" : "chapters")") - .foregroundStyle(.primary) - Spacer() - if case .chapters(let c) = audioPlayer.sleepTimer, c == count { - Image(systemName: "checkmark") - .foregroundStyle(.amber) + } + } + + // ── Chapter-based ───────────────────────────────────── + VStack(spacing: 0) { + SectionLabel("Chapter-based") + TimerCard { + ForEach([1, 2, 3, 4], id: \.self) { count in + let isSelected: Bool = { + if case .chapters(let c) = audioPlayer.sleepTimer { return c == count } + return false + }() + TimerOptionRow( + label: "\(count) \(count == 1 ? "chapter" : "chapters")", + systemImage: "book", + isSelected: isSelected + ) { + audioPlayer.setSleepTimer(.chapters(count)) + dismiss() } + if count < 4 { Divider().padding(.leading, 56) } + } + } + } + + // ── Time-based ──────────────────────────────────────── + VStack(spacing: 0) { + SectionLabel("Time-based") + TimerCard { + ForEach([20, 40, 60, 120], id: \.self) { minutes in + let isSelected: Bool = { + if case .minutes(let m) = audioPlayer.sleepTimer { return m == minutes } + return false + }() + TimerOptionRow( + label: formatTimerOption(minutes), + systemImage: "clock", + isSelected: isSelected + ) { + audioPlayer.setSleepTimer(.minutes(minutes)) + dismiss() + } + if minutes != 120 { Divider().padding(.leading, 56) } } } } } - - Section { - ForEach([20, 40, 60, 120], id: \.self) { minutes in - Button { - audioPlayer.setSleepTimer(.minutes(minutes)) - dismiss() - } label: { - HStack { - Text(formatTimerOption(minutes)) - .foregroundStyle(.primary) - Spacer() - if case .minutes(let m) = audioPlayer.sleepTimer, m == minutes { - Image(systemName: "checkmark") - .foregroundStyle(.amber) - } - } - } - } - } header: { - Text("Time-based") - } + .padding(20) } + .background(Color(.systemGroupedBackground)) .navigationTitle("Sleep Timer") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button("Done") { - dismiss() - } - .fontWeight(.semibold) + Button("Done") { dismiss() } + .fontWeight(.semibold) } } } } - + private func formatTimerOption(_ minutes: Int) -> String { - if minutes < 60 { - return "\(minutes) mins" - } else { - let hours = minutes / 60 - return "\(hours) \(hours == 1 ? "hour" : "hours")" + if minutes < 60 { return "\(minutes) mins" } + let hours = minutes / 60 + return "\(hours) \(hours == 1 ? "hour" : "hours")" + } +} + +// MARK: - Sleep timer helper views + +private struct TimerCard: View { + @ViewBuilder let content: Content + + var body: some View { + VStack(spacing: 0) { + content } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } +} + +private struct SectionLabel: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 4) + .padding(.bottom, 8) + } +} + +private struct TimerOptionRow: View { + let label: String + let systemImage: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + }) { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 16)) + .foregroundStyle(isSelected ? Color.amber : .secondary) + .frame(width: 28) + + Text(label) + .font(.body) + .foregroundStyle(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.amber) + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) } } @@ -1002,39 +886,79 @@ struct ChaptersListSheet: View { let onChapterSelect: (Int) -> Void @Environment(\.dismiss) private var dismiss + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + @State private var searchText: String = "" + @State private var filterOfflineOnly = false + @State private var showingDownloadAll = false /// The block label the jump bar is currently scrolling to (e.g. "1–100"). @State private var activeBlock: String? = nil // MARK: Derived data + + /// Count of downloaded chapters for this book + private var downloadedCount: Int { + chapters.filter { ch in + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: ch.number, + voice: audioPlayer.voice + ) + }.count + } + + /// Count of downloading chapters + private var downloadingCount: Int { + downloadService.downloads.filter { key, _ in + key.hasPrefix("\(audioPlayer.slug)::") + }.count + } /// 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) + var result = chapters + + // Apply offline filter + if filterOfflineOnly { + result = result.filter { ch in + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: ch.number, + voice: audioPlayer.voice + ) + } } + + // Apply search filter + if !searchText.isEmpty { + let q = searchText.lowercased() + result = result.filter { + "\($0.number)".contains(q) || $0.title.lowercased().contains(q) + } + } + + return result } /// Chapters grouped into blocks of 100: ["1–100": [...], "101–200": [...], …] - /// When the user is searching we use a single "Results" group so the jump + /// When the user is searching or filtering 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 { + guard searchText.isEmpty && !filterOfflineOnly else { return filtered.isEmpty ? [] : [("Results", filtered)] } - guard !chapters.isEmpty else { return [] } + guard !filtered.isEmpty else { return [] } let blockSize = 100 - let minN = chapters.map(\.number).min() ?? 1 - let maxN = chapters.map(\.number).max() ?? 1 + let minN = filtered.map(\.number).min() ?? 1 + let maxN = filtered.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 } + let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) } @@ -1043,7 +967,7 @@ struct ChaptersListSheet: View { return result } - /// Jump-bar labels (shown only when not searching). + /// Jump-bar labels (shown only when not searching/filtering). private var jumpLabels: [String] { groups.map(\.label) } // MARK: Body @@ -1053,6 +977,50 @@ struct ChaptersListSheet: View { ZStack(alignment: .trailing) { // ── Main chapter list ────────────────────────────────────── List { + // Download summary section + if downloadedCount > 0 || downloadingCount > 0 { + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Offline Downloads") + .font(.headline) + Text("\(downloadedCount) of \(chapters.count) chapters") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + showingDownloadAll = true + } label: { + Label("Manage", systemImage: "arrow.down.circle") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.bordered) + .tint(.blue) + } + + if downloadingCount > 0 { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + // Quick filter toggle + Toggle("Show offline only", isOn: $filterOfflineOnly) + .font(.subheadline) + .tint(.amber) + } + .padding(.vertical, 8) + } + } + ForEach(groups, id: \.label) { group in // Section header — shows block range (e.g. "1–100") Section { @@ -1065,7 +1033,7 @@ struct ChaptersListSheet: View { .id(group.label) // anchor for jump-bar scrollTo } } header: { - if searchText.isEmpty { + if searchText.isEmpty && !filterOfflineOnly { Text(group.label) .font(.caption.bold()) .foregroundStyle(.secondary) @@ -1078,15 +1046,15 @@ struct ChaptersListSheet: View { .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 { + // ── Right-edge jump bar (hidden while searching/filtering) ─────────── + if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 { JumpBar(labels: jumpLabels, currentChapter: currentChapter, groups: groups) { label in withAnimation { activeBlock = label } } .padding(.trailing, 4) } } - .navigationTitle("Chapters (\(chapters.count))") + .navigationTitle("Chapters (\(filtered.count))") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -1094,6 +1062,18 @@ struct ChaptersListSheet: View { .fontWeight(.semibold) } } + .sheet(isPresented: $showingDownloadAll) { + DownloadManagementSheet( + chapters: chapters, + slug: audioPlayer.slug, + voice: Binding( + get: { audioPlayer.voice }, + set: { audioPlayer.voice = $0 } + ) + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } // Scroll to the currently playing chapter's block on first appear. .onAppear { if let block = groups.first(where: { g in @@ -1106,44 +1086,99 @@ struct ChaptersListSheet: View { } } -// MARK: - Individual chapter row +// MARK: - Individual chapter row with download status private struct ChapterRow: View { let chapter: ChapterIndexBrief let isCurrent: Bool let onSelect: () -> Void + + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + private var isDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: chapter.number, + voice: audioPlayer.voice + ) + } + + private var downloadProgress: DownloadProgress? { + let key = downloadService.makeKey(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + return downloadService.downloads[key] + } + + private var isDownloading: Bool { + downloadProgress != nil + } 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)) - ) + // Number badge with download indicator + ZStack { + Text("\(chapter.number)") + .font(.caption.bold()) + .foregroundStyle(isCurrent ? .white : .secondary) + .frame(width: 40, height: 40) + .background( + Circle().fill(isCurrent ? Color.amber : Color(.systemGray5)) + ) + + // Download progress ring + if isDownloading, let progress = downloadProgress { + Circle() + .trim(from: 0, to: progress.progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 44, height: 44) + .animation(.easeInOut(duration: 0.3), value: progress.progress) + } + } - // Title + "Now Playing" subtitle + // Title + status 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) + + HStack(spacing: 8) { + if isCurrent { + Label("Now Playing", systemImage: "waveform") + .font(.caption2) + .foregroundStyle(.amber) + .symbolEffect(.variableColor.cumulative, isActive: isCurrent) + } + + if isDownloading, let progress = downloadProgress { + Label("\(Int(progress.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundStyle(.blue) + } else if isDownloaded { + Label("Downloaded", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + } } } Spacer() + // Right side indicator if isCurrent { - Image(systemName: "speaker.wave.2.fill") + Image(systemName: "waveform") .font(.caption.bold()) .foregroundStyle(.amber) + .symbolEffect(.variableColor.cumulative, isActive: isCurrent) + } else if isDownloaded { + Image(systemName: "arrow.down.circle.fill") + .font(.body) + .foregroundStyle(.green) + } else if isDownloading { + ProgressView() + .scaleEffect(0.8) } } .padding(.vertical, 6) @@ -1151,6 +1186,45 @@ private struct ChapterRow: View { } .buttonStyle(.plain) .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + // Download/Delete action + if isDownloaded { + Button(role: .destructive) { + Task { + try? downloadService.deleteDownload( + slug: audioPlayer.slug, + chapter: chapter.number, + voice: audioPlayer.voice + ) + } + } label: { + Label("Delete", systemImage: "trash") + } + } else if isDownloading { + Button(role: .destructive) { + downloadService.cancelDownload( + slug: audioPlayer.slug, + chapter: chapter.number, + voice: audioPlayer.voice + ) + } label: { + Label("Cancel", systemImage: "xmark") + } + } else { + Button { + Task { + try? await downloadService.download( + slug: audioPlayer.slug, + chapter: chapter.number, + voice: audioPlayer.voice + ) + } + } label: { + Label("Download", systemImage: "arrow.down.circle") + } + .tint(.blue) + } + } } } @@ -1218,6 +1292,7 @@ struct PlayerSlider: View { let range: ClosedRange @State private var isDragging = false + @State private var didFireHaptic = false var body: some View { GeometryReader { geo in @@ -1225,44 +1300,57 @@ struct PlayerSlider: View { let fraction = (value - range.lowerBound) / (range.upperBound - range.lowerBound) let clampedFraction = max(0, min(1, fraction)) let filled = width * clampedFraction - let thumbSize: CGFloat = isDragging ? 22 : 22 + let thumbSize: CGFloat = isDragging ? 26 : 20 let trackHeight: CGFloat = isDragging ? 5 : 4 ZStack(alignment: .leading) { - // Track + // Track background Capsule() .fill(Color.white.opacity(0.2)) .frame(height: trackHeight) - // Fill + // Filled portion — amber gradient Capsule() - .fill(Color.amber) + .fill( + LinearGradient( + colors: [Color.amber.opacity(0.9), Color.amber], + startPoint: .leading, + endPoint: .trailing + ) + ) .frame(width: max(filled, thumbSize / 2), height: trackHeight) // Thumb Circle() .fill(Color.white) .frame(width: thumbSize, height: thumbSize) - .shadow(color: .black.opacity(0.25), radius: 3, y: 1) + .shadow(color: .black.opacity(0.3), radius: isDragging ? 6 : 3, y: isDragging ? 2 : 1) .offset(x: max(0, filled - thumbSize / 2)) - .animation(.spring(response: 0.2), value: isDragging) + .animation(.spring(response: 0.2, dampingFraction: 0.65), value: isDragging) } - .frame(height: 28) // generous touch target + .frame(height: 36) // generous touch target .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { drag in - isDragging = true + if !isDragging { + isDragging = true + if !didFireHaptic { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + didFireHaptic = true + } + } let raw = drag.location.x / width let clamped = max(0, min(1, raw)) value = range.lowerBound + clamped * (range.upperBound - range.lowerBound) } .onEnded { _ in isDragging = false + didFireHaptic = false } ) } - .frame(height: 28) + .frame(height: 36) } } @@ -1333,27 +1421,50 @@ private struct PlayerPlayPauseButton: View { let isGenerating: Bool let onToggle: () -> Void + @State private var isPressed = false + var body: some View { - Button { onToggle() } label: { + Button { + onToggle() + } label: { ZStack { + // Outer glow ring (visible while playing) Circle() - .fill(.white.opacity(0.15)) + .fill(Color.amber.opacity(progress.isPlaying ? 0.18 : 0)) + .frame(width: 80, height: 80) + .animation(.easeInOut(duration: 0.35), value: progress.isPlaying) + + // Main fill circle + Circle() + .fill( + LinearGradient( + colors: [Color.amber.opacity(0.9), Color.amber.opacity(0.65)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) .frame(width: 64, height: 64) + .shadow(color: Color.amber.opacity(0.45), radius: 12, y: 4) + .scaleEffect(isPressed ? 0.92 : 1.0) + .animation(.spring(response: 0.2, dampingFraction: 0.6), value: isPressed) + if isGenerating { ProgressView() .tint(.white) .scaleEffect(1.2) } else { Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 30, weight: .bold)) + .font(.system(size: 28, weight: .bold)) .foregroundStyle(.white) .offset(x: progress.isPlaying ? 0 : 2) + .contentTransition(.symbolEffect(.replace.downUp)) } } .frame(maxWidth: .infinity) } .buttonStyle(.plain) .disabled(isGenerating) + ._onButtonGesture(pressing: { isPressed = $0 }, perform: {}) } } @@ -1374,3 +1485,576 @@ private struct MiniPlayerPlayPauseButton: View { .buttonStyle(.plain) } } + +// MARK: - Download Management Sheet + +struct DownloadManagementSheet: View { + let chapters: [ChapterIndexBrief] + let slug: String + @Binding var voice: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var authStore: AuthStore + @State private var showingDeleteAll = false + @State private var isDownloadingAll = false + @State private var showingVoiceSelector = false + @State private var showingRangeSelector = false + @StateObject private var voiceVM = VoiceSelectionViewModel() + + // Range selection state + @State private var rangeStart: Int = 1 + @State private var rangeEnd: Int = 1 + + private var downloadedChapters: [ChapterIndexBrief] { + chapters.filter { ch in + downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: voice) + } + } + + private var notDownloadedChapters: [ChapterIndexBrief] { + chapters.filter { ch in + !downloadService.isDownloaded(slug: slug, chapter: ch.number, voice: voice) + } + } + + var body: some View { + NavigationStack { + List { + // Voice info section + Section { + Button { + showingVoiceSelector = true + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Download Voice") + .font(.subheadline) + .foregroundStyle(.secondary) + HStack(spacing: 6) { + Text(voiceLabel(voice)) + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + if BookVoicePreferences.shared.hasOverride(for: slug) { + Text("(Custom)") + .font(.caption) + .foregroundStyle(.blue) + } + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } footer: { + Text("Tap to change voice. All downloads will use the selected voice for this book.") + .font(.caption) + } + + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\(downloadedChapters.count) Downloaded") + .font(.title2.bold()) + Text("\(notDownloadedChapters.count) remaining") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + // Circular completion indicator + ZStack { + Circle() + .stroke(Color(.systemGray5), lineWidth: 4) + Circle() + .trim(from: 0, to: chapters.isEmpty ? 0 : CGFloat(downloadedChapters.count) / CGFloat(chapters.count)) + .stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.4), value: downloadedChapters.count) + Text("\(chapters.isEmpty ? 0 : Int(Double(downloadedChapters.count) / Double(chapters.count) * 100))%") + .font(.caption2.bold()) + .foregroundStyle(.secondary) + } + .frame(width: 44, height: 44) + } + + HStack(spacing: 10) { + if notDownloadedChapters.count > 0 { + Button { + showingRangeSelector = true + } label: { + Label("Range", systemImage: "list.number") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.blue) + + Button { + downloadAllRemaining() + } label: { + HStack(spacing: 6) { + if isDownloadingAll { + ProgressView().scaleEffect(0.75) + } else { + Image(systemName: "arrow.down.circle.fill") + } + Text("All (\(notDownloadedChapters.count))") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(isDownloadingAll) + } + + if downloadedChapters.count > 0 { + Button { + showingDeleteAll = true + } label: { + Label("Delete All", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.red) + } + } + } + .padding(.vertical, 8) + } + + if downloadedChapters.count > 0 { + Section { + ForEach(downloadedChapters, id: \.number) { chapter in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(chapter.number)") + .font(.subheadline.weight(.semibold)) + Text(chapter.title.strippingTrailingDate()) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + .onDelete { indexSet in + deleteChapters(at: indexSet, from: downloadedChapters) + } + } header: { + Text("Downloaded (\(downloadedChapters.count))") + } + } + } + .navigationTitle("Manage Downloads") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + .confirmationDialog( + "Delete all downloads?", + isPresented: $showingDeleteAll, + titleVisibility: .visible + ) { + Button("Delete All Downloads", role: .destructive) { + deleteAllDownloads() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will delete \(downloadedChapters.count) downloaded chapters. You can re-download them later.") + } + .sheet(isPresented: $showingVoiceSelector) { + VoiceSelectorSheet( + selectedVoice: voice, + slug: slug, + voiceVM: voiceVM, + onSelectVoice: { newVoice in + voice = newVoice + // Save per-book voice override + BookVoicePreferences.shared.setVoice(newVoice, for: slug) + showingVoiceSelector = false + } + ) + } + .sheet(isPresented: $showingRangeSelector) { + RangeDownloadSheet( + chapters: notDownloadedChapters, + slug: slug, + voice: voice, + onDownload: { start, end in + downloadRange(from: start, to: end) + showingRangeSelector = false + } + ) + .presentationDetents([.medium]) + } + } + } + + private func downloadRange(from start: Int, to end: Int) { + isDownloadingAll = true + Task { + let chaptersToDownload = notDownloadedChapters.filter { ch in + ch.number >= start && ch.number <= end + } + for chapter in chaptersToDownload { + try? await downloadService.download(slug: slug, chapter: chapter.number, voice: voice) + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s + } + await MainActor.run { + isDownloadingAll = false + } + } + } + + private func downloadAllRemaining() { + isDownloadingAll = true + Task { + for chapter in notDownloadedChapters { + try? await downloadService.download(slug: slug, chapter: chapter.number, voice: voice) + // Small delay to avoid overwhelming the API + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s + } + await MainActor.run { + isDownloadingAll = false + } + } + } + + private func deleteChapters(at indexSet: IndexSet, from chapters: [ChapterIndexBrief]) { + for index in indexSet { + let chapter = chapters[index] + try? downloadService.deleteDownload(slug: slug, chapter: chapter.number, voice: voice) + } + } + + private func deleteAllDownloads() { + for chapter in downloadedChapters { + try? downloadService.deleteDownload(slug: slug, chapter: chapter.number, voice: voice) + } + } + + // Voice label formatting (matches VoiceSelectionViewModel) + private func voiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + + let prefix = String(parts[0]) + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + + // Parse prefix for language/gender info + var info = "" + switch prefix { + case "af": info = "US F" + case "am": info = "US M" + case "bf": info = "UK F" + case "bm": info = "UK M" + default: info = prefix.uppercased() + } + + return "\(name) (\(info))" + } +} + +// MARK: - Voice Selector Panel (inline, expandable) + +private struct VoiceSelectorPanel: View { + let voices: [String] + let selectedVoice: String + let playingVoice: String? + let voiceVM: VoiceSelectionViewModel + let onSelectVoice: (String) -> Void + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Choose Voice") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.45)) + .textCase(.uppercase) + .tracking(0.8) + Spacer() + } + .padding(.horizontal, 18) + .padding(.top, 10) + .padding(.bottom, 6) + + // Voice list (scrollable) + ScrollView { + VStack(spacing: 0) { + ForEach(voices, id: \.self) { voice in + VoiceOptionRow( + voice: voice, + isSelected: selectedVoice == voice, + isPlaying: playingVoice == voice, + voiceLabel: voiceVM.voiceLabel(voice), + voiceId: voiceVM.voiceId(voice), + onSelect: { onSelectVoice(voice) }, + onPlaySample: { + Task { await voiceVM.playSample(voice) } + } + ) + + if voice != voices.last { + Divider() + .overlay(Color.white.opacity(0.08)) + .padding(.leading, 52) + } + } + } + } + .frame(maxHeight: 220) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 16) + + // Footer note + Text("New voice applies on next chapter") + .font(.caption2) + .foregroundStyle(.white.opacity(0.35)) + .padding(.top, 7) + .padding(.bottom, 10) + } + .background(.ultraThinMaterial) + } +} + +// MARK: - Voice Option Row (for inline panel) + +private struct VoiceOptionRow: View { + let voice: String + let isSelected: Bool + let isPlaying: Bool + let voiceLabel: String + let voiceId: String + let onSelect: () -> Void + let onPlaySample: () -> Void + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onSelect() + } label: { + HStack(spacing: 12) { + // Selection indicator with spring bounce + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18)) + .foregroundStyle(isSelected ? Color.amber : .white.opacity(0.25)) + .scaleEffect(isSelected ? 1.1 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.55), value: isSelected) + .frame(width: 24) + + // Voice info + VStack(alignment: .leading, spacing: 2) { + Text(voiceLabel) + .font(.subheadline) + .foregroundStyle(isSelected ? Color.amber : .white) + .fontWeight(isSelected ? .semibold : .regular) + .animation(.easeInOut(duration: 0.2), value: isSelected) + + Text(voiceId) + .font(.caption2) + .fontDesign(.monospaced) + .foregroundStyle(.white.opacity(0.4)) + } + + Spacer() + + // Play/Stop sample button + Button { + onPlaySample() + } label: { + Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 24)) + .foregroundStyle(isPlaying ? Color.red : Color.amber.opacity(0.8)) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(isSelected ? Color.amber.opacity(0.08) : Color.clear) + .animation(.easeInOut(duration: 0.2), value: isSelected) + } +} + +// MARK: - Voice Selector Sheet (for download management) + +private struct VoiceSelectorSheet: View { + let selectedVoice: String + let slug: String + let voiceVM: VoiceSelectionViewModel + let onSelectVoice: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var authStore: AuthStore + + var body: some View { + NavigationStack { + List { + Section { + ForEach(voiceVM.voices, id: \.self) { voice in + Button { + onSelectVoice(voice) + } label: { + HStack(spacing: 12) { + // Checkmark for selected voice + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.blue) + .opacity(voice == selectedVoice ? 1 : 0) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(voiceVM.voiceLabel(voice)) + .font(.body) + .foregroundStyle(.primary) + Text(voice) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + + Spacer() + + // Play/stop button + Button { + Task { + await voiceVM.playSample(voice) + } + } label: { + Image(systemName: voiceVM.playingVoice == voice ? "stop.circle.fill" : "play.circle") + .font(.system(size: 24)) + .foregroundStyle(voiceVM.playingVoice == voice ? .red : .blue) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } + } header: { + Text("Select Voice") + } footer: { + if BookVoicePreferences.shared.hasOverride(for: slug) { + Button("Reset to Global Voice") { + BookVoicePreferences.shared.removeVoice(for: slug) + onSelectVoice(authStore.settings.voice) + } + .font(.subheadline) + } + } + } + .navigationTitle("Download Voice") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + voiceVM.stopSample() + dismiss() + } + .fontWeight(.semibold) + } + } + .task { + if voiceVM.voices.isEmpty { + await voiceVM.loadVoices() + } + } + } + } +} + +// MARK: - Range Download Sheet + +private struct RangeDownloadSheet: View { + let chapters: [ChapterIndexBrief] + let slug: String + let voice: String + let onDownload: (Int, Int) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var startChapter: Int + @State private var endChapter: Int + + init(chapters: [ChapterIndexBrief], slug: String, voice: String, onDownload: @escaping (Int, Int) -> Void) { + self.chapters = chapters + self.slug = slug + self.voice = voice + self.onDownload = onDownload + + let firstChapter = chapters.first?.number ?? 1 + let lastChapter = chapters.last?.number ?? 1 + _startChapter = State(initialValue: firstChapter) + _endChapter = State(initialValue: min(firstChapter + 9, lastChapter)) + } + + private var chapterRange: [Int] { + guard let first = chapters.first?.number, + let last = chapters.last?.number else { return [] } + return Array(first...last) + } + + private var selectedCount: Int { + guard startChapter <= endChapter else { return 0 } + return endChapter - startChapter + 1 + } + + var body: some View { + NavigationStack { + Form { + Section { + Picker("Start Chapter", selection: $startChapter) { + ForEach(chapterRange, id: \.self) { num in + Text("Chapter \(num)").tag(num) + } + } + + Picker("End Chapter", selection: $endChapter) { + ForEach(chapterRange.filter { $0 >= startChapter }, id: \.self) { num in + Text("Chapter \(num)").tag(num) + } + } + } header: { + Text("Select Range") + } footer: { + Text("\(selectedCount) chapters will be downloaded") + } + + Section { + Button { + onDownload(startChapter, endChapter) + dismiss() + } label: { + HStack { + Spacer() + Image(systemName: "arrow.down.circle.fill") + Text("Download \(selectedCount) Chapters") + Spacer() + } + } + .disabled(selectedCount == 0) + } + } + .navigationTitle("Download Range") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Cancel") { dismiss() } + } + } + } + } +} diff --git a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift index bb768e4..b25508c 100644 --- a/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift +++ b/ios/LibNovel/LibNovel/Views/Profile/ProfileView.swift @@ -6,6 +6,8 @@ struct ProfileView: View { @EnvironmentObject var authStore: AuthStore @StateObject private var vm = ProfileViewModel() @State private var showChangePassword = false + @State private var showVoiceSelection = false + @State private var showDownloads = false // Avatar upload state @State private var photoPickerItem: PhotosPickerItem? @@ -52,6 +54,19 @@ struct ProfileView: View { } )) .tint(.amber) + + Button { + showDownloads = true + } label: { + HStack { + Text("Downloads") + .foregroundStyle(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } } // ── Sessions ─────────────────────────────────────────────── @@ -83,6 +98,12 @@ struct ProfileView: View { .sheet(isPresented: $showChangePassword) { ChangePasswordView() } + .sheet(isPresented: $showVoiceSelection) { + VoiceSelectionView(currentVoice: authStore.settings.voice) + } + .sheet(isPresented: $showDownloads) { + DownloadsView() + } .sheet(item: Binding( get: { pendingCropImage.map { CropImageItem(image: $0) } }, set: { if $0 == nil { pendingCropImage = nil } } @@ -191,25 +212,27 @@ struct ProfileView: View { @ViewBuilder private var voicePicker: some View { - Picker("TTS Voice", selection: Binding( - get: { authStore.settings.voice }, - set: { newVoice in - Task { - var s = authStore.settings - s.voice = newVoice - await authStore.saveSettings(s) - } - } - )) { - if vm.voices.isEmpty { - Text("Default").tag("af_bella") - } else { - ForEach(vm.voices, id: \.self) { v in - Text(v).tag(v) - } + Button { + showVoiceSelection = true + } label: { + HStack { + Text("TTS Voice") + .foregroundStyle(.primary) + Spacer() + Text(formatVoiceLabel(authStore.settings.voice)) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) } } - .task { await vm.loadVoices() } + } + + private func formatVoiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + return name } // MARK: - Speed slider diff --git a/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift b/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift index ae02b0c..f365079 100644 --- a/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift +++ b/ios/LibNovel/LibNovel/Views/Profile/UserProfileView.swift @@ -157,18 +157,6 @@ struct UserProfileView: 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: - Book card for profile shelves private struct ProfileBookCard: View { diff --git a/ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift b/ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift new file mode 100644 index 0000000..4b6b057 --- /dev/null +++ b/ios/LibNovel/LibNovel/Views/Profile/VoiceSelectionView.swift @@ -0,0 +1,158 @@ +import SwiftUI + +struct VoiceSelectionView: View { + @StateObject private var vm = VoiceSelectionViewModel() + @EnvironmentObject var authStore: AuthStore + @Environment(\.dismiss) private var dismiss + + @State private var selectedVoice: String + + init(currentVoice: String) { + _selectedVoice = State(initialValue: currentVoice) + } + + var body: some View { + NavigationStack { + Group { + if vm.isLoading { + ProgressView("Loading voices...") + } else if let error = vm.error { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(.amber) + Text(error) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding() + } else { + voiceList + } + } + .navigationTitle("Select Voice") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + saveAndDismiss() + } + .fontWeight(.semibold) + .disabled(selectedVoice == authStore.settings.voice) + } + } + .task { + await vm.loadVoices() + } + } + } + + // MARK: - Voice List + + @ViewBuilder + private var voiceList: some View { + List { + Section { + ForEach(vm.voices, id: \.self) { voice in + VoiceRow( + voice: voice, + isSelected: voice == selectedVoice, + isPlaying: vm.playingVoice == voice, + voiceLabel: vm.voiceLabel(voice), + voiceId: vm.voiceId(voice), + onSelect: { + vm.stopSample() + selectedVoice = voice + }, + onPlaySample: { + Task { + await vm.playSample(voice) + } + } + ) + } + } header: { + Text("Available Voices") + } footer: { + if selectedVoice != authStore.settings.voice { + Text("New voice will apply to next audio playback") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - Actions + + private func saveAndDismiss() { + Task { + var settings = authStore.settings + settings.voice = selectedVoice + await authStore.saveSettings(settings) + dismiss() + } + } +} + +// MARK: - Voice Row + +private struct VoiceRow: View { + let voice: String + let isSelected: Bool + let isPlaying: Bool + let voiceLabel: String + let voiceId: String + let onSelect: () -> Void + let onPlaySample: () -> Void + + var body: some View { + HStack(spacing: 12) { + // Selection checkmark + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(isSelected ? .amber : .secondary.opacity(0.3)) + .frame(width: 28) + + // Voice info + VStack(alignment: .leading, spacing: 4) { + Text(voiceLabel) + .font(.body) + .fontWeight(isSelected ? .semibold : .regular) + + Text(voiceId) + .font(.caption) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + } + + Spacer() + + // Play sample button + Button { + onPlaySample() + } label: { + Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 28)) + .foregroundStyle(isPlaying ? .red : .amber) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + onSelect() + } + } +} + +// MARK: - Preview + +#Preview { + VoiceSelectionView(currentVoice: "af_bella") + .environmentObject(AuthStore()) +} diff --git a/ios/LibNovel/LibNovel/Views/Search/SearchView.swift b/ios/LibNovel/LibNovel/Views/Search/SearchView.swift index 6da5af4..51c80aa 100644 --- a/ios/LibNovel/LibNovel/Views/Search/SearchView.swift +++ b/ios/LibNovel/LibNovel/Views/Search/SearchView.swift @@ -1,9 +1,8 @@ import SwiftUI // MARK: - SearchView -// Dedicated search tab modelled after Apple Books' Search screen. -// Shows a prominent search bar; while idle displays recent searches and -// trending/popular novels; after a query shows a results grid. +// Dedicated search tab for intentional, fuzzy search. +// Live search as you type, shows recent searches when idle. struct SearchView: View { @StateObject private var vm = SearchViewModel() @@ -11,129 +10,115 @@ struct SearchView: View { var body: some View { NavigationStack { VStack(spacing: 0) { - // ── Search bar ────────────────────────────────────────────── - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField("Search novels, authors…", text: $vm.query) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .submitLabel(.search) - .onSubmit { vm.submitSearch() } - if !vm.query.isEmpty { - Button { vm.clear() } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - } - } - .padding(10) - .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10)) - .padding(.horizontal) - .padding(.top, 8) - .padding(.bottom, 12) - - Divider() - - // ── Content ───────────────────────────────────────────────── - if vm.query.isEmpty && vm.results.isEmpty { + OfflineBanner() + + Group { + // ── Content ───────────────────────────────────────────────── + if vm.query.isEmpty && vm.results.isEmpty { idleContent - } else if vm.isLoading { + } else if vm.isLoading && vm.results.isEmpty { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if vm.results.isEmpty { + } else if vm.results.isEmpty && !vm.query.isEmpty { EmptyStateView( icon: "magnifyingglass", title: "No results", message: "Try a different title or author name." ) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - resultsGrid + } else { + resultsGrid + } } } .navigationTitle("Search") + .searchable( + text: $vm.query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search novels, authors…" + ) + .autocorrectionDisabled() + .onChange(of: vm.query) { _, newValue in + vm.onQueryChange(newValue) + } + .onSubmit(of: .search) { + vm.submitSearch() + } .appNavigationDestination() .toolbar { ToolbarItem(placement: .topBarTrailing) { - AvatarToolbarButton() + HStack(spacing: 16) { + DownloadQueueButton() + AvatarToolbarButton() + } } } } } - // MARK: - Idle screen (recent searches + popular) + // MARK: - Idle screen (recent searches) @ViewBuilder private var idleContent: some View { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - // Recent searches - if !vm.recentSearches.isEmpty { - VStack(alignment: .leading, spacing: 0) { - HStack { - Text("Recent") - .font(.title3.bold()) - Spacer() - Button("Clear") { vm.clearRecent() } - .font(.subheadline) - .foregroundStyle(.amber) - } - .padding(.horizontal) - .padding(.bottom, 10) - - ForEach(vm.recentSearches, id: \.self) { term in - Button { - vm.query = term - vm.submitSearch() - } label: { - HStack { - Image(systemName: "clock") - .foregroundStyle(.secondary) - .frame(width: 20) - Text(term) - .foregroundStyle(.primary) - Spacer() - Image(systemName: "arrow.up.left") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal) - .padding(.vertical, 11) - } - Divider().padding(.leading, 44) - } - } - } - - // Popular / trending novels (loaded from browse popular) - if !vm.popular.isEmpty { - VStack(alignment: .leading, spacing: 10) { - Text("Popular") - .font(.title3.bold()) - .padding(.horizontal) - - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ], - spacing: 16 - ) { - ForEach(vm.popular) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - SearchNovelCard(novel: novel) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal) - } - } - - Color.clear.frame(height: 20) + if vm.recentSearches.isEmpty { + // Empty state - prompt to search + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 56)) + .foregroundStyle(.secondary.opacity(0.5)) + Text("Search for novels") + .font(.title2.bold()) + .foregroundStyle(.primary) + Text("Find your next favorite book by title, author, or genre") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // Recent searches list + ScrollView { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Recent Searches") + .font(.title3.bold()) + Spacer() + Button("Clear") { vm.clearRecent() } + .font(.subheadline) + .foregroundStyle(.amber) + } + .padding(.horizontal) + .padding(.top, 16) + .padding(.bottom, 12) + + ForEach(vm.recentSearches, id: \.self) { term in + Button { + vm.query = term + vm.submitSearch() + } label: { + HStack(spacing: 12) { + Image(systemName: "clock") + .foregroundStyle(.secondary) + .frame(width: 20) + Text(term) + .foregroundStyle(.primary) + Spacer() + Image(systemName: "arrow.up.left") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal) + .padding(.vertical, 12) + } + + if term != vm.recentSearches.last { + Divider() + .padding(.leading, 44) + } + } + } } - .padding(.top, 16) } } @@ -142,21 +127,34 @@ struct SearchView: View { @ViewBuilder private var resultsGrid: some View { ScrollView { - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: 12), - GridItem(.flexible(), spacing: 12) - ], - spacing: 16 - ) { - ForEach(vm.results) { novel in - NavigationLink(value: NavDestination.book(novel.slug)) { - SearchNovelCard(novel: novel) - } - .buttonStyle(.plain) + VStack(spacing: 8) { + // Result count + HStack { + Text("\(vm.results.count) result\(vm.results.count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() } + .padding(.horizontal) + .padding(.top, 8) + + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ], + spacing: 14 + ) { + ForEach(vm.results) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + SearchNovelCard(novel: novel) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + .padding(.bottom, 100) } - .padding() } } } @@ -167,19 +165,35 @@ private struct SearchNovelCard: View { let novel: BrowseNovel var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 0) { AsyncCoverImage(url: novel.cover) .frame(maxWidth: .infinity) .aspectRatio(2/3, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .clipShape(RoundedRectangle(cornerRadius: 10)) .bookCoverZoomSource(slug: novel.slug) - Text(novel.title) - .font(.subheadline.bold()) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2) } } @@ -189,29 +203,50 @@ private struct SearchNovelCard: View { final class SearchViewModel: ObservableObject { @Published var query: String = "" @Published var results: [BrowseNovel] = [] - @Published var popular: [BrowseNovel] = [] @Published var isLoading = false // Persisted in UserDefaults (max 10 recent terms) @Published var recentSearches: [String] = [] private let recentKey = "searchRecentTerms" + private var searchTask: Task? init() { recentSearches = (UserDefaults.standard.stringArray(forKey: recentKey) ?? []) - Task { await loadPopular() } + } + + /// Called when query changes - implements debounced live search + func onQueryChange(_ newValue: String) { + // Cancel previous search task + searchTask?.cancel() + + // If query is empty, clear results + guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + results = [] + return + } + + // Debounce: wait 300ms before searching + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) // 300ms + guard !Task.isCancelled else { return } + await runSearch(newValue) + } } func submitSearch() { let term = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !term.isEmpty else { return } saveRecent(term) + // Cancel debounce and search immediately + searchTask?.cancel() Task { await runSearch(term) } } func clear() { query = "" results = [] + searchTask?.cancel() } func clearRecent() { @@ -220,23 +255,27 @@ final class SearchViewModel: ObservableObject { } private func runSearch(_ term: String) async { + let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + results = [] + return + } + isLoading = true do { - let result = try await APIClient.shared.search(query: term) - results = result.results + let result = try await APIClient.shared.search(query: trimmed) + // Only update results if query hasn't changed + if query.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed { + results = result.results + } } catch { - results = [] + if !(error is CancellationError) { + results = [] + } } isLoading = false } - private func loadPopular() async { - do { - let result = try await APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all") - popular = Array(result.novels.prefix(12)) - } catch {} - } - private func saveRecent(_ term: String) { var list = recentSearches.filter { $0 != term } list.insert(term, at: 0) diff --git a/ios/LibNovelV2/App/ContentView.swift b/ios/LibNovelV2/App/ContentView.swift new file mode 100644 index 0000000..824cf9a --- /dev/null +++ b/ios/LibNovelV2/App/ContentView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +// MARK: - Root content view +// Switches between AuthView (unauthenticated) and RootTabView (authenticated). + +struct ContentView: View { + @EnvironmentObject var authStore: AuthStore + + var body: some View { + Group { + if authStore.isAuthenticated { + RootTabView() + } else { + AuthView() + } + } + .animation(.easeInOut(duration: 0.25), value: authStore.isAuthenticated) + } +} diff --git a/ios/LibNovelV2/App/LibNovelV2App.swift b/ios/LibNovelV2/App/LibNovelV2App.swift new file mode 100644 index 0000000..80ace13 --- /dev/null +++ b/ios/LibNovelV2/App/LibNovelV2App.swift @@ -0,0 +1,21 @@ +import SwiftUI + +@main +struct LibNovelV2App: App { + @StateObject private var authStore = AuthStore() + @StateObject private var audioPlayer = AudioPlayerService() + @StateObject private var downloadService = AudioDownloadService.shared + @StateObject private var networkMonitor = NetworkMonitor() + @StateObject private var bookVoicePrefs = BookVoicePreferences.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(authStore) + .environmentObject(audioPlayer) + .environmentObject(downloadService) + .environmentObject(networkMonitor) + .environmentObject(bookVoicePrefs) + } + } +} diff --git a/ios/LibNovelV2/App/RootTabView.swift b/ios/LibNovelV2/App/RootTabView.swift new file mode 100644 index 0000000..43e8d88 --- /dev/null +++ b/ios/LibNovelV2/App/RootTabView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +// MARK: - Root tab container with persistent mini-player overlay + +struct RootTabView: View { + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var audioPlayer: AudioPlayerService + + @State private var selectedTab: Tab = .home + @State private var showFullPlayer: Bool = false + @State private var readerIsActive: Bool = false + @State private var fullPlayerDragOffset: CGFloat = 0 + + enum Tab: Hashable { + case home, library, browse, search, profile + } + + var body: some View { + ZStack(alignment: .bottom) { + TabView(selection: $selectedTab) { + HomeView() + .tabItem { Label("Home", systemImage: "house.fill") } + .tag(Tab.home) + + LibraryView() + .tabItem { Label("Library", systemImage: "book.pages.fill") } + .tag(Tab.library) + + BrowseView() + .tabItem { Label("Discover", systemImage: "sparkles") } + .tag(Tab.browse) + + SearchView() + .tabItem { Label("Search", systemImage: "magnifyingglass") } + .tag(Tab.search) + + ProfileView() + .tabItem { Label("Profile", systemImage: "person.fill") } + .tag(Tab.profile) + } + + // Mini player bar — sits above the tab bar + if audioPlayer.isActive && !showFullPlayer && !readerIsActive { + MiniPlayerBar(showFullPlayer: $showFullPlayer) + .padding(.bottom, 49) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) + } + + // Full player — slides up from the bottom + if showFullPlayer { + FullPlayerView(onDismiss: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + fullPlayerDragOffset = 0 + } + }) + .offset(y: max(fullPlayerDragOffset, 0)) + .gesture( + DragGesture(minimumDistance: 10) + .onChanged { value in + if value.translation.height > 0 { + fullPlayerDragOffset = value.translation.height + } + } + .onEnded { value in + let velocity = value.predictedEndTranslation.height - value.translation.height + if value.translation.height > 120 || velocity > 400 { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + fullPlayerDragOffset = 0 + } + } else { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + fullPlayerDragOffset = 0 + } + } + } + ) + .transition(.move(edge: .bottom)) + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) + .ignoresSafeArea() + } + } + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: showFullPlayer) + .onPreferenceChange(HideMiniPlayerKey.self) { hide in + readerIsActive = hide + } + } +} diff --git a/ios/LibNovelV2/Extensions/NavDestination.swift b/ios/LibNovelV2/Extensions/NavDestination.swift new file mode 100644 index 0000000..d4dda25 --- /dev/null +++ b/ios/LibNovelV2/Extensions/NavDestination.swift @@ -0,0 +1,138 @@ +import SwiftUI + +// MARK: - Navigation destination enum + +enum NavDestination: Hashable { + case book(String) // slug + case chapter(String, Int) // slug + chapter number + case userProfile(String) // username + case browseCategory(sort: String, genre: String, status: String, title: String) +} + +// MARK: - View helpers + +extension View { + /// Registers app-wide navigationDestination for NavDestination values. + /// Apply once per NavigationStack. + func appNavigationDestination() -> some View { + modifier(AppNavigationDestinationModifier()) + } + + /// Standard "Error" alert driven by an optional String binding. + /// Suppresses network errors silently when offline (banner handles them). + func errorAlert(_ error: Binding) -> some View { + modifier(ErrorAlertModifier(error: error)) + } + + /// Signal to the root overlay that the mini player should be hidden. + func hideMiniPlayer() -> some View { + preference(key: HideMiniPlayerKey.self, value: true) + } + + /// Marks a cover image as the zoom source for a book navigation transition (iOS 18+). + func bookCoverZoomSource(slug: String) -> some View { + modifier(BookCoverZoomSource(slug: slug)) + } +} + +// MARK: - Error alert modifier + +private struct ErrorAlertModifier: ViewModifier { + @Binding var error: String? + @EnvironmentObject var networkMonitor: NetworkMonitor + + private var shouldShowAlert: Bool { + guard let msg = error else { return false } + if !networkMonitor.isConnected { + let keywords = ["internet", "offline", "network", "connection", "unreachable", "timed out", "no data"] + if keywords.contains(where: { msg.lowercased().contains($0) }) { + DispatchQueue.main.async { self.error = nil } + return false + } + } + return true + } + + func body(content: Content) -> some View { + content.alert("Error", isPresented: Binding( + get: { shouldShowAlert }, + set: { if !$0 { error = nil } } + )) { + Button("OK") { error = nil } + } message: { + Text(error ?? "") + } + } +} + +// 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) + case .userProfile(let username): + UserProfileView(username: username) + case .browseCategory(let sort, let genre, let status, let title): + BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) + } + } + .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) + case .userProfile(let username): UserProfileView(username: username) + case .browseCategory(let sort, let genre, let status, let title): + BrowseCategoryView(sort: sort, genre: genre, status: status, title: title) + } + } + } + } +} + +// MARK: - Environment key: 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: - Preference key: hide mini player + +struct HideMiniPlayerKey: PreferenceKey { + static var defaultValue = false + static func reduce(value: inout Bool, nextValue: () -> Bool) { value = value || nextValue() } +} + +// MARK: - Cover zoom source modifier + +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 + } + } +} diff --git a/ios/LibNovelV2/Extensions/String+App.swift b/ios/LibNovelV2/Extensions/String+App.swift new file mode 100644 index 0000000..e96307f --- /dev/null +++ b/ios/LibNovelV2/Extensions/String+App.swift @@ -0,0 +1,19 @@ +import Foundation + +extension String { + /// Strips trailing date parentheticals from chapter titles. + /// Handles formats like: + /// " (January 5, 2025)" + /// " - Jan 01 2024" + func strippingTrailingDate() -> String { + let patterns = [ + #"\s*\([A-Za-z]+ \d{1,2},\s+\d{4}\)\s*$"#, + #"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"#, + ] + var result = self + for pattern in patterns { + result = result.replacingOccurrences(of: pattern, with: "", options: .regularExpression) + } + return result.trimmingCharacters(in: .whitespaces) + } +} diff --git a/ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj b/ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj new file mode 100644 index 0000000..227599a --- /dev/null +++ b/ios/LibNovelV2/LibNovelV2.xcodeproj/project.pbxproj @@ -0,0 +1,577 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */; }; + 280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */; }; + 29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */; }; + 2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2634D20198A966396121230 /* LibraryView.swift */; }; + 30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */; }; + 43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */; }; + 464782001051686356AF728B /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736DA6CB7D7759E1791F6236 /* SearchView.swift */; }; + 4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378336A1684E738283821857 /* ContentView.swift */; }; + 5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4A3006B972DFF660959FE3 /* APIClient.swift */; }; + 6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D37A1BCABF9787BA6E243C8F /* ProfileView.swift */; }; + 64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */; }; + 7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C68D19B123EC191D53A694E /* BookDetailView.swift */; }; + 78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D006B236F6FE653131FFD2 /* PlayerViews.swift */; }; + 792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 125054A25A37A42295D49B10 /* NetworkMonitor.swift */; }; + 7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF859D4970913FEBA89CB0F /* CommonViews.swift */; }; + 9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F48756100041DE38F573449 /* ChapterReaderView.swift */; }; + 9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */; }; + A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880A0B86A80386BEA76FF388 /* NavDestination.swift */; }; + B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */; }; + ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */; }; + ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92BE9AB59740382D85BD5296 /* DownloadsView.swift */; }; + ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */; }; + B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5E37231B0150A128C72D49 /* HomeView.swift */; }; + B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC088495FFC3053AAE0F124 /* RootTabView.swift */; }; + BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D715E3B2A6FE40FB628ADD2D /* AuthView.swift */; }; + C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB713100B2B1F429924A107C /* VoiceSelectionView.swift */; }; + DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */; }; + E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */; }; + E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */; }; + F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98B6C380A20E783F1F7A7DB /* AuthStore.swift */; }; + F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B8078366569A958BE54D23 /* BrowseViewModel.swift */; }; + FC954C552CC0BDFB619BF207 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4180EB2AEECC51E4A7F5231 /* Models.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; + 125054A25A37A42295D49B10 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; + 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookVoicePreferences.swift; sourceTree = ""; }; + 378336A1684E738283821857 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 3C5E37231B0150A128C72D49 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 3EF859D4970913FEBA89CB0F /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = ""; }; + 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = ""; }; + 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + 5C68D19B123EC191D53A694E /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = ""; }; + 71D006B236F6FE653131FFD2 /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = ""; }; + 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDownloadService.swift; sourceTree = ""; }; + 736DA6CB7D7759E1791F6236 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = ""; }; + 7F4A3006B972DFF660959FE3 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelV2App.swift; sourceTree = ""; }; + 880A0B86A80386BEA76FF388 /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = ""; }; + C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = ""; }; + 8F48756100041DE38F573449 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = ""; }; + 92BE9AB59740382D85BD5296 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; + 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = ""; }; + 94CB555099A941E16AD0531A /* LibNovelV2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovelV2.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; + ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseCategoryView.swift; sourceTree = ""; }; + B4180EB2AEECC51E4A7F5231 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + BFC088495FFC3053AAE0F124 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = ""; }; + D37A1BCABF9787BA6E243C8F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + D715E3B2A6FE40FB628ADD2D /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; + DB713100B2B1F429924A107C /* VoiceSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSelectionView.swift; sourceTree = ""; }; + F2634D20198A966396121230 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + F2B8078366569A958BE54D23 /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = ""; }; + F98B6C380A20E783F1F7A7DB /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = ""; }; + FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 03533E32FF0C2EAF1915AD15 /* Products */ = { + isa = PBXGroup; + children = ( + 94CB555099A941E16AD0531A /* LibNovelV2.app */, + ); + name = Products; + sourceTree = ""; + }; + 19F98554C19DCB1FD6ED835E /* Services */ = { + isa = PBXGroup; + children = ( + 72BA2BF82A660E953CBB526A /* AudioDownloadService.swift */, + 930C6A69F3E601E2297071CD /* AudioPlayerService.swift */, + F98B6C380A20E783F1F7A7DB /* AuthStore.swift */, + 2E76C0661FC6FAB3BAA86711 /* BookVoicePreferences.swift */, + 125054A25A37A42295D49B10 /* NetworkMonitor.swift */, + ); + path = Services; + sourceTree = ""; + }; + 20E9B4B0C0EDDB3313149544 /* Common */ = { + isa = PBXGroup; + children = ( + 3EF859D4970913FEBA89CB0F /* CommonViews.swift */, + ); + path = Common; + sourceTree = ""; + }; + 25D179F65B0041EE826DEF5B /* App */ = { + isa = PBXGroup; + children = ( + 378336A1684E738283821857 /* ContentView.swift */, + 84D88622224F38A541CE9F8D /* LibNovelV2App.swift */, + BFC088495FFC3053AAE0F124 /* RootTabView.swift */, + ); + path = App; + sourceTree = ""; + }; + 2F4B97A2A2234F71AE2C46B2 /* Home */ = { + isa = PBXGroup; + children = ( + 3C5E37231B0150A128C72D49 /* HomeView.swift */, + ); + path = Home; + sourceTree = ""; + }; + 36240FA179A3701F15D1AAE1 /* Extensions */ = { + isa = PBXGroup; + children = ( + 880A0B86A80386BEA76FF388 /* NavDestination.swift */, + C2D3E4F5A6B7C8D9E0F1A2B3 /* String+App.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 3A6125CA86E249F3D6DC7F8C /* BookDetail */ = { + isa = PBXGroup; + children = ( + 5C68D19B123EC191D53A694E /* BookDetailView.swift */, + ); + path = BookDetail; + sourceTree = ""; + }; + 448620B67D4AEEEF2CAED3C0 /* Models */ = { + isa = PBXGroup; + children = ( + B4180EB2AEECC51E4A7F5231 /* Models.swift */, + ); + path = Models; + sourceTree = ""; + }; + 716D22431B17611F7A418D9F /* Profile */ = { + isa = PBXGroup; + children = ( + D37A1BCABF9787BA6E243C8F /* ProfileView.swift */, + 1548C08BADD28B057A9DFD5F /* UserProfileView.swift */, + DB713100B2B1F429924A107C /* VoiceSelectionView.swift */, + ); + path = Profile; + sourceTree = ""; + }; + 8BCE05349B706BF8EE0E16DD /* LibNovelV2 */ = { + isa = PBXGroup; + children = ( + ); + name = LibNovelV2; + path = .; + sourceTree = ""; + }; + 9AFE0816FF2E9D8DBBA470BD /* Downloads */ = { + isa = PBXGroup; + children = ( + 92BE9AB59740382D85BD5296 /* DownloadsView.swift */, + ); + path = Downloads; + sourceTree = ""; + }; + 9CFE23EEA1B9E264A36D0FC4 /* Search */ = { + isa = PBXGroup; + children = ( + 736DA6CB7D7759E1791F6236 /* SearchView.swift */, + ); + path = Search; + sourceTree = ""; + }; + 9E5A2471B9D5ECAF6B65FD22 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 7E61714857FDAA22186D7A6C /* BookDetailViewModel.swift */, + F2B8078366569A958BE54D23 /* BrowseViewModel.swift */, + 4753E3FCFD2C6AEB0E58D5A1 /* ChapterReaderViewModel.swift */, + 4A6F099EE054F6EF867B19D9 /* HomeViewModel.swift */, + 96B3C942AFBA555D43F56C53 /* LibraryViewModel.swift */, + FCC1125FE0F6CD9F01F69B75 /* SearchViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + A05A1FE213A8E179B2302EF2 /* Auth */ = { + isa = PBXGroup; + children = ( + D715E3B2A6FE40FB628ADD2D /* AuthView.swift */, + ); + path = Auth; + sourceTree = ""; + }; + AA1F8D9C3DA40A1ADCF2B432 = { + isa = PBXGroup; + children = ( + 25D179F65B0041EE826DEF5B /* App */, + 36240FA179A3701F15D1AAE1 /* Extensions */, + 8BCE05349B706BF8EE0E16DD /* LibNovelV2 */, + 448620B67D4AEEEF2CAED3C0 /* Models */, + AFDC950B142FEDA471F394EC /* Networking */, + C468271A8BC443D1B82A1BE0 /* Resources */, + 19F98554C19DCB1FD6ED835E /* Services */, + 9E5A2471B9D5ECAF6B65FD22 /* ViewModels */, + CBC1A32FA53E9B3D5E15995D /* Views */, + 03533E32FF0C2EAF1915AD15 /* Products */, + ); + indentWidth = 4; + sourceTree = ""; + tabWidth = 4; + usesTabs = 0; + }; + AF1FE530FDE94947D4966251 /* ChapterReader */ = { + isa = PBXGroup; + children = ( + 8F48756100041DE38F573449 /* ChapterReaderView.swift */, + ); + path = ChapterReader; + sourceTree = ""; + }; + AFDC950B142FEDA471F394EC /* Networking */ = { + isa = PBXGroup; + children = ( + 7F4A3006B972DFF660959FE3 /* APIClient.swift */, + ); + path = Networking; + sourceTree = ""; + }; + BFA030D1CE2D312C539318DA /* Browse */ = { + isa = PBXGroup; + children = ( + ABE69B91683576A056DE99EC /* BrowseCategoryView.swift */, + 06C95D52A96318B6CAD22EB0 /* BrowseView.swift */, + ); + path = Browse; + sourceTree = ""; + }; + C468271A8BC443D1B82A1BE0 /* Resources */ = { + isa = PBXGroup; + children = ( + ); + path = Resources; + sourceTree = ""; + }; + CBC1A32FA53E9B3D5E15995D /* Views */ = { + isa = PBXGroup; + children = ( + A05A1FE213A8E179B2302EF2 /* Auth */, + 3A6125CA86E249F3D6DC7F8C /* BookDetail */, + BFA030D1CE2D312C539318DA /* Browse */, + AF1FE530FDE94947D4966251 /* ChapterReader */, + 20E9B4B0C0EDDB3313149544 /* Common */, + 9AFE0816FF2E9D8DBBA470BD /* Downloads */, + 2F4B97A2A2234F71AE2C46B2 /* Home */, + ED5843EA1B9CB1AD97664571 /* Library */, + F9025CCFC608DCEB21B4D9F5 /* Player */, + 716D22431B17611F7A418D9F /* Profile */, + 9CFE23EEA1B9E264A36D0FC4 /* Search */, + ); + path = Views; + sourceTree = ""; + }; + ED5843EA1B9CB1AD97664571 /* Library */ = { + isa = PBXGroup; + children = ( + F2634D20198A966396121230 /* LibraryView.swift */, + ); + path = Library; + sourceTree = ""; + }; + F9025CCFC608DCEB21B4D9F5 /* Player */ = { + isa = PBXGroup; + children = ( + 71D006B236F6FE653131FFD2 /* PlayerViews.swift */, + ); + path = Player; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7EEA688C50B734EA22C04CF1 /* LibNovelV2 */ = { + isa = PBXNativeTarget; + buildConfigurationList = 38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */; + buildPhases = ( + BE6BEAD873B53447AABD2346 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LibNovelV2; + packageProductDependencies = ( + ); + productName = LibNovelV2; + productReference = 94CB555099A941E16AD0531A /* LibNovelV2.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1AC8476B8E9026EB9CE2B4FF /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = 92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = AA1F8D9C3DA40A1ADCF2B432; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 03533E32FF0C2EAF1915AD15 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7EEA688C50B734EA22C04CF1 /* LibNovelV2 */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + BE6BEAD873B53447AABD2346 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5FCFCBFBEEFDFD2081068317 /* APIClient.swift in Sources */, + 280AC764BC30130EDB27A3F0 /* AudioDownloadService.swift in Sources */, + E64BCBBA92A983C3851754B5 /* AudioPlayerService.swift in Sources */, + F1DB9BC6DC6DFEEA010B7CDF /* AuthStore.swift in Sources */, + BEE8DF9B5E6C35389FB07951 /* AuthView.swift in Sources */, + 7431E92F141CFFF28E891A11 /* BookDetailView.swift in Sources */, + 30EE28A725E2FA69F8FFCEF8 /* BookDetailViewModel.swift in Sources */, + ACE6D62D8E547A90380FB689 /* BookVoicePreferences.swift in Sources */, + 43034688B18F6F6CD65C5DE5 /* BrowseCategoryView.swift in Sources */, + ABB16424CEED3C5E9AAC08B2 /* BrowseView.swift in Sources */, + F4DAA587A097C597A9841563 /* BrowseViewModel.swift in Sources */, + 9F4A645472DC48AD32D5EDCD /* ChapterReaderView.swift in Sources */, + 64B17B6E30F44E87F33B886B /* ChapterReaderViewModel.swift in Sources */, + 7C59289066AFD8A999DB9A0A /* CommonViews.swift in Sources */, + 4F72B63F12BB364C561B5B69 /* ContentView.swift in Sources */, + ACCA21E0EDF8BED26E193A76 /* DownloadsView.swift in Sources */, + B4C6205A3A7A7A29EDA691FF /* HomeView.swift in Sources */, + 075C7E597E108D806195B2F0 /* HomeViewModel.swift in Sources */, + 9FD80E1B54ED74F430064904 /* LibNovelV2App.swift in Sources */, + 2FB2A044EBE6B90CFB51CF58 /* LibraryView.swift in Sources */, + DDBAD183F7974A6FDAECB93C /* LibraryViewModel.swift in Sources */, + FC954C552CC0BDFB619BF207 /* Models.swift in Sources */, + A753C2AE73CAA00BF1AB0EA4 /* NavDestination.swift in Sources */, + B1E2F3A4C5D6E7F8A9B0C1D2 /* String+App.swift in Sources */, + 792042C137942BCF8CB99C4F /* NetworkMonitor.swift in Sources */, + 78F2392702ACB553CAFDB335 /* PlayerViews.swift in Sources */, + 6340BF19FE12FCEBE9607889 /* ProfileView.swift in Sources */, + B8C5C43F299C89CFAE4000F1 /* RootTabView.swift in Sources */, + 464782001051686356AF728B /* SearchView.swift in Sources */, + 29D0FB039902E6691FBE40DA /* SearchViewModel.swift in Sources */, + E8112B785D129C26FEC054AB /* UserProfileView.swift in Sources */, + C0EA8DBE751CB22F058CBF20 /* VoiceSelectionView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 019B1386650D49B9F4F6CCF7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.10; + }; + name = Release; + }; + 086D97837CBA0A9177D50BB2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = GHZXC6FVMU; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2; + PROVISIONING_PROFILE = "af592c3a-f60b-4ac1-a14f-30b8a206017f"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 1A953D152E39A2F172BB4DE4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = GHZXC6FVMU; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.kalekber.LibNovelV2; + PROVISIONING_PROFILE_SPECIFIER = ""; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C91972DB753AE2CF04BED70E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_PREVIEWS = YES; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc"; + MARKETING_VERSION = 1.0.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.10; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 38B2D5E78E086CB61602C375 /* Build configuration list for PBXNativeTarget "LibNovelV2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A953D152E39A2F172BB4DE4 /* Debug */, + 086D97837CBA0A9177D50BB2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 92AD4EEF6E109D5DC11B2A6F /* Build configuration list for PBXProject "LibNovelV2" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C91972DB753AE2CF04BED70E /* Debug */, + 019B1386650D49B9F4F6CCF7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1AC8476B8E9026EB9CE2B4FF /* Project object */; +} diff --git a/ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/LibNovelV2/LibNovelV2.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/LibNovelV2/LibNovelV2.xcodeproj/xcshareddata/xcschemes/LibNovelV2.xcscheme b/ios/LibNovelV2/LibNovelV2.xcodeproj/xcshareddata/xcschemes/LibNovelV2.xcscheme new file mode 100644 index 0000000..3a7f4a9 --- /dev/null +++ b/ios/LibNovelV2/LibNovelV2.xcodeproj/xcshareddata/xcschemes/LibNovelV2.xcscheme @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/LibNovelV2/Models/Models.swift b/ios/LibNovelV2/Models/Models.swift new file mode 100644 index 0000000..24f8055 --- /dev/null +++ b/ios/LibNovelV2/Models/Models.swift @@ -0,0 +1,417 @@ +import Foundation +import SwiftUI + +// MARK: - Book + +struct Book: Identifiable, Codable, Hashable { + let id: String + let slug: String + let title: String + let author: String + let cover: String // proxied via /api/cover/... + let status: String + let genres: [String] + let summary: String + let totalChapters: Int + let sourceURL: String + let ranking: Int + let metaUpdated: String + + enum CodingKeys: String, CodingKey { + case id, slug, title, author, cover, status, genres, summary, ranking + case totalChapters = "total_chapters" + case sourceURL = "source_url" + case metaUpdated = "meta_updated" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + slug = try c.decode(String.self, forKey: .slug) + title = try c.decode(String.self, forKey: .title) + author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" + cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" + status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" + totalChapters = try c.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0 + sourceURL = try c.decodeIfPresent(String.self, forKey: .sourceURL) ?? "" + ranking = try c.decodeIfPresent(Int.self, forKey: .ranking) ?? 0 + metaUpdated = try c.decodeIfPresent(String.self, forKey: .metaUpdated) ?? "" + summary = try c.decodeIfPresent(String.self, forKey: .summary) ?? "" + + // genres can arrive as a JSON-encoded string or a real array + if let arr = try? c.decode([String].self, forKey: .genres) { + genres = arr + } else if let raw = try? c.decode(String.self, forKey: .genres), + let data = raw.data(using: .utf8), + let arr = try? JSONDecoder().decode([String].self, from: data) { + genres = arr + } else { + genres = [] + } + } +} + +// MARK: - Chapter index + +struct ChapterIndex: Identifiable, Codable, Hashable { + let id: String + let slug: String + let number: Int + let title: String + let dateLabel: String + + enum CodingKeys: String, CodingKey { + case id, slug, number, title + case dateLabel = "date_label" + } +} + +struct ChapterBrief: Identifiable, Codable, Hashable { + var id: Int { number } + let number: Int + let title: String +} + +// Full chapter response from /api/chapter-text/{slug}/{n} +struct ChapterResponse: Decodable { + struct BookBrief: Decodable { + let slug: String + let title: String + let cover: String + } + struct ChapterDetail: Decodable { + let number: Int + let title: String + let dateLabel: String + + enum CodingKeys: String, CodingKey { + case number, title + case dateLabel = "date_label" + } + } + + let book: BookBrief + let chapter: ChapterDetail + let chapters: [ChapterBrief] + let html: String + let text: String + let prev: Int? + let next: Int? +} + +// MARK: - Ranking + +struct RankingItem: Codable, Identifiable { + var id: String { slug } + let rank: Int + let slug: String + let title: String + let author: String + let cover: String + let status: String + let genres: [String] + let sourceURL: String + + enum CodingKeys: String, CodingKey { + case rank, slug, title, author, cover, status, genres + case sourceURL = "source_url" + } +} + +// MARK: - Browse listing + +struct NovelListing: Codable, Identifiable { + var id: String { slug } + let slug: String + let title: String + let author: String? + let cover: String? + let status: String? + let genres: [String]? + let rank: Int? + let rating: String? + let chapters: String? // e.g. "123 chapters" + let url: String? + let sourceURL: String? + + enum CodingKeys: String, CodingKey { + case slug, title, author, cover, status, genres, rank, rating, chapters, url + case sourceURL = "source_url" + } +} + +// MARK: - Home + +struct HomeStats: Codable { + let totalBooks: Int + let totalChapters: Int + let booksInProgress: Int + + enum CodingKeys: String, CodingKey { + case totalBooks = "total_books" + case totalChapters = "total_chapters" + case booksInProgress = "books_in_progress" + } +} + +struct ContinueReadingItem: Identifiable { + var id: String { book.id } + let book: Book + let chapter: Int +} + +struct SubscriptionFeedItem: Identifiable, Decodable { + var id: String { book.id + readerUsername } + let book: Book + let readerUsername: String + + enum CodingKeys: String, CodingKey { + case book + case readerUsername = "readerUsername" + } +} + +// MARK: - User + +struct AppUser: Codable, Identifiable { + let id: String + let username: String + let role: String + let created: String + let avatarURL: String? + + var isAdmin: Bool { role == "admin" } + + enum CodingKeys: String, CodingKey { + case id, username, role, created + case avatarURL = "avatar_url" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + username = try c.decode(String.self, forKey: .username) + role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user" + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + avatarURL = try c.decodeIfPresent(String.self, forKey: .avatarURL) + } + + init(id: String, username: String, role: String, created: String, avatarURL: String?) { + self.id = id + self.username = username + self.role = role + self.created = created + self.avatarURL = avatarURL + } +} + +// MARK: - User settings + +struct UserSettings: Codable { + var autoNext: Bool + var voice: String + var speed: Double + + static let `default` = UserSettings(autoNext: false, voice: "af_bella", speed: 1.0) +} + +// MARK: - Session + +struct UserSession: Codable, Identifiable { + let id: String + let userAgent: String + let ip: String + let createdAt: String + let lastSeen: String + var isCurrent: Bool + + enum CodingKeys: String, CodingKey { + case id, ip + case userAgent = "user_agent" + case createdAt = "created_at" + case lastSeen = "last_seen" + case isCurrent = "is_current" + } +} + +// MARK: - Comments + +struct BookComment: Identifiable, Codable, Hashable { + let id: String + let slug: String + let userId: String + let username: String + let body: String + var upvotes: Int + var downvotes: Int + let created: String + let parentId: String + var replies: [BookComment]? + + enum CodingKeys: String, CodingKey { + case id, slug, username, body, upvotes, downvotes, created, replies + case userId = "user_id" + case parentId = "parent_id" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" + userId = try c.decodeIfPresent(String.self, forKey: .userId) ?? "" + username = try c.decodeIfPresent(String.self, forKey: .username) ?? "" + body = try c.decodeIfPresent(String.self, forKey: .body) ?? "" + upvotes = try c.decodeIfPresent(Int.self, forKey: .upvotes) ?? 0 + downvotes = try c.decodeIfPresent(Int.self, forKey: .downvotes) ?? 0 + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + parentId = try c.decodeIfPresent(String.self, forKey: .parentId) ?? "" + replies = try c.decodeIfPresent([BookComment].self, forKey: .replies) + } +} + +struct CommentsResponse: Decodable { + let comments: [BookComment] + let myVotes: [String: String] + let avatarUrls: [String: String] + + enum CodingKeys: String, CodingKey { + case comments, myVotes, avatarUrls + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + comments = try c.decode([BookComment].self, forKey: .comments) + myVotes = try c.decodeIfPresent([String: String].self, forKey: .myVotes) ?? [:] + avatarUrls = try c.decodeIfPresent([String: String].self, forKey: .avatarUrls) ?? [:] + } +} + +// MARK: - Public user profile + +struct PublicUserProfile: Decodable, Identifiable { + let id: String + let username: String + let avatarUrl: String? + let created: String + let followerCount: Int + let followingCount: Int + let isSubscribed: Bool + let isSelf: Bool + + enum CodingKeys: String, CodingKey { + case id, username, created + case avatarUrl = "avatarUrl" + case followerCount = "followerCount" + case followingCount = "followingCount" + case isSubscribed = "isSubscribed" + case isSelf = "isSelf" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + username = try c.decode(String.self, forKey: .username) + avatarUrl = try c.decodeIfPresent(String.self, forKey: .avatarUrl) + created = try c.decodeIfPresent(String.self, forKey: .created) ?? "" + followerCount = try c.decodeIfPresent(Int.self, forKey: .followerCount) ?? 0 + followingCount = try c.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0 + isSubscribed = try c.decodeIfPresent(Bool.self, forKey: .isSubscribed) ?? false + isSelf = try c.decodeIfPresent(Bool.self, forKey: .isSelf) ?? false + } +} + +struct PublicLibraryItem: Decodable, Identifiable { + var id: String { book.id } + let book: Book + let lastChapter: Int? + let saved: Bool + + enum CodingKeys: String, CodingKey { + case book + case lastChapter = "last_chapter" + case saved + } +} + +struct PublicUserLibraryResponse: Decodable { + let currentlyReading: [PublicLibraryItem] + let library: [PublicLibraryItem] + + enum CodingKeys: String, CodingKey { + case currentlyReading = "currently_reading" + case library + } +} + +// MARK: - Reader Settings (local — 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.10, 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 + 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 = 17 + var lineSpacing: CGFloat = 1.7 + var font: ReaderFont = .system + var theme: ReaderTheme = .white + var scrollMode: Bool = false + + private static let key = "v2.readerSettings" + + static func load() -> ReaderSettings { + guard let data = UserDefaults.standard.data(forKey: key), + let decoded = try? JSONDecoder().decode(ReaderSettings.self, from: data) + else { return ReaderSettings() } + return decoded + } + + func save() { + if let data = try? JSONEncoder().encode(self) { + UserDefaults.standard.set(data, forKey: ReaderSettings.key) + } + } +} + +// MARK: - Audio prefetch status + +enum NextPrefetchStatus { + case none, prefetching, prefetched, failed +} diff --git a/ios/LibNovelV2/Networking/APIClient.swift b/ios/LibNovelV2/Networking/APIClient.swift new file mode 100644 index 0000000..96f0196 --- /dev/null +++ b/ios/LibNovelV2/Networking/APIClient.swift @@ -0,0 +1,520 @@ +import Foundation + +// MARK: - API Client +// Communicates with the SvelteKit UI server (/api/* endpoints). +// Auth is carried via the libnovel_auth cookie (HMAC-signed token). + +actor APIClient { + static let shared = APIClient() + + var baseURL: URL + private var authCookie: String? // raw "libnovel_auth=" header value + + private let session: URLSession = { + let config = URLSessionConfiguration.default + config.httpCookieAcceptPolicy = .always + config.httpShouldSetCookies = true + config.httpCookieStorage = HTTPCookieStorage.shared + return URLSession(configuration: config) + }() + + private init() { + let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + baseURL = URL(string: urlString)! + } + + // MARK: - Auth cookie management + + func setAuthCookie(_ value: String?) { + authCookie = value + if let value { + let cookieProps: [HTTPCookiePropertyKey: Any] = [ + .name: "libnovel_auth", + .value: value, + .domain: baseURL.host ?? "localhost", + .path: "/" + ] + if let cookie = HTTPCookie(properties: cookieProps) { + HTTPCookieStorage.shared.setCookie(cookie) + } + } else { + let storage = HTTPCookieStorage.shared + storage.cookies(for: baseURL)?.forEach { storage.deleteCookie($0) } + } + } + + // MARK: - Low-level request builder + + private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest { + let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = URL(string: urlString) else { throw APIError.invalidResponse } + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + } + return req + } + + // MARK: - Generic fetch + + func fetch(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T { + let req = try makeRequest(path, method: method, body: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + let rawBody = String(data: data, encoding: .utf8) ?? "" + guard (200..<300).contains(http.statusCode) else { + if http.statusCode == 401 { throw APIError.unauthorized } + throw APIError.httpError(http.statusCode, rawBody) + } + do { + return try JSONDecoder.apiDecoder.decode(T.self, from: data) + } catch { + throw APIError.decodingError(error) + } + } + + func fetchVoid(_ path: String, method: String = "GET", body: Encodable? = nil) async throws { + let req = try makeRequest(path, method: method, body: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { + let rawBody = String(data: data, encoding: .utf8) ?? "" + throw APIError.httpError(http.statusCode, rawBody) + } + } + + // MARK: - Auth + + private struct LoginRequest: Encodable { + let username: String + let password: String + } + + struct LoginResponse: Decodable { + let token: String + let user: AppUser + } + + func login(username: String, password: String) async throws -> LoginResponse { + try await fetch("/api/auth/login", method: "POST", + body: LoginRequest(username: username, password: password)) + } + + func register(username: String, password: String) async throws -> LoginResponse { + try await fetch("/api/auth/register", method: "POST", + body: LoginRequest(username: username, password: password)) + } + + func logout() async throws { + let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST") + setAuthCookie(nil) + } + + // MARK: - Home + + func homeData() async throws -> HomeDataResponse { + try await fetch("/api/home") + } + + // MARK: - Library + + func library() async throws -> [LibraryItem] { + try await fetch("/api/library") + } + + func saveBook(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST") + } + + func unsaveBook(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE") + } + + // MARK: - Book Detail + + func bookDetail(slug: String) async throws -> BookDetailResponse { + try await fetch("/api/book/\(slug)") + } + + // MARK: - Chapter + + func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse { + try await fetch("/api/chapter/\(slug)/\(chapter)") + } + + // MARK: - Browse + + func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse { + let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)" + return try await fetch("/api/browse-page\(query)") + } + + func search(query: String) async throws -> SearchResponse { + let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query + return try await fetch("/api/search?q=\(encoded)") + } + + func ranking() async throws -> [RankingItem] { + try await fetch("/api/ranking") + } + + // MARK: - Progress + + func progress() async throws -> [ProgressEntry] { + try await fetch("/api/progress") + } + + func setProgress(slug: String, chapter: Int) async throws { + struct Body: Encodable { let chapter: Int } + let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter)) + } + + func deleteProgress(slug: String) async throws { + let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "DELETE") + } + + func audioTime(slug: String, chapter: Int) async throws -> Double? { + struct Response: Decodable { + let audioTime: Double? + enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } + } + let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)") + return r.audioTime + } + + func setAudioTime(slug: String, chapter: Int, time: Double) async throws { + struct Body: Encodable { + let slug: String; let chapter: Int; let audioTime: Double + enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } + } + let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", + body: Body(slug: slug, chapter: chapter, audioTime: time)) + } + + // MARK: - Audio + + func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioTriggerResponse { + struct Body: Encodable { let voice: String; let speed: Double } + return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed)) + } + + /// Poll until the TTS job is done, failed, or the task is cancelled. + /// Returns the playback URL on success. + func pollAudioStatus(slug: String, chapter: Int, voice: String) async throws -> String { + let path = "/api/audio/status/\(slug)/\(chapter)?voice=\(voice)" + struct StatusResponse: Decodable { + let status: String + let url: String? + let error: String? + } + while true { + try Task.checkCancellation() + let r: StatusResponse = try await fetch(path) + switch r.status { + case "done": + guard let url = r.url, !url.isEmpty else { throw URLError(.badServerResponse) } + return url + case "failed": + throw NSError(domain: "AudioGeneration", code: 0, + userInfo: [NSLocalizedDescriptionKey: r.error ?? "Audio generation failed"]) + default: + try await Task.sleep(nanoseconds: 2_000_000_000) + } + } + } + + func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String { + struct Response: Decodable { let url: String } + let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)") + return r.url + } + + func presignVoiceSample(voice: String) async throws -> String { + struct Response: Decodable { let url: String } + let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)") + return r.url + } + + func voices() async throws -> [String] { + struct Response: Decodable { let voices: [String] } + let r: Response = try await fetch("/api/voices") + return r.voices + } + + // MARK: - Settings + + func settings() async throws -> UserSettings { + try await fetch("/api/settings") + } + + func updateSettings(_ settings: UserSettings) async throws { + let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings) + } + + // MARK: - Sessions + + func sessions() async throws -> [UserSession] { + struct Response: Decodable { let sessions: [UserSession] } + let r: Response = try await fetch("/api/sessions") + return r.sessions + } + + func revokeSession(id: String) async throws { + let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE") + } + + // MARK: - Avatar + + struct AvatarPresignResponse: Decodable { + let uploadURL: String + let key: String + enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key } + } + + struct AvatarResponse: Decodable { + let avatarURL: String? + enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" } + } + + func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? { + let presign: AvatarPresignResponse = try await fetch( + "/api/profile/avatar", method: "POST", body: ["mime_type": mimeType]) + + guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse } + var putReq = URLRequest(url: putURL) + putReq.httpMethod = "PUT" + putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type") + putReq.httpBody = imageData + let (_, putResp) = try await session.data(for: putReq) + guard let putHttp = putResp as? HTTPURLResponse, (200..<300).contains(putHttp.statusCode) else { + throw APIError.httpError((putResp as? HTTPURLResponse)?.statusCode ?? 0, "MinIO PUT failed") + } + + let result: AvatarResponse = try await fetch("/api/profile/avatar", method: "PATCH", body: ["key": presign.key]) + return result.avatarURL + } + + func fetchAvatarPresignedURL() async throws -> String? { + let result: AvatarResponse = try await fetch("/api/profile/avatar") + return result.avatarURL + } + + // MARK: - User Profiles & Subscriptions + + func fetchUserProfile(username: String) async throws -> PublicUserProfile { + try await fetch("/api/users/\(username)") + } + + @discardableResult + func subscribeUser(username: String) async throws -> Bool { + struct Response: Decodable { let subscribed: Bool } + let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "POST") + return r.subscribed + } + + @discardableResult + func unsubscribeUser(username: String) async throws -> Bool { + struct Response: Decodable { let subscribed: Bool } + let r: Response = try await fetch("/api/users/\(username)/subscribe", method: "DELETE") + return r.subscribed + } + + func fetchUserLibrary(username: String) async throws -> PublicUserLibraryResponse { + try await fetch("/api/users/\(username)/library") + } + + // MARK: - Comments + + func fetchComments(slug: String, sort: String = "top") async throws -> CommentsResponse { + try await fetch("/api/comments/\(slug)?sort=\(sort)") + } + + private struct PostCommentBody: Encodable { + let body: String + let parent_id: String? + } + + func postComment(slug: String, body: String, parentId: String? = nil) async throws -> BookComment { + try await fetch("/api/comments/\(slug)", method: "POST", + body: PostCommentBody(body: body, parent_id: parentId)) + } + + func voteComment(commentId: String, vote: String) async throws -> BookComment { + struct VoteBody: Encodable { let vote: String } + return try await fetch("/api/comment/\(commentId)/vote", method: "POST", body: VoteBody(vote: vote)) + } + + func deleteComment(commentId: String) async throws { + try await fetchVoid("/api/comment/\(commentId)", method: "DELETE") + } +} + +// MARK: - Response types + +struct HomeDataResponse: Decodable { + struct ContinueItem: Decodable { + let book: Book + let chapter: Int + } + let continueReading: [ContinueItem] + let recentlyUpdated: [Book] + let stats: HomeStats + let subscriptionFeed: [SubscriptionFeedItem] + + enum CodingKeys: String, CodingKey { + case continueReading = "continue_reading" + case recentlyUpdated = "recently_updated" + case stats + case subscriptionFeed = "subscription_feed" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + continueReading = try c.decodeIfPresent([ContinueItem].self, forKey: .continueReading) ?? [] + recentlyUpdated = try c.decodeIfPresent([Book].self, forKey: .recentlyUpdated) ?? [] + stats = try c.decode(HomeStats.self, forKey: .stats) + subscriptionFeed = try c.decodeIfPresent([SubscriptionFeedItem].self, forKey: .subscriptionFeed) ?? [] + } +} + +struct LibraryItem: Decodable, Identifiable { + var id: String { book.id } + let book: Book + let savedAt: String + let lastChapter: Int? + + enum CodingKeys: String, CodingKey { + case book + case savedAt = "saved_at" + case lastChapter = "last_chapter" + } +} + +struct BookDetailResponse: Decodable { + let book: Book + let chapters: [ChapterIndex] + let inLib: Bool + let saved: Bool + let lastChapter: Int? + + enum CodingKeys: String, CodingKey { + case book, chapters + case inLib = "in_lib" + case saved + case lastChapter = "last_chapter" + } +} + +struct BrowseResponse: Decodable { + let novels: [BrowseNovel] + let page: Int + let hasNext: Bool +} + +struct BrowseNovel: Decodable, Identifiable, Hashable { + var id: String { slug.isEmpty ? url : slug } + let slug: String + let title: String + let cover: String + let rank: String + let rating: String + let chapters: String + let url: String + let author: String + let status: String + let genres: [String] + + enum CodingKeys: String, CodingKey { + case slug, title, cover, rank, rating, chapters, url, author, status, genres + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? "" + title = try c.decode(String.self, forKey: .title) + cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? "" + rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? "" + rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? "" + chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? "" + url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" + author = try c.decodeIfPresent(String.self, forKey: .author) ?? "" + status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" + genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? [] + } +} + +struct SearchResponse: Decodable { + let results: [BrowseNovel] + let localCount: Int + let remoteCount: Int + + enum CodingKeys: String, CodingKey { + case results + case localCount = "local_count" + case remoteCount = "remote_count" + } +} + +struct AudioTriggerResponse: Decodable { + let jobId: String? + let status: String? + let url: String? + let filename: String? + + enum CodingKeys: String, CodingKey { + case jobId = "job_id" + case status, url, filename + } + + var isAsync: Bool { jobId != nil } +} + +struct ProgressEntry: Decodable, Identifiable { + var id: String { slug } + let slug: String + let chapter: Int + let audioTime: Double? + let updated: String + + enum CodingKeys: String, CodingKey { + case slug, chapter, updated + case audioTime = "audio_time" + } +} + +struct EmptyResponse: Decodable {} + +// MARK: - API Error + +enum APIError: LocalizedError { + case invalidResponse + case httpError(Int, String) + case decodingError(Error) + case unauthorized + case networkError(Error) + + var errorDescription: String? { + switch self { + case .invalidResponse: return "Invalid server response" + case .httpError(let code, let m): return "HTTP \(code): \(m)" + case .decodingError(let e): return "Decode error: \(e.localizedDescription)" + case .unauthorized: return "Not authenticated" + case .networkError(let e): return e.localizedDescription + } + } +} + +// MARK: - JSONDecoder helper + +extension JSONDecoder { + static let apiDecoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() +} diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/LibNovelV2/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..cea2357 --- /dev/null +++ b/ios/LibNovelV2/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,12 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { "alpha": "1.000", "blue": "0.043", "green": "0.620", "red": "0.961" } + }, + "idiom": "universal" + } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..efca0a1 --- /dev/null +++ b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,11 @@ +{ + "images": [ + { + "filename": "icon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..820557a Binary files /dev/null and b/ios/LibNovelV2/Resources/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/ios/LibNovelV2/Resources/Assets.xcassets/Contents.json b/ios/LibNovelV2/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..319a86b --- /dev/null +++ b/ios/LibNovelV2/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info": { "author": "xcode", "version": 1 } +} diff --git a/ios/LibNovelV2/Resources/Info.plist b/ios/LibNovelV2/Resources/Info.plist new file mode 100644 index 0000000..43230b5 --- /dev/null +++ b/ios/LibNovelV2/Resources/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDisplayName + LibNovel + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + LibNovel + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LIBNOVEL_BASE_URL + $(LIBNOVEL_BASE_URL) + LSRequiresIPhoneOS + + UIBackgroundModes + + audio + fetch + processing + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/LibNovelV2/Services/AudioDownloadService.swift b/ios/LibNovelV2/Services/AudioDownloadService.swift new file mode 100644 index 0000000..8c7ac03 --- /dev/null +++ b/ios/LibNovelV2/Services/AudioDownloadService.swift @@ -0,0 +1,230 @@ +import Foundation +import Combine + +// MARK: - AudioDownloadService +// Manages offline TTS audio downloads with progress tracking. +// Uses a background URLSession so downloads survive app suspension. +// Keys use "::" separator (slugs contain hyphens). + +@MainActor +final class AudioDownloadService: NSObject, ObservableObject { + static let shared = AudioDownloadService() + + // MARK: - Published state + + @Published var downloads: [String: DownloadProgress] = [:] // key: "slug::chapter::voice" + @Published var downloadedChapters: Set = [] // key: "slug::chapter::voice" + + // MARK: - Private + + private var session: URLSession! + private var activeTasks: [String: URLSessionDownloadTask] = [:] + private let fileManager = FileManager.default + private let metadataKey = "v2.downloadedChapters" + + // MARK: - Init + + private override init() { + super.init() + let config = URLSessionConfiguration.background( + withIdentifier: "cc.kalekber.libnovel.v2.audio-downloads") + config.isDiscretionary = false + config.sessionSendsLaunchEvents = true + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + loadMetadata() + } + + // MARK: - Public API + + func isDownloaded(slug: String, chapter: Int, voice: String) -> Bool { + downloadedChapters.contains(makeKey(slug: slug, chapter: chapter, voice: voice)) + } + + func localURL(slug: String, chapter: Int, voice: String) -> URL? { + guard isDownloaded(slug: slug, chapter: chapter, voice: voice) else { return nil } + return audioFileURL(slug: slug, chapter: chapter, voice: voice) + } + + func download(slug: String, chapter: Int, voice: String) async throws { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + guard !downloadedChapters.contains(key), activeTasks[key] == nil else { return } + + let urlString = try await APIClient.shared.presignAudio(slug: slug, chapter: chapter, voice: voice) + guard let url = URL(string: urlString) else { throw URLError(.badURL) } + + let task = session.downloadTask(with: url) + task.taskDescription = key + activeTasks[key] = task + + downloads[key] = DownloadProgress( + slug: slug, chapter: chapter, voice: voice, + progress: 0, totalBytes: 0, downloadedBytes: 0, status: .downloading) + task.resume() + } + + func cancelDownload(slug: String, chapter: Int, voice: String) { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + activeTasks[key]?.cancel() + activeTasks.removeValue(forKey: key) + downloads.removeValue(forKey: key) + } + + func deleteDownload(slug: String, chapter: Int, voice: String) throws { + let key = makeKey(slug: slug, chapter: chapter, voice: voice) + let fileURL = audioFileURL(slug: slug, chapter: chapter, voice: voice) + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } + downloadedChapters.remove(key) + downloads.removeValue(forKey: key) + saveMetadata() + } + + func deleteAllDownloads() throws { + if let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + let audioDir = docs.appendingPathComponent("audio") + if fileManager.fileExists(atPath: audioDir.path) { + try fileManager.removeItem(at: audioDir) + } + } + downloadedChapters.removeAll() + downloads.removeAll() + activeTasks.values.forEach { $0.cancel() } + activeTasks.removeAll() + saveMetadata() + } + + func totalStorageUsed() -> Int64 { + guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return 0 } + let audioDir = docs.appendingPathComponent("audio") + guard let enumerator = fileManager.enumerator(at: audioDir, + includingPropertiesForKeys: [.fileSizeKey]) else { return 0 } + var total: Int64 = 0 + for case let url as URL in enumerator { + if let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize { + total += Int64(size) + } + } + return total + } + + func offlineBookSlugs() -> [String] { + Array(Set(downloadedChapters.compactMap { key -> String? in + let parts = key.split(separator: "::") + return parts.count == 3 ? String(parts[0]) : nil + })).sorted() + } + + func downloadedChapterCount(for slug: String) -> Int { + downloadedChapters.filter { $0.hasPrefix("\(slug)::") }.count + } + + // MARK: - Key / path helpers + + func makeKey(slug: String, chapter: Int, voice: String) -> String { + "\(slug)::\(chapter)::\(voice)" + } + + nonisolated private func audioFileURL(slug: String, chapter: Int, voice: String) -> URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return docs + .appendingPathComponent("audio") + .appendingPathComponent(slug) + .appendingPathComponent("\(chapter)-\(voice).mp3") + } + + // MARK: - Persistence + + private func loadMetadata() { + if let data = UserDefaults.standard.data(forKey: metadataKey), + let decoded = try? JSONDecoder().decode(Set.self, from: data) { + downloadedChapters = decoded + } + } + + private func saveMetadata() { + if let encoded = try? JSONEncoder().encode(downloadedChapters) { + UserDefaults.standard.set(encoded, forKey: metadataKey) + } + } +} + +// MARK: - URLSessionDownloadDelegate + +extension AudioDownloadService: URLSessionDownloadDelegate { + + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL) { + guard let key = downloadTask.taskDescription else { return } + let parts = key.split(separator: "::") + guard parts.count == 3, let chapter = Int(parts[1]) else { return } + let slug = String(parts[0]) + let voice = String(parts[2]) + let dest = audioFileURL(slug: slug, chapter: chapter, voice: voice) + + do { + let dir = dest.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.moveItem(at: location, to: dest) + Task { @MainActor in + self.downloadedChapters.insert(key) + self.downloads.removeValue(forKey: key) + self.activeTasks.removeValue(forKey: key) + self.saveMetadata() + } + } catch { + Task { @MainActor in + self.downloads[key]?.status = .failed(error.localizedDescription) + self.activeTasks.removeValue(forKey: key) + } + } + } + + nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, + didWriteData _: Int64, totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64) { + guard let key = downloadTask.taskDescription else { return } + let progress = totalBytesExpectedToWrite > 0 + ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0 + Task { @MainActor in + if var p = self.downloads[key] { + p.downloadedBytes = totalBytesWritten + p.totalBytes = totalBytesExpectedToWrite + p.progress = progress + self.downloads[key] = p + } + } + } + + nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) { + guard let key = task.taskDescription, let error else { return } + let nsErr = error as NSError + guard nsErr.code != NSURLErrorCancelled else { return } + Task { @MainActor in + self.downloads[key]?.status = .failed(error.localizedDescription) + self.activeTasks.removeValue(forKey: key) + } + } +} + +// MARK: - Supporting types + +struct DownloadProgress: Equatable { + let slug: String + let chapter: Int + let voice: String + var progress: Double + var totalBytes: Int64 + var downloadedBytes: Int64 + var status: DownloadStatus +} + +enum DownloadStatus: Equatable { + case downloading + case completed + case failed(String) +} diff --git a/ios/LibNovelV2/Services/AudioPlayerService.swift b/ios/LibNovelV2/Services/AudioPlayerService.swift new file mode 100644 index 0000000..03d441d --- /dev/null +++ b/ios/LibNovelV2/Services/AudioPlayerService.swift @@ -0,0 +1,492 @@ +import Foundation +import AVFoundation +import MediaPlayer +import Combine + +// MARK: - PlaybackProgress +// High-frequency playback state isolated into its own ObservableObject so that +// the 0.5-second time-observer ticks only invalidate views that explicitly +// subscribe to this object (seek bar, play/pause button), leaving menus and +// other stable UI untouched. + +@MainActor +final class PlaybackProgress: ObservableObject { + @Published var currentTime: Double = 0 + @Published var duration: Double = 0 + @Published var isPlaying: Bool = false +} + +// MARK: - AudioPlayerService +// Central singleton owning AVPlayer, lock-screen controls (NowPlayingInfoCenter +// + MPRemoteCommandCenter), and next-chapter prefetch. + +@MainActor +final class AudioPlayerService: ObservableObject { + + // MARK: - Published state + + @Published var slug: String = "" + @Published var chapter: Int = 0 + @Published var chapterTitle: String = "" + @Published var bookTitle: String = "" + @Published var coverURL: String = "" + @Published var voice: String = "af_bella" + @Published var speed: Double = 1.0 + @Published var chapters: [ChapterBrief] = [] + + @Published var status: AudioPlayerStatus = .idle + @Published var audioURL: String = "" + @Published var errorMessage: String = "" + @Published var generationProgress: Double = 0 + + /// High-frequency playback state — subscribe directly to avoid re-rendering parents. + let progress = PlaybackProgress() + + // Convenience forwarders for callers that don't need granular isolation. + var currentTime: Double { get { progress.currentTime } set { progress.currentTime = newValue } } + var duration: Double { get { progress.duration } set { progress.duration = newValue } } + var isPlaying: Bool { get { progress.isPlaying } set { progress.isPlaying = newValue } } + + @Published var autoNext: Bool = false + @Published var nextChapter: Int? = nil + @Published var prevChapter: Int? = nil + + @Published var sleepTimer: SleepTimerOption? = nil + @Published var sleepTimerRemainingText: String = "" + + @Published var nextPrefetchStatus: NextPrefetchStatus = .none + @Published var nextAudioURL: String = "" + @Published var nextPrefetchedChapter: Int? = nil + + var isActive: Bool { + if case .idle = status { return false } + return true + } + + // MARK: - Private + + private var player: AVPlayer? + private var playerItem: AVPlayerItem? + private var timeObserver: Any? + private var statusObserver: AnyCancellable? + private var durationObserver: AnyCancellable? + private var finishObserver: AnyCancellable? + private var generationTask: Task? + private var prefetchTask: Task? + + private var cachedCoverArtwork: MPMediaItemArtwork? + private var cachedCoverURL: String = "" + + private var sleepTimerTask: Task? + private var sleepTimerStartChapter: Int = 0 + private var sleepTimerDeadline: Date? = nil + private var sleepTimerCountdownTask: Task? = nil + + // MARK: - Init + + init() { + configureAudioSession() + setupRemoteCommandCenter() + } + + // MARK: - Public API + + func load(slug: String, chapter: Int, chapterTitle: String, + bookTitle: String, coverURL: String, voice: String, speed: Double, + chapters: [ChapterBrief], nextChapter: Int?, prevChapter: Int?) { + generationTask?.cancel() + prefetchTask?.cancel() + stop() + + self.slug = slug + self.chapter = chapter + self.chapterTitle = chapterTitle + self.bookTitle = bookTitle + self.coverURL = coverURL + self.voice = voice + self.speed = speed + self.chapters = chapters + self.nextChapter = nextChapter + self.prevChapter = prevChapter + self.nextPrefetchStatus = .none + self.nextAudioURL = "" + self.nextPrefetchedChapter = nil + + if case .chapters = sleepTimer { sleepTimerStartChapter = chapter } + + status = .generating + generationProgress = 0 + + if coverURL != cachedCoverURL { + cachedCoverArtwork = nil + cachedCoverURL = coverURL + Task { await prefetchCoverArtwork(from: coverURL) } + } + + generationTask = Task { await generateAudio() } + } + + func play() { + player?.play() + player?.rate = Float(speed) + isPlaying = true + updateNowPlaying() + } + + func pause() { + player?.pause() + isPlaying = false + updateNowPlaying() + } + + func togglePlayPause() { + isPlaying ? pause() : play() + } + + func seek(to seconds: Double) { + let time = CMTime(seconds: seconds, preferredTimescale: 600) + currentTime = seconds + player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in + guard let self else { return } + Task { @MainActor in self.updateNowPlaying() } + } + } + + func skip(by seconds: Double) { + seek(to: max(0, min(currentTime + seconds, duration))) + } + + func setSpeed(_ newSpeed: Double) { + speed = newSpeed + if isPlaying { player?.rate = Float(newSpeed) } + updateNowPlaying() + } + + func setSleepTimer(_ option: SleepTimerOption?) { + sleepTimerTask?.cancel(); sleepTimerTask = nil + sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil + sleepTimerDeadline = nil + sleepTimer = option + + guard let option else { sleepTimerRemainingText = ""; return } + + switch option { + case .chapters(let count): + sleepTimerStartChapter = chapter + updateChapterTimerLabel(chaptersRemaining: count) + + case .minutes(let minutes): + let deadline = Date().addingTimeInterval(Double(minutes) * 60) + sleepTimerDeadline = deadline + 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 = "" } + } + 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 d = self.sleepTimerDeadline else { return } + self.sleepTimerRemainingText = Self.formatCountdown(max(0, d.timeIntervalSinceNow)) + } + } + } + sleepTimerRemainingText = Self.formatCountdown(Double(minutes) * 60) + } + } + + func stop() { + player?.pause() + teardownPlayer() + isPlaying = false + currentTime = 0 + duration = 0 + audioURL = "" + status = .idle + sleepTimerTask?.cancel(); sleepTimerTask = nil + sleepTimerCountdownTask?.cancel(); sleepTimerCountdownTask = nil + sleepTimerDeadline = nil + sleepTimer = nil + sleepTimerRemainingText = "" + } + + // MARK: - Private helpers + + 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)) + return "\(s / 60):\(String(format: "%02d", s % 60))" + } + + // MARK: - Audio generation + + private func generateAudio() async { + guard !slug.isEmpty, chapter > 0 else { return } + + // Local file first (offline download) + if let localURL = AudioDownloadService.shared.localURL(slug: slug, chapter: chapter, voice: voice) { + audioURL = localURL.absoluteString + status = .ready + generationProgress = 100 + await playURL(localURL.absoluteString) + await prefetchNext() + return + } + + do { + // Fast path: audio already in MinIO + if let presigned = try? await APIClient.shared.presignAudio( + slug: slug, chapter: chapter, voice: voice) { + audioURL = presigned + status = .ready + generationProgress = 100 + await playURL(presigned) + await prefetchNext() + return + } + + // Slow path: trigger TTS generation + status = .generating + generationProgress = 10 + let trigger = try await APIClient.shared.triggerAudio( + slug: slug, chapter: chapter, voice: voice, speed: speed) + + let playableURL: String + if trigger.isAsync { + generationProgress = 30 + playableURL = try await APIClient.shared.pollAudioStatus( + slug: slug, chapter: chapter, voice: voice) + } else { + guard let url = trigger.url, !url.isEmpty else { throw URLError(.badServerResponse) } + playableURL = url + } + + audioURL = playableURL + status = .ready + generationProgress = 100 + await playURL(playableURL) + await prefetchNext() + } catch is CancellationError { + // Cancelled — no-op + } catch { + status = .error(error.localizedDescription) + errorMessage = error.localizedDescription + } + } + + // MARK: - Prefetch next chapter + + private func prefetchNext() async { + guard let next = nextChapter, !Task.isCancelled else { return } + nextPrefetchStatus = .prefetching + nextPrefetchedChapter = next + do { + if let presigned = try? await APIClient.shared.presignAudio( + slug: slug, chapter: next, voice: voice) { + nextAudioURL = presigned + nextPrefetchStatus = .prefetched + return + } + let trigger = try await APIClient.shared.triggerAudio( + slug: slug, chapter: next, voice: voice, speed: speed) + let url: String + if trigger.isAsync { + url = try await APIClient.shared.pollAudioStatus(slug: slug, chapter: next, voice: voice) + } else { + guard let u = trigger.url, !u.isEmpty else { throw URLError(.badServerResponse) } + url = u + } + nextAudioURL = url + nextPrefetchStatus = .prefetched + } catch { + nextPrefetchStatus = .failed + } + } + + // MARK: - AVPlayer management + + private func playURL(_ urlString: String) async { + let resolved: URL? + if urlString.hasPrefix("http://") || urlString.hasPrefix("https://") { + resolved = URL(string: urlString) + } else { + resolved = URL(string: urlString, + relativeTo: await APIClient.shared.baseURL)?.absoluteURL + } + guard let url = resolved else { return } + + teardownPlayer() + let item = AVPlayerItem(url: url) + playerItem = item + player = AVPlayer(playerItem: item) + + durationObserver = item.publisher(for: \.duration) + .receive(on: RunLoop.main) + .sink { [weak self] dur in + guard let self else { return } + let secs = dur.seconds + if secs.isFinite && secs > 0 { self.duration = secs; self.updateNowPlaying() } + } + + statusObserver = item.publisher(for: \.status) + .receive(on: RunLoop.main) + .sink { [weak self] s in + guard let self else { return } + switch s { + case .readyToPlay: + self.player?.rate = Float(self.speed) + self.isPlaying = true + self.updateNowPlaying() + case .failed: + self.status = .error(item.error?.localizedDescription ?? "Playback failed") + self.errorMessage = item.error?.localizedDescription ?? "Playback failed" + default: break + } + } + + timeObserver = player?.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), + queue: .main + ) { [weak self] time in + guard let self else { return } + Task { @MainActor in + let secs = time.seconds + if secs.isFinite && secs >= 0 { self.currentTime = secs } + } + } + + finishObserver = NotificationCenter.default + .publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item) + .sink { [weak self] _ in Task { @MainActor in self?.handlePlaybackFinished() } } + + player?.play() + } + + private func teardownPlayer() { + if let obs = timeObserver { player?.removeTimeObserver(obs) } + timeObserver = nil; statusObserver = nil; durationObserver = nil; finishObserver = nil + player = nil; playerItem = nil + } + + private func handlePlaybackFinished() { + isPlaying = false + guard let next = nextChapter else { return } + + // Chapter-based sleep timer + if case .chapters(let count) = sleepTimer { + let played = chapter - sleepTimerStartChapter + 1 + if played >= count { stop(); return } + updateChapterTimerLabel(chaptersRemaining: count - played) + } + + NotificationCenter.default.post( + name: .audioDidFinishChapter, object: nil, + userInfo: ["next": next, "autoNext": autoNext]) + + guard autoNext else { return } + + let nextTitle = chapters.first(where: { $0.number == next })?.title ?? "" + let nextNextChapter = chapters.first(where: { $0.number > next })?.number + + if nextPrefetchStatus == .prefetched, !nextAudioURL.isEmpty { + let url = nextAudioURL + chapter = next + chapterTitle = nextTitle + nextChapter = nextNextChapter + prevChapter = chapter + nextPrefetchStatus = .none + nextAudioURL = "" + nextPrefetchedChapter = nil + audioURL = url + status = .ready + generationProgress = 100 + if case .chapters = sleepTimer { sleepTimerStartChapter = next } + generationTask = Task { await playURL(url); await prefetchNext() } + } else { + load(slug: slug, chapter: next, chapterTitle: nextTitle, + bookTitle: bookTitle, coverURL: coverURL, + voice: voice, speed: speed, chapters: chapters, + nextChapter: nextNextChapter, prevChapter: chapter) + } + } + + // MARK: - Cover art (URLSession — no Kingfisher) + + private func prefetchCoverArtwork(from urlString: String) async { + guard !urlString.isEmpty, let url = URL(string: urlString) else { return } + guard let (data, _) = try? await URLSession.shared.data(from: url), + let image = UIImage(data: data) else { return } + let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + cachedCoverArtwork = artwork + updateNowPlaying() + } + + // MARK: - Audio session + + private func configureAudioSession() { + try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) + try? AVAudioSession.sharedInstance().setActive(true) + } + + // MARK: - Lock-screen controls + + private func setupRemoteCommandCenter() { + let center = MPRemoteCommandCenter.shared() + center.playCommand.addTarget { [weak self] _ in self?.play(); return .success } + center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success } + center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlayPause(); return .success } + center.skipForwardCommand.preferredIntervals = [15] + center.skipForwardCommand.addTarget { [weak self] _ in self?.skip(by: 15); return .success } + center.skipBackwardCommand.preferredIntervals = [15] + center.skipBackwardCommand.addTarget { [weak self] _ in self?.skip(by: -15); return .success } + center.changePlaybackPositionCommand.addTarget { [weak self] event in + if let e = event as? MPChangePlaybackPositionCommandEvent { self?.seek(to: e.positionTime) } + return .success + } + } + + private func updateNowPlaying() { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle, + MPMediaItemPropertyArtist: bookTitle, + MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime, + MPMediaItemPropertyPlaybackDuration: duration, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0 + ] + if let artwork = cachedCoverArtwork { info[MPMediaItemPropertyArtwork] = artwork } + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } +} + +// MARK: - Supporting types + +enum AudioPlayerStatus: Equatable { + case idle + case generating + case ready + case error(String) + + static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.generating, .generating), (.ready, .ready): return true + case (.error(let a), .error(let b)): return a == b + default: return false + } + } +} + +enum SleepTimerOption: Equatable { + case chapters(Int) + case minutes(Int) +} + +extension Notification.Name { + static let audioDidFinishChapter = Notification.Name("v2.audioDidFinishChapter") + static let skipToNextChapter = Notification.Name("v2.skipToNextChapter") + static let skipToPrevChapter = Notification.Name("v2.skipToPrevChapter") +} diff --git a/ios/LibNovelV2/Services/AuthStore.swift b/ios/LibNovelV2/Services/AuthStore.swift new file mode 100644 index 0000000..ceebede --- /dev/null +++ b/ios/LibNovelV2/Services/AuthStore.swift @@ -0,0 +1,144 @@ +import Foundation +import Combine + +// MARK: - AuthStore +// Owns the authenticated user, the HMAC auth token, and user settings. +// Persists the token to Keychain so the user stays logged in across launches. + +@MainActor +final class AuthStore: ObservableObject { + @Published var user: AppUser? + @Published var settings: UserSettings = .default + @Published var isLoading: Bool = false + @Published var error: String? + + var isAuthenticated: Bool { user != nil } + + private let keychainKey = "libnovel_v2_auth_token" + + init() { + if let token = loadToken() { + Task { await validateToken(token) } + } + } + + // MARK: - Login / Register + + func login(username: String, password: String) async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.login(username: username, password: password) + await APIClient.shared.setAuthCookie(response.token) + saveToken(response.token) + user = response.user + await loadSettings() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func register(username: String, password: String) async { + isLoading = true + error = nil + do { + let response = try await APIClient.shared.register(username: username, password: password) + await APIClient.shared.setAuthCookie(response.token) + saveToken(response.token) + user = response.user + await loadSettings() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + func logout() async { + do { try await APIClient.shared.logout() } catch {} + clearToken() + user = nil + settings = .default + } + + // MARK: - Settings + + func loadSettings() async { + do { settings = try await APIClient.shared.settings() } catch {} + } + + func saveSettings(_ updated: UserSettings) async { + do { + try await APIClient.shared.updateSettings(updated) + settings = updated + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - Token validation + + func validateToken() async { + guard let token = loadToken() else { return } + await validateToken(token) + } + + private func validateToken(_ token: String) async { + await APIClient.shared.setAuthCookie(token) + do { + async let me: AppUser = APIClient.shared.fetch("/api/auth/me") + async let s: UserSettings = APIClient.shared.settings() + var (restoredUser, restoredSettings) = try await (me, s) + // Exchange raw MinIO key for a presigned URL if needed. + if let key = restoredUser.avatarURL, !key.hasPrefix("http") { + if let presignedURL = try? await APIClient.shared.fetchAvatarPresignedURL() { + restoredUser = AppUser( + id: restoredUser.id, + username: restoredUser.username, + role: restoredUser.role, + created: restoredUser.created, + avatarURL: presignedURL + ) + } + } + user = restoredUser + settings = restoredSettings + } catch let e as APIError { + if case .httpError(let code, _) = e, code == 401 { clearToken() } + } catch {} + } + + // MARK: - Keychain helpers + + private func saveToken(_ token: String) { + let data = Data(token.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecValueData as String: data + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + private func loadToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private func clearToken() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keychainKey + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/ios/LibNovelV2/Services/BookVoicePreferences.swift b/ios/LibNovelV2/Services/BookVoicePreferences.swift new file mode 100644 index 0000000..f817c4f --- /dev/null +++ b/ios/LibNovelV2/Services/BookVoicePreferences.swift @@ -0,0 +1,59 @@ +import Foundation + +// MARK: - BookVoicePreferences +// Manages per-book voice overrides with global fallback. +// Persisted in UserDefaults as a slug → voice dictionary. + +@MainActor +final class BookVoicePreferences: ObservableObject { + static let shared = BookVoicePreferences() + + @Published private(set) var bookVoices: [String: String] = [:] + + private let key = "v2.bookVoicePreferences" + + private init() { + if let data = UserDefaults.standard.data(forKey: key), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) { + bookVoices = decoded + } + } + + // MARK: - Public API + + func voice(for slug: String) -> String? { + bookVoices[slug] + } + + /// Voice priority: book override → globalVoice → "af_bella" + func voiceWithFallback(for slug: String, globalVoice: String) -> String { + bookVoices[slug] ?? globalVoice + } + + func setVoice(_ voice: String, for slug: String) { + bookVoices[slug] = voice + save() + } + + func removeVoice(for slug: String) { + bookVoices.removeValue(forKey: slug) + save() + } + + func hasOverride(for slug: String) -> Bool { + bookVoices[slug] != nil + } + + func clearAll() { + bookVoices.removeAll() + save() + } + + // MARK: - Persistence + + private func save() { + if let encoded = try? JSONEncoder().encode(bookVoices) { + UserDefaults.standard.set(encoded, forKey: key) + } + } +} diff --git a/ios/LibNovelV2/Services/NetworkMonitor.swift b/ios/LibNovelV2/Services/NetworkMonitor.swift new file mode 100644 index 0000000..d1a0800 --- /dev/null +++ b/ios/LibNovelV2/Services/NetworkMonitor.swift @@ -0,0 +1,43 @@ +import Foundation +import Network + +// MARK: - NetworkMonitor +// Monitors network connectivity. Inject as an environment object for offline UI. + +@MainActor +final class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + @Published var isConnected: Bool = true + @Published var connectionType: NWInterface.InterfaceType? + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "cc.kalekber.libnovel.v2.network-monitor") + + init() { + monitor.pathUpdateHandler = { [weak self] path in + Task { @MainActor [weak self] in + self?.isConnected = path.status == .satisfied + self?.connectionType = path.availableInterfaces.first?.type + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } +} + +extension NWInterface.InterfaceType { + var displayName: String { + switch self { + case .wifi: return "Wi-Fi" + case .cellular: return "Cellular" + case .wiredEthernet: return "Ethernet" + case .loopback: return "Loopback" + case .other: return "Other" + @unknown default: return "Unknown" + } + } +} diff --git a/ios/LibNovelV2/ViewModels/BookDetailViewModel.swift b/ios/LibNovelV2/ViewModels/BookDetailViewModel.swift new file mode 100644 index 0000000..f337b20 --- /dev/null +++ b/ios/LibNovelV2/ViewModels/BookDetailViewModel.swift @@ -0,0 +1,80 @@ +import Foundation + +// MARK: - BookDetailViewModel +// Loads book metadata, chapter index, save state, and reading progress. +// Uses @Observable (iOS 17+). + +@Observable +@MainActor +final class BookDetailViewModel { + let slug: String + + var book: Book? + var chapters: [ChapterIndex] = [] + var inLib: Bool = false + var saved: Bool = false + var lastChapter: Int? + + var isLoading = false + var isSaving = false + var error: String? + + init(slug: String) { + self.slug = slug + } + + // MARK: - Load + + func load() async { + guard !isLoading else { return } + isLoading = true + error = nil + do { + let response = try await APIClient.shared.bookDetail(slug: slug) + book = response.book + chapters = response.chapters + inLib = response.inLib + saved = response.saved + lastChapter = response.lastChapter + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + // MARK: - Toggle saved (bookmark) + + func toggleSaved() async { + guard !isSaving else { return } + isSaving = true + let targetSaved = !saved + saved = targetSaved // optimistic update + do { + if targetSaved { + try await APIClient.shared.saveBook(slug: slug) + if !inLib { inLib = true } + } else { + try await APIClient.shared.unsaveBook(slug: slug) + } + } catch { + saved = !targetSaved // revert on failure + self.error = error.localizedDescription + } + isSaving = false + } + + // MARK: - Chapter helpers + + /// Title stripped of trailing " - Month DD YYYY" date suffixes. + func displayTitle(for chapter: ChapterIndex) -> String { + let stripped = chapter.title.strippingTrailingDate() + if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { + return "Chapter \(chapter.number)" + } + return stripped + } +} + + diff --git a/ios/LibNovelV2/ViewModels/BrowseViewModel.swift b/ios/LibNovelV2/ViewModels/BrowseViewModel.swift new file mode 100644 index 0000000..7b78e9d --- /dev/null +++ b/ios/LibNovelV2/ViewModels/BrowseViewModel.swift @@ -0,0 +1,146 @@ +import Foundation + +// MARK: - BrowseViewModel +// Powers both the Discover shelves (BrowseView) and the full paginated grid (BrowseCategoryView). +// Uses @Observable (iOS 17+). + +@Observable +@MainActor +final class BrowseViewModel { + + // MARK: - Discover shelves (BrowseView) + + var trending: [BrowseNovel] = [] + var newReleases: [BrowseNovel] = [] + var recentlyUpdated: [BrowseNovel] = [] + var ranking: [BrowseNovel] = [] + + // MARK: - Paginated grid (BrowseCategoryView) + + var novels: [BrowseNovel] = [] + var currentPage = 1 + var hasNext = false + + // Filter params (BrowseCategoryView sets these before calling loadFirstPage) + var sort: String = "popular" + var genre: String = "all" + var status: String = "all" + + // MARK: - UI state + + var isLoading = false + var isLoadingMore = false + var error: String? + + // MARK: - Discover load (fetches multiple shelves in parallel) + + func loadShelves() async { + isLoading = true + error = nil + + do { + async let trendingTask = APIClient.shared.browse(page: 1, genre: "all", sort: "popular", status: "all") + async let newTask = APIClient.shared.browse(page: 1, genre: "all", sort: "new", status: "all") + async let updatedTask = APIClient.shared.browse(page: 1, genre: "all", sort: "update", status: "all") + async let rankingTask = APIClient.shared.ranking() + + let (trendingResp, newResp, updatedResp, rankItems) = try await ( + trendingTask, newTask, updatedTask, rankingTask + ) + + trending = Array(trendingResp.novels.prefix(12)) + newReleases = Array(newResp.novels.prefix(12)) + recentlyUpdated = Array(updatedResp.novels.prefix(12)) + ranking = rankItems.prefix(12).map { item in + BrowseNovelFromRanking(item) + } + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + // MARK: - Paginated category load + + func loadFirstPage() async { + guard !isLoading else { return } + novels = [] + currentPage = 1 + hasNext = false + isLoading = true + error = nil + + do { + let resp = try await APIClient.shared.browse( + page: 1, genre: genre, sort: sort, status: status) + novels = resp.novels + currentPage = resp.page + hasNext = resp.hasNext + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + func loadNextPage() async { + guard hasNext, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + + let next = currentPage + 1 + do { + let resp = try await APIClient.shared.browse( + page: next, genre: genre, sort: sort, status: status) + novels += resp.novels + currentPage = resp.page + hasNext = resp.hasNext + } catch { + // Silently ignore — user can scroll again + } + isLoadingMore = false + } + + // MARK: - Ranking load (for rank sort mode) + + func loadRanking() async { + guard !isLoading else { return } + novels = [] + hasNext = false + isLoading = true + error = nil + + do { + let items = try await APIClient.shared.ranking() + novels = items.map { BrowseNovelFromRanking($0) } + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } +} + +// MARK: - RankingItem → BrowseNovel adapter + +private func BrowseNovelFromRanking(_ item: RankingItem) -> BrowseNovel { + // Synthesise a minimal JSON blob so we can decode via the standard init + let rankStr = "#\(item.rank)" + let dict: [String: Any] = [ + "slug": item.slug, + "title": item.title, + "cover": item.cover, + "rank": rankStr, + "rating": "", + "chapters": "", + "url": item.sourceURL, + "author": item.author, + "status": item.status, + "genres": item.genres + ] + let data = try! JSONSerialization.data(withJSONObject: dict) + return try! JSONDecoder.apiDecoder.decode(BrowseNovel.self, from: data) +} diff --git a/ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift b/ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift new file mode 100644 index 0000000..f32fa2e --- /dev/null +++ b/ios/LibNovelV2/ViewModels/ChapterReaderViewModel.swift @@ -0,0 +1,69 @@ +import Foundation + +// MARK: - ChapterReaderViewModel + +@Observable @MainActor +final class ChapterReaderViewModel { + let slug: String + private(set) var chapter: Int + + var content: ChapterResponse? + var isLoading = false + var error: String? + + init(slug: String, chapter: Int) { + self.slug = slug + self.chapter = chapter + } + + /// Switch to a different chapter in-place; `chapter` change causes `.task(id: chapter)` to re-fire `load()`. + func switchChapter(to newChapter: Int) { + guard newChapter != chapter else { return } + chapter = newChapter + content = nil + error = nil + } + + func load() async { + isLoading = true + error = nil + do { + content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter) + try? await APIClient.shared.setProgress(slug: slug, chapter: chapter) + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) { + guard let content else { return } + + let isCurrent = audioPlayer.isActive + && audioPlayer.slug == slug + && audioPlayer.chapter == chapter + + if isCurrent { + audioPlayer.togglePlayPause() + } else { + let voice = BookVoicePreferences.shared.voiceWithFallback( + for: slug, + globalVoice: settings.voice + ) + audioPlayer.load( + slug: slug, + chapter: chapter, + chapterTitle: content.chapter.title, + bookTitle: content.book.title, + coverURL: content.book.cover, + voice: voice, + speed: settings.speed, + chapters: content.chapters, + nextChapter: content.next, + prevChapter: content.prev + ) + } + } +} diff --git a/ios/LibNovelV2/ViewModels/HomeViewModel.swift b/ios/LibNovelV2/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..2a8cb7d --- /dev/null +++ b/ios/LibNovelV2/ViewModels/HomeViewModel.swift @@ -0,0 +1,35 @@ +import Foundation + +// MARK: - HomeViewModel +// Fetches home-screen data: continue reading, recently updated, stats, subscription feed. +// Uses @Observable (iOS 17+). + +@Observable +@MainActor +final class HomeViewModel { + var continueReading: [ContinueReadingItem] = [] + var recentlyUpdated: [Book] = [] + var stats: HomeStats? + var subscriptionFeed: [SubscriptionFeedItem] = [] + var isLoading = false + var error: String? + + func load() async { + isLoading = true + error = nil + do { + let data = try await APIClient.shared.homeData() + continueReading = data.continueReading.map { + ContinueReadingItem(book: $0.book, chapter: $0.chapter) + } + recentlyUpdated = data.recentlyUpdated + stats = data.stats + subscriptionFeed = data.subscriptionFeed + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } +} diff --git a/ios/LibNovelV2/ViewModels/LibraryViewModel.swift b/ios/LibNovelV2/ViewModels/LibraryViewModel.swift new file mode 100644 index 0000000..1ae7765 --- /dev/null +++ b/ios/LibNovelV2/ViewModels/LibraryViewModel.swift @@ -0,0 +1,164 @@ +import Foundation + +// MARK: - LibraryViewModel +// Loads library items and exposes filtered/sorted views for LibraryView. +// Uses @Observable (iOS 17+). + +enum LibrarySortOrder: String, CaseIterable { + case recent = "Recent" + case title = "Title" + case author = "Author" + case progress = "Progress" +} + +enum LibraryReadingFilter: String, CaseIterable { + case all = "All" + case inProgress = "In Progress" + case completed = "Completed" +} + +@Observable +@MainActor +final class LibraryViewModel { + // Raw data + var items: [LibraryItem] = [] + var progressMap: [String: Int] = [:] // slug -> last chapter read + + // Filter & sort state + var sortOrder: LibrarySortOrder = .recent + var readingFilter: LibraryReadingFilter = .all + var selectedGenre: String = "All" + + // UI state + var isLoading = false + var error: String? + + // MARK: - Derived + + var allGenres: [String] { + var seen = Set() + var result: [String] = ["All"] + for item in items { + for genre in item.book.genres where !seen.contains(genre) { + seen.insert(genre) + result.append(genre) + } + } + return result + } + + var filteredItems: [LibraryItem] { + var list = items + + // Genre filter + if selectedGenre != "All" { + list = list.filter { $0.book.genres.contains(selectedGenre) } + } + + // Reading filter + switch readingFilter { + case .all: + break + case .inProgress: + list = list.filter { item in + let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0 + return ch > 0 && ch < item.book.totalChapters + } + case .completed: + list = list.filter { item in + let ch = progressMap[item.book.slug] ?? item.lastChapter ?? 0 + return item.book.totalChapters > 0 && ch >= item.book.totalChapters + } + } + + // Sort + switch sortOrder { + case .recent: + // server already returns newest-saved first; preserve order + break + case .title: + list.sort { $0.book.title.localizedCaseInsensitiveCompare($1.book.title) == .orderedAscending } + case .author: + list.sort { $0.book.author.localizedCaseInsensitiveCompare($1.book.author) == .orderedAscending } + case .progress: + list.sort { a, b in + let pa = progressFraction(for: a) + let pb = progressFraction(for: b) + return pa > pb + } + } + + return list + } + + // MARK: - Progress helpers + + func lastChapter(for item: LibraryItem) -> Int { + progressMap[item.book.slug] ?? item.lastChapter ?? 0 + } + + func progressFraction(for item: LibraryItem) -> Double { + let total = item.book.totalChapters + guard total > 0 else { return 0 } + return Double(lastChapter(for: item)) / Double(total) + } + + func progressPercent(for item: LibraryItem) -> String { + let fraction = progressFraction(for: item) + let pct = fraction * 100 + if pct < 10 { + return String(format: "%.1f%%", pct) + } else { + return String(format: "%.0f%%", pct) + } + } + + func isCompleted(for item: LibraryItem) -> Bool { + let total = item.book.totalChapters + guard total > 0 else { return false } + return lastChapter(for: item) >= total + } + + // MARK: - Load + + func load() async { + isLoading = true + error = nil + do { + async let libraryTask = APIClient.shared.library() + async let progressTask = APIClient.shared.progress() + + let (library, progressEntries) = try await (libraryTask, progressTask) + items = library + progressMap = Dictionary(uniqueKeysWithValues: progressEntries.map { ($0.slug, $0.chapter) }) + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + } + } + isLoading = false + } + + // MARK: - Mutations + + func removeFromLibrary(slug: String) async { + // Optimistic remove + items.removeAll { $0.book.slug == slug } + do { + try await APIClient.shared.unsaveBook(slug: slug) + } catch { + // Silently fail — user can pull-to-refresh to restore + } + } + + func markFinished(item: LibraryItem) async { + let total = item.book.totalChapters + guard total > 0 else { return } + progressMap[item.book.slug] = total + do { + try await APIClient.shared.setProgress(slug: item.book.slug, chapter: total) + } catch { + // Silently fail + } + } +} diff --git a/ios/LibNovelV2/ViewModels/SearchViewModel.swift b/ios/LibNovelV2/ViewModels/SearchViewModel.swift new file mode 100644 index 0000000..dc18be3 --- /dev/null +++ b/ios/LibNovelV2/ViewModels/SearchViewModel.swift @@ -0,0 +1,115 @@ +import Foundation + +// MARK: - SearchViewModel +// Debounced live search (300 ms) + recent searches persisted in UserDefaults. +// Uses @Observable (iOS 17+). + +@Observable +@MainActor +final class SearchViewModel { + var query: String = "" + var results: [BrowseNovel] = [] + var localCount: Int = 0 + var remoteCount: Int = 0 + var isLoading = false + var error: String? + + // Persisted recent searches (max 10, prefixed with "v2.") + var recentSearches: [String] = [] + + private let recentKey = "v2.searchRecentTerms" + private var searchTask: Task? + + init() { + recentSearches = UserDefaults.standard.stringArray(forKey: recentKey) ?? [] + } + + // MARK: - Query change (debounced) + + func onQueryChange(_ newValue: String) { + searchTask?.cancel() + let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + results = [] + localCount = 0 + remoteCount = 0 + return + } + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) // 300 ms debounce + guard !Task.isCancelled else { return } + await runSearch(trimmed) + } + } + + // MARK: - Submit (immediate, saves to recent) + + func submitSearch() { + let term = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !term.isEmpty else { return } + saveRecent(term) + searchTask?.cancel() + searchTask = Task { await runSearch(term) } + } + + // MARK: - Recent search tap + + func selectRecent(_ term: String) { + query = term + searchTask?.cancel() + searchTask = Task { await runSearch(term) } + } + + // MARK: - Clear + + func clear() { + query = "" + results = [] + localCount = 0 + remoteCount = 0 + error = nil + searchTask?.cancel() + } + + func clearRecent() { + recentSearches = [] + UserDefaults.standard.removeObject(forKey: recentKey) + } + + // MARK: - Core search + + private func runSearch(_ term: String) async { + guard !term.isEmpty else { + results = [] + return + } + isLoading = true + error = nil + do { + let response = try await APIClient.shared.search(query: term) + // Only update if the query hasn't changed since we started + let currentTrimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + if currentTrimmed == term || currentTrimmed.isEmpty { + results = response.results + localCount = response.localCount + remoteCount = response.remoteCount + } + } catch { + if !(error is CancellationError) { + self.error = error.localizedDescription + results = [] + } + } + isLoading = false + } + + // MARK: - Persist recent + + private func saveRecent(_ term: String) { + var list = recentSearches.filter { $0 != term } + list.insert(term, at: 0) + if list.count > 10 { list = Array(list.prefix(10)) } + recentSearches = list + UserDefaults.standard.set(list, forKey: recentKey) + } +} diff --git a/ios/LibNovelV2/Views/Auth/AuthView.swift b/ios/LibNovelV2/Views/Auth/AuthView.swift new file mode 100644 index 0000000..b046ef6 --- /dev/null +++ b/ios/LibNovelV2/Views/Auth/AuthView.swift @@ -0,0 +1,386 @@ +import SwiftUI + +// MARK: - AuthView +// Full-screen login / register view. +// Mirrors the web UI's login page: zinc-900 background, tab switcher with +// amber underline indicator, zinc-800 text fields with amber focus ring, +// amber CTA button, inline error banner, loading state. + +struct AuthView: View { + @EnvironmentObject var authStore: AuthStore + @EnvironmentObject var networkMonitor: NetworkMonitor + + @State private var mode: AuthMode = .login + + // Login fields + @State private var loginUsername: String = "" + @State private var loginPassword: String = "" + + // Register fields + @State private var regUsername: String = "" + @State private var regPassword: String = "" + @State private var regConfirm: String = "" + + // Focus management + @FocusState private var focus: AuthField? + + // Local validation error (client-side, e.g. password mismatch) + @State private var localError: String? + + private var displayError: String? { localError ?? authStore.error } + + var body: some View { + ZStack { + Color.appBackground.ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: 60) + + // ── Wordmark ────────────────────────────────────────── + wordmark + + Spacer(minLength: 48) + + // ── Card ────────────────────────────────────────────── + VStack(spacing: 0) { + tabSwitcher + formContent + } + .background(Color.cardBackground) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 24) + + Spacer(minLength: 40) + } + } + .scrollDismissesKeyboard(.interactively) + } + .onChange(of: mode) { _, _ in + localError = nil + authStore.error = nil + } + } + + // MARK: - Wordmark + + private var wordmark: some View { + VStack(spacing: 6) { + Image(systemName: "books.vertical.fill") + .font(.system(size: 44)) + .foregroundStyle(Color.amber) + .symbolEffect(.bounce, value: mode) + + Text("libnovel") + .font(.title.bold()) + .fontDesign(.serif) + .foregroundStyle(.primary) + } + } + + // MARK: - Tab switcher + + private var tabSwitcher: some View { + HStack(spacing: 0) { + tabButton(label: "Sign in", tab: .login) + tabButton(label: "Create account", tab: .register) + } + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.cardBorder) + .frame(height: 1) + } + } + + @ViewBuilder + private func tabButton(label: String, tab: AuthMode) -> some View { + let isActive = mode == tab + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + mode = tab + } + } label: { + VStack(spacing: 0) { + Text(label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(isActive ? Color.amber : Color.secondary) + .padding(.vertical, 14) + .frame(maxWidth: .infinity) + + // Active underline indicator + Rectangle() + .fill(isActive ? Color.amber : Color.clear) + .frame(height: 2) + .offset(y: 1) // sits on top of the border + } + } + .accessibilityAddTraits(isActive ? [.isSelected] : []) + } + + // MARK: - Form content + + @ViewBuilder + private var formContent: some View { + VStack(spacing: 16) { + // Error banner + if let err = displayError { + errorBanner(err) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + switch mode { + case .login: loginForm + case .register: registerForm + } + } + .padding(20) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: displayError) + .animation(.spring(response: 0.35, dampingFraction: 0.75), value: mode) + } + + // MARK: - Error banner + + private func errorBanner(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.errorText) + .font(.footnote) + Text(message) + .font(.footnote) + .foregroundStyle(Color.errorText) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.errorBackground) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.errorBorder, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + // MARK: - Login form + + private var loginForm: some View { + VStack(spacing: 16) { + AuthInputField( + label: "Username", + placeholder: "your_username", + text: $loginUsername, + contentType: .username, + keyboardType: .default, + focusState: $focus, + field: .loginUsername, + nextField: .loginPassword + ) + + AuthInputField( + label: "Password", + placeholder: "••••••••", + text: $loginPassword, + contentType: .password, + isSecure: true, + focusState: $focus, + field: .loginPassword, + onSubmit: submitLogin + ) + + ctaButton(label: "Sign in", action: submitLogin) + } + } + + // MARK: - Register form + + private var registerForm: some View { + VStack(spacing: 16) { + VStack(spacing: 4) { + AuthInputField( + label: "Username", + placeholder: "your_username", + text: $regUsername, + contentType: .username, + focusState: $focus, + field: .regUsername, + nextField: .regPassword + ) + Text("3–32 characters: letters, numbers, _ or -") + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 2) + } + + VStack(spacing: 4) { + AuthInputField( + label: "Password", + placeholder: "••••••••", + text: $regPassword, + contentType: .newPassword, + isSecure: true, + focusState: $focus, + field: .regPassword, + nextField: .regConfirm + ) + Text("At least 8 characters") + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 2) + } + + AuthInputField( + label: "Confirm password", + placeholder: "••••••••", + text: $regConfirm, + contentType: .newPassword, + isSecure: true, + focusState: $focus, + field: .regConfirm, + onSubmit: submitRegister + ) + + ctaButton(label: "Create account", action: submitRegister) + } + } + + // MARK: - CTA button + + private func ctaButton(label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + ZStack { + if authStore.isLoading { + ProgressView() + .tint(Color(uiColor: .systemBackground)) + } else { + Text(label) + .font(.subheadline.bold()) + .foregroundStyle(Color.ctaText) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .background(Color.amber) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .disabled(authStore.isLoading || !networkMonitor.isConnected) + .opacity(authStore.isLoading ? 0.8 : 1) + .animation(.easeInOut(duration: 0.15), value: authStore.isLoading) + .accessibilityLabel(label) + } + + // MARK: - Actions + + private func submitLogin() { + guard !authStore.isLoading else { return } + localError = nil + focus = nil + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await authStore.login(username: loginUsername, password: loginPassword) } + } + + private func submitRegister() { + guard !authStore.isLoading else { return } + localError = nil + // Client-side validation + if regUsername.count < 3 || regUsername.count > 32 { + localError = "Username must be 3–32 characters." + return + } + if regPassword.count < 8 { + localError = "Password must be at least 8 characters." + return + } + if regPassword != regConfirm { + localError = "Passwords do not match." + return + } + focus = nil + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await authStore.register(username: regUsername, password: regPassword) } + } +} + +// MARK: - Auth mode enum + +private enum AuthMode: Equatable { case login, register } + +// MARK: - Focus field enum + +private enum AuthField: Hashable { + case loginUsername, loginPassword + case regUsername, regPassword, regConfirm +} + +// MARK: - AuthInputField component + +private struct AuthInputField: View { + let label: String + let placeholder: String + @Binding var text: String + var contentType: UITextContentType? = nil + var keyboardType: UIKeyboardType = .default + var isSecure: Bool = false + @FocusState.Binding var focusState: AuthField? + let field: AuthField + var nextField: AuthField? = nil + var onSubmit: (() -> Void)? = nil + + private var isFocused: Bool { focusState == field } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + + Group { + if isSecure { + SecureField(placeholder, text: $text) + } else { + TextField(placeholder, text: $text) + .keyboardType(keyboardType) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + .textContentType(contentType) + .focused($focusState, equals: field) + .submitLabel(nextField != nil ? .next : .done) + .onSubmit { + if let next = nextField { + focusState = next + } else { + onSubmit?() + } + } + .padding(.horizontal, 12) + .frame(height: 44) + .background(Color.fieldBackground) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke( + isFocused ? Color.amber : Color.cardBorder, + lineWidth: isFocused ? 1.5 : 1 + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .animation(.spring(response: 0.2, dampingFraction: 0.7), value: isFocused) + } + } +} + +// MARK: - Local color helpers + +private extension Color { + static let appBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.09, green: 0.09, blue: 0.11, alpha: 1) : UIColor.systemGroupedBackground }) + static let cardBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1) : UIColor.secondarySystemGroupedBackground }) + static let cardBorder = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(white: 0.25, alpha: 1) : UIColor.separator }) + static let fieldBackground = Color(uiColor: UIColor { t in t.userInterfaceStyle == .dark ? UIColor(red: 0.11, green: 0.11, blue: 0.13, alpha: 1) : UIColor.secondarySystemBackground }) + static let ctaText = Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1)) // zinc-900 + static let errorBackground = Color(red: 0.40, green: 0.05, blue: 0.05).opacity(0.40) + static let errorBorder = Color(red: 0.70, green: 0.20, blue: 0.20).opacity(0.60) + static let errorText = Color(red: 0.98, green: 0.60, blue: 0.60) +} diff --git a/ios/LibNovelV2/Views/BookDetail/BookDetailView.swift b/ios/LibNovelV2/Views/BookDetail/BookDetailView.swift new file mode 100644 index 0000000..b079fa6 --- /dev/null +++ b/ios/LibNovelV2/Views/BookDetail/BookDetailView.swift @@ -0,0 +1,671 @@ +import SwiftUI + +// MARK: - BookDetailView +// Displays book hero (blurred cover bg + cover art + title), meta stats, +// expandable summary, CTA buttons, chapters row (→ sheet), and bottom save toggle. +// Matches the web UI at ui/src/routes/books/[slug]/+page.svelte. + +struct BookDetailView: View { + let slug: String + + @State private var vm: BookDetailViewModel + @State private var showChapters = false + @State private var summaryExpanded = false + @EnvironmentObject private var networkMonitor: NetworkMonitor + @EnvironmentObject private var authStore: AuthStore + + init(slug: String) { + self.slug = slug + _vm = State(initialValue: BookDetailViewModel(slug: slug)) + } + + var body: some View { + VStack(spacing: 0) { + OfflineBanner() + + Group { + if vm.isLoading && vm.book == nil { + loadingState + } else if let book = vm.book { + content(book: book) + } else if vm.error != nil { + errorState + } + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationBarTitleDisplayMode(.inline) + .appNavigationDestination() + .toolbar { toolbarContent } + .task { + guard networkMonitor.isConnected else { return } + await vm.load() + } + .errorAlert($vm.error) + .sheet(isPresented: $showChapters) { + BookChaptersSheet( + slug: slug, + chapters: vm.chapters, + lastChapter: vm.lastChapter + ) + } + } + + // MARK: - Main content + + private func content(book: Book) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + heroSection(book: book) + statsRow(book: book) + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + .padding(.horizontal, 16) + summarySection(book: book) + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + .padding(.horizontal, 16) + ctaButtons + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + chaptersRow + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + + Color.clear.frame(height: 120) + } + } + .ignoresSafeArea(edges: .top) + } + + // MARK: - Hero + + private func heroSection(book: Book) -> some View { + ZStack(alignment: .bottom) { + // Blurred cover background + AsyncCoverImage(url: book.cover, isBackground: true) + .frame(maxWidth: .infinity) + .frame(height: 340) + .blur(radius: 28) + .clipped() + .overlay( + LinearGradient( + colors: [ + Color.black.opacity(0.2), + Color.black.opacity(0.72), + ], + startPoint: .top, + endPoint: .bottom + ) + ) + + VStack(spacing: 16) { + // Cover art + AsyncCoverImage(url: book.cover) + .frame(width: 130, height: 188) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.55), radius: 18, x: 0, y: 10) + .shadow(color: .black.opacity(0.3), radius: 6, x: 0, y: 3) + + // Title + author + VStack(spacing: 5) { + Text(book.title) + .font(.title3.bold()) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .lineLimit(3) + .padding(.horizontal, 24) + + if !book.author.isEmpty { + Text(book.author) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.7)) + } + } + + // Status badge + genre chips + VStack(spacing: 8) { + if !book.status.isEmpty { + BookStatusBadge(status: book.status) + } + if !book.genres.isEmpty { + HStack(spacing: 6) { + ForEach(book.genres.prefix(3), id: \.self) { genre in + Text(genre) + .font(.caption2.bold()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: Capsule()) + .foregroundStyle(.white.opacity(0.9)) + } + } + } + } + + // "Not in library" badge + if !vm.inLib { + HStack(spacing: 6) { + Image(systemName: "icloud.and.arrow.down") + .font(.caption2) + Text("Not in library") + .font(.caption2) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.regularMaterial, in: Capsule()) + } + } + .padding(.horizontal) + .padding(.bottom, 28) + } + .frame(minHeight: 340) + } + + // MARK: - Stats row + + private func statsRow(book: Book) -> some View { + HStack(spacing: 0) { + BookMetaStat( + value: "\(vm.chapters.isEmpty ? book.totalChapters : vm.chapters.count)", + label: "Chapters", + icon: "doc.text" + ) + Divider().frame(height: 36) + BookMetaStat( + value: book.status.isEmpty ? "—" : book.status.capitalized, + label: "Status", + icon: "flag" + ) + if book.ranking > 0 { + Divider().frame(height: 36) + BookMetaStat(value: "#\(book.ranking)", label: "Rank", icon: "chart.bar.fill") + } + } + .padding(.vertical, 16) + .frame(maxWidth: .infinity) + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + } + + // MARK: - Summary + + private func summarySection(book: Book) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("About") + .font(.headline) + .padding(.horizontal, 16) + + if book.summary.isEmpty { + Text("No description available.") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.horizontal, 16) + } else { + Text(book.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(summaryExpanded ? nil : 4) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: summaryExpanded) + .padding(.horizontal, 16) + + if book.summary.count > 200 { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + summaryExpanded.toggle() + } + } label: { + Text(summaryExpanded ? "Less" : "More") + .font(.caption.bold()) + .foregroundStyle(Color.amber) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 44) + .padding(.horizontal, 16) + } + } + } + .padding(.vertical, 16) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - CTA buttons + + private var ctaButtons: some View { + HStack(spacing: 10) { + if let last = vm.lastChapter, last > 0 { + // Continue reading + NavigationLink(value: NavDestination.chapter(slug, last)) { + Label("Continue Ch.\(last)", systemImage: "play.fill") + .font(.subheadline.bold()) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color.amber) + .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + }) + + // Start from ch.1 + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label("Ch.1", systemImage: "arrow.counterclockwise") + .font(.subheadline.bold()) + .frame(height: 44) + .padding(.horizontal, 16) + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + .foregroundStyle(.primary) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + }) + } else { + // Start reading + NavigationLink(value: NavDestination.chapter(slug, 1)) { + Label(vm.inLib ? "Start Reading" : "Preview Ch.1", systemImage: "book.fill") + .font(.subheadline.bold()) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(vm.chapters.isEmpty ? Color.amber.opacity(0.4) : Color.amber) + .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(vm.chapters.isEmpty) + .simultaneousGesture(TapGesture().onEnded { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + }) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + } + + // MARK: - Chapters row + + private var chaptersRow: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showChapters = true + } label: { + HStack(spacing: 12) { + Image(systemName: "list.number") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.amber) + .frame(width: 28) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text("Chapters") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + let count = vm.chapters.count + if let last = vm.lastChapter, last > 0, count > 0 { + Text("Reading Ch.\(last) of \(count)") + .font(.caption) + .foregroundStyle(.secondary) + } else if count > 0 { + Text("\(count) chapter\(count == 1 ? "" : "s")") + .font(.caption) + .foregroundStyle(.secondary) + } else if vm.isLoading { + Text("Loading…") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Chapters list") + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + Task { await vm.toggleSaved() } + } label: { + Image(systemName: vm.saved ? "bookmark.fill" : "bookmark") + .foregroundStyle(vm.saved ? Color.amber : .primary) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .disabled(vm.isSaving) + .accessibilityLabel(vm.saved ? "Remove from library" : "Save to library") + } + } + + // MARK: - Loading / Error states + + private var loadingState: some View { + VStack { + Spacer() + ProgressView() + .tint(Color.amber) + .scaleEffect(1.4) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var errorState: some View { + VStack { + Spacer() + EmptyStateView( + icon: "wifi.slash", + title: "Couldn't load book", + message: vm.error ?? "Something went wrong.", + ctaLabel: "Retry", + ctaAction: { + Task { + guard networkMonitor.isConnected else { return } + await vm.load() + } + } + ) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - BookChaptersSheet +// Shows all chapters in groups of 100 with a searchable list and right-edge jump bar. + +struct BookChaptersSheet: View { + let slug: String + let chapters: [ChapterIndex] + let lastChapter: Int? + + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + + private var filtered: [ChapterIndex] { + guard !searchText.isEmpty else { return chapters } + let q = searchText.lowercased() + return chapters.filter { + "\($0.number)".contains(q) || $0.title.lowercased().contains(q) + } + } + + /// Chapters in blocks of 100, or a flat "Results" group when searching. + private var groups: [(label: String, chapters: [ChapterIndex])] { + guard searchText.isEmpty else { + return filtered.isEmpty ? [] : [("Results", filtered)] + } + guard !filtered.isEmpty else { return [] } + let blockSize = 100 + let minN = filtered.map(\.number).min() ?? 1 + let maxN = filtered.map(\.number).max() ?? 1 + let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 + var result: [(label: String, chapters: [ChapterIndex])] = [] + var blockStart = firstBlock + while blockStart <= maxN { + let blockEnd = blockStart + blockSize - 1 + let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } + if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) } + blockStart += blockSize + } + return result + } + + @State private var activeBlock: String? + + var body: some View { + NavigationStack { + ZStack(alignment: .trailing) { + List { + ForEach(groups, id: \.label) { group in + Section { + ForEach(group.chapters, id: \.number) { ch in + ChapterListRow( + chapter: ch, + slug: slug, + isCurrent: ch.number == lastChapter + ) + .id(ch.number) + } + } header: { + if searchText.isEmpty { + Text(group.label) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .id("header_\(group.label)") + } + } + } + + if chapters.isEmpty { + Section { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .listRowBackground(Color.clear) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Chapter number or title" + ) + .scrollPosition(id: $activeBlock, anchor: .top) + .appNavigationDestination() + + // Jump bar (hidden while searching) + if searchText.isEmpty && groups.count > 1 { + ChapterJumpBar( + labels: groups.map(\.label), + currentChapter: lastChapter ?? 0, + groups: groups + ) { label in + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + activeBlock = label + } + } + .padding(.trailing, 4) + } + } + .navigationTitle("Chapters (\(filtered.count))") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + .onAppear { + // Scroll to current chapter's block on open + if let block = groups.first(where: { g in + g.chapters.contains(where: { $0.number == (lastChapter ?? 0) }) + }) { + activeBlock = block.label + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } +} + +// MARK: - ChapterListRow + +private struct ChapterListRow: View { + let chapter: ChapterIndex + let slug: String + let isCurrent: Bool + + private var displayTitle: String { + let pattern = #"\s*[-–]\s*\w+\s+\d{1,2}\s+\d{4}\s*$"# + let stripped = (try? NSRegularExpression(pattern: pattern))? + .stringByReplacingMatches( + in: chapter.title, + range: NSRange(chapter.title.startIndex..., in: chapter.title), + withTemplate: "" + ).trimmingCharacters(in: .whitespaces) ?? chapter.title + if stripped.isEmpty || stripped == "Chapter \(chapter.number)" { + return "Chapter \(chapter.number)" + } + return stripped + } + + var body: some View { + NavigationLink(value: NavDestination.chapter(slug, chapter.number)) { + HStack(spacing: 14) { + // Number badge + ZStack { + Circle() + .fill(isCurrent ? Color.amber : Color(.systemGray5)) + .frame(width: 40, height: 40) + Text("\(chapter.number)") + .font(.caption.bold().monospacedDigit()) + .foregroundStyle(isCurrent ? .white : .secondary) + .minimumScaleFactor(0.6) + .frame(width: 40, height: 40) + } + + VStack(alignment: .leading, spacing: 3) { + Text(displayTitle) + .font(.subheadline.weight(isCurrent ? .semibold : .regular)) + .foregroundStyle(isCurrent ? Color.amber : .primary) + .lineLimit(1) + + if isCurrent { + Label("Reading", systemImage: "bookmark.fill") + .font(.caption2) + .foregroundStyle(Color.amber) + } else if !chapter.dateLabel.isEmpty { + Text(chapter.dateLabel) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + Spacer(minLength: 4) + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) + .listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + } +} + +// MARK: - ChapterJumpBar + +private struct ChapterJumpBar: View { + let labels: [String] + let currentChapter: Int + let groups: [(label: String, chapters: [ChapterIndex])] + let onSelect: (String) -> Void + + private func shortLabel(_ full: String) -> String { + full.components(separatedBy: "–").first ?? full + } + + 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 + let itemHeight: CGFloat = 28 + let index = Int(value.location.y / itemHeight) + let clamped = max(0, min(labels.count - 1, index)) + onSelect(labels[clamped]) + } + ) + } +} + +// MARK: - BookStatusBadge + +private struct BookStatusBadge: 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()) + } +} + +// MARK: - BookMetaStat + +private struct BookMetaStat: View { + let value: String + let label: String + let icon: String + + var body: some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(Color.amber) + Text(value) + .font(.subheadline.bold()) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} diff --git a/ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift b/ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift new file mode 100644 index 0000000..c6b5a28 --- /dev/null +++ b/ios/LibNovelV2/Views/Browse/BrowseCategoryView.swift @@ -0,0 +1,446 @@ +import SwiftUI + +// MARK: - BrowseCategoryView +// Full paginated grid for "See All" / genre deep-dives. +// Supports browse (infinite scroll) and rank (flat list) modes. +// Sort/genre/status can be adjusted via the filters sheet. + +struct BrowseCategoryView: View { + let sort: String + let genre: String + let status: String + let title: String + + @State private var vm = BrowseViewModel() + @State private var showFilters = false + @EnvironmentObject private var networkMonitor: NetworkMonitor + + init(sort: String, genre: String, status: String, title: String) { + self.sort = sort + self.genre = genre + self.status = status + self.title = title + } + + private var isRankMode: Bool { sort == "rank" } + + var body: some View { + Group { + if vm.isLoading && vm.novels.isEmpty { + loadingState + } else if let err = vm.error, vm.novels.isEmpty { + errorState(message: err) + } else if vm.novels.isEmpty && !vm.isLoading { + emptyState + } else if isRankMode { + rankList + } else { + novelGrid + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .toolbar { toolbarContent } + .task { + guard networkMonitor.isConnected else { return } + vm.sort = sort + vm.genre = genre + vm.status = status + if vm.novels.isEmpty { + if isRankMode { + await vm.loadRanking() + } else { + await vm.loadFirstPage() + } + } + } + .onChange(of: vm.sort) { _, _ in + Task { await refreshForFilters() } + } + .onChange(of: vm.genre) { _, _ in + Task { await refreshForFilters() } + } + .onChange(of: vm.status) { _, _ in + Task { await refreshForFilters() } + } + .sheet(isPresented: $showFilters) { + BrowseFiltersSheet(vm: vm) + } + .errorAlert($vm.error) + } + + // MARK: - Grid view + + private let columns = [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14) + ] + + private var novelGrid: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 14) { + ForEach(vm.novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + BrowseCategoryCard(novel: novel) + } + .buttonStyle(.plain) + .onAppear { + if novel.id == vm.novels.last?.id && vm.hasNext { + Task { await vm.loadNextPage() } + } + } + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + + // Load-more indicator + if vm.isLoadingMore { + ProgressView() + .padding(.vertical, 24) + .tint(Color.amber) + } else if !vm.hasNext && !vm.novels.isEmpty { + Text("All novels loaded") + .font(.caption) + .foregroundStyle(.quaternary) + .padding(.vertical, 24) + } + + Color.clear.frame(height: 120) + } + .refreshable { await vm.loadFirstPage() } + } + + // MARK: - Rank list view + + private var rankList: some View { + List { + ForEach(vm.novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + RankListRow(novel: novel) + } + .listRowBackground(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .listRowSeparatorTint(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .refreshable { await vm.loadRanking() } + } + + // MARK: - Loading / error / empty + + private var loadingState: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 14) { + ForEach(0..<10, id: \.self) { _ in + BrowseCategoryCardSkeleton() + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + } + } + + private func errorState(message: String) -> some View { + VStack(spacing: 16) { + Spacer() + EmptyStateView( + icon: "wifi.slash", + title: "Couldn't load", + message: message, + ctaLabel: "Retry", + ctaAction: { + Task { + if isRankMode { await vm.loadRanking() } + else { await vm.loadFirstPage() } + } + } + ) + Spacer() + } + } + + private var emptyState: some View { + VStack { + Spacer() + EmptyStateView( + icon: "books.vertical", + title: "No novels found", + message: "Try different filters.", + ctaLabel: "Change Filters", + ctaAction: { showFilters = true } + ) + Spacer() + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showFilters = true + } label: { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(Color.amber) + } + .accessibilityLabel("Filter novels") + } + } + + // MARK: - Filter change + + private func refreshForFilters() async { + if vm.sort == "rank" { + await vm.loadRanking() + } else { + await vm.loadFirstPage() + } + } +} + +// MARK: - BrowseCategoryCard + +struct BrowseCategoryCard: View { + let novel: BrowseNovel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topLeading) { + AsyncCoverImage(url: novel.cover) + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .bookCoverZoomSource(slug: novel.slug) + + if !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .foregroundStyle(Color.amber) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + } + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if !novel.chapters.isEmpty { + Text(novel.chapters) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) + } +} + +// MARK: - BrowseCategoryCardSkeleton + +private struct BrowseCategoryCardSkeleton: View { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + RoundedRectangle(cornerRadius: 10) + .fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1))) + .aspectRatio(2/3, contentMode: .fit) + + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) + .frame(height: 14) + RoundedRectangle(cornerRadius: 4) + .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) + .frame(width: 80, height: 11) + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +// MARK: - RankListRow + +private struct RankListRow: View { + let novel: BrowseNovel + + var body: some View { + HStack(spacing: 12) { + // Rank number + Text(novel.rank.isEmpty ? "–" : novel.rank) + .font(.subheadline.bold()) + .foregroundStyle(Color.amber) + .frame(width: 36, alignment: .trailing) + + // Cover thumbnail + AsyncCoverImage(url: novel.cover) + .frame(width: 44, height: 62) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + + // Title + meta + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .foregroundStyle(.primary) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + HStack(spacing: 6) { + if !novel.status.isEmpty { + TagChip(label: novel.status.capitalized) + } + if !novel.rating.isEmpty { + TagChip(label: "★ \(novel.rating)") + } + } + } + + Spacer() + } + .padding(.vertical, 6) + .frame(minHeight: 44) + } +} + +// MARK: - BrowseFiltersSheet + +struct BrowseFiltersSheet: View { + var vm: BrowseViewModel + @Environment(\.dismiss) private var dismiss + + private let sortOptions: [(value: String, label: String)] = [ + ("popular", "Popular"), + ("new", "New"), + ("update", "Updated"), + ("rank", "Ranking"), + ] + private let genreOptions: [(value: String, label: String)] = [ + ("all", "All Genres"), + ("action", "Action"), + ("adventure", "Adventure"), + ("comedy", "Comedy"), + ("drama", "Drama"), + ("fantasy", "Fantasy"), + ("harem", "Harem"), + ("historical", "Historical"), + ("horror", "Horror"), + ("isekai", "Isekai"), + ("martial-arts", "Martial Arts"), + ("mystery", "Mystery"), + ("psychological", "Psychological"), + ("romance", "Romance"), + ("sci-fi", "Sci-Fi"), + ("system", "System"), + ("xianxia", "Xianxia"), + ] + private let statusOptions: [(value: String, label: String)] = [ + ("all", "All"), + ("ongoing", "Ongoing"), + ("completed", "Completed"), + ] + + var body: some View { + NavigationStack { + Form { + Section("Sort") { + ForEach(sortOptions, id: \.value) { opt in + filterRow(label: opt.label, isSelected: vm.sort == opt.value) { + vm.sort = opt.value + dismiss() + } + } + } + + Section("Genre") { + ForEach(genreOptions, id: \.value) { opt in + filterRow(label: opt.label, isSelected: vm.genre == opt.value) { + vm.genre = opt.value + dismiss() + } + } + } + .disabled(vm.sort == "rank") + + Section("Status") { + ForEach(statusOptions, id: \.value) { opt in + filterRow(label: opt.label, isSelected: vm.status == opt.value) { + vm.status = opt.value + dismiss() + } + } + } + .disabled(vm.sort == "rank") + + if vm.sort == "rank" { + Section { + Text("Genre & status filters apply to Browse only") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + private func filterRow(label: String, isSelected: Bool, action: @escaping () -> Void) -> some View { + HStack { + Text(label) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(Color.amber) + .fontWeight(.semibold) + } + } + .contentShape(Rectangle()) + .onTapGesture { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + } + .frame(minHeight: 44) + } +} diff --git a/ios/LibNovelV2/Views/Browse/BrowseView.swift b/ios/LibNovelV2/Views/Browse/BrowseView.swift new file mode 100644 index 0000000..5383ff2 --- /dev/null +++ b/ios/LibNovelV2/Views/Browse/BrowseView.swift @@ -0,0 +1,411 @@ +import SwiftUI + +// MARK: - BrowseView +// "Discover" tab: curated horizontal shelves (Trending, New, Updated, Ranking) +// plus a genre picker sheet. Mirrors the web UI's serendipitous browse experience. + +struct BrowseView: View { + @State private var vm = BrowseViewModel() + @State private var showGenreSheet = false + @EnvironmentObject private var networkMonitor: NetworkMonitor + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + Group { + if vm.isLoading && vm.trending.isEmpty { + loadingState + } else if let err = vm.error, vm.trending.isEmpty { + errorState(message: err) + } else { + shelvesContent + } + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Discover") + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .task { + guard networkMonitor.isConnected else { return } + if vm.trending.isEmpty { await vm.loadShelves() } + } + .refreshable { await vm.loadShelves() } + .errorAlert($vm.error) + .sheet(isPresented: $showGenreSheet) { + GenrePickerSheet() + } + } + } + + // MARK: - Shelves content + + private var shelvesContent: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 32) { + + // Trending Now + if !vm.trending.isEmpty { + BrowseShelf( + title: "Trending Now", + novels: vm.trending, + destination: NavDestination.browseCategory( + sort: "popular", genre: "all", status: "all", title: "Trending Now" + ) + ) + } + + // New Releases + if !vm.newReleases.isEmpty { + BrowseShelf( + title: "New Releases", + novels: vm.newReleases, + destination: NavDestination.browseCategory( + sort: "new", genre: "all", status: "all", title: "New Releases" + ) + ) + } + + // Recently Updated + if !vm.recentlyUpdated.isEmpty { + BrowseShelf( + title: "Recently Updated", + novels: vm.recentlyUpdated, + destination: NavDestination.browseCategory( + sort: "update", genre: "all", status: "all", title: "Recently Updated" + ) + ) + } + + // Rankings (list-style shelf) + if !vm.ranking.isEmpty { + BrowseShelf( + title: "Rankings", + novels: vm.ranking, + destination: NavDestination.browseCategory( + sort: "rank", genre: "all", status: "all", title: "Rankings" + ), + showRank: true + ) + } + + // Browse by Genre + CategoriesRow { showGenreSheet = true } + .padding(.horizontal, 16) + + Color.clear.frame(height: 120) + } + .padding(.top, 8) + } + } + + // MARK: - Loading / error states + + private var loadingState: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 32) { + ForEach(0..<3, id: \.self) { _ in + BrowseShelfSkeleton() + } + } + .padding(.top, 8) + } + } + + private func errorState(message: String) -> some View { + VStack(spacing: 16) { + Spacer() + EmptyStateView( + icon: "wifi.slash", + title: "Couldn't load", + message: message, + ctaLabel: "Retry", + ctaAction: { Task { await vm.loadShelves() } } + ) + Spacer() + } + } +} + +// MARK: - BrowseShelf +// Amber-accented header + horizontal card scroll + "See All" link. + +struct BrowseShelf: View { + let title: String + let novels: [BrowseNovel] + let destination: NavDestination + var showRank: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header row + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.amber) + .frame(width: 3, height: 18) + Text(title) + .font(.title3.bold()) + Spacer() + NavigationLink(value: destination) { + HStack(spacing: 4) { + Text("See All") + .font(.subheadline) + Image(systemName: "chevron.right") + .font(.caption.bold()) + } + .foregroundStyle(Color.amber) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + + // Horizontal scroll + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 12) { + ForEach(novels) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + BrowseShelfCard(novel: novel, showRank: showRank) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 4) + } + } + } +} + +// MARK: - BrowseShelfCard + +struct BrowseShelfCard: View { + let novel: BrowseNovel + var showRank: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .topLeading) { + AsyncCoverImage(url: novel.cover) + .frame(width: 120, height: 173) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .bookCoverZoomSource(slug: novel.slug) + + if showRank && !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .foregroundStyle(Color.amber) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } else if !novel.rank.isEmpty { + Text(novel.rank) + .font(.caption2.bold()) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } + } + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.caption.bold()) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(width: 120, alignment: .leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 120, alignment: .leading) + } else if !novel.chapters.isEmpty { + Text(novel.chapters) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 120, alignment: .leading) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 8) + } + .frame(width: 132) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) + } +} + +// MARK: - BrowseShelfSkeleton + +struct BrowseShelfSkeleton: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header skeleton + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.amber.opacity(0.3)) + .frame(width: 3, height: 18) + RoundedRectangle(cornerRadius: 6) + .fill(Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1))) + .frame(width: 140, height: 20) + Spacer() + } + .padding(.horizontal, 16) + + // Cards skeleton + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(0..<5, id: \.self) { _ in + RoundedRectangle(cornerRadius: 14) + .fill(Color(uiColor: UIColor(red: 0.18, green: 0.18, blue: 0.20, alpha: 1))) + .frame(width: 132, height: 220) + } + } + .padding(.horizontal, 16) + } + } + } +} + +// MARK: - CategoriesRow + +struct CategoriesRow: View { + let onTap: () -> Void + + var body: some View { + Button(action: { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onTap() + }) { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.amber.opacity(0.15)) + .frame(width: 44, height: 44) + Image(systemName: "square.grid.2x2") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(Color.amber) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Browse by Genre") + .font(.body.weight(.semibold)) + .foregroundStyle(.primary) + Text("Action, Fantasy, Romance & more") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(14) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("Browse by Genre") + } +} + +// MARK: - GenrePickerSheet + +struct GenrePickerSheet: View { + @Environment(\.dismiss) private var dismiss + + private let genres: [(label: String, value: String, icon: String)] = [ + ("All Novels", "all", "books.vertical.fill"), + ("Action", "action", "bolt.fill"), + ("Adventure", "adventure", "map.fill"), + ("Comedy", "comedy", "face.smiling.fill"), + ("Drama", "drama", "theatermasks.fill"), + ("Fantasy", "fantasy", "wand.and.stars"), + ("Harem", "harem", "person.3.fill"), + ("Historical", "historical", "building.columns.fill"), + ("Horror", "horror", "moon.fill"), + ("Isekai", "isekai", "globe.americas.fill"), + ("Martial Arts", "martial-arts", "figure.martial.arts"), + ("Mystery", "mystery", "magnifyingglass"), + ("Psychological","psychological","brain.head.profile"), + ("Romance", "romance", "heart.fill"), + ("Sci-Fi", "sci-fi", "sparkles"), + ("System", "system", "cpu"), + ("Xianxia", "xianxia", "leaf.fill"), + ] + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + var body: some View { + NavigationStack { + ScrollView { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(genres, id: \.value) { item in + NavigationLink(value: NavDestination.browseCategory( + sort: "popular", + genre: item.value, + status: "all", + title: item.label + )) { + GenreTile(label: item.label, icon: item.icon) + } + .buttonStyle(.plain) + .simultaneousGesture(TapGesture().onEnded { dismiss() }) + } + } + .padding(16) + .padding(.bottom, 20) + } + .navigationTitle("Genres") + .navigationBarTitleDisplayMode(.large) + .appNavigationDestination() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(20) + } +} + +// MARK: - GenreTile + +private struct GenreTile: View { + let label: String + let icon: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color.amber) + .frame(width: 24) + Text(label) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .frame(minHeight: 44) + } +} diff --git a/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift new file mode 100644 index 0000000..5ee68e0 --- /dev/null +++ b/ios/LibNovelV2/Views/ChapterReader/ChapterReaderView.swift @@ -0,0 +1,1232 @@ +import SwiftUI +import CoreText + +// MARK: - Chapter Reader View + +struct ChapterReaderView: View { + let slug: String + let chapterNumber: Int + + @State private var currentChapter: Int + @State private var vm: ChapterReaderViewModel + @State private var readerSettings = ReaderSettingsStore() + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var authStore: AuthStore + + @State private var chromeVisible = true + @State private var showSettingsPanel = false + @State private var showToCSheet = false + + @Environment(\.dismiss) private var dismiss + + init(slug: String, chapterNumber: Int) { + self.slug = slug + self.chapterNumber = chapterNumber + _currentChapter = State(initialValue: chapterNumber) + _vm = State(initialValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber)) + } + + var body: some View { + ZStack { + // Full-bleed background + readerSettings.settings.theme.backgroundColor + .ignoresSafeArea() + + if vm.isLoading { + ProgressView() + .tint(readerSettings.settings.theme.textColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let content = vm.content { + if readerSettings.settings.scrollMode { + ScrollReaderContent( + content: content, + readerSettings: readerSettings, + chromeVisible: $chromeVisible, + onNavigateChapter: navigateToChapter + ) + } else { + PaginatedReaderContent( + content: content, + readerSettings: readerSettings, + chromeVisible: $chromeVisible, + onNavigateChapter: navigateToChapter + ) + } + } else if let errMsg = vm.error { + readerErrorView(errMsg) + } + + // Chrome overlay + if chromeVisible { + VStack(spacing: 0) { + topChrome + Spacer() + if let content = vm.content { + bottomChrome(content: content) + } + } + .transition(.opacity.animation(.easeInOut(duration: 0.22))) + .ignoresSafeArea(edges: .top) + } + } + .ignoresSafeArea(edges: .all) + .navigationBarHidden(true) + .toolbar(.hidden, for: .tabBar) + .preferredColorScheme(readerSettings.settings.theme.colorScheme) + .hideMiniPlayer() + .task(id: currentChapter) { await vm.load() } + .sheet(isPresented: $showSettingsPanel) { + ReaderSettingsPanel(store: readerSettings, isPresented: $showSettingsPanel) + .presentationDetents([.height(460)]) + .presentationDragIndicator(.visible) + .presentationCornerRadius(24) + .presentationBackground(.regularMaterial) + } + .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) + } + } + .onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in + 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, + currentChapter == audioPlayer.chapter else { return } + navigateToChapter(next) + } + .onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in + guard let prev = note.userInfo?["prev"] as? Int, + currentChapter == audioPlayer.chapter else { return } + navigateToChapter(prev) + } + } + + // MARK: - Top chrome + + private var topChrome: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(.ultraThinMaterial) + .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) + .ignoresSafeArea(edges: .top) + + VStack(spacing: 0) { + HStack(spacing: 0) { + // Back + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel("Back") + + Spacer() + + // Chapter title + if let content = vm.content { + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.85)) + .lineLimit(1) + .frame(maxWidth: 200) + } + + Spacer() + + // ToC + Aa + HStack(spacing: 0) { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showToCSheet = true + } label: { + Image(systemName: "list.bullet") + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel("Table of Contents") + + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showSettingsPanel.toggle() + } + } label: { + Text("Aa") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(readerSettings.settings.theme.textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .accessibilityLabel("Reader Settings") + } + } + .padding(.horizontal, 4) + .frame(height: 44) + + // Progress bar + if let content = vm.content { + ChapterProgressBar( + currentChapter: content.chapter.number, + totalChapters: content.chapters.last?.number ?? content.chapter.number, + color: accentColor + ) + } + } + } + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: - Bottom chrome + + private func bottomChrome(content: ChapterResponse) -> some View { + HStack(alignment: .center, spacing: 12) { + + // Prev chapter + if let prev = content.prev { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + navigateToChapter(prev) + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .bold)) + Text("Ch.\(prev)") + .font(.caption.weight(.semibold)) + } + .foregroundStyle(readerSettings.settings.theme.textColor.opacity(0.7)) + .frame(minWidth: 64) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Previous chapter \(prev)") + } else { + Color.clear.frame(width: 64, height: 40) + } + + Spacer(minLength: 0) + + // Download + DownloadAudioButton( + slug: slug, + chapter: currentChapter, + voice: audioPlayer.voice, + theme: readerSettings.settings.theme + ) + + // Listen pill + ListenButton( + audioPlayer: audioPlayer, + vm: vm, + authStore: authStore, + theme: readerSettings.settings.theme + ) + + Spacer(minLength: 0) + + // Next chapter + if let next = content.next { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + navigateToChapter(next) + } label: { + HStack(spacing: 4) { + Text("Ch.\(next)") + .font(.caption.weight(.semibold)) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .bold)) + } + .foregroundStyle(.white) + .frame(minWidth: 64) + .padding(.vertical, 10) + .background(Capsule().fill(accentColor)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Next chapter \(next)") + } else { + Color.clear.frame(width: 64, height: 40) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .colorScheme(readerSettings.settings.theme.colorScheme ?? .light) + .ignoresSafeArea(edges: .bottom) + ) + } + + // MARK: - Helpers + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) + : .amber + } + + private func readerErrorView(_ 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: - Paginated reader content + +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 + + private let topReserve: CGFloat = 80 + private let bottomReserve: CGFloat = 64 + + var body: some View { + GeometryReader { geo in + let size = geo.size + TabView(selection: $currentPage) { + 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() } + } + + ChapterEndPage( + content: content, + readerSettings: readerSettings, + onNavigateChapter: onNavigateChapter + ) + .tag(pages.count) + .onTapGesture { toggleChrome() } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .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 } + .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 } + if value.translation.width > 0, currentPage == -1, let prev = content.prev { + onNavigateChapter(prev) + } else if value.translation.width < 0, currentPage == pages.count, let next = content.next { + 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 + let hPad: CGFloat = 28 + let textWidth = size.width - hPad * 2 + let textHeight = size.height - topReserve - bottomReserve + + 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 + ) + if currentPage > pages.count - 1 { + currentPage = max(0, pages.count - 1) + } + } +} + +// MARK: - Scroll mode reader content + +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 accent: Color = settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + // Chapter header + VStack(alignment: .leading, spacing: 10) { + Text(content.book.title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(settings.theme.textColor.opacity(0.45)) + .textCase(.uppercase) + .tracking(1.2) + Rectangle() + .fill(accent.opacity(0.6)) + .frame(width: 36, 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, 20) + .padding(.bottom, 20) + + // Body + 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) + + // Footer + 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(accent)) + } + .buttonStyle(.plain) + .padding(.horizontal, hPad) + } + } + .padding(.vertical, 24) + .padding(.bottom, 80) + } + } + .safeAreaInset(edge: .top) { Color.clear.frame(height: 52) } + .background(settings.theme.backgroundColor) + .ignoresSafeArea() + .onTapGesture { + withAnimation(.easeInOut(duration: 0.22)) { chromeVisible.toggle() } + } + } +} + +// MARK: - Individual reader page (paginated mode) + +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 = 80 + let bottomPad: CGFloat = 56 + + GeometryReader { geo in + ZStack(alignment: .bottom) { + 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) + .frame(maxWidth: .infinity) + + Text("\(pageNumber) of \(totalPages)") + .font(.system(size: 11, weight: .regular).monospacedDigit()) + .foregroundStyle(settings.theme.textColor.opacity(0.3)) + .padding(.bottom, bottomPad - 24) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(settings.theme.backgroundColor) + } + } +} + +// MARK: - Chapter title page + +private struct ChapterTitlePage: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + + private var totalChapters: Int { + content.chapters.last?.number ?? content.chapter.number + } + + private var progressPercent: Int { + guard totalChapters > 1 else { return 100 } + return Int((Double(content.chapter.number) / Double(totalChapters)) * 100) + } + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + let settings = readerSettings.settings + GeometryReader { geo in + VStack(alignment: .leading, spacing: 0) { + Spacer() + + VStack(alignment: .leading, spacing: 14) { + Text(content.book.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(settings.theme.textColor.opacity(0.45)) + .textCase(.uppercase) + .tracking(1.4) + .lineLimit(2) + + Rectangle() + .fill(accentColor) + .frame(width: 36, height: 2) + .clipShape(Capsule()) + + Text(content.chapter.title.strippingTrailingDate()) + .font(.system(size: min(32, geo.size.width / 10.5), weight: .bold, design: .serif)) + .foregroundStyle(settings.theme.textColor) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(4) + + HStack(spacing: 8) { + if !content.chapter.dateLabel.isEmpty { + Text(content.chapter.dateLabel) + .font(.caption) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + } + if totalChapters > 1 { + if !content.chapter.dateLabel.isEmpty { + Circle() + .fill(settings.theme.textColor.opacity(0.25)) + .frame(width: 3, height: 3) + } + Text("\(progressPercent)% through") + .font(.caption.weight(.medium)) + .foregroundStyle(accentColor.opacity(0.85)) + } + } + } + .padding(.horizontal, 36) + + Spacer() + Spacer() + + HStack(spacing: 6) { + Image(systemName: "arrow.right") + .font(.caption2.weight(.semibold)) + Text("Swipe to read") + .font(.caption2) + } + .foregroundStyle(settings.theme.textColor.opacity(0.5)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.bottom, 96) + .phaseAnimator([false, true]) { v, phase in + v.offset(x: phase ? 4 : -2).opacity(phase ? 0.55 : 0.15) + } animation: { _ in .easeInOut(duration: 0.9) } + } + .frame(maxWidth: .infinity) + .background(settings.theme.backgroundColor) + } + } +} + +// MARK: - Chapter end page + +private struct ChapterEndPage: View { + let content: ChapterResponse + let readerSettings: ReaderSettingsStore + let onNavigateChapter: (Int) -> Void + + @State private var appeared = false + + private var accentColor: Color { + readerSettings.settings.theme == .sepia + ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + let settings = readerSettings.settings + VStack(spacing: 32) { + Spacer() + + VStack(spacing: 20) { + ZStack { + Circle().fill(accentColor.opacity(0.07)).frame(width: 96, height: 96) + Circle().fill(accentColor.opacity(0.14)).frame(width: 72, height: 72) + Image(systemName: "checkmark") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(accentColor) + .symbolEffect(.bounce, value: appeared) + } + .scaleEffect(appeared ? 1 : 0.7) + .opacity(appeared ? 1 : 0) + .animation(.spring(response: 0.5, dampingFraction: 0.65).delay(0.05), value: appeared) + + VStack(spacing: 6) { + Text("Chapter \(content.chapter.number)") + .font(.caption.weight(.semibold)) + .foregroundStyle(accentColor) + .textCase(.uppercase) + .tracking(1.2) + Text("Complete") + .font(.title2.bold()) + .foregroundStyle(settings.theme.textColor) + if content.next == nil { + Text("You've reached the latest chapter") + .font(.subheadline) + .foregroundStyle(settings.theme.textColor.opacity(0.4)) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + .animation(.easeOut(duration: 0.35).delay(0.15), value: appeared) + } + + if let next = content.next { + Button { onNavigateChapter(next) } label: { + HStack(spacing: 8) { + Text("Chapter \(next)").fontWeight(.semibold) + Image(systemName: "arrow.right").font(.system(size: 14, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(height: 52) + .frame(maxWidth: 240) + .background(Capsule().fill(accentColor)) + } + .buttonStyle(.plain) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 12) + .animation(.easeOut(duration: 0.35).delay(0.25), value: appeared) + .accessibilityLabel("Go to chapter \(next)") + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(settings.theme.backgroundColor) + .onAppear { appeared = true } + .onDisappear { appeared = false } + } +} + +// 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.10)) + Rectangle() + .fill(LinearGradient( + colors: [color.opacity(0.7), color], + startPoint: .leading, + endPoint: .trailing + )) + .frame(width: geo.size.width * progress) + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: progress) + } + } + .frame(height: 3) + } +} + +// MARK: - Listen button + +/// Isolated sub-view to avoid re-rendering ChapterReaderView on every audioPlayer update. +private struct ListenButton: View { + @ObservedObject var audioPlayer: AudioPlayerService + let vm: ChapterReaderViewModel + @ObservedObject var authStore: AuthStore + let theme: ReaderTheme + + private var isActive: Bool { + audioPlayer.isActive && audioPlayer.slug == vm.slug && audioPlayer.chapter == vm.chapter + } + + private var isGenerating: Bool { + audioPlayer.status == .generating + && audioPlayer.slug == vm.slug + && audioPlayer.chapter == vm.chapter + } + + private var accentColor: Color { + theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings) + } label: { + HStack(spacing: 7) { + if isGenerating { + ProgressView() + .scaleEffect(0.75) + .tint(isActive ? .white : accentColor) + } else { + Image(systemName: isActive ? "waveform" : "headphones") + .font(.system(size: 15, weight: .semibold)) + .contentTransition(.symbolEffect(.replace.downUp)) + .symbolEffect(.variableColor.cumulative, isActive: isActive) + } + Text(isGenerating ? "Generating…" : (isActive ? "Listening" : "Listen")) + .font(.subheadline.weight(.semibold)) + } + .foregroundStyle(isActive ? .white : accentColor) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background(Capsule().fill(isActive ? accentColor : accentColor.opacity(0.13))) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isActive) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isGenerating) + .accessibilityLabel(isGenerating ? "Generating audio" : (isActive ? "Pause audio" : "Listen")) + } +} + +// MARK: - Download audio button + +private struct DownloadAudioButton: View { + let slug: String + let chapter: Int + let voice: String + let theme: ReaderTheme + + @EnvironmentObject private var downloadService: AudioDownloadService + + private var key: String { "\(slug)::\(chapter)::\(voice)" } + private var isDownloaded: Bool { downloadService.isDownloaded(slug: slug, chapter: chapter, voice: voice) } + private var progress: Double? { downloadService.downloads[key]?.progress } + + private var accentColor: Color { + theme == .sepia ? Color(red: 0.65, green: 0.45, blue: 0.15) : .amber + } + private var iconColor: Color { theme.textColor.opacity(0.6) } + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if isDownloaded { + try? downloadService.deleteDownload(slug: slug, chapter: chapter, voice: voice) + } else if downloadService.downloads[key] != nil { + downloadService.cancelDownload(slug: slug, chapter: chapter, voice: voice) + } else { + Task { try? await downloadService.download(slug: slug, chapter: chapter, voice: voice) } + } + } label: { + Group { + if let frac = progress { + // In-progress ring + ZStack { + Circle().stroke(accentColor.opacity(0.2), lineWidth: 2) + .frame(width: 22, height: 22) + Circle().trim(from: 0, to: frac) + .stroke(accentColor, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .frame(width: 22, height: 22) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 0.2), value: frac) + } + } else { + Image(systemName: isDownloaded ? "arrow.down.circle.fill" : "arrow.down.circle") + .font(.system(size: 20)) + .foregroundStyle(isDownloaded ? accentColor : iconColor) + .contentTransition(.symbolEffect(.replace.downUp)) + } + } + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isDownloaded ? "Delete downloaded audio" : "Download audio") + } +} + +// MARK: - Chapters list sheet (ToC) + +private struct ChaptersListSheet: View { + let chapters: [ChapterBrief] + let currentChapter: Int + let onChapterSelect: (Int) -> Void + + @State private var searchText = "" + + private var filtered: [ChapterBrief] { + guard !searchText.isEmpty else { return chapters } + return chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) } + } + + var body: some View { + NavigationStack { + List(filtered) { ch in + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onChapterSelect(ch.number) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(ch.title) + .font(.subheadline) + .foregroundStyle(ch.number == currentChapter ? Color.amber : .primary) + Text("Chapter \(ch.number)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if ch.number == currentChapter { + Image(systemName: "bookmark.fill") + .font(.caption) + .foregroundStyle(Color.amber) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .listStyle(.plain) + .searchable(text: $searchText, prompt: "Search chapters") + .navigationTitle("Chapters") + .navigationBarTitleDisplayMode(.inline) + } + } +} + +// MARK: - Reader settings panel + +struct ReaderSettingsPanel: View { + @ObservedObject var store: ReaderSettingsStore + @Binding var isPresented: Bool + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color(.systemGray4)) + .frame(width: 36, height: 5) + .padding(.top, 10) + .padding(.bottom, 18) + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 22) { + + // Font size + VStack(alignment: .leading, spacing: 10) { + ReaderSectionLabel("Font Size") + HStack(spacing: 0) { + Button { adjustFontSize(-1) } label: { + Text("A").font(.system(size: 13, weight: .regular)) + .frame(width: 44, height: 44).contentShape(Rectangle()) + } + .buttonStyle(.plain).foregroundStyle(.primary) + 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: 21, weight: .semibold)) + .frame(width: 44, height: 44).contentShape(Rectangle()) + } + .buttonStyle(.plain).foregroundStyle(.primary) + } + } + + Divider().padding(.horizontal, 4) + + // Font family + VStack(alignment: .leading, spacing: 10) { + ReaderSectionLabel("Font") + HStack(spacing: 8) { + ForEach(ReaderFont.allCases, id: \.self) { font in + ReaderFontChip(font: font, isSelected: store.settings.font == font) { + var s = store.settings; s.font = font; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + } + } + + Divider().padding(.horizontal, 4) + + // Theme + VStack(alignment: .leading, spacing: 10) { + ReaderSectionLabel("Theme") + HStack(spacing: 8) { + ForEach(ReaderTheme.allCases, id: \.self) { theme in + ReaderThemeChip(theme: theme, isSelected: store.settings.theme == theme) { + var s = store.settings; s.theme = theme; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + } + } + + Divider().padding(.horizontal, 4) + + // Line spacing + VStack(alignment: .leading, spacing: 10) { + ReaderSectionLabel("Line Spacing") + HStack(spacing: 8) { + Image(systemName: "text.alignleft") + .font(.system(size: 13)).foregroundStyle(.secondary).frame(width: 28) + 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: "text.alignleft") + .font(.system(size: 20)).foregroundStyle(.secondary).frame(width: 28) + } + } + + Divider().padding(.horizontal, 4) + + // Scroll vs pages + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(store.settings.scrollMode ? "Scroll" : "Pages") + .font(.subheadline.weight(.medium)) + Text(store.settings.scrollMode + ? "Continuous vertical scroll" + : "Swipe horizontally between pages") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: Binding( + get: { store.settings.scrollMode }, + set: { v in + var s = store.settings; s.scrollMode = v; store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + )) + .tint(.amber) + .labelsHidden() + } + + Color.clear.frame(height: 8) + } + .padding(.horizontal, 20) + } + } + } + + private func adjustFontSize(_ delta: CGFloat) { + var s = store.settings + s.fontSize = max(12, min(26, s.fontSize + delta)) + store.update(s) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } +} + +// MARK: - ReaderSettingsStore + +final class ReaderSettingsStore: ObservableObject { + @Published private(set) var settings: ReaderSettings + + init() { settings = ReaderSettings.load() } + + func update(_ new: ReaderSettings) { + settings = new + new.save() + } +} + +// MARK: - Settings sub-components + +private struct ReaderSectionLabel: View { + let title: String + init(_ title: String) { self.title = title } + var body: some View { + Text(title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.8) + } +} + +private struct ReaderFontChip: 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: 46) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5) + ) + ) + .foregroundStyle(isSelected ? Color.amber : .primary) + .scaleEffect(isSelected ? 1.03 : 1.0) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) + } +} + +private struct ReaderThemeChip: 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: 46) + .background(theme.backgroundColor) + .foregroundStyle(theme.textColor) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.amber : Color(.systemGray4), + lineWidth: isSelected ? 2 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .scaleEffect(isSelected ? 1.03 : 1.0) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isSelected) + } +} + +// MARK: - HTML → AttributedString parser + +enum HTMLParser { + static func stripLeadingChapterHeader(from html: String) -> String { + var result = html + for _ in 0..<3 { + let pattern = #"^(\s*]*>)(.*?)(

)"# + guard let regex = try? NSRegularExpression( + pattern: pattern, options: [.dotMatchesLineSeparators, .caseInsensitive] + ) else { break } + + guard let match = regex.firstMatch( + in: result, range: NSRange(result.startIndex..., in: result) + ) else { break } + + let innerRange = match.range(at: 2) + guard innerRange.location != NSNotFound, + let swiftRange = Range(innerRange, in: result) else { break } + + let inner = String(result[swiftRange]) + let plain = inner + .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard plain.range(of: #"^\d*\s*[Cc]hapter\s+\d+"#, options: .regularExpression) != nil + else { break } + + guard let fullRange = Range(match.range(at: 0), in: result) else { break } + result.removeSubrange(fullRange) + } + return result + } + + static func toAttributedString( + html: String, + fontSize: CGFloat, + lineSpacing: CGFloat, + fontName: String?, + textColor: Color + ) -> AttributedString { + let uiFont: UIFont = fontName.flatMap { UIFont(name: $0, size: fontSize) } + ?? UIFont.systemFont(ofSize: fontSize) + + let uiColor = UIColor(textColor) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = (lineSpacing - 1.0) * fontSize + paragraphStyle.paragraphSpacing = fontSize * 0.7 + + let cleanedHtml = stripLeadingChapterHeader(from: html) + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ] + + let nsAttr: NSMutableAttributedString + if let parsed = try? NSMutableAttributedString( + data: Data(cleanedHtml.utf8), options: options, documentAttributes: nil + ) { + nsAttr = parsed + } else { + let plain = cleanedHtml.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + nsAttr = NSMutableAttributedString(string: plain) + } + + 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 { + 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 guard_ = 0 + + while startIndex < totalLength { + guard_ += 1 + if guard_ > 2000 { break } + + let range = CFRange(location: startIndex, length: totalLength - startIndex) + let frame = CTFramesetterCreateFrame(framesetter, range, path, nil) + let visible = CTFrameGetVisibleStringRange(frame) + + let pageLength = visible.length > 0 ? visible.length : max(1, totalLength - startIndex) + let endIndex = min(startIndex + pageLength, totalLength) + + let pageAttr = nsAttr.attributedSubstring(from: NSRange(location: startIndex, length: endIndex - startIndex)) + if let pageAS = try? AttributedString(pageAttr, including: \.uiKit) { + pages.append(pageAS) + } + + if visible.length <= 0 { break } + startIndex = endIndex + } + + return pages.isEmpty ? [attributed] : pages + } +} + + diff --git a/ios/LibNovelV2/Views/Common/CommonViews.swift b/ios/LibNovelV2/Views/Common/CommonViews.swift new file mode 100644 index 0000000..27ae76e --- /dev/null +++ b/ios/LibNovelV2/Views/Common/CommonViews.swift @@ -0,0 +1,235 @@ +import SwiftUI + +// MARK: - CommonViews +// Shared reusable components used across multiple screens. +// No external dependencies — images are loaded via URLSession with an in-memory cache. + +// MARK: - Color extensions (design system tokens) + +extension Color { + /// Amber-400 accent — #f59e0b + static let amber = Color(red: 0.961, green: 0.620, blue: 0.043) +} + +// MARK: - AsyncCoverImage +// URLSession-backed cover image loader with in-memory cache. +// Displays a zinc-800 placeholder skeleton while loading, book-closed icon on failure. + +private actor ImageCache { + static let shared = ImageCache() + private var cache: [URL: Data] = [:] + private var inFlight: [URL: Task] = [:] + + func data(for url: URL) async -> Data? { + if let cached = cache[url] { return cached } + if let existing = inFlight[url] { return await existing.value } + + let task = Task { + do { + let (d, _) = try await URLSession.shared.data(from: url) + return d + } catch { return nil } + } + inFlight[url] = task + let result = await task.value + inFlight.removeValue(forKey: url) + if let result { cache[url] = result } + return result + } +} + +struct AsyncCoverImage: View { + let url: String? + /// When true the placeholder is a plain colour fill (used for blurred hero backgrounds). + var isBackground: Bool = false + + @State private var image: UIImage? + @State private var hasFailed = false + + var body: some View { + Group { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else if hasFailed { + placeholder + } else { + placeholder + .task(id: url) { await load() } + } + } + } + + @ViewBuilder + private var placeholder: some View { + if isBackground { + Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1)) + } else { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color(uiColor: UIColor(red: 0.14, green: 0.14, blue: 0.16, alpha: 1))) + .overlay( + Image(systemName: "book.closed") + .font(.title3) + .foregroundStyle(.tertiary) + ) + } + } + + private func load() async { + guard let urlString = url, let parsedURL = URL(string: urlString) else { + hasFailed = true + return + } + guard let data = await ImageCache.shared.data(for: parsedURL), + let loaded = UIImage(data: data) else { + hasFailed = true + return + } + image = loaded + } +} + +// MARK: - EmptyStateView + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + var ctaLabel: String? = nil + var ctaAction: (() -> Void)? = nil + + var body: some View { + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 52)) + .foregroundStyle(.tertiary) + .symbolEffect(.pulse) + + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + if let label = ctaLabel, let action = ctaAction { + Button(action: action) { + Text(label) + .font(.subheadline.bold()) + .foregroundStyle(Color(uiColor: UIColor(red: 0.11, green: 0.09, blue: 0.04, alpha: 1))) + .padding(.horizontal, 24) + .frame(height: 44) + .background(Color.amber) + .clipShape(Capsule()) + } + .padding(.top, 4) + } + } + } +} + +// MARK: - ShelfHeader +// Amber accent-bar + bold title. Used by Home, Profile, Browse shelves. + +struct ShelfHeader: View { + let title: String + + var body: some View { + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(Color.amber) + .frame(width: 3, height: 18) + Text(title) + .font(.title3.bold()) + } + .padding(.horizontal, 16) + .padding(.bottom, 10) + } +} + +// MARK: - ChipButton +// Unified selection chip (filled or outlined style). + +enum ChipButtonStyle { case filled, outlined } + +struct ChipButton: View { + let label: String + let isSelected: Bool + var style: ChipButtonStyle = .filled + let action: () -> Void + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + } label: { + Text(label) + .font(style == .filled + ? .caption.weight(isSelected ? .semibold : .regular) + : .subheadline.weight(isSelected ? .semibold : .regular)) + .padding(.horizontal, style == .filled ? 12 : 14) + .padding(.vertical, 6) + .foregroundStyle(isSelected + ? (style == .filled ? Color.white : Color.amber) + : Color.primary) + .background(chipBackground) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 44) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } + + @ViewBuilder + private var chipBackground: some View { + switch style { + case .filled: + Capsule().fill(isSelected ? Color.amber : Color(.systemGray5)) + case .outlined: + Capsule() + .fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6)) + .overlay(Capsule().stroke(isSelected ? Color.amber : Color.clear, lineWidth: 1.5)) + } + } +} + +// MARK: - TagChip (read-only label) + +struct TagChip: View { + let label: String + + var body: some View { + Text(label) + .font(.caption2.bold()) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.systemGray5), in: Capsule()) + } +} + +// MARK: - OfflineBanner +// Shown at the top of any view when the device is offline. + +struct OfflineBanner: View { + @EnvironmentObject var networkMonitor: NetworkMonitor + + var body: some View { + if !networkMonitor.isConnected { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .font(.caption.bold()) + Text("You're offline — showing cached content") + .font(.caption) + Spacer() + } + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } +} diff --git a/ios/LibNovelV2/Views/Downloads/DownloadsView.swift b/ios/LibNovelV2/Views/Downloads/DownloadsView.swift new file mode 100644 index 0000000..d485448 --- /dev/null +++ b/ios/LibNovelV2/Views/Downloads/DownloadsView.swift @@ -0,0 +1,359 @@ +import SwiftUI + +// MARK: - DownloadsView +// Shows active downloads (in-progress), downloaded chapters grouped by book, and storage usage. +// Purely local — no network calls needed. + +struct DownloadsView: View { + @ObservedObject private var downloadService = AudioDownloadService.shared + @Environment(\.dismiss) private var dismiss + + // Completed chapters grouped by slug, sorted alphabetically + private var groupedDownloads: [(slug: String, keys: [String])] { + let slugs = downloadService.offlineBookSlugs() + return slugs.map { slug in + let keys = downloadService.downloadedChapters + .filter { $0.hasPrefix("\(slug)::") } + .sorted { lhs, rhs in + let lhsChapter = chapterNumber(from: lhs) + let rhsChapter = chapterNumber(from: rhs) + return lhsChapter < rhsChapter + } + return (slug: slug, keys: keys) + } + } + + private var activeDownloads: [(key: String, progress: DownloadProgress)] { + downloadService.downloads + .sorted { $0.key < $1.key } + .map { (key: $0.key, progress: $0.value) } + } + + private var storageFormatted: String { + ByteCountFormatter.string( + fromByteCount: downloadService.totalStorageUsed(), + countStyle: .file + ) + } + + private var hasAnyContent: Bool { + !downloadService.downloadedChapters.isEmpty || !downloadService.downloads.isEmpty + } + + var body: some View { + NavigationStack { + Group { + if hasAnyContent { + contentList + } else { + emptyState + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Downloads") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + dismiss() + } + .foregroundStyle(Color.amber) + } + } + } + } + + // MARK: - Content list + + private var contentList: some View { + List { + // Storage info + storageSection + + // Active downloads + if !activeDownloads.isEmpty { + Section { + ForEach(activeDownloads, id: \.key) { item in + ActiveDownloadRow(key: item.key, progress: item.progress) + } + } header: { + Text("Downloading") + .font(.subheadline.bold()) + .foregroundStyle(Color.amber) + .textCase(nil) + } + } + + // Completed, grouped by book + ForEach(groupedDownloads, id: \.slug) { group in + Section { + ForEach(group.keys, id: \.self) { key in + DownloadedChapterRow(key: key) + } + } header: { + HStack(spacing: 6) { + Image(systemName: "book.closed.fill") + .font(.caption) + .foregroundStyle(.secondary) + Text(group.slug) + .font(.subheadline.bold()) + .foregroundStyle(.primary) + .textCase(nil) + Spacer() + Text("\(group.keys.count) ch.") + .font(.caption) + .foregroundStyle(.secondary) + .textCase(nil) + } + } + } + + // Delete all + if !downloadService.downloadedChapters.isEmpty { + Section { + Button(role: .destructive) { + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + try? downloadService.deleteAllDownloads() + } label: { + HStack { + Spacer() + Label("Delete All Downloads", systemImage: "trash.fill") + .font(.subheadline.bold()) + Spacer() + } + } + .accessibilityLabel("Delete all downloaded audio chapters") + } + } + } + .scrollContentBackground(.hidden) + .listStyle(.insetGrouped) + } + + // MARK: - Storage section + + private var storageSection: some View { + Section { + HStack(spacing: 12) { + Image(systemName: "internaldrive.fill") + .font(.body) + .foregroundStyle(Color.amber) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text("Storage Used") + .font(.subheadline) + Text(storageFormatted) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text("\(downloadService.downloadedChapters.count) chapters") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + + // MARK: - Empty state + + private var emptyState: some View { + VStack(spacing: 0) { + Spacer() + EmptyStateView( + icon: "arrow.down.circle", + title: "No Downloads", + message: "Downloaded audio chapters appear here for offline listening." + ) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Helpers + + private func chapterNumber(from key: String) -> Int { + let parts = key.split(separator: "::") + guard parts.count >= 2, let n = Int(parts[1]) else { return 0 } + return n + } +} + +// MARK: - ActiveDownloadRow + +private struct ActiveDownloadRow: View { + let key: String + let progress: DownloadProgress + @ObservedObject private var downloadService = AudioDownloadService.shared + + var body: some View { + HStack(spacing: 12) { + // Icon with status + ZStack { + Circle() + .fill(statusColor.opacity(0.15)) + .frame(width: 36, height: 36) + Image(systemName: statusIcon) + .font(.subheadline.bold()) + .foregroundStyle(statusColor) + .contentTransition(.symbolEffect(.replace.downUp)) + } + + VStack(alignment: .leading, spacing: 3) { + Text("Chapter \(progress.chapter)") + .font(.subheadline.bold()) + .lineLimit(1) + + HStack(spacing: 4) { + Text(progress.slug) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Text("·") + .font(.caption) + .foregroundStyle(.tertiary) + Text(formatVoice(progress.voice)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Progress or error indicator + if case .failed(let msg) = progress.status { + VStack(alignment: .trailing, spacing: 2) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.subheadline) + .foregroundStyle(.red) + .symbolEffect(.pulse) + Text("Failed") + .font(.caption2) + .foregroundStyle(.red) + } + .accessibilityLabel("Download failed: \(msg)") + } else { + VStack(alignment: .trailing, spacing: 4) { + Text("\(Int(progress.progress * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + ProgressView(value: progress.progress) + .tint(Color.amber) + .frame(width: 64) + } + } + + // Cancel button (only while downloading) + if progress.status == .downloading { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + downloadService.cancelDownload( + slug: progress.slug, + chapter: progress.chapter, + voice: progress.voice + ) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel("Cancel download for chapter \(progress.chapter)") + } + } + .padding(.vertical, 4) + } + + private var statusColor: Color { + if case .failed = progress.status { return .red } + return Color.amber + } + + private var statusIcon: String { + if case .failed = progress.status { return "exclamationmark.triangle" } + return "arrow.down" + } +} + +// MARK: - DownloadedChapterRow + +private struct DownloadedChapterRow: View { + let key: String + @ObservedObject private var downloadService = AudioDownloadService.shared + + // Parse "slug::chapterNumber::voice" — v2 keys use "::" separator + private var components: (slug: String, chapter: Int, voice: String) { + let parts = key.split(separator: "::") + guard parts.count == 3, let chapter = Int(parts[1]) else { + return ("", 0, "") + } + return (String(parts[0]), chapter, String(parts[2])) + } + + var body: some View { + let c = components + HStack(spacing: 12) { + // Checkmark badge + ZStack { + Circle() + .fill(Color.green.opacity(0.15)) + .frame(width: 36, height: 36) + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.green) + } + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text("Chapter \(c.chapter)") + .font(.subheadline.bold()) + .lineLimit(1) + + Text(formatVoice(c.voice)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "waveform") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + .accessibilityLabel("Chapter \(c.chapter), voice \(formatVoice(c.voice)), downloaded") + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + try? downloadService.deleteDownload( + slug: c.slug, + chapter: c.chapter, + voice: c.voice + ) + } label: { + Label("Delete", systemImage: "trash") + } + } + } +} + +// MARK: - Shared voice formatter + +private func formatVoice(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count == 2 else { return voice } + let prefix = String(parts[0]) + let name = String(parts[1]).capitalized + let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : "" + let accent = prefix.hasPrefix("af") || prefix.hasPrefix("am") ? "US" + : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" + : "" + if !gender.isEmpty && !accent.isEmpty { return "\(name) (\(accent) \(gender))" } + if !gender.isEmpty { return "\(name) (\(gender))" } + return name +} diff --git a/ios/LibNovelV2/Views/Home/HomeView.swift b/ios/LibNovelV2/Views/Home/HomeView.swift new file mode 100644 index 0000000..67617d9 --- /dev/null +++ b/ios/LibNovelV2/Views/Home/HomeView.swift @@ -0,0 +1,384 @@ +import SwiftUI + +// MARK: - HomeView +// "Reading Now" tab: stats bar + Continue Reading shelf + Recently Updated shelf +// + Subscription Feed shelf + empty state. +// Design mirrors the web UI home page (zinc-900 bg, amber accents, horizontal shelves). + +struct HomeView: View { + @State private var vm = HomeViewModel() + @EnvironmentObject var networkMonitor: NetworkMonitor + @EnvironmentObject var authStore: AuthStore + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + + + // ── Stats bar ─────────────────────────────────────── + if let stats = vm.stats { + StatsBar(stats: stats) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 28) + .transition(.opacity) + } + + // ── Continue Reading ──────────────────────────────── + if !vm.continueReading.isEmpty { + ShelfHeader(title: "Continue Reading") + horizontalShelf { + ForEach(vm.continueReading) { item in + NavigationLink(value: NavDestination.chapter(item.book.slug, item.chapter)) { + ContinueReadingCard(item: item) + } + .buttonStyle(.plain) + .contextMenu { + continueReadingContextMenu(item: item) + } + } + } + } + + // ── Recently Updated ──────────────────────────────── + if !vm.recentlyUpdated.isEmpty { + ShelfHeader(title: "Recently Updated") + horizontalShelf { + ForEach(vm.recentlyUpdated) { book in + NavigationLink(value: NavDestination.book(book.slug)) { + ShelfBookCard(book: book) + } + .buttonStyle(.plain) + } + } + } + + // ── Subscription Feed ─────────────────────────────── + if !vm.subscriptionFeed.isEmpty { + ShelfHeader(title: "From People You Follow") + horizontalShelf { + ForEach(vm.subscriptionFeed) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + SubscriptionFeedCard(item: item) + } + .buttonStyle(.plain) + } + } + } + + // ── Empty state ───────────────────────────────────── + if !vm.isLoading && + vm.continueReading.isEmpty && + vm.recentlyUpdated.isEmpty && + vm.subscriptionFeed.isEmpty { + EmptyStateView( + icon: "books.vertical", + title: "Your library is empty", + message: "Head to Discover to find novels to read.", + ctaLabel: "Discover Novels", + ctaAction: nil // tab switching handled externally + ) + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + // ── Loading indicator ─────────────────────────────── + if vm.isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 60) + } + + Color.clear.frame(height: 24) + } + } + .refreshable { await vm.load() } + } + .navigationTitle("Reading Now") + .appNavigationDestination() + .task { + guard networkMonitor.isConnected else { return } + await vm.load() + } + .errorAlert($vm.error) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: vm.isLoading) + } + } + + // MARK: - Horizontal shelf wrapper + + @ViewBuilder + private func horizontalShelf(@ViewBuilder content: () -> Content) -> some View { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: 14) { + content() + } + .padding(.horizontal, 16) + .padding(.bottom, 4) + } + .padding(.bottom, 28) + } + + // MARK: - Context menu for continue reading cards + + @ViewBuilder + private func continueReadingContextMenu(item: ContinueReadingItem) -> some View { + let isFinished = item.book.totalChapters > 0 && item.chapter >= item.book.totalChapters + + ShareLink(item: shareURL(for: item.book)) { + Label("Share", systemImage: "square.and.arrow.up") + } + + if !isFinished { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await markAsFinished(item.book) } + } label: { + Label("Mark as Finished", systemImage: "checkmark.circle") + } + } + + Button(role: .destructive) { + Task { await removeFromLibrary(item.book.slug) } + } label: { + Label("Remove from Library", systemImage: "trash") + } + } + + // MARK: - Actions + + private func markAsFinished(_ book: Book) async { + do { + try await APIClient.shared.setProgress(slug: book.slug, chapter: book.totalChapters) + await vm.load() + } catch { + vm.error = error.localizedDescription + } + } + + private func removeFromLibrary(_ slug: String) async { + do { + try await APIClient.shared.deleteProgress(slug: slug) + await vm.load() + } catch { + vm.error = error.localizedDescription + } + } + + private func shareURL(for book: Book) -> URL { + let base = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String + ?? "https://v2.libnovel.kalekber.cc" + return URL(string: "\(base)/books/\(book.slug)")! + } +} + +// MARK: - Stats bar +// Three amber-value cards: Books / Chapters / In Progress + +private struct StatsBar: View { + let stats: HomeStats + + var body: some View { + HStack(spacing: 12) { + StatCard( + icon: "books.vertical.fill", + value: "\(stats.totalBooks)", + label: "Books" + ) + StatCard( + icon: "text.alignleft", + value: stats.totalChapters.formatted(), + label: "Chapters" + ) + StatCard( + icon: "bookmark.fill", + value: "\(stats.booksInProgress)", + label: "In Progress" + ) + } + } +} + +private struct StatCard: View { + let icon: String + let value: String + let label: String + + var body: some View { + VStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color.amber) + Text(value) + .font(.title3.bold().monospacedDigit()) + .foregroundStyle(.primary) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + +// MARK: - Continue Reading card (Apple Books style with progress bar) + +private struct ContinueReadingCard: View { + let item: ContinueReadingItem + + private static let cardWidth: CGFloat = 130 + private static let cardHeight: CGFloat = 188 // 2:3 aspect + + private var progressFraction: Double { + guard item.book.totalChapters > 0 else { return 0 } + return min(1.0, Double(item.chapter) / Double(item.book.totalChapters)) + } + + private var progressText: String { + let pct = progressFraction * 100 + if pct > 0 && pct < 10 { + return String(format: "%.1f%% complete", pct) + } + return "\(max(1, Int(round(pct))))% complete" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Cover with gradient scrim + chapter badge + ZStack(alignment: .bottom) { + AsyncCoverImage(url: item.book.cover) + .frame(width: Self.cardWidth, height: Self.cardHeight) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(color: .black.opacity(0.22), radius: 8, y: 4) + .bookCoverZoomSource(slug: item.book.slug) + + // Gradient scrim + LinearGradient( + colors: [.clear, .black.opacity(0.55)], + startPoint: .center, + endPoint: .bottom + ) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .frame(height: 60) + + // Chapter pill + HStack(spacing: 4) { + Image(systemName: "play.fill") + .font(.system(size: 8, weight: .bold)) + Text("Ch.\(item.chapter)") + .font(.system(size: 10, weight: .bold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Capsule().fill(Color.amber)) + .padding(.bottom, 10) + } + + // Title + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: Self.cardWidth, alignment: .leading) + .foregroundStyle(.primary) + + // Progress bar (min 4pt sliver so early chapters are visible) + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.2)) + Capsule() + .fill(Color.amber.opacity(0.9)) + .frame(width: max(4, geo.size.width * progressFraction)) + } + } + .frame(width: Self.cardWidth, height: 3) + + Text(progressText) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(width: Self.cardWidth) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(item.book.title), chapter \(item.chapter), \(progressText)") + } +} + +// MARK: - Shelf book card (recently updated) + +private struct ShelfBookCard: View { + let book: Book + private static let cardWidth: CGFloat = 110 + private static let cardHeight: CGFloat = 158 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .topTrailing) { + AsyncCoverImage(url: book.cover) + .frame(width: Self.cardWidth, height: Self.cardHeight) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .bookCoverZoomSource(slug: book.slug) + + Text("\(book.totalChapters) ch") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.black.opacity(0.55))) + .padding(6) + } + + Text(book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: Self.cardWidth, alignment: .leading) + + Text(book.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: Self.cardWidth, alignment: .leading) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(book.title) by \(book.author), \(book.totalChapters) chapters") + } +} + +// MARK: - Subscription feed card + +private struct SubscriptionFeedCard: View { + let item: SubscriptionFeedItem + private static let cardWidth: CGFloat = 110 + private static let cardHeight: CGFloat = 158 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + AsyncCoverImage(url: item.book.cover) + .frame(width: Self.cardWidth, height: Self.cardHeight) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 4, y: 2) + .bookCoverZoomSource(slug: item.book.slug) + + Text(item.book.title) + .font(.caption.bold()) + .lineLimit(2) + .frame(width: Self.cardWidth, alignment: .leading) + + NavigationLink(value: NavDestination.userProfile(item.readerUsername)) { + Text("via @\(item.readerUsername)") + .font(.caption2) + .foregroundStyle(Color.amber) + .lineLimit(1) + .frame(width: Self.cardWidth, alignment: .leading) + } + .buttonStyle(.plain) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(item.book.title), via \(item.readerUsername)") + } +} diff --git a/ios/LibNovelV2/Views/Library/LibraryView.swift b/ios/LibNovelV2/Views/Library/LibraryView.swift new file mode 100644 index 0000000..1ac174d --- /dev/null +++ b/ios/LibNovelV2/Views/Library/LibraryView.swift @@ -0,0 +1,325 @@ +import SwiftUI + +// MARK: - LibraryView +// 2-column grid of saved books with progress overlay, genre/sort/reading-status filters. + +struct LibraryView: View { + @State private var viewModel = LibraryViewModel() + @EnvironmentObject private var networkMonitor: NetworkMonitor + + // Sort sheet + @State private var showingSortSheet = false + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + // Filter bar + filterBar + + if viewModel.isLoading && viewModel.items.isEmpty { + loadingState + } else if viewModel.filteredItems.isEmpty && !viewModel.isLoading { + emptyState + } else { + bookGrid + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Library") + .navigationBarTitleDisplayMode(.large) + .toolbar { toolbarContent } + .appNavigationDestination() + .task { + guard networkMonitor.isConnected else { return } + await viewModel.load() + } + .refreshable { await viewModel.load() } + .errorAlert($viewModel.error) + .confirmationDialog("Sort By", isPresented: $showingSortSheet, titleVisibility: .visible) { + ForEach(LibrarySortOrder.allCases, id: \.self) { order in + Button(order.rawValue) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.sortOrder = order + } + } + } + Button("Cancel", role: .cancel) {} + } + } + } + + // MARK: - Filter bar + + private var filterBar: some View { + VStack(spacing: 0) { + // Reading filter chips + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(LibraryReadingFilter.allCases, id: \.self) { filter in + ChipButton(label: filter.rawValue, + isSelected: viewModel.readingFilter == filter, + style: .filled) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.readingFilter = filter + } + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + // Genre chips (only show if there are genres) + if viewModel.allGenres.count > 1 { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(viewModel.allGenres, id: \.self) { genre in + ChipButton(label: genre, + isSelected: viewModel.selectedGenre == genre, + style: .outlined) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + viewModel.selectedGenre = genre + } + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + } + + Divider() + .background(Color(uiColor: UIColor(red: 0.247, green: 0.247, blue: 0.275, alpha: 1))) + } + } + + // MARK: - Book grid + + private let columns = [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ] + + private var bookGrid: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(viewModel.filteredItems) { item in + NavigationLink(value: NavDestination.book(item.book.slug)) { + LibraryBookCard( + item: item, + progress: viewModel.progressFraction(for: item), + progressLabel: viewModel.progressPercent(for: item), + isCompleted: viewModel.isCompleted(for: item), + lastChapter: viewModel.lastChapter(for: item) + ) + .bookCoverZoomSource(slug: item.book.slug) + .contextMenu { + contextMenu(for: item) + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 120) // clear mini player + } + } + + // MARK: - Context menu + + @ViewBuilder + private func contextMenu(for item: LibraryItem) -> some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + // Share: nothing to share without a URL from API, placeholder + } label: { + Label("Share", systemImage: "square.and.arrow.up") + } + + if !viewModel.isCompleted(for: item) { + Button { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await viewModel.markFinished(item: item) } + } label: { + Label("Mark as Finished", systemImage: "checkmark.circle") + } + } + + Button(role: .destructive) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await viewModel.removeFromLibrary(slug: item.book.slug) } + } label: { + Label("Remove from Library", systemImage: "trash") + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showingSortSheet = true + } label: { + Label("Sort", systemImage: "arrow.up.arrow.down") + .labelStyle(.iconOnly) + } + .accessibilityLabel("Sort library") + } + } + + // MARK: - Loading state + + private var loadingState: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(0..<8, id: \.self) { _ in + LibraryBookCardSkeleton() + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + } + } + + // MARK: - Empty state + + private var emptyState: some View { + VStack { + Spacer() + EmptyStateView( + icon: "books.vertical", + title: viewModel.items.isEmpty ? "Your library is empty" : "No books match", + message: viewModel.items.isEmpty + ? "Browse and save books to build your collection." + : "Try a different filter or genre.", + ctaLabel: viewModel.items.isEmpty ? "Browse Books" : nil, + ctaAction: nil + ) + Spacer() + } + } +} + +// MARK: - LibraryBookCard + +struct LibraryBookCard: View { + let item: LibraryItem + let progress: Double // 0…1 + let progressLabel: String // "47%" or "3.4%" + let isCompleted: Bool + let lastChapter: Int + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Cover with progress arc overlay + ZStack(alignment: .topTrailing) { + AsyncCoverImage(url: item.book.cover) + .aspectRatio(2/3, contentMode: .fill) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + + if isCompleted { + completedBadge + } else if progress > 0 { + progressArcBadge + } + } + + // Title + Text(item.book.title) + .font(.caption.bold()) + .foregroundStyle(.primary) + .lineLimit(2) + + // Chapter subtitle + if lastChapter > 0 { + Text(isCompleted ? "Completed" : "Ch. \(lastChapter)") + .font(.caption2) + .foregroundStyle(isCompleted ? Color.amber : .secondary) + } + } + } + + // MARK: - Completed badge + + private var completedBadge: some View { + Image(systemName: "checkmark.circle.fill") + .font(.title3) + .foregroundStyle(Color.amber) + .padding(6) + .background(.regularMaterial, in: Circle()) + .padding(6) + .accessibilityLabel("Completed") + } + + // MARK: - Progress arc + + private var progressArcBadge: some View { + ZStack { + // Track + Circle() + .stroke(Color.white.opacity(0.25), lineWidth: 3) + .frame(width: 32, height: 32) + + // Fill + Circle() + .trim(from: 0, to: progress) + .stroke(Color.amber, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 32, height: 32) + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: progress) + + Text(progressLabel) + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.white) + } + .padding(6) + .background(.ultraThinMaterial, in: Circle()) + .padding(6) + .accessibilityLabel("Progress: \(progressLabel)") + } +} + +// MARK: - LibraryBookCardSkeleton +// Shimmer placeholder used while data is loading. + +struct LibraryBookCardSkeleton: View { + @State private var phase: Double = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(shimmerGradient) + .aspectRatio(2/3, contentMode: .fill) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(height: 12) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 60, height: 10) + } + .onAppear { + withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: true)) { + phase = 1 + } + } + } + + private var shimmerGradient: LinearGradient { + LinearGradient( + colors: [ + Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)), + Color(uiColor: UIColor(red: 0.22, green: 0.22, blue: 0.25, alpha: 1)), + Color(uiColor: UIColor(red: 0.15, green: 0.15, blue: 0.17, alpha: 1)) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} diff --git a/ios/LibNovelV2/Views/Player/PlayerViews.swift b/ios/LibNovelV2/Views/Player/PlayerViews.swift new file mode 100644 index 0000000..b79f3ab --- /dev/null +++ b/ios/LibNovelV2/Views/Player/PlayerViews.swift @@ -0,0 +1,1826 @@ +import SwiftUI +import AVFoundation +import AVKit // AVRoutePickerView + +// MARK: - VoiceSelectionViewModel +// Minimal inline VM for the FullPlayerView voice panel and DownloadManagementSheet. +// New type → @Observable (iOS 17+). + +@Observable @MainActor +final class VoiceSelectionViewModel { + var voices: [String] = [] + var isLoading = false + var error: String? + var playingVoice: String? + + private var audioPlayer: AVPlayer? + private var endObserverToken: NSObjectProtocol? + + func voiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + let prefix = String(parts[0]) + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + var info = "" + switch prefix { + case "af": info = "US F" + case "am": info = "US M" + case "bf": info = "UK F" + case "bm": info = "UK M" + default: info = prefix.uppercased() + } + return "\(name) (\(info))" + } + + func voiceId(_ voice: String) -> String { voice } + + func loadVoices() async { + isLoading = true + error = nil + defer { isLoading = false } + do { + let fetched = try await APIClient.shared.voices() + voices = fetched.isEmpty ? fallbackVoices() : fetched + } catch { + self.error = error.localizedDescription + voices = fallbackVoices() + } + } + + func playSample(_ voice: String) async { + if playingVoice == voice { stopSample(); return } + stopSample() + playingVoice = voice + do { + let url = try await APIClient.shared.presignVoiceSample(voice: voice) + guard let parsed = URL(string: url) else { playingVoice = nil; return } + let item = AVPlayerItem(url: parsed) + audioPlayer = AVPlayer(playerItem: item) + endObserverToken = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.stopSample() } + } + audioPlayer?.play() + } catch { + playingVoice = nil + } + } + + func stopSample() { + audioPlayer?.pause() + audioPlayer = nil + if let token = endObserverToken { + NotificationCenter.default.removeObserver(token) + endObserverToken = nil + } + playingVoice = nil + } + + private func fallbackVoices() -> [String] { + ["af_bella", "af_sarah", "af_nicole", + "am_adam", "am_michael", + "bf_emma", "bf_isabella", + "bm_george", "bm_lewis", "af_sky"] + } +} + +// MARK: - MiniPlayerBar +// Spotify-style bar fixed above the tab bar. +// Swipe up → full player. Swipe down → stop. + +struct MiniPlayerBar: View { + @Binding var showFullPlayer: Bool + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + @State private var dragOffset: CGFloat = 0 + + private var isCurrentChapterDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + + var body: some View { + VStack(spacing: 0) { + // Amber progress strip + MiniBarProgress(progress: audioPlayer.progress) + + HStack(spacing: 12) { + // Cover art + Button { showFullPlayer = true } label: { + AsyncCoverImage(url: audioPlayer.coverURL) + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + .shadow(color: .black.opacity(0.18), radius: 6, y: 2) + } + .buttonStyle(.plain) + .accessibilityLabel("Open full player") + + // Track info + Button { showFullPlayer = true } label: { + VStack(alignment: .leading, spacing: 2) { + Text(chapterLabel) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + HStack(spacing: 4) { + Text(audioPlayer.bookTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + if isCurrentChapterDownloaded { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 9)) + .foregroundStyle(.green) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + // Prev chapter + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if let prev = audioPlayer.prevChapter { + NotificationCenter.default.post( + name: .skipToPrevChapter, object: nil, + userInfo: ["prev": prev] + ) + } + } label: { + Image(systemName: "backward.end.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(audioPlayer.prevChapter == nil) + .opacity(audioPlayer.prevChapter == nil ? 0.3 : 1) + .accessibilityLabel("Previous chapter") + + // Play / Pause — isolated observer + MiniBarPlayPause(progress: audioPlayer.progress) { + audioPlayer.togglePlayPause() + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + .disabled(audioPlayer.status == .generating) + + // Next chapter + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if let next = audioPlayer.nextChapter { + NotificationCenter.default.post( + name: .skipToNextChapter, object: nil, + userInfo: ["next": next] + ) + } + } label: { + Image(systemName: "forward.end.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(audioPlayer.nextChapter == nil) + .opacity(audioPlayer.nextChapter == nil ? 0.3 : 1) + .accessibilityLabel("Next chapter") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .background(.regularMaterial) + .offset(y: dragOffset) + .opacity(dragOffset > 0 ? max(0.3, 1 - dragOffset / 200) : 1) + .gesture( + DragGesture(minimumDistance: 8, coordinateSpace: .local) + .onChanged { value in + let dy = value.translation.height + dragOffset = dy < 0 ? dy * 0.25 : dy * 0.7 + } + .onEnded { value in + let dy = value.translation.height + let velocity = value.predictedEndTranslation.height - value.translation.height + if dy < -30 || velocity < -150 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { dragOffset = 0 } + showFullPlayer = true + } else if dy > 60 || velocity > 200 { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { dragOffset = 200 } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 150_000_000) + audioPlayer.stop() + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { dragOffset = 0 } + } + } + ) + } + + private var chapterLabel: String { + let raw = audioPlayer.chapterTitle.isEmpty + ? "Chapter \(audioPlayer.chapter)" + : audioPlayer.chapterTitle + return raw.strippingTrailingDate() + } +} + +// MARK: - Isolated progress strip + +private struct MiniBarProgress: View { + @ObservedObject var progress: PlaybackProgress + + var body: some View { + GeometryReader { geo in + let fraction = progress.duration > 0 + ? CGFloat(progress.currentTime / progress.duration) + : 0 + Rectangle() + .fill(Color.amber) + .frame(width: geo.size.width * max(0, min(1, fraction)), height: 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 2) + } +} + +// MARK: - Isolated play/pause for mini bar + +private struct MiniBarPlayPause: View { + @ObservedObject var progress: PlaybackProgress + let onToggle: () -> Void + + var body: some View { + Button(action: onToggle) { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 44) + .contentShape(Rectangle()) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .buttonStyle(.plain) + .accessibilityLabel(progress.isPlaying ? "Pause" : "Play") + } +} + +// MARK: - FullPlayerView + +struct FullPlayerView: View { + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var authStore: AuthStore + var onDismiss: () -> Void = {} + + @State private var showingChaptersList = false + @State private var showingSleepTimer = false + @State private var showingVoiceSelector = false + @State private var voiceVM = VoiceSelectionViewModel() + @State private var coverAppeared = false + + private var isCurrentChapterDownloaded: Bool { + downloadService.isDownloaded( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + + private var currentDownloadProgress: DownloadProgress? { + let key = downloadService.makeKey( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + return downloadService.downloads[key] + } + + var body: some View { + GeometryReader { geo in + ZStack { + // Blurred cover background + AsyncCoverImage(url: audioPlayer.coverURL, isBackground: true) + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + .blur(radius: 55, opaque: true) + .overlay(Color.black.opacity(0.55)) + .ignoresSafeArea() + .id(audioPlayer.coverURL) + + VStack(spacing: 0) { + // Drag handle + Capsule() + .fill(Color.white.opacity(0.25)) + .frame(width: 36, height: 4) + .padding(.top, 14) + + // Cover art + let coverSize = min(geo.size.width - 56, geo.size.height * 0.42) + ZStack { + AsyncCoverImage(url: audioPlayer.coverURL) + .frame(width: coverSize, height: coverSize) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .shadow(color: .black.opacity(0.55), radius: 36, y: 18) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.black.opacity(audioPlayer.status == .generating ? 0.5 : 0)) + .animation(.easeInOut(duration: 0.3), value: audioPlayer.status == .generating) + ) + .scaleEffect(audioPlayer.progress.isPlaying && coverAppeared ? 1.02 : 0.97) + .animation(.spring(response: 0.45, dampingFraction: 0.7), value: audioPlayer.progress.isPlaying) + + // Generating overlay + if audioPlayer.status == .generating { + VStack(spacing: 10) { + ProgressView() + .tint(.white) + .scaleEffect(1.4) + Text("Generating audio…") + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(0.8)) + } + .transition(.opacity) + } + + // Voice watermark + VStack { + Spacer() + HStack { + Text(voiceName) + .font(.custom("Snell Roundhand", size: 17)) + .foregroundStyle(.white.opacity(0.5)) + .shadow(color: .black.opacity(0.5), radius: 2) + .padding(12) + Spacer() + } + } + .frame(width: coverSize, height: coverSize) + } + .frame(width: coverSize, height: coverSize) + .padding(.top, 18) + .onAppear { + withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1)) { + coverAppeared = true + } + } + .onChange(of: audioPlayer.slug) { _, _ in + coverAppeared = false + withAnimation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.05)) { + coverAppeared = true + } + } + + // Title block + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text((audioPlayer.chapterTitle.isEmpty + ? "Chapter \(audioPlayer.chapter)" + : audioPlayer.chapterTitle).strippingTrailingDate()) + .font(.title3.weight(.bold)) + .foregroundStyle(.white) + .lineLimit(2) + Text(audioPlayer.bookTitle) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + + HStack(spacing: 8) { + if !audioPlayer.chapters.isEmpty { + Text(chapterPositionText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white.opacity(0.3)) + } + if let p = currentDownloadProgress { + Label("\(Int(p.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundStyle(.blue) + } else if isCurrentChapterDownloaded { + Label("Offline", systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(.green) + } + } + .padding(.top, 1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Quick download + if !isCurrentChapterDownloaded && currentDownloadProgress == nil { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + Task { + try? await downloadService.download( + slug: audioPlayer.slug, + chapter: audioPlayer.chapter, + voice: audioPlayer.voice + ) + } + } label: { + Image(systemName: "arrow.down.circle") + .font(.system(size: 24)) + .foregroundStyle(.white.opacity(0.65)) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("Download chapter") + } + + // Auto-next toggle + Button { + audioPlayer.autoNext.toggle() + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } label: { + Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle") + .font(.system(size: 28)) + .foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.4)) + .contentTransition(.symbolEffect(.replace)) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(audioPlayer.autoNext ? "Auto-next on" : "Auto-next off") + } + .padding(.horizontal, 28) + .padding(.top, 22) + + // Seek bar (isolated) + PlayerProgressSection( + progress: audioPlayer.progress, + onSeek: { audioPlayer.seek(to: $0) } + ) + .padding(.top, 18) + .opacity(audioPlayer.status == .generating ? 0.3 : 1) + .allowsHitTesting(audioPlayer.status != .generating) + + // Transport row + HStack(spacing: 0) { + PlayerSecondaryButton(systemName: "gobackward.15", size: 24, + disabled: audioPlayer.status == .generating) { + audioPlayer.skip(by: -15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + PlayerChapterSkipButton(systemName: "backward.end.fill", size: 30, + disabled: audioPlayer.prevChapter == nil) { + if let prev = audioPlayer.prevChapter { + NotificationCenter.default.post( + name: .skipToPrevChapter, object: nil, userInfo: ["prev": prev]) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } + + PlayerPlayPauseButton( + progress: audioPlayer.progress, + isGenerating: audioPlayer.status == .generating + ) { + audioPlayer.togglePlayPause() + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + + PlayerChapterSkipButton( + systemName: "forward.end.fill", size: 30, + disabled: audioPlayer.nextChapter == nil, + prefetching: audioPlayer.nextPrefetchStatus == .prefetching + ) { + if let next = audioPlayer.nextChapter { + NotificationCenter.default.post( + name: .skipToNextChapter, object: nil, userInfo: ["next": next]) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + } + + PlayerSecondaryButton(systemName: "goforward.15", size: 24, + disabled: audioPlayer.status == .generating) { + audioPlayer.skip(by: 15) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + + // Bottom toolbar + HStack(spacing: 0) { + // AirPlay + AirPlayButton() + .frame(width: 24, height: 24) + .frame(maxWidth: .infinity, minHeight: 44) + .accessibilityLabel("AirPlay") + + // Speed picker + Menu { + ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in + Button { + audioPlayer.setSpeed(s) + } label: { + if s == audioPlayer.speed { + Label("\(s, specifier: "%.2g")×", systemImage: "checkmark") + } else { + Text("\(s, specifier: "%.2g")×") + } + } + } + } label: { + Text("\(audioPlayer.speed, specifier: "%.2g")×") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(0.65)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule().fill(.white.opacity(0.12))) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + + // Voice selector toggle + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + showingVoiceSelector.toggle() + } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if !showingVoiceSelector { voiceVM.stopSample() } + } label: { + Image(systemName: showingVoiceSelector ? "mic.fill" : "mic") + .font(.system(size: 20)) + .foregroundStyle(showingVoiceSelector ? Color.amber : .white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .accessibilityLabel(showingVoiceSelector ? "Hide voice selector" : "Select voice") + + // Collapse + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onDismiss() + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("Collapse player") + + // Chapters list + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showingChaptersList = true + } label: { + Image(systemName: "list.bullet") + .font(.system(size: 20)) + .foregroundStyle(.white.opacity(0.65)) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("Chapters list") + + // Sleep timer + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showingSleepTimer = true + } label: { + VStack(spacing: 1) { + Image(systemName: sleepTimerIcon) + .font(.system(size: 20)) + .foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.65)) + .contentTransition(.symbolEffect(.replace)) + if !audioPlayer.sleepTimerRemainingText.isEmpty { + Text(audioPlayer.sleepTimerRemainingText) + .font(.system(size: 9, weight: .semibold).monospacedDigit()) + .foregroundStyle(Color.amber) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(audioPlayer.sleepTimer != nil ? "Sleep timer active" : "Sleep timer") + } + .padding(.horizontal, 12) + .padding(.bottom, showingVoiceSelector ? 0 : 8) + + // Voice selector panel (expandable) + if showingVoiceSelector { + VoiceSelectorPanel( + voiceVM: voiceVM, + selectedVoice: audioPlayer.voice, + onSelectVoice: { newVoice in + voiceVM.stopSample() + audioPlayer.voice = newVoice + BookVoicePreferences.shared.setVoice(newVoice, for: audioPlayer.slug) + Task { + var settings = authStore.settings + settings.voice = newVoice + await authStore.saveSettings(settings) + } + } + ) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .task { + if voiceVM.voices.isEmpty { await voiceVM.loadVoices() } + } + } + } + .ignoresSafeArea(edges: .bottom) + } + } + .ignoresSafeArea() + .sheet(isPresented: $showingChaptersList) { + PlayerChaptersListSheet( + chapters: audioPlayer.chapters, + currentChapter: audioPlayer.chapter, + onChapterSelect: { selected in + showingChaptersList = false + guard selected != audioPlayer.chapter else { return } + let title = audioPlayer.chapters.first(where: { $0.number == selected })?.title ?? "" + let next = audioPlayer.chapters.filter({ $0.number > selected }).min(by: { $0.number < $1.number })?.number + let prev: Int? = selected > 1 ? selected - 1 : nil + audioPlayer.load( + slug: audioPlayer.slug, chapter: selected, chapterTitle: title, + bookTitle: audioPlayer.bookTitle, coverURL: audioPlayer.coverURL, + voice: audioPlayer.voice, speed: audioPlayer.speed, + chapters: audioPlayer.chapters, nextChapter: next, prevChapter: prev + ) + } + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showingSleepTimer) { + SleepTimerSheet(audioPlayer: audioPlayer) + .presentationDetents([.height(500)]) + .presentationDragIndicator(.visible) + } + } + + // MARK: - Helpers + + private var chapterPositionText: String { + let total = audioPlayer.chapters.count + guard total > 0 else { return "" } + 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 { + let parts = audioPlayer.voice.split(separator: "_") + if parts.count > 1 { return String(parts[1]).capitalized } + return audioPlayer.voice.capitalized + } + + private var sleepTimerIcon: String { + audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz" + } +} + +// MARK: - Secondary transport button (±15 s skips) + +private struct PlayerSecondaryButton: View { + let systemName: String + let size: CGFloat + let disabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.85)) + .frame(maxWidth: .infinity) + .frame(height: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + } +} + +// MARK: - Chapter-skip button (prev / next chapter) + +private struct PlayerChapterSkipButton: View { + let systemName: String + let size: CGFloat + let disabled: Bool + var prefetching: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack { + Image(systemName: systemName) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(.white.opacity(disabled ? 0.3 : 0.9)) + + if prefetching { + VStack { + Spacer() + HStack { + Spacer() + ProgressView() + .scaleEffect(0.55) + .tint(.amber) + .padding(3) + .background(Circle().fill(.black.opacity(0.6))) + } + } + } + } + .frame(maxWidth: .infinity) + .frame(height: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .opacity(disabled ? 0.4 : 1.0) + } +} + +// MARK: - AirPlay Button + +struct AirPlayButton: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + let vc = UIViewController() + vc.view.backgroundColor = .clear + let picker = AVRoutePickerView() + picker.tintColor = UIColor.white.withAlphaComponent(0.7) + picker.activeTintColor = UIColor.systemOrange + picker.prioritizesVideoDevices = false + picker.translatesAutoresizingMaskIntoConstraints = false + vc.view.addSubview(picker) + NSLayoutConstraint.activate([ + picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor), + picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor), + picker.topAnchor.constraint(equalTo: vc.view.topAnchor), + picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor), + ]) + return vc + } + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +// MARK: - Isolated seek bar + timestamps + +private struct PlayerProgressSection: View { + @ObservedObject var progress: PlaybackProgress + let onSeek: (Double) -> Void + + var body: some View { + VStack(spacing: 4) { + PlayerSlider( + value: Binding(get: { progress.currentTime }, set: { onSeek($0) }), + range: 0...max(progress.duration, 1) + ) + HStack { + Text(formatTime(progress.currentTime)) + Spacer() + Text("-" + formatTime(progress.duration - progress.currentTime)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.5)) + } + .padding(.horizontal, 28) + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let s = Int(seconds) + return "\(s / 60):\(String(format: "%02d", s % 60))" + } +} + +// MARK: - Custom amber seek slider + +struct PlayerSlider: View { + @Binding var value: Double + let range: ClosedRange + + @State private var isDragging = false + @State private var didFireHaptic = false + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + let fraction = (value - range.lowerBound) / (range.upperBound - range.lowerBound) + let clamped = max(0, min(1, fraction)) + let filled = width * clamped + let thumbSize: CGFloat = isDragging ? 26 : 20 + let trackHeight: CGFloat = isDragging ? 5 : 4 + + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(height: trackHeight) + + Capsule() + .fill(LinearGradient( + colors: [Color.amber.opacity(0.9), Color.amber], + startPoint: .leading, endPoint: .trailing + )) + .frame(width: max(filled, thumbSize / 2), height: trackHeight) + + Circle() + .fill(Color.white) + .frame(width: thumbSize, height: thumbSize) + .shadow(color: .black.opacity(0.3), radius: isDragging ? 6 : 3, + y: isDragging ? 2 : 1) + .offset(x: max(0, filled - thumbSize / 2)) + .animation(.spring(response: 0.2, dampingFraction: 0.65), value: isDragging) + } + .frame(height: 36) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { drag in + if !isDragging { + isDragging = true + if !didFireHaptic { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + didFireHaptic = true + } + } + let raw = drag.location.x / width + value = range.lowerBound + max(0, min(1, raw)) * (range.upperBound - range.lowerBound) + } + .onEnded { _ in isDragging = false; didFireHaptic = false } + ) + } + .frame(height: 36) + } +} + +// MARK: - Isolated play/pause button (full player) + +private struct PlayerPlayPauseButton: View { + @ObservedObject var progress: PlaybackProgress + let isGenerating: Bool + let onToggle: () -> Void + + @State private var isPressed = false + + var body: some View { + Button { onToggle() } label: { + ZStack { + Circle() + .fill(Color.amber.opacity(progress.isPlaying ? 0.18 : 0)) + .frame(width: 80, height: 80) + .animation(.easeInOut(duration: 0.35), value: progress.isPlaying) + + Circle() + .fill(LinearGradient( + colors: [Color.amber.opacity(0.9), Color.amber.opacity(0.65)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .frame(width: 64, height: 64) + .shadow(color: Color.amber.opacity(0.45), radius: 12, y: 4) + .scaleEffect(isPressed ? 0.92 : 1.0) + .animation(.spring(response: 0.2, dampingFraction: 0.6), value: isPressed) + + if isGenerating { + ProgressView().tint(.white).scaleEffect(1.2) + } else { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white) + .offset(x: progress.isPlaying ? 0 : 2) + .contentTransition(.symbolEffect(.replace.downUp)) + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .disabled(isGenerating) + ._onButtonGesture(pressing: { isPressed = $0 }, perform: {}) + .accessibilityLabel(progress.isPlaying ? "Pause" : "Play") + } +} + +// MARK: - Sleep Timer Sheet + +struct SleepTimerSheet: View { + @ObservedObject var audioPlayer: AudioPlayerService + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 20) { + // Off + TimerCard { + TimerOptionRow( + label: "Off", systemImage: "moon.zzz", + isSelected: audioPlayer.sleepTimer == nil + ) { + audioPlayer.setSleepTimer(nil) + dismiss() + } + } + + // Chapter-based + VStack(spacing: 0) { + SectionLabel("Chapter-based") + TimerCard { + ForEach([1, 2, 3, 4], id: \.self) { count in + let isSelected: Bool = { + if case .chapters(let c) = audioPlayer.sleepTimer { return c == count } + return false + }() + TimerOptionRow( + label: "\(count) \(count == 1 ? "chapter" : "chapters")", + systemImage: "book", isSelected: isSelected + ) { + audioPlayer.setSleepTimer(.chapters(count)) + dismiss() + } + if count < 4 { Divider().padding(.leading, 56) } + } + } + } + + // Time-based + VStack(spacing: 0) { + SectionLabel("Time-based") + TimerCard { + ForEach([20, 40, 60, 120], id: \.self) { mins in + let isSelected: Bool = { + if case .minutes(let m) = audioPlayer.sleepTimer { return m == mins } + return false + }() + TimerOptionRow( + label: formatTimerOption(mins), systemImage: "clock", + isSelected: isSelected + ) { + audioPlayer.setSleepTimer(.minutes(mins)) + dismiss() + } + if mins != 120 { Divider().padding(.leading, 56) } + } + } + } + } + .padding(20) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Sleep Timer") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() }.fontWeight(.semibold) + } + } + } + } + + private func formatTimerOption(_ minutes: Int) -> String { + if minutes < 60 { return "\(minutes) mins" } + let h = minutes / 60 + return "\(h) \(h == 1 ? "hour" : "hours")" + } +} + +// MARK: - Sleep timer helper views + +private struct TimerCard: View { + @ViewBuilder let content: Content + var body: some View { + VStack(spacing: 0) { content } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } +} + +private struct SectionLabel: View { + let text: String + init(_ text: String) { self.text = text } + var body: some View { + Text(text.uppercased()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 4) + .padding(.bottom, 8) + } +} + +private struct TimerOptionRow: View { + let label: String + let systemImage: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + } label: { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 16)) + .foregroundStyle(isSelected ? Color.amber : .secondary) + .frame(width: 28) + Text(label) + .font(.body) + .foregroundStyle(.primary) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.amber) + .transition(.scale.combined(with: .opacity)) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected) + } +} + +// MARK: - Player Chapters List Sheet +// Groups chapters into blocks of 100 with a right-edge jump bar. +// Includes per-chapter download status and swipe actions. + +struct PlayerChaptersListSheet: View { + let chapters: [ChapterBrief] + let currentChapter: Int + let onChapterSelect: (Int) -> Void + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + @State private var searchText: String = "" + @State private var filterOfflineOnly = false + @State private var showingManage = false + @State private var activeBlock: String? = nil + + // MARK: Derived data + + private var downloadedCount: Int { + chapters.filter { + downloadService.isDownloaded(slug: audioPlayer.slug, chapter: $0.number, voice: audioPlayer.voice) + }.count + } + + private var downloadingCount: Int { + downloadService.downloads.filter { key, _ in key.hasPrefix("\(audioPlayer.slug)::") }.count + } + + private var filtered: [ChapterBrief] { + var result = chapters + if filterOfflineOnly { + result = result.filter { + downloadService.isDownloaded(slug: audioPlayer.slug, chapter: $0.number, voice: audioPlayer.voice) + } + } + if !searchText.isEmpty { + let q = searchText.lowercased() + result = result.filter { "\($0.number)".contains(q) || $0.title.lowercased().contains(q) } + } + return result + } + + private var groups: [(label: String, chapters: [ChapterBrief])] { + guard searchText.isEmpty && !filterOfflineOnly else { + return filtered.isEmpty ? [] : [("Results", filtered)] + } + guard !filtered.isEmpty else { return [] } + let blockSize = 100 + let minN = filtered.map(\.number).min() ?? 1 + let maxN = filtered.map(\.number).max() ?? 1 + let firstBlock = ((minN - 1) / blockSize) * blockSize + 1 + var result: [(label: String, chapters: [ChapterBrief])] = [] + var blockStart = firstBlock + while blockStart <= maxN { + let blockEnd = blockStart + blockSize - 1 + let slice = filtered.filter { $0.number >= blockStart && $0.number <= blockEnd } + if !slice.isEmpty { result.append(("\(blockStart)–\(blockEnd)", slice)) } + blockStart += blockSize + } + return result + } + + private var jumpLabels: [String] { groups.map(\.label) } + + var body: some View { + NavigationStack { + ZStack(alignment: .trailing) { + List { + // Download summary + if downloadedCount > 0 || downloadingCount > 0 { + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Offline Downloads").font(.headline) + Text("\(downloadedCount) of \(chapters.count) chapters") + .font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + Button { showingManage = true } label: { + Label("Manage", systemImage: "arrow.down.circle") + .font(.subheadline.weight(.semibold)) + } + .buttonStyle(.bordered).tint(.blue) + } + if downloadingCount > 0 { + HStack(spacing: 8) { + ProgressView().scaleEffect(0.8) + Text("Downloading \(downloadingCount) \(downloadingCount == 1 ? "chapter" : "chapters")") + .font(.caption).foregroundStyle(.secondary) + } + } + Toggle("Show offline only", isOn: $filterOfflineOnly) + .font(.subheadline).tint(Color.amber) + } + .padding(.vertical, 8) + } + } + + ForEach(groups, id: \.label) { group in + Section { + ForEach(group.chapters, id: \.number) { ch in + PlayerChapterRow( + chapter: ch, + isCurrent: ch.number == currentChapter, + onSelect: { onChapterSelect(ch.number) } + ) + .id(group.label) + } + } header: { + if searchText.isEmpty && !filterOfflineOnly { + Text(group.label) + .font(.caption.bold()) + .foregroundStyle(.secondary) + .id("header_\(group.label)") + } + } + } + } + .listStyle(.plain) + .searchable(text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Chapter number or title") + .scrollPosition(id: $activeBlock, anchor: .top) + + // Jump bar + if searchText.isEmpty && !filterOfflineOnly && jumpLabels.count > 1 { + PlayerJumpBar(labels: jumpLabels, currentChapter: currentChapter, groups: groups) { label in + withAnimation { activeBlock = label } + } + .padding(.trailing, 4) + } + } + .navigationTitle("Chapters (\(filtered.count))") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() }.fontWeight(.semibold) + } + } + .sheet(isPresented: $showingManage) { + DownloadManagementSheet( + chapters: chapters, slug: audioPlayer.slug, + voice: Binding(get: { audioPlayer.voice }, set: { audioPlayer.voice = $0 }) + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .onAppear { + if let block = groups.first(where: { g in + g.chapters.contains(where: { $0.number == currentChapter }) + }) { + activeBlock = block.label + } + } + } + } +} + +// MARK: - Individual chapter row (player chapters list) + +private struct PlayerChapterRow: View { + let chapter: ChapterBrief + let isCurrent: Bool + let onSelect: () -> Void + + @EnvironmentObject var audioPlayer: AudioPlayerService + @EnvironmentObject var downloadService: AudioDownloadService + + private var isDownloaded: Bool { + downloadService.isDownloaded(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + } + private var downloadProgress: DownloadProgress? { + let key = downloadService.makeKey(slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + return downloadService.downloads[key] + } + private var isDownloading: Bool { downloadProgress != nil } + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 14) { + // Number badge + ZStack { + Text("\(chapter.number)") + .font(.caption.bold()) + .foregroundStyle(isCurrent ? .white : .secondary) + .frame(width: 40, height: 40) + .background(Circle().fill(isCurrent ? Color.amber : Color(.systemGray5))) + + if isDownloading, let p = downloadProgress { + Circle() + .trim(from: 0, to: p.progress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 44, height: 44) + .animation(.easeInOut(duration: 0.3), value: p.progress) + } + } + + // Title + status + VStack(alignment: .leading, spacing: 3) { + Text(chapter.title.strippingTrailingDate()) + .font(.subheadline.weight(isCurrent ? .semibold : .regular)) + .foregroundStyle(.primary) + .lineLimit(2) + + HStack(spacing: 8) { + if isCurrent { + Label("Now Playing", systemImage: "waveform") + .font(.caption2) + .foregroundStyle(Color.amber) + .symbolEffect(.variableColor.cumulative, isActive: isCurrent) + } + if isDownloading, let p = downloadProgress { + Label("\(Int(p.progress * 100))%", systemImage: "arrow.down.circle") + .font(.caption2).foregroundStyle(.blue) + } else if isDownloaded { + Label("Downloaded", systemImage: "checkmark.circle.fill") + .font(.caption2).foregroundStyle(.green) + } + } + } + + Spacer() + + if isCurrent { + Image(systemName: "waveform") + .font(.caption.bold()) + .foregroundStyle(Color.amber) + .symbolEffect(.variableColor.cumulative, isActive: isCurrent) + } else if isDownloaded { + Image(systemName: "arrow.down.circle.fill") + .font(.body).foregroundStyle(.green) + } else if isDownloading { + ProgressView().scaleEffect(0.8) + } + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(isCurrent ? Color.amber.opacity(0.08) : Color.clear) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if isDownloaded { + Button(role: .destructive) { + try? downloadService.deleteDownload( + slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + } label: { Label("Delete", systemImage: "trash") } + } else if isDownloading { + Button(role: .destructive) { + downloadService.cancelDownload( + slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + } label: { Label("Cancel", systemImage: "xmark") } + } else { + Button { + Task { + try? await downloadService.download( + slug: audioPlayer.slug, chapter: chapter.number, voice: audioPlayer.voice) + } + } label: { Label("Download", systemImage: "arrow.down.circle") } + .tint(.blue) + } + } + } +} + +// MARK: - Jump bar (right edge) + +private struct PlayerJumpBar: View { + let labels: [String] + let currentChapter: Int + let groups: [(label: String, chapters: [ChapterBrief])] + let onSelect: (String) -> Void + + @State private var isDragging = false + + private func shortLabel(_ full: String) -> String { + full.components(separatedBy: "–").first ?? full + } + + private var currentBlock: String? { + groups.first(where: { $0.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 index = max(0, min(labels.count - 1, Int(value.location.y / 28))) + onSelect(labels[index]) + } + .onEnded { _ in isDragging = false } + ) + .animation(.easeInOut(duration: 0.15), value: isDragging) + } +} + +// MARK: - Voice selector panel (inline, expandable inside FullPlayerView) + +private struct VoiceSelectorPanel: View { + let voiceVM: VoiceSelectionViewModel + let selectedVoice: String + let onSelectVoice: (String) -> Void + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Choose Voice") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.45)) + .textCase(.uppercase) + .tracking(0.8) + Spacer() + } + .padding(.horizontal, 18) + .padding(.top, 10) + .padding(.bottom, 6) + + ScrollView { + VStack(spacing: 0) { + ForEach(voiceVM.voices, id: \.self) { voice in + VoiceOptionRow( + voice: voice, + isSelected: selectedVoice == voice, + isPlaying: voiceVM.playingVoice == voice, + voiceLabel: voiceVM.voiceLabel(voice), + voiceId: voiceVM.voiceId(voice), + onSelect: { onSelectVoice(voice) }, + onPlaySample: { Task { await voiceVM.playSample(voice) } } + ) + if voice != voiceVM.voices.last { + Divider().overlay(Color.white.opacity(0.08)).padding(.leading, 52) + } + } + } + } + .frame(maxHeight: 220) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .padding(.horizontal, 16) + + Text("New voice applies on next chapter") + .font(.caption2) + .foregroundStyle(.white.opacity(0.35)) + .padding(.top, 7) + .padding(.bottom, 10) + } + .background(.ultraThinMaterial) + } +} + +// MARK: - Voice option row (inside VoiceSelectorPanel) + +private struct VoiceOptionRow: View { + let voice: String + let isSelected: Bool + let isPlaying: Bool + let voiceLabel: String + let voiceId: String + let onSelect: () -> Void + let onPlaySample: () -> Void + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + onSelect() + } label: { + HStack(spacing: 12) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 18)) + .foregroundStyle(isSelected ? Color.amber : .white.opacity(0.25)) + .scaleEffect(isSelected ? 1.1 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.55), value: isSelected) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(voiceLabel) + .font(.subheadline) + .foregroundStyle(isSelected ? Color.amber : .white) + .fontWeight(isSelected ? .semibold : .regular) + .animation(.easeInOut(duration: 0.2), value: isSelected) + Text(voiceId) + .font(.caption2) + .fontDesign(.monospaced) + .foregroundStyle(.white.opacity(0.4)) + } + + Spacer() + + Button { onPlaySample() } label: { + Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 24)) + .foregroundStyle(isPlaying ? Color.red : Color.amber.opacity(0.8)) + .contentTransition(.symbolEffect(.replace.downUp)) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(isPlaying ? "Stop sample" : "Play sample") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(isSelected ? Color.amber.opacity(0.08) : Color.clear) + .animation(.easeInOut(duration: 0.2), value: isSelected) + } +} + +// MARK: - Download Management Sheet + +struct DownloadManagementSheet: View { + let chapters: [ChapterBrief] + let slug: String + @Binding var voice: String + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var downloadService: AudioDownloadService + @EnvironmentObject var authStore: AuthStore + + @State private var showingDeleteAll = false + @State private var isDownloadingAll = false + @State private var showingVoiceSelector = false + @State private var showingRangeSelector = false + @State private var voiceVM = VoiceSelectionViewModel() + + private var downloadedChapters: [ChapterBrief] { + chapters.filter { downloadService.isDownloaded(slug: slug, chapter: $0.number, voice: voice) } + } + private var notDownloadedChapters: [ChapterBrief] { + chapters.filter { !downloadService.isDownloaded(slug: slug, chapter: $0.number, voice: voice) } + } + + var body: some View { + NavigationStack { + List { + // Voice info + Section { + Button { showingVoiceSelector = true } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Download Voice").font(.subheadline).foregroundStyle(.secondary) + HStack(spacing: 6) { + Text(voiceLabel(voice)).font(.body.weight(.semibold)) + if BookVoicePreferences.shared.hasOverride(for: slug) { + Text("(Custom)").font(.caption).foregroundStyle(.blue) + } + } + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)).foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } footer: { + Text("Tap to change voice. Downloads will use the selected voice for this book.") + .font(.caption) + } + + // Stats + actions + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("\(downloadedChapters.count) Downloaded").font(.title2.bold()) + Text("\(notDownloadedChapters.count) remaining") + .font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + ZStack { + Circle().stroke(Color(.systemGray5), lineWidth: 4) + Circle() + .trim(from: 0, to: chapters.isEmpty ? 0 : CGFloat(downloadedChapters.count) / CGFloat(chapters.count)) + .stroke(Color.green, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.4), value: downloadedChapters.count) + Text("\(chapters.isEmpty ? 0 : Int(Double(downloadedChapters.count) / Double(chapters.count) * 100))%") + .font(.caption2.bold()).foregroundStyle(.secondary) + } + .frame(width: 44, height: 44) + } + + HStack(spacing: 10) { + if notDownloadedChapters.count > 0 { + Button { showingRangeSelector = true } label: { + Label("Range", systemImage: "list.number").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered).tint(.blue) + + Button { downloadAllRemaining() } label: { + HStack(spacing: 6) { + if isDownloadingAll { ProgressView().scaleEffect(0.75) } + else { Image(systemName: "arrow.down.circle.fill") } + Text("All (\(notDownloadedChapters.count))") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent).tint(.blue) + .disabled(isDownloadingAll) + } + + if downloadedChapters.count > 0 { + Button { showingDeleteAll = true } label: { + Label("Delete All", systemImage: "trash").frame(maxWidth: .infinity) + } + .buttonStyle(.bordered).tint(.red) + } + } + } + .padding(.vertical, 8) + } + + // Downloaded list + if downloadedChapters.count > 0 { + Section { + ForEach(downloadedChapters, id: \.number) { ch in + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(ch.number)").font(.subheadline.weight(.semibold)) + Text(ch.title.strippingTrailingDate()) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + } + .onDelete { indexSet in + for i in indexSet { + let ch = downloadedChapters[i] + try? downloadService.deleteDownload(slug: slug, chapter: ch.number, voice: voice) + } + } + } header: { + Text("Downloaded (\(downloadedChapters.count))") + } + } + } + .navigationTitle("Manage Downloads") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() }.fontWeight(.semibold) + } + } + .confirmationDialog("Delete all downloads?", isPresented: $showingDeleteAll, titleVisibility: .visible) { + Button("Delete All Downloads", role: .destructive) { + for ch in downloadedChapters { + try? downloadService.deleteDownload(slug: slug, chapter: ch.number, voice: voice) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will delete \(downloadedChapters.count) downloaded chapters. You can re-download them later.") + } + .sheet(isPresented: $showingVoiceSelector) { + VoiceSelectorSheet( + selectedVoice: voice, slug: slug, voiceVM: voiceVM, + onSelectVoice: { newVoice in + voice = newVoice + BookVoicePreferences.shared.setVoice(newVoice, for: slug) + showingVoiceSelector = false + } + ) + } + .sheet(isPresented: $showingRangeSelector) { + RangeDownloadSheet( + chapters: notDownloadedChapters, slug: slug, voice: voice, + onDownload: { start, end in + downloadRange(from: start, to: end) + showingRangeSelector = false + } + ) + .presentationDetents([.medium]) + } + } + } + + private func downloadAllRemaining() { + isDownloadingAll = true + Task { + for ch in notDownloadedChapters { + try? await downloadService.download(slug: slug, chapter: ch.number, voice: voice) + try? await Task.sleep(nanoseconds: 500_000_000) + } + isDownloadingAll = false + } + } + + private func downloadRange(from start: Int, to end: Int) { + isDownloadingAll = true + Task { + let toDownload = notDownloadedChapters.filter { $0.number >= start && $0.number <= end } + for ch in toDownload { + try? await downloadService.download(slug: slug, chapter: ch.number, voice: voice) + try? await Task.sleep(nanoseconds: 500_000_000) + } + isDownloadingAll = false + } + } + + private func voiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + let prefix = String(parts[0]) + let name = parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + var info = "" + switch prefix { + case "af": info = "US F"; case "am": info = "US M" + case "bf": info = "UK F"; case "bm": info = "UK M" + default: info = prefix.uppercased() + } + return "\(name) (\(info))" + } +} + +// MARK: - Voice Selector Sheet (for DownloadManagementSheet) + +private struct VoiceSelectorSheet: View { + let selectedVoice: String + let slug: String + let voiceVM: VoiceSelectionViewModel + let onSelectVoice: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @EnvironmentObject var authStore: AuthStore + + var body: some View { + NavigationStack { + List { + Section { + ForEach(voiceVM.voices, id: \.self) { voice in + Button { onSelectVoice(voice) } label: { + HStack(spacing: 12) { + Image(systemName: "checkmark") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.blue) + .opacity(voice == selectedVoice ? 1 : 0) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(voiceVM.voiceLabel(voice)).font(.body).foregroundStyle(.primary) + Text(voice).font(.caption.monospaced()).foregroundStyle(.secondary) + } + + Spacer() + + Button { + Task { await voiceVM.playSample(voice) } + } label: { + Image(systemName: voiceVM.playingVoice == voice ? "stop.circle.fill" : "play.circle") + .font(.system(size: 24)) + .foregroundStyle(voiceVM.playingVoice == voice ? .red : .blue) + .frame(minWidth: 44, minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(voiceVM.playingVoice == voice ? "Stop sample" : "Play sample") + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + } + } header: { + Text("Select Voice") + } footer: { + if BookVoicePreferences.shared.hasOverride(for: slug) { + Button("Reset to Global Voice") { + BookVoicePreferences.shared.removeVoice(for: slug) + onSelectVoice(authStore.settings.voice) + } + .font(.subheadline) + } + } + } + .navigationTitle("Download Voice") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { voiceVM.stopSample(); dismiss() }.fontWeight(.semibold) + } + } + .task { + if voiceVM.voices.isEmpty { await voiceVM.loadVoices() } + } + } + } +} + +// MARK: - Range Download Sheet + +private struct RangeDownloadSheet: View { + let chapters: [ChapterBrief] + let slug: String + let voice: String + let onDownload: (Int, Int) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var startChapter: Int + @State private var endChapter: Int + + init(chapters: [ChapterBrief], slug: String, voice: String, onDownload: @escaping (Int, Int) -> Void) { + self.chapters = chapters + self.slug = slug + self.voice = voice + self.onDownload = onDownload + let first = chapters.first?.number ?? 1 + let last = chapters.last?.number ?? 1 + _startChapter = State(initialValue: first) + _endChapter = State(initialValue: min(first + 9, last)) + } + + private var chapterRange: [Int] { + guard let first = chapters.first?.number, let last = chapters.last?.number else { return [] } + return Array(first...last) + } + private var selectedCount: Int { + guard startChapter <= endChapter else { return 0 } + return endChapter - startChapter + 1 + } + + var body: some View { + NavigationStack { + Form { + Section { + Picker("Start Chapter", selection: $startChapter) { + ForEach(chapterRange, id: \.self) { n in Text("Chapter \(n)").tag(n) } + } + Picker("End Chapter", selection: $endChapter) { + ForEach(chapterRange.filter { $0 >= startChapter }, id: \.self) { n in + Text("Chapter \(n)").tag(n) + } + } + } header: { Text("Select Range") } + footer: { Text("\(selectedCount) chapters will be downloaded") } + + Section { + Button { + onDownload(startChapter, endChapter) + dismiss() + } label: { + HStack { + Spacer() + Image(systemName: "arrow.down.circle.fill") + Text("Download \(selectedCount) Chapters") + Spacer() + } + } + .disabled(selectedCount == 0) + } + } + .navigationTitle("Download Range") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Cancel") { dismiss() } + } + } + } + } +} diff --git a/ios/LibNovelV2/Views/Profile/ProfileView.swift b/ios/LibNovelV2/Views/Profile/ProfileView.swift new file mode 100644 index 0000000..df6757e --- /dev/null +++ b/ios/LibNovelV2/Views/Profile/ProfileView.swift @@ -0,0 +1,702 @@ +import SwiftUI +import PhotosUI + +// MARK: - ProfileViewModel +// Loads and manages active sessions. Uses @Observable (iOS 17+). + +@Observable @MainActor +final class ProfileViewModel { + var sessions: [UserSession] = [] + var sessionsLoading = false + var error: String? + + func loadSessions() async { + sessionsLoading = true + error = nil + do { + sessions = try await APIClient.shared.sessions() + } catch { + self.error = error.localizedDescription + } + sessionsLoading = false + } + + func revokeSession(id: String) async { + do { + try await APIClient.shared.revokeSession(id: id) + sessions.removeAll { $0.id == id } + } catch { + self.error = error.localizedDescription + } + } +} + +// MARK: - ProfileView +// Full-screen profile/account management tab. + +struct ProfileView: View { + @EnvironmentObject private var authStore: AuthStore + @EnvironmentObject private var networkMonitor: NetworkMonitor + @State private var vm = ProfileViewModel() + + @State private var showChangePassword = false + @State private var showVoiceSelection = false + @State private var showDownloads = false + + // Avatar upload + @State private var photoPickerItem: PhotosPickerItem? + @State private var pendingCropImage: UIImage? + @State private var localAvatarURL: String? + @State private var avatarUploading = false + @State private var avatarError: String? + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + List { + // ── User header ───────────────────────────────────────── + Section { + HStack(spacing: 16) { + avatarPickerView + VStack(alignment: .leading, spacing: 3) { + Text(authStore.user?.username ?? "") + .font(.headline) + Text(authStore.user?.role.capitalized ?? "") + .font(.caption) + .foregroundStyle(.secondary) + if let err = avatarError { + Text(err) + .font(.caption2) + .foregroundStyle(.red) + } + } + } + .padding(.vertical, 6) + } + + // ── Reading settings ───────────────────────────────────── + Section("Reading Settings") { + // Voice picker row — opens VoiceSelectionView sheet + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showVoiceSelection = true + } label: { + HStack { + Text("TTS Voice") + .foregroundStyle(.primary) + Spacer() + Text(formatVoiceLabel(authStore.settings.voice)) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .accessibilityLabel("TTS Voice: \(formatVoiceLabel(authStore.settings.voice)). Tap to change.") + + // Speed slider + speedSliderRow + + // Auto-advance toggle + Toggle("Auto-advance chapter", isOn: Binding( + get: { authStore.settings.autoNext }, + set: { newVal in + Task { + var s = authStore.settings + s.autoNext = newVal + await authStore.saveSettings(s) + } + } + )) + .tint(Color.amber) + + // Downloads row + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showDownloads = true + } label: { + HStack { + Text("Downloads") + .foregroundStyle(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + // ── Active sessions ────────────────────────────────────── + Section("Active Sessions") { + if vm.sessionsLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 4) + } else if vm.sessions.isEmpty { + Text("No sessions found") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + ForEach(vm.sessions) { session in + SessionRow(session: session) { + Task { await vm.revokeSession(id: session.id) } + } + } + } + } + + // ── Account ─────────────────────────────────────────────── + Section("Account") { + Button("Change Password") { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showChangePassword = true + } + Button("Sign Out", role: .destructive) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + Task { await authStore.logout() } + } + } + } + .scrollContentBackground(.hidden) + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Profile") + .navigationBarTitleDisplayMode(.large) + .task { + guard networkMonitor.isConnected else { return } + await vm.loadSessions() + } + .sheet(isPresented: $showChangePassword) { + ChangePasswordView() + } + .sheet(isPresented: $showVoiceSelection) { + VoiceSelectionView(currentVoice: authStore.settings.voice) + } + .sheet(isPresented: $showDownloads) { + DownloadsView() + } + .sheet(item: Binding( + get: { pendingCropImage.map { CropImageItem(image: $0) } }, + set: { if $0 == nil { pendingCropImage = nil } } + )) { item in + AvatarCropView(image: item.image) { croppedData in + pendingCropImage = nil + Task { await uploadCroppedData(croppedData) } + } onCancel: { + pendingCropImage = nil + } + } + .errorAlert(Binding( + get: { vm.error }, + set: { vm.error = $0 } + )) + } + } + + // MARK: - Avatar upload + + private func loadImageForCrop(_ item: PhotosPickerItem) async { + guard let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) else { + avatarError = "Could not read image" + return + } + pendingCropImage = image + } + + private func uploadCroppedData(_ data: Data) async { + avatarUploading = true + avatarError = nil + defer { avatarUploading = false } + do { + let url = try await APIClient.shared.uploadAvatar(data, mimeType: "image/jpeg") + localAvatarURL = url + await authStore.validateToken() + } catch { + avatarError = "Upload failed: \(error.localizedDescription)" + } + } + + // MARK: - Avatar picker view + + @ViewBuilder + private var avatarPickerView: some View { + PhotosPicker(selection: $photoPickerItem, + matching: .images, + photoLibrary: .shared()) { + ZStack { + Circle() + .fill(Color(uiColor: .systemGray5)) + .frame(width: 72, height: 72) + + if avatarUploading { + ProgressView() + .frame(width: 72, height: 72) + } else { + let urlStr = localAvatarURL ?? authStore.user?.avatarURL + if let urlStr, !urlStr.isEmpty { + AsyncImage(url: URL(string: urlStr)) { phase in + switch phase { + case .success(let img): + img.resizable() + .scaledToFill() + .frame(width: 72, height: 72) + .clipShape(Circle()) + default: + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(Color.amber) + .frame(width: 72, height: 72) + } + } + } else { + Image(systemName: "person.circle.fill") + .font(.system(size: 52)) + .foregroundStyle(Color.amber) + .frame(width: 72, height: 72) + } + } + + // Camera badge + if !avatarUploading { + VStack { + Spacer() + HStack { + Spacer() + ZStack { + Circle() + .fill(Color.amber) + .frame(width: 22, height: 22) + Image(systemName: "camera.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.black) + } + .offset(x: 2, y: 2) + } + } + .frame(width: 72, height: 72) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Change avatar photo") + .onChange(of: photoPickerItem) { _, item in + guard let item else { return } + Task { await loadImageForCrop(item) } + } + } + + // MARK: - Speed slider row + + @ViewBuilder + private var speedSliderRow: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Playback Speed") + Spacer() + Text("\(authStore.settings.speed, specifier: "%.2g")×") + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: Binding( + get: { authStore.settings.speed }, + set: { newSpeed in + Task { + var s = authStore.settings + s.speed = newSpeed + await authStore.saveSettings(s) + } + } + ), + in: 0.5...2.0, step: 0.25 + ) + .tint(Color.amber) + } + .padding(.vertical, 2) + } + + // MARK: - Helpers + + private func formatVoiceLabel(_ voice: String) -> String { + let parts = voice.split(separator: "_") + guard parts.count >= 2 else { return voice } + return parts.dropFirst().map { $0.capitalized }.joined(separator: " ") + } +} + +// MARK: - SessionRow + +private struct SessionRow: View { + let session: UserSession + let onRevoke: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: "iphone") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent) + .font(.subheadline) + .lineLimit(1) + Spacer() + if session.isCurrent { + Text("This device") + .font(.caption2.bold()) + .foregroundStyle(Color.amber) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.amber.opacity(0.12), in: Capsule()) + } else { + Button("Revoke", role: .destructive, action: onRevoke) + .font(.caption) + } + } + Text("Last seen \(session.lastSeen.prefix(10))") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } +} + +// MARK: - CropImageItem + +private struct CropImageItem: Identifiable { + let id = UUID() + let image: UIImage +} + +// MARK: - ChangePasswordView + +struct ChangePasswordView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authStore: AuthStore + + @State private var current = "" + @State private var newPwd = "" + @State private var confirm = "" + @State private var isLoading = false + @State private var error: String? + @State private var success = false + + var body: some View { + NavigationStack { + Form { + Section { + SecureField("Current password", text: $current) + SecureField("New password", text: $newPwd) + SecureField("Confirm new password", text: $confirm) + } + if let error { + Section { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + if success { + Section { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text("Password changed successfully") + .font(.caption) + .foregroundStyle(.green) + } + } + } + } + .navigationTitle("Change Password") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + if isLoading { + ProgressView() + } else { + Button("Save") { save() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + .disabled(current.isEmpty || newPwd.count < 4 || newPwd != confirm) + } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private func save() { + guard newPwd == confirm else { error = "Passwords do not match"; return } + isLoading = true + error = nil + Task { + do { + struct Body: Encodable { let currentPassword, newPassword: String } + let _: EmptyResponse = try await APIClient.shared.fetch( + "/api/auth/change-password", method: "POST", + body: Body(currentPassword: current, newPassword: newPwd) + ) + success = true + try? await Task.sleep(nanoseconds: 1_200_000_000) + dismiss() + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + } +} + +// MARK: - AvatarToolbarButton +// Drop-in toolbar button showing the user's avatar. Opens the profile tab or an account sheet. + +struct AvatarToolbarButton: View { + @EnvironmentObject private var authStore: AuthStore + @State private var showAccount = false + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + showAccount = true + } label: { + AvatarThumb(urlString: authStore.user?.avatarURL, size: 30) + } + .accessibilityLabel("Account") + .sheet(isPresented: $showAccount) { + ProfileView() + } + } +} + +// MARK: - AvatarThumb +// Small circular avatar used in toolbars and list headers. + +struct AvatarThumb: View { + let urlString: String? + let size: CGFloat + + var body: some View { + Group { + if let str = urlString, let url = URL(string: str) { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable().scaledToFill() + default: + placeholderFill + } + } + } else { + placeholderFill + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.amber.opacity(0.6), lineWidth: 1.5)) + } + + private var placeholderFill: some View { + Circle() + .fill(Color(uiColor: .systemGray4)) + .overlay( + Image(systemName: "person.fill") + .font(.system(size: size * 0.5)) + .foregroundStyle(Color.amber) + ) + } +} + +// MARK: - AvatarCropView +// Sheet that lets the user pan and pinch a photo to fill a 1:1 circular crop region. + +struct AvatarCropView: View { + let image: UIImage + let onConfirm: (Data) -> Void + let onCancel: () -> Void + + private let cropSize: CGFloat = 280 + + @State private var scale: CGFloat = 1.0 + @State private var lastScale: CGFloat = 1.0 + @State private var offset: CGSize = .zero + @State private var lastOffset: CGSize = .zero + @State private var containerSize: CGSize = .zero + + var body: some View { + NavigationStack { + GeometryReader { geo in + ZStack { + Color.black.ignoresSafeArea() + + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + .scaleEffect(scale, anchor: .center) + .offset(offset) + .gesture( + SimultaneousGesture( + MagnificationGesture() + .onChanged { value in + let proposed = lastScale * value + scale = max(1.0, proposed) + } + .onEnded { _ in + lastScale = scale + offset = clampedOffset(offset, in: geo.size) + lastOffset = offset + }, + DragGesture() + .onChanged { value in + let proposed = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + offset = clampedOffset(proposed, in: geo.size) + } + .onEnded { _ in lastOffset = offset } + ) + ) + .clipped() + + CropOverlay(cropSize: cropSize, containerSize: geo.size) + .allowsHitTesting(false) + } + .onAppear { + containerSize = geo.size + scale = 1.0; lastScale = 1.0 + offset = .zero; lastOffset = .zero + } + } + .navigationTitle("Crop Photo") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel", action: onCancel) + .foregroundStyle(.white) + } + ToolbarItem(placement: .topBarTrailing) { + Button("Use Photo") { confirmCrop() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + } + } + .toolbarColorScheme(.dark, for: .navigationBar) + } + } + + // MARK: - Clamp helpers + + private func displayedImageSize(in containerSize: CGSize, userScale: CGFloat) -> CGSize { + let imgAspect = image.size.width / image.size.height + let conAspect = containerSize.width / containerSize.height + let baseW: CGFloat + let baseH: CGFloat + if imgAspect > conAspect { + baseH = containerSize.height; baseW = baseH * imgAspect + } else { + baseW = containerSize.width; baseH = baseW / imgAspect + } + return CGSize(width: baseW * userScale, height: baseH * userScale) + } + + private func clampedOffset(_ proposed: CGSize, in containerSize: CGSize) -> CGSize { + let displayed = displayedImageSize(in: containerSize, userScale: scale) + let maxX = max(0, (displayed.width - cropSize) / 2) + let maxY = max(0, (displayed.height - cropSize) / 2) + return CGSize( + width: min(maxX, max(-maxX, proposed.width)), + height: min(maxY, max(-maxY, proposed.height)) + ) + } + + // MARK: - Confirm crop + + private func confirmCrop() { + let size = containerSize.width > 0 ? containerSize : CGSize(width: 390, height: 844) + let outputSize = CGSize(width: 400, height: 400) + + let imgAspect = image.size.width / image.size.height + let conAspect = size.width / size.height + let baseDisplayW: CGFloat + let baseDisplayH: CGFloat + if imgAspect > conAspect { + baseDisplayH = size.height; baseDisplayW = baseDisplayH * imgAspect + } else { + baseDisplayW = size.width; baseDisplayH = baseDisplayW / imgAspect + } + let displayW = baseDisplayW * scale + let displayH = baseDisplayH * scale + + let imageCentreX = size.width / 2 + offset.width + let imageCentreY = size.height / 2 + offset.height + let cropOriginX = (size.width - cropSize) / 2 + let cropOriginY = (size.height - cropSize) / 2 + let imageOriginX = imageCentreX - displayW / 2 + let imageOriginY = imageCentreY - displayH / 2 + let cropInImageX = cropOriginX - imageOriginX + let cropInImageY = cropOriginY - imageOriginY + + let dtpX = image.size.width / displayW + let dtpY = image.size.height / displayH + let cropRect = CGRect( + x: cropInImageX * dtpX, y: cropInImageY * dtpY, + width: cropSize * dtpX, height: cropSize * dtpY + ).intersection(CGRect(origin: .zero, size: image.size)) + + guard cropRect.width > 0, cropRect.height > 0 else { + if let jpeg = image.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) } + return + } + + let renderer = UIGraphicsImageRenderer(size: outputSize) + let cropped = renderer.image { _ in + if let cgImg = image.cgImage?.cropping(to: cropRect) { + UIImage(cgImage: cgImg, scale: image.scale, + orientation: image.imageOrientation) + .draw(in: CGRect(origin: .zero, size: outputSize)) + } else { + image.draw(in: CGRect(origin: .zero, size: outputSize)) + } + } + if let jpeg = cropped.jpegData(compressionQuality: 0.9) { onConfirm(jpeg) } + } +} + +// MARK: - CropOverlay (internal) + +private struct CropOverlay: View { + let cropSize: CGFloat + let containerSize: CGSize + + var body: some View { + Canvas { context, size in + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.black.opacity(0.55))) + let origin = CGPoint(x: (size.width - cropSize) / 2, y: (size.height - cropSize) / 2) + let rect = CGRect(origin: origin, size: CGSize(width: cropSize, height: cropSize)) + context.blendMode = .destinationOut + context.fill(Path(ellipseIn: rect), with: .color(.white)) + } + .compositingGroup() + .overlay { + let ox = (containerSize.width - cropSize) / 2 + let oy = (containerSize.height - cropSize) / 2 + Circle() + .stroke(Color.amber.opacity(0.8), lineWidth: 2) + .frame(width: cropSize, height: cropSize) + .position(x: ox + cropSize / 2, y: oy + cropSize / 2) + } + .frame(width: containerSize.width, height: containerSize.height) + .allowsHitTesting(false) + } +} diff --git a/ios/LibNovelV2/Views/Profile/UserProfileView.swift b/ios/LibNovelV2/Views/Profile/UserProfileView.swift new file mode 100644 index 0000000..60c3e07 --- /dev/null +++ b/ios/LibNovelV2/Views/Profile/UserProfileView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +// Public user profile — shown when navigating to another user's page. +// Displays their public library and follower info. +// NOTE: This is distinct from ProfileView (self-account management tab). +struct UserProfileView: View { + let username: String + + var body: some View { + Text(username) + .navigationTitle(username) + } +} diff --git a/ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift b/ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift new file mode 100644 index 0000000..96e6b8f --- /dev/null +++ b/ios/LibNovelV2/Views/Profile/VoiceSelectionView.swift @@ -0,0 +1,189 @@ +import SwiftUI + +// MARK: - VoiceSelectionView +// Sheet for selecting TTS voice. Loads voices from the API, plays sample audio, and +// saves the selection back to user settings on confirm. +// VoiceSelectionViewModel is defined in PlayerViews.swift (shared with the full player). + +struct VoiceSelectionView: View { + @EnvironmentObject private var authStore: AuthStore + @Environment(\.dismiss) private var dismiss + + @State private var selectedVoice: String + @State private var vm = VoiceSelectionViewModel() + + init(currentVoice: String) { + _selectedVoice = State(initialValue: currentVoice) + } + + var body: some View { + NavigationStack { + Group { + if vm.isLoading { + loadingState + } else if let error = vm.error { + errorState(error) + } else { + voiceList + } + } + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Select Voice") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + vm.stopSample() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { saveAndDismiss() } + .fontWeight(.semibold) + .foregroundStyle(Color.amber) + .disabled(selectedVoice == authStore.settings.voice) + } + } + .task { await vm.loadVoices() } + .onDisappear { vm.stopSample() } + } + } + + // MARK: - States + + private var loadingState: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.3) + Text("Loading voices…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorState(_ message: String) -> some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 48)) + .foregroundStyle(Color.amber) + .symbolEffect(.pulse) + Text(message) + .font(.subheadline) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, 32) + Button("Retry") { Task { await vm.loadVoices() } } + .font(.subheadline.bold()) + .foregroundStyle(Color.amber) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Voice list + + private var voiceList: some View { + List { + Section { + ForEach(vm.voices, id: \.self) { voice in + VoiceSelectionRow( + voice: voice, + isSelected: voice == selectedVoice, + isPlaying: vm.playingVoice == voice, + voiceLabel: vm.voiceLabel(voice), + voiceId: vm.voiceId(voice), + onSelect: { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + vm.stopSample() + selectedVoice = voice + }, + onPlaySample: { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + Task { await vm.playSample(voice) } + } + ) + } + } header: { + Text("Available Voices") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + .textCase(nil) + } footer: { + if selectedVoice != authStore.settings.voice { + Text("New voice will apply to the next audio playback.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .scrollContentBackground(.hidden) + .listStyle(.insetGrouped) + } + + // MARK: - Save + + private func saveAndDismiss() { + vm.stopSample() + Task { + var s = authStore.settings + s.voice = selectedVoice + await authStore.saveSettings(s) + dismiss() + } + } +} + +// MARK: - VoiceSelectionRow + +private struct VoiceSelectionRow: View { + let voice: String + let isSelected: Bool + let isPlaying: Bool + let voiceLabel: String + let voiceId: String + let onSelect: () -> Void + let onPlaySample: () -> Void + + var body: some View { + HStack(spacing: 12) { + // Selection indicator + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(isSelected ? Color.amber : Color.secondary.opacity(0.4)) + .frame(width: 28) + .contentTransition(.symbolEffect(.replace.downUp)) + .accessibilityHidden(true) + + // Voice name + id + VStack(alignment: .leading, spacing: 3) { + Text(voiceLabel) + .font(.body) + .fontWeight(isSelected ? .semibold : .regular) + Text(voiceId) + .font(.caption) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + } + + Spacer() + + // Play sample button + Button { + onPlaySample() + } label: { + Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill") + .font(.system(size: 28)) + .foregroundStyle(isPlaying ? Color.red : Color.amber) + .contentTransition(.symbolEffect(.replace.downUp)) + } + .buttonStyle(.plain) + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(isPlaying ? "Stop sample for \(voiceLabel)" : "Play sample for \(voiceLabel)") + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { onSelect() } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } +} diff --git a/ios/LibNovelV2/Views/Search/SearchView.swift b/ios/LibNovelV2/Views/Search/SearchView.swift new file mode 100644 index 0000000..f25689a --- /dev/null +++ b/ios/LibNovelV2/Views/Search/SearchView.swift @@ -0,0 +1,255 @@ +import SwiftUI + +// MARK: - SearchView +// Full-screen search tab. +// Idle: recent searches list (or prompt if empty). +// Active: debounced live results in a 2-col grid with local/remote count header. + +struct SearchView: View { + @State private var vm = SearchViewModel() + @EnvironmentObject private var networkMonitor: NetworkMonitor + + private let columns = [ + GridItem(.flexible(), spacing: 14), + GridItem(.flexible(), spacing: 14), + ] + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + OfflineBanner() + + Group { + if vm.isLoading { + loadingState + } else if !vm.query.isEmpty && vm.results.isEmpty { + emptyResultsState + } else if !vm.results.isEmpty { + resultsGrid + } else { + idleState + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .appNavigationDestination() + .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) + .navigationTitle("Search") + .navigationBarTitleDisplayMode(.large) + .searchable( + text: $vm.query, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search novels, authors…" + ) + .onChange(of: vm.query) { _, newValue in + guard networkMonitor.isConnected else { return } + vm.onQueryChange(newValue) + } + .onSubmit(of: .search) { + guard networkMonitor.isConnected else { return } + UIImpactFeedbackGenerator(style: .light).impactOccurred() + vm.submitSearch() + } + .errorAlert($vm.error) + } + } + + // MARK: - Idle state + + @ViewBuilder + private var idleState: some View { + if vm.recentSearches.isEmpty { + emptyIdleState + } else { + recentSearchesList + } + } + + private var emptyIdleState: some View { + VStack(spacing: 16) { + Spacer() + Image(systemName: "magnifyingglass") + .font(.system(size: 60)) + .foregroundStyle(.tertiary) + Text("Search for novels") + .font(.title3.bold()) + .foregroundStyle(.primary) + Text("Find books by title, author, or genre") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + Spacer() + } + } + + private var recentSearchesList: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Recent Searches") + .font(.subheadline.bold()) + .foregroundStyle(.secondary) + Spacer() + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + vm.clearRecent() + } label: { + Text("Clear") + .font(.subheadline) + .foregroundStyle(Color.amber) + } + .frame(minWidth: 44, minHeight: 44) + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 4) + + ForEach(vm.recentSearches, id: \.self) { term in + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + vm.selectRecent(term) + } label: { + HStack(spacing: 12) { + Image(systemName: "clock") + .font(.subheadline) + .foregroundStyle(.tertiary) + .frame(width: 24) + Text(term) + .font(.body) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer() + Image(systemName: "arrow.up.left") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .frame(minHeight: 44) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + + Divider() + .padding(.leading, 52) + } + } + + Color.clear.frame(height: 120) + } + } + + // MARK: - Loading state + + private var loadingState: some View { + VStack { + Spacer() + ProgressView() + .tint(Color.amber) + .scaleEffect(1.4) + Spacer() + } + } + + // MARK: - Empty results state + + private var emptyResultsState: some View { + VStack { + Spacer() + EmptyStateView( + icon: "magnifyingglass", + title: "No results", + message: "Nothing matched \"\(vm.query)\". Try a different term." + ) + Spacer() + } + } + + // MARK: - Results grid + + private var resultsGrid: some View { + ScrollView { + // Count header + HStack(spacing: 6) { + Text("\(vm.results.count) results") + .font(.subheadline.bold()) + .foregroundStyle(.primary) + + if vm.localCount > 0 || vm.remoteCount > 0 { + Text("·") + .foregroundStyle(.tertiary) + if vm.localCount > 0 { + Text("\(vm.localCount) in library") + .font(.caption) + .foregroundStyle(Color.amber) + } + if vm.localCount > 0 && vm.remoteCount > 0 { + Text("+") + .font(.caption) + .foregroundStyle(.tertiary) + } + if vm.remoteCount > 0 { + Text("\(vm.remoteCount) online") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 4) + + LazyVGrid(columns: columns, spacing: 14) { + ForEach(vm.results) { novel in + NavigationLink(value: NavDestination.book(novel.slug)) { + SearchNovelCard(novel: novel) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.top, 4) + + Color.clear.frame(height: 120) + } + } +} + +// MARK: - SearchNovelCard + +private struct SearchNovelCard: View { + let novel: BrowseNovel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + AsyncCoverImage(url: novel.cover) + .frame(maxWidth: .infinity) + .aspectRatio(2/3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .bookCoverZoomSource(slug: novel.slug) + + VStack(alignment: .leading, spacing: 3) { + Text(novel.title) + .font(.subheadline.bold()) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if !novel.author.isEmpty { + Text(novel.author) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: UIColor(red: 0.153, green: 0.153, blue: 0.169, alpha: 1))) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 6, x: 0, y: 2) + } +} diff --git a/ios/LibNovelV2/features.md b/ios/LibNovelV2/features.md new file mode 100644 index 0000000..726b266 --- /dev/null +++ b/ios/LibNovelV2/features.md @@ -0,0 +1,57 @@ +# LibNovel v2 iOS — Feature Tracker + +Design reference: `ui/src/routes/` (SvelteKit web UI) +All new code lives in `ios/LibNovelV2/`. + +--- + +## Status legend +- ✅ Done +- 🔨 In progress +- ⏳ Not started + +--- + +## Features + +| # | Feature | Files | Status | +|---|---------|-------|--------| +| 1 | Directory scaffold | `ios/LibNovelV2/` tree | ✅ | +| 2 | Models | `Models/Models.swift` | ✅ | +| 3 | Networking | `Networking/APIClient.swift` | ✅ | +| 4 | Services | `AuthStore`, `AudioPlayerService`, `AudioDownloadService`, `NetworkMonitor`, `BookVoicePreferences` | ✅ | +| 4b | App entry + RootTabView + stub views | `App/LibNovelV2App.swift`, `App/ContentView.swift`, `App/RootTabView.swift`, `Extensions/NavDestination.swift` | ✅ | +| 5 | Auth / Login | `Views/Auth/AuthView.swift` | ✅ | +| 6 | Home screen | `Views/Home/HomeView.swift`, `ViewModels/HomeViewModel.swift`, `Views/Common/CommonViews.swift` | ✅ | +| 7 | Library screen | `Views/Library/LibraryView.swift`, `ViewModels/LibraryViewModel.swift` | ✅ | +| 8 | Browse / Discover | `Views/Browse/BrowseView.swift`, `Views/Browse/BrowseCategoryView.swift`, `ViewModels/BrowseViewModel.swift` | ✅ | +| 9 | Search | `Views/Search/SearchView.swift`, `ViewModels/SearchViewModel.swift` | ✅ | +| 10 | Book Detail | `Views/BookDetail/BookDetailView.swift`, `ViewModels/BookDetailViewModel.swift` | ✅ | +| 11 | Chapter Reader | `Views/ChapterReader/ChapterReaderView.swift`, `ViewModels/ChapterReaderViewModel.swift` | ✅ | +| 12 | Audio mini-player + full player | `Views/Player/PlayerViews.swift` | ✅ | +| 13 | Downloads screen | `Views/Downloads/DownloadsView.swift` | ✅ | +| 14 | Profile / Account | `Views/Profile/ProfileView.swift`, `Views/Profile/VoiceSelectionView.swift` | ✅ | + +--- + +## Design system recap + +| Token | Value | +|-------|-------| +| Main bg | `zinc-900` `#18181b` | +| Card bg | `zinc-800` `#27272a` | +| Border | `zinc-700` `#3f3f46` | +| Primary text | `zinc-100` `#f4f4f5` | +| Secondary text | `zinc-400` `#a1a1aa` | +| Accent / CTA | `amber-400` `#f59e0b` | + +## Key patterns (quick ref) + +- **Cover images**: always proxy via `/api/cover/{domain}/{slug}` +- **Download keys**: `slug::chapterN::voice` (`::` separator — slugs contain `-`) +- **Voice fallback**: book override → global default → `"af_bella"` +- **Offline**: `NetworkMonitor` env object + `OfflineBanner` at top of every networked view +- **Observable**: new types use `@Observable`; existing services use `ObservableObject` +- **Navigation**: `NavigationStack` + `NavDestination` enum + `.appNavigationDestination()` +- **Haptics**: `.light` for selection, `.medium` for primary actions +- **Animations**: `.spring(response:dampingFraction:)` for all interactive transitions diff --git a/ios/LibNovelV2/project.yml b/ios/LibNovelV2/project.yml new file mode 100644 index 0000000..80fd17c --- /dev/null +++ b/ios/LibNovelV2/project.yml @@ -0,0 +1,70 @@ +name: LibNovelV2 +options: + bundleIdPrefix: com.kalekber + deploymentTarget: + iOS: "17.0" + xcodeVersion: "16.0" + generateEmptyDirectories: true + indentWidth: 4 + tabWidth: 4 + usesTabs: false + +settings: + base: + SWIFT_VERSION: "5.10" + ENABLE_PREVIEWS: YES + MARKETING_VERSION: "1.0.0" + CURRENT_PROJECT_VERSION: "1" + LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc" + configs: + Debug: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + Release: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: "" + +targets: + LibNovelV2: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: . + excludes: + - "**/.DS_Store" + - "Resources/Info.plist" + - "Resources/Assets.xcassets" + - "features.md" + - "project.yml" + resources: + - path: Resources/Assets.xcassets + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.kalekber.LibNovelV2 + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: Resources/Info.plist + configs: + Release: + CODE_SIGN_STYLE: Manual + DEVELOPMENT_TEAM: GHZXC6FVMU + CODE_SIGN_IDENTITY: "Apple Distribution" + PROVISIONING_PROFILE: "af592c3a-f60b-4ac1-a14f-30b8a206017f" + +schemes: + LibNovelV2: + build: + targets: + LibNovelV2: all + run: + config: Debug + environmentVariables: + LIBNOVEL_BASE_URL: + value: "https://v2.libnovel.kalekber.cc" + isEnabled: true + profile: + config: Release + analyze: + config: Debug + archive: + config: Release diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..e15c722 --- /dev/null +++ b/opencode.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "gh_grep": { + "type": "remote", + "url": "https://mcp.grep.app", + "enabled": true + } + }, + "instructions": [ + "ios/AGENTS.md" + ] +} diff --git a/scraper/internal/server/integration_test.go b/scraper/internal/server/integration_test.go index fc5847e..a2f5688 100644 --- a/scraper/internal/server/integration_test.go +++ b/scraper/internal/server/integration_test.go @@ -93,7 +93,7 @@ func startTestServer(t *testing.T, store storage.Store) string { log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) // nopScraper satisfies scraper.NovelScraper without hitting the network. - srv := New(addr, orchestrator.Config{}, nopScraper{}, log, store, "", "af_bella") + srv := New(addr, orchestrator.Config{}, nopScraper{}, log, store, "", "af_bella", "", "") ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel)