v2 #1

Open
kamil wants to merge 231 commits from v2 into main
19 changed files with 183 additions and 402 deletions
Showing only changes of commit 2793ad8cfa - Show all commits

View File

@@ -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 */;

View File

@@ -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

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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 ?? "")
}
}
}

View File

@@ -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 ""
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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"
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)")
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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