v2 #1
@@ -24,7 +24,6 @@
|
||||
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
|
||||
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
|
||||
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
|
||||
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; };
|
||||
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
|
||||
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
|
||||
@@ -86,7 +85,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
|
||||
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -327,7 +325,6 @@
|
||||
name = LibNovel;
|
||||
packageProductDependencies = (
|
||||
09584EAB68A07B47F876A062 /* Kingfisher */,
|
||||
6313AB4B3A5464F647791174 /* MarkdownUI */,
|
||||
);
|
||||
productName = LibNovel;
|
||||
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
|
||||
@@ -353,7 +350,6 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
|
||||
@@ -670,14 +666,6 @@
|
||||
minimumVersion = 8.0.0;
|
||||
};
|
||||
};
|
||||
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.4.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -686,11 +674,6 @@
|
||||
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||
productName = Kingfisher;
|
||||
};
|
||||
6313AB4B3A5464F647791174 /* MarkdownUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||
productName = MarkdownUI;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;
|
||||
|
||||
@@ -9,33 +9,6 @@
|
||||
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
|
||||
"version" : "8.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"state" : {
|
||||
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||
"version" : "0.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||
"state" : {
|
||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -31,7 +31,7 @@ struct RootTabView: View {
|
||||
.tag(Tab.library)
|
||||
|
||||
BrowseView()
|
||||
.tabItem { Label("Discover", systemImage: "globe") }
|
||||
.tabItem { Label("Discover", systemImage: "globe.americas.fill") }
|
||||
.tag(Tab.browse)
|
||||
|
||||
ProfileView()
|
||||
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
// MARK: - App accent color (amber — mirrors Tailwind amber-500 #f59e0b)
|
||||
extension Color {
|
||||
static let amber = Color(red: 0.96, green: 0.62, blue: 0.04)
|
||||
static let amberLight = Color(red: 1.0, green: 0.84, blue: 0.40)
|
||||
}
|
||||
|
||||
extension ShapeStyle where Self == Color {
|
||||
|
||||
@@ -6,3 +6,31 @@ enum NavDestination: Hashable {
|
||||
case book(String) // slug
|
||||
case chapter(String, Int) // slug + chapter number
|
||||
}
|
||||
|
||||
// MARK: - View extensions for shared navigation + error alert patterns
|
||||
|
||||
extension View {
|
||||
/// Registers the app-wide navigation destinations for NavDestination values.
|
||||
/// Apply once per NavigationStack instead of repeating the switch in every tab.
|
||||
func appNavigationDestination() -> some View {
|
||||
navigationDestination(for: NavDestination.self) { dest in
|
||||
switch dest {
|
||||
case .book(let slug): BookDetailView(slug: slug)
|
||||
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Presents a standard "Error" alert driven by an optional String binding.
|
||||
/// Dismissing the alert sets the binding back to nil.
|
||||
func errorAlert(_ error: Binding<String?>) -> 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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,28 @@ import Foundation
|
||||
// MARK: - String helpers for display purposes
|
||||
|
||||
extension String {
|
||||
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago")
|
||||
/// that may be embedded in chapter titles coming from older scraped records.
|
||||
/// Strips trailing relative-date suffixes (e.g. "2 years ago", "3 days ago",
|
||||
/// or "(One)4 years ago" where the number is attached without a preceding space).
|
||||
func strippingTrailingDate() -> String {
|
||||
let units = ["second", "minute", "hour", "day", "week", "month", "year"]
|
||||
let lower = self.lowercased()
|
||||
for unit in units {
|
||||
for suffix in [unit + "s ago", unit + " ago"] {
|
||||
guard let suffixRange = lower.range(of: suffix, options: .backwards) else { continue }
|
||||
// Walk backwards past whitespace to find the numeric token
|
||||
// Everything before the suffix
|
||||
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
|
||||
let trimmed = before.trimmingCharacters(in: .whitespaces)
|
||||
// Find start of last word (should be a number)
|
||||
// Strip trailing digits (the numeric count, which may be attached without a space)
|
||||
var result = trimmed
|
||||
while let last = result.last, last.isNumber {
|
||||
result.removeLast()
|
||||
}
|
||||
result = result.trimmingCharacters(in: .whitespaces)
|
||||
if result != trimmed {
|
||||
// We actually stripped some digits — return cleaned result
|
||||
return result
|
||||
}
|
||||
// Fallback: number preceded by space
|
||||
if let spaceIdx = trimmed.lastIndex(of: " ") {
|
||||
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
|
||||
if Int(potentialNum) != nil {
|
||||
@@ -22,7 +32,6 @@ extension String {
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
} else if Int(trimmed) != nil {
|
||||
// The entire string is just a number + "ago"
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,22 +126,6 @@ struct AppUser: Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Browse / Novel Listing
|
||||
|
||||
struct NovelListing: 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]
|
||||
}
|
||||
|
||||
// MARK: - Ranking
|
||||
|
||||
struct RankingItem: Codable, Identifiable {
|
||||
@@ -201,17 +185,6 @@ struct UserSession: Codable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Book Detail
|
||||
|
||||
struct BookDetailData {
|
||||
let book: Book
|
||||
let chapters: [ChapterIndex]
|
||||
let previewChapters: [PreviewChapter]?
|
||||
let inLib: Bool
|
||||
let saved: Bool
|
||||
let lastChapter: Int?
|
||||
}
|
||||
|
||||
struct PreviewChapter: Codable, Identifiable {
|
||||
var id: Int { number }
|
||||
let number: Int
|
||||
@@ -219,19 +192,6 @@ struct PreviewChapter: Codable, Identifiable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
// MARK: - Chapter Content
|
||||
|
||||
struct ChapterContent {
|
||||
let book: BookBrief
|
||||
let chapter: ChapterIndex
|
||||
let html: String
|
||||
let voices: [String]
|
||||
let prev: Int?
|
||||
let next: Int?
|
||||
let chapters: [ChapterIndexBrief]
|
||||
let isPreview: Bool
|
||||
}
|
||||
|
||||
struct BookBrief: Codable {
|
||||
let slug: String
|
||||
let title: String
|
||||
|
||||
@@ -63,9 +63,7 @@ actor APIClient {
|
||||
// paths like /api/chapter/slug/1. URL(string:) preserves slashes correctly.
|
||||
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
+ "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
print("[APIClient] ▶ \(method) \(urlString)")
|
||||
guard let url = URL(string: urlString) else {
|
||||
print("[APIClient] ✗ Could not construct URL from: \(urlString)")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
@@ -75,12 +73,6 @@ actor APIClient {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
}
|
||||
// Log cookies being sent
|
||||
if let cookies = HTTPCookieStorage.shared.cookies(for: url), !cookies.isEmpty {
|
||||
print("[APIClient] cookies: \(cookies.map { "\($0.name)=\($0.value.prefix(20))…" })")
|
||||
} else {
|
||||
print("[APIClient] cookies: (none)")
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -90,40 +82,15 @@ actor APIClient {
|
||||
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 {
|
||||
print("[APIClient] ✗ \(path) — not an HTTP response")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
let rawBody = String(data: data, encoding: .utf8) ?? "<non-utf8 data, \(data.count) bytes>"
|
||||
print("[APIClient] ◀ \(http.statusCode) \(path) — \(data.count) bytes")
|
||||
if data.count < 2000 {
|
||||
print("[APIClient] body: \(rawBody)")
|
||||
} else {
|
||||
print("[APIClient] body (first 2000 chars): \(rawBody.prefix(2000))")
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw APIError.httpError(http.statusCode, rawBody)
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder.iso8601.decode(T.self, from: data)
|
||||
print("[APIClient] ✓ decoded \(T.self) from \(path)")
|
||||
return decoded
|
||||
return try JSONDecoder.iso8601.decode(T.self, from: data)
|
||||
} catch {
|
||||
print("[APIClient] ✗ decode error for \(T.self) from \(path)")
|
||||
print("[APIClient] decode error: \(error)")
|
||||
if let decodingError = error as? DecodingError {
|
||||
switch decodingError {
|
||||
case .typeMismatch(let type, let ctx):
|
||||
print("[APIClient] typeMismatch: expected \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
case .valueNotFound(let type, let ctx):
|
||||
print("[APIClient] valueNotFound: \(type) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
case .keyNotFound(let key, let ctx):
|
||||
print("[APIClient] keyNotFound: '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
case .dataCorrupted(let ctx):
|
||||
print("[APIClient] dataCorrupted: \(ctx.debugDescription) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))")
|
||||
@unknown default:
|
||||
print("[APIClient] unknown decoding error: \(error)")
|
||||
}
|
||||
}
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": { "alpha": "1.000", "blue": "0.588", "green": "0.467", "red": "1.000" }
|
||||
"components": { "alpha": "1.000", "blue": "0.040", "green": "0.620", "red": "0.960" }
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
|
||||
@@ -50,14 +50,12 @@ final class BrowseViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func loadPage(_ page: Int) async {
|
||||
print("[Browse] loadPage(\(page)) genre=\(genre) sort=\(sort) status=\(status)")
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
let result = try await APIClient.shared.browse(
|
||||
page: page, genre: genre, sort: sort, status: status
|
||||
)
|
||||
print("[Browse] ✓ page \(page) — \(result.novels.count) novels, hasNext=\(result.hasNext)")
|
||||
if page == 1 {
|
||||
novels = result.novels
|
||||
} else {
|
||||
@@ -67,7 +65,6 @@ final class BrowseViewModel: ObservableObject {
|
||||
currentPage = page
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
print("[Browse] ✗ error on page \(page): \(error)")
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,34 +3,38 @@ import Foundation
|
||||
@MainActor
|
||||
final class ChapterReaderViewModel: ObservableObject {
|
||||
let slug: String
|
||||
let chapter: Int
|
||||
private(set) var chapter: Int
|
||||
|
||||
@Published var content: ChapterResponse?
|
||||
@Published var isLoading = false
|
||||
@Published var error: String?
|
||||
@Published var navigateTo: Int? // set to trigger navigation to next chapter
|
||||
|
||||
init(slug: String, chapter: Int) {
|
||||
self.slug = slug
|
||||
self.chapter = chapter
|
||||
}
|
||||
|
||||
/// Switch to a different chapter in-place: resets state and updates `chapter`
|
||||
/// so that `.task(id: currentChapter)` in the View re-fires `load()`.
|
||||
func switchChapter(to newChapter: Int) {
|
||||
guard newChapter != chapter else { return }
|
||||
chapter = newChapter
|
||||
content = nil
|
||||
error = nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
print("[ChapterReader] load() slug=\(slug) chapter=\(chapter)")
|
||||
isLoading = true
|
||||
error = nil
|
||||
do {
|
||||
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
|
||||
print("[ChapterReader] ✓ loaded chapter \(chapter), html length=\(content?.html.count ?? 0)")
|
||||
// Record reading progress
|
||||
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
|
||||
} catch {
|
||||
if !(error is CancellationError) {
|
||||
print("[ChapterReader] ✗ error: \(error)")
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
print("[ChapterReader] done — isLoading=false, content=\(content != nil), error=\(String(describing: self.error))")
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,7 @@ struct BookDetailView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { bookmarkButton }
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
|
||||
@@ -90,12 +90,7 @@ struct BrowseView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Discover")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.appNavigationDestination()
|
||||
.sheet(isPresented: $showFilters) {
|
||||
BrowseFiltersView(vm: vm)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ struct ChapterReaderView: View {
|
||||
let slug: String
|
||||
let chapterNumber: Int
|
||||
|
||||
/// Tracks the currently displayed chapter — updated in-place by skip/auto-next
|
||||
/// so we never accumulate stale listeners on the navigation stack.
|
||||
@State private var currentChapter: Int
|
||||
@StateObject private var vm: ChapterReaderViewModel
|
||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||
@EnvironmentObject var authStore: AuthStore
|
||||
@@ -14,20 +17,17 @@ struct ChapterReaderView: View {
|
||||
init(slug: String, chapterNumber: Int) {
|
||||
self.slug = slug
|
||||
self.chapterNumber = chapterNumber
|
||||
_currentChapter = State(initialValue: chapterNumber)
|
||||
_vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let _ = print("[ChapterReaderView] body eval — slug=\(slug) ch=\(chapterNumber) isLoading=\(vm.isLoading) hasContent=\(vm.content != nil) error=\(vm.error ?? "nil")")
|
||||
Group {
|
||||
if vm.isLoading {
|
||||
let _ = print("[ChapterReaderView] branch: LOADING")
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let content = vm.content {
|
||||
let _ = print("[ChapterReaderView] branch: CONTENT html=\(content.html.count)chars title='\(content.chapter.title)'")
|
||||
readerContent(content)
|
||||
} else if let errMsg = vm.error {
|
||||
let _ = print("[ChapterReaderView] branch: ERROR '\(errMsg)'")
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
@@ -42,7 +42,6 @@ struct ChapterReaderView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
let _ = print("[ChapterReaderView] branch: BLANK (no loading, no content, no error)")
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
@@ -54,34 +53,34 @@ struct ChapterReaderView: View {
|
||||
floatingAudioButton
|
||||
}
|
||||
}
|
||||
.task {
|
||||
print("[ChapterReaderView] .task fired — calling vm.load()")
|
||||
.task(id: currentChapter) {
|
||||
await vm.load()
|
||||
print("[ChapterReaderView] .task completed")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
|
||||
guard let next = note.userInfo?["next"] as? Int else { return }
|
||||
let shouldAutoNavigate = note.userInfo?["autoNext"] as? Bool ?? false
|
||||
if shouldAutoNavigate {
|
||||
vm.navigateTo = next
|
||||
}
|
||||
// Only handle if this is the top-most (currently active) chapter view
|
||||
guard shouldAutoNavigate, currentChapter == audioPlayer.chapter else { return }
|
||||
navigateToChapter(next)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in
|
||||
guard let next = note.userInfo?["next"] as? Int else { return }
|
||||
vm.navigateTo = next
|
||||
// Only the view whose chapter matches the currently playing chapter should handle this
|
||||
guard currentChapter == audioPlayer.chapter else { return }
|
||||
navigateToChapter(next)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in
|
||||
guard let prev = note.userInfo?["prev"] as? Int else { return }
|
||||
vm.navigateTo = prev
|
||||
}
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { vm.navigateTo != nil },
|
||||
set: { if !$0 { vm.navigateTo = nil } }
|
||||
)) {
|
||||
if let next = vm.navigateTo {
|
||||
ChapterReaderView(slug: slug, chapterNumber: next)
|
||||
guard currentChapter == audioPlayer.chapter else { return }
|
||||
navigateToChapter(prev)
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to a chapter in-place: reloads content without pushing to the navigation stack.
|
||||
/// Back button always returns to BookDetailView regardless of how many chapters were visited.
|
||||
private func navigateToChapter(_ chapter: Int) {
|
||||
vm.switchChapter(to: chapter)
|
||||
currentChapter = chapter
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
@@ -90,7 +89,6 @@ struct ChapterReaderView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func readerContent(_ content: ChapterResponse) -> some View {
|
||||
let _ = print("[ChapterReaderView] readerContent — html=\(content.html.count)chars webHeight=\(webHeight)")
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
@@ -114,17 +112,21 @@ struct ChapterReaderView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
// Prev / Next navigation
|
||||
// Prev / Next navigation — in-place swap so back button always returns to book
|
||||
HStack(spacing: 12) {
|
||||
if let prev = content.prev {
|
||||
NavigationLink(value: NavDestination.chapter(slug, prev)) {
|
||||
Button {
|
||||
navigateToChapter(prev)
|
||||
} label: {
|
||||
Label("Ch.\(prev)", systemImage: "chevron.left")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
if let next = content.next {
|
||||
NavigationLink(value: NavDestination.chapter(slug, next)) {
|
||||
Button {
|
||||
navigateToChapter(next)
|
||||
} label: {
|
||||
Label("Ch.\(next)", systemImage: "chevron.right")
|
||||
.labelStyle(ReverseLabelStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -171,23 +173,6 @@ struct ChapterReaderView: View {
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
|
||||
// MARK: - Audio toolbar button (kept for when player is active)
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var audioToolbarButton: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
|
||||
} label: {
|
||||
Image(systemName: audioPlayer.isActive &&
|
||||
audioPlayer.slug == slug &&
|
||||
audioPlayer.chapter == chapterNumber
|
||||
? "speaker.wave.2.fill" : "speaker.wave.2")
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTML content renderer using WKWebView
|
||||
@@ -224,7 +209,6 @@ struct HTMLContentView: UIViewRepresentable {
|
||||
p { margin: 0 0 1em 0; }
|
||||
"""
|
||||
let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>"
|
||||
print("[HTMLContentView] updateUIView — html=\(html.count)chars loading into WKWebView")
|
||||
uiView.loadHTMLString(wrapped, baseURL: nil)
|
||||
}
|
||||
|
||||
@@ -233,18 +217,12 @@ struct HTMLContentView: UIViewRepresentable {
|
||||
init(_ parent: HTMLContentView) { self.parent = parent }
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
print("[HTMLContentView] didFinish — evaluating scrollHeight")
|
||||
webView.evaluateJavaScript("document.body.scrollHeight") { result, error in
|
||||
DispatchQueue.main.async {
|
||||
print("[HTMLContentView] scrollHeight result=\(String(describing: result)) error=\(String(describing: error))")
|
||||
if let h = result as? CGFloat, h > 0 {
|
||||
print("[HTMLContentView] height set to CGFloat \(h)")
|
||||
self.parent.height = h
|
||||
} else if let h = result as? Double, h > 0 {
|
||||
print("[HTMLContentView] height set to Double \(h)")
|
||||
self.parent.height = CGFloat(h)
|
||||
} else {
|
||||
print("[HTMLContentView] ⚠️ could not read height — keeping \(self.parent.height)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,19 +75,10 @@ struct HomeView: View {
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: {
|
||||
Text(vm.error ?? "")
|
||||
}
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,17 +34,10 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Library")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.appNavigationDestination()
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,10 @@ struct MiniPlayerView: View {
|
||||
|
||||
/// Live drag offset while the user is swiping up/down (negative = moving up).
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
/// Track horizontal drag for seek gesture
|
||||
@State private var seekDragOffset: CGFloat = 0
|
||||
@State private var isSeeking: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Interactive progress bar as background (full bleed behind content)
|
||||
// Static progress bar as background (full bleed behind content)
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
// Background track - full pill shape
|
||||
@@ -26,24 +23,8 @@ struct MiniPlayerView: View {
|
||||
// Progress fill - rounded to match pill shape
|
||||
RoundedRectangle(cornerRadius: 40)
|
||||
.fill(Color.amber.opacity(0.3))
|
||||
.frame(width: max(0, geo.size.width * (isSeeking ? seekProgress : progress)))
|
||||
.frame(width: max(0, geo.size.width * progress))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.highPriorityGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
isSeeking = true
|
||||
let ratio = max(0, min(1, value.location.x / geo.size.width))
|
||||
seekDragOffset = ratio
|
||||
}
|
||||
.onEnded { value in
|
||||
let ratio = max(0, min(1, value.location.x / geo.size.width))
|
||||
let newTime = audioPlayer.duration * ratio
|
||||
audioPlayer.seek(to: newTime)
|
||||
isSeeking = false
|
||||
seekDragOffset = 0
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Content layer
|
||||
@@ -165,8 +146,6 @@ struct MiniPlayerView: View {
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
// Block interaction with content layer from affecting progress bar
|
||||
.allowsHitTesting(true)
|
||||
}
|
||||
.background(
|
||||
// Dark rounded background (pill-shaped with full circular ends)
|
||||
@@ -231,10 +210,6 @@ struct MiniPlayerView: View {
|
||||
return CGFloat(audioPlayer.currentTime / audioPlayer.duration)
|
||||
}
|
||||
|
||||
private var seekProgress: CGFloat {
|
||||
return seekDragOffset
|
||||
}
|
||||
|
||||
private var chapterLabel: String {
|
||||
let raw = audioPlayer.chapterTitle.isEmpty
|
||||
? "Chapter \(audioPlayer.chapter)"
|
||||
@@ -250,8 +225,6 @@ struct FullPlayerView: View {
|
||||
/// Called when the view wants to close itself (Done button or drag-to-dismiss).
|
||||
var onDismiss: () -> Void = {}
|
||||
|
||||
@State private var isFavorited = false
|
||||
@State private var isLiked = false
|
||||
@State private var showingSpeedMenu = false
|
||||
@State private var showingChaptersList = false
|
||||
@State private var showingSleepTimer = false
|
||||
@@ -278,9 +251,7 @@ struct FullPlayerView: View {
|
||||
.fill(Color.white.opacity(0.35))
|
||||
.frame(width: 36, height: 4)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
// Cover art with watermark
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
@@ -296,23 +267,21 @@ struct FullPlayerView: View {
|
||||
)
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: 260, height: 260)
|
||||
.frame(width: 240, height: 240)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.shadow(color: .black.opacity(0.5), radius: 24, y: 12)
|
||||
|
||||
// Watermark (voice name from audio player)
|
||||
Text(voiceName)
|
||||
.font(.custom("Snell Roundhand", size: 22))
|
||||
.font(.custom("Snell Roundhand", size: 20))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.shadow(color: .black.opacity(0.4), radius: 2)
|
||||
.padding(16)
|
||||
.padding(14)
|
||||
}
|
||||
.padding(.horizontal, 48)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Title block
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 4) {
|
||||
Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate())
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
@@ -324,51 +293,21 @@ struct FullPlayerView: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 28)
|
||||
.padding(.top, 20)
|
||||
|
||||
// Action buttons row (like, favorite, menu)
|
||||
HStack(spacing: 32) {
|
||||
// Action buttons row + metadata inline
|
||||
HStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
isLiked.toggle()
|
||||
// Metadata pill (only when ready)
|
||||
if audioPlayer.status != .generating {
|
||||
Text("\(yearText) · \(cacheStatusText) · OPUS")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isLiked ? "heart.fill" : "heart")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(isLiked ? .pink : .white.opacity(0.7))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
isFavorited.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isFavorited ? "star.fill" : "star")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(isFavorited ? .amber : .white.opacity(0.7))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Menu {
|
||||
Button {
|
||||
// Share
|
||||
} label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Button {
|
||||
// Add to library
|
||||
} label: {
|
||||
Label("Add to Library", systemImage: "plus")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
audioPlayer.autoNext.toggle()
|
||||
} label: {
|
||||
@@ -379,52 +318,19 @@ struct FullPlayerView: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(width: 44, height: 44)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
// Metadata row (year, cache status, format)
|
||||
HStack(spacing: 8) {
|
||||
Text(yearText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
.padding(.top, 10)
|
||||
|
||||
// Seek bar — hidden while generating
|
||||
if audioPlayer.status != .generating {
|
||||
Text("•")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.3))
|
||||
|
||||
Text(cacheStatusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
|
||||
Text("•")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.3))
|
||||
|
||||
Text("OPUS")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
|
||||
Text("•")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.3))
|
||||
|
||||
Text("149 kbps")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
|
||||
// Seek bar
|
||||
VStack(spacing: 6) {
|
||||
VStack(spacing: 4) {
|
||||
PlayerSlider(
|
||||
value: Binding(
|
||||
get: { audioPlayer.currentTime },
|
||||
@@ -441,18 +347,33 @@ struct FullPlayerView: View {
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 24)
|
||||
.padding(.top, 16)
|
||||
} else {
|
||||
// Generating state: compact progress indicator with label
|
||||
VStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.tint(.white.opacity(0.7))
|
||||
.scaleEffect(1.1)
|
||||
Text("Generating audio…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
// Controls
|
||||
HStack(spacing: 0) {
|
||||
// ← skip back 15s
|
||||
Button { audioPlayer.skip(by: -15) } label: {
|
||||
Image(systemName: "gobackward.15")
|
||||
.font(.system(size: 24, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.font(.system(size: 22, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(audioPlayer.status == .generating)
|
||||
|
||||
// ← previous chapter
|
||||
Button {
|
||||
@@ -466,26 +387,29 @@ struct FullPlayerView: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "backward.end.fill")
|
||||
.font(.system(size: 32, weight: .regular))
|
||||
.font(.system(size: 28, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(audioPlayer.absolutePrevChapter == nil)
|
||||
.opacity(audioPlayer.absolutePrevChapter == nil ? 0.5 : 1.0)
|
||||
.opacity(audioPlayer.absolutePrevChapter == nil ? 0.4 : 1.0)
|
||||
|
||||
// play / pause
|
||||
// play / pause — large circle button
|
||||
Button { audioPlayer.togglePlayPause() } label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.15))
|
||||
.frame(width: 64, height: 64)
|
||||
if audioPlayer.status == .generating {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(1.3)
|
||||
.scaleEffect(1.2)
|
||||
} else {
|
||||
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 38, weight: .bold))
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.offset(x: audioPlayer.isPlaying ? 0 : 3)
|
||||
.offset(x: audioPlayer.isPlaying ? 0 : 2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -506,19 +430,18 @@ struct FullPlayerView: View {
|
||||
} label: {
|
||||
ZStack {
|
||||
Image(systemName: "forward.end.fill")
|
||||
.font(.system(size: 32, weight: .regular))
|
||||
.font(.system(size: 28, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
|
||||
// Show small loading indicator if next chapter is being prefetched
|
||||
if audioPlayer.nextPrefetchStatus == .prefetching {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
.scaleEffect(0.55)
|
||||
.tint(.amber)
|
||||
.padding(4)
|
||||
.padding(3)
|
||||
.background(Circle().fill(.black.opacity(0.6)))
|
||||
}
|
||||
}
|
||||
@@ -528,21 +451,21 @@ struct FullPlayerView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(audioPlayer.absoluteNextChapter == nil)
|
||||
.opacity(audioPlayer.absoluteNextChapter == nil ? 0.5 : 1.0)
|
||||
.opacity(audioPlayer.absoluteNextChapter == nil ? 0.4 : 1.0)
|
||||
|
||||
// → skip forward 15s
|
||||
Button { audioPlayer.skip(by: 15) } label: {
|
||||
Image(systemName: "goforward.15")
|
||||
.font(.system(size: 24, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.font(.system(size: 22, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(audioPlayer.status == .generating)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 32)
|
||||
|
||||
Spacer(minLength: 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
// Bottom toolbar
|
||||
HStack(spacing: 0) {
|
||||
@@ -551,7 +474,7 @@ struct FullPlayerView: View {
|
||||
.frame(width: 22, height: 22)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Settings (Speed control)
|
||||
// Speed control
|
||||
Menu {
|
||||
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in
|
||||
Button {
|
||||
@@ -565,17 +488,16 @@ struct FullPlayerView: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 22))
|
||||
// Show current speed as a badge instead of gear icon
|
||||
Text("\(audioPlayer.speed, specifier: "%.2g")×")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Collapse
|
||||
Button {
|
||||
onDismiss()
|
||||
} label: {
|
||||
Button { onDismiss() } label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
@@ -583,20 +505,8 @@ struct FullPlayerView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Comments (placeholder)
|
||||
Button {} label: {
|
||||
Image(systemName: "text.bubble")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(true)
|
||||
|
||||
// Queue (show chapters list)
|
||||
Button {
|
||||
showingChaptersList = true
|
||||
} label: {
|
||||
Button { showingChaptersList = true } label: {
|
||||
Image(systemName: "list.bullet")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
@@ -605,9 +515,7 @@ struct FullPlayerView: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Sleep timer
|
||||
Button {
|
||||
showingSleepTimer = true
|
||||
} label: {
|
||||
Button { showingSleepTimer = true } label: {
|
||||
Image(systemName: sleepTimerIcon)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7))
|
||||
@@ -616,7 +524,7 @@ struct FullPlayerView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
}
|
||||
@@ -678,12 +586,16 @@ struct FullPlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private static let yearFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy"
|
||||
return f
|
||||
}()
|
||||
|
||||
private var yearText: String {
|
||||
// TODO: Could fetch actual publication year from book metadata
|
||||
// For now, return current year or placeholder
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy"
|
||||
return formatter.string(from: Date())
|
||||
return Self.yearFormatter.string(from: Date())
|
||||
}
|
||||
|
||||
private var sleepTimerIcon: String {
|
||||
|
||||
@@ -68,9 +68,7 @@ struct ProfileView: View {
|
||||
.sheet(isPresented: $showChangePassword) {
|
||||
ChangePasswordView()
|
||||
}
|
||||
.alert("Error", isPresented: Binding(get: { vm.error != nil }, set: { if !$0 { vm.error = nil } })) {
|
||||
Button("OK") { vm.error = nil }
|
||||
} message: { Text(vm.error ?? "") }
|
||||
.errorAlert($vm.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +119,7 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
),
|
||||
in: 0.5...3.0, step: 0.25
|
||||
in: 0.5...2.0, step: 0.25
|
||||
)
|
||||
.tint(.amber)
|
||||
}
|
||||
@@ -208,7 +206,8 @@ struct ChangePasswordView: View {
|
||||
body: Body(currentPassword: current, newPassword: newPwd)
|
||||
)
|
||||
success = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() }
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
@@ -23,10 +23,6 @@ settings:
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
|
||||
|
||||
packages:
|
||||
# Markdown rendering — renders chapter HTML from the server
|
||||
MarkdownUI:
|
||||
url: https://github.com/gonzalezreal/swift-markdown-ui
|
||||
from: "2.4.0"
|
||||
# Async image loading with caching
|
||||
Kingfisher:
|
||||
url: https://github.com/onevcat/Kingfisher
|
||||
@@ -46,7 +42,6 @@ targets:
|
||||
- path: LibNovel/Resources/Assets.xcassets
|
||||
dependencies:
|
||||
- package: Kingfisher
|
||||
- package: MarkdownUI
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel
|
||||
|
||||
Reference in New Issue
Block a user