iOS: fix mini player overlap, empty cover square, chapter nav, and UI polish
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 9s
CI / Scraper / Test (pull_request) Failing after 8s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped

- Add safeAreaInset on ChapterReaderView ScrollView so Prev/Next buttons
  clear the mini player bar when audio is active
- Fix mini player height reservation (66pt constant via AppLayout)
- Add AsyncCoverImage(isBackground:) to suppress rounded-rect placeholder
  showing through blurred hero background (empty square bug)
- Reduce chapter page size 100→50 on iOS
- Fix ChapterRow date/chevron layout: minLength spacer + fixedSize date
- Add String.strippingTrailingDate() extension; apply in MiniPlayer,
  FullPlayer, and ChapterReader header to clean up stale concatenated titles
- Make Prev/Next buttons equal-width (maxWidth: .infinity) for easier tapping
This commit is contained in:
Admin
2026-03-07 22:55:26 +05:00
parent 884c82b2c3
commit 228d4902bb
8 changed files with 355 additions and 112 deletions

View File

@@ -30,6 +30,7 @@
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F67890123456789A /* String+App.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 */; };
@@ -67,6 +68,7 @@
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
B2C3D4E5F67890123456789A /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = "<group>"; };
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
@@ -274,6 +276,7 @@
children = (
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
B2C3D4E5F67890123456789A /* String+App.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -398,6 +401,7 @@
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
A1B2C3D4E5F6789012345678 /* String+App.swift in Sources */,
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,

View File

@@ -13,6 +13,9 @@ struct RootTabView: View {
case home, library, browse, profile
}
/// Height of the mini player bar (progress line 2pt + vertical padding 20pt + content ~44pt)
private let miniPlayerBarHeight: CGFloat = AppLayout.miniPlayerBarHeight
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
@@ -32,10 +35,11 @@ struct RootTabView: View {
.tabItem { Label("Profile", systemImage: "person.fill") }
.tag(Tab.profile)
}
// Push tab bar up when mini-player is visible
// Reserve space for the mini-player above the tab bar so scroll content
// never slides beneath it.
.safeAreaInset(edge: .bottom) {
if audioPlayer.isActive {
Color.clear.frame(height: 64)
Color.clear.frame(height: miniPlayerBarHeight)
}
}

View File

@@ -0,0 +1,40 @@
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.
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
let before = String(self[self.startIndex ..< suffixRange.lowerBound])
let trimmed = before.trimmingCharacters(in: .whitespaces)
// Find start of last word (should be a number)
if let spaceIdx = trimmed.lastIndex(of: " ") {
let potentialNum = String(trimmed[trimmed.index(after: spaceIdx)...])
if Int(potentialNum) != nil {
return String(trimmed[trimmed.startIndex ..< spaceIdx])
.trimmingCharacters(in: .whitespaces)
}
} else if Int(trimmed) != nil {
// The entire string is just a number + "ago"
return ""
}
}
}
return self
}
}
// MARK: - App-wide layout constants
enum AppLayout {
/// Height of the persistent mini-player bar:
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
static let miniPlayerBarHeight: CGFloat = 66
}

View File

@@ -7,7 +7,7 @@ struct BookDetailView: View {
@EnvironmentObject var audioPlayer: AudioPlayerService
@State private var summaryExpanded = false
@State private var chapterPage = 0
private let pageSize = 100
private let pageSize = 50
init(slug: String) {
self.slug = slug
@@ -40,8 +40,9 @@ struct BookDetailView: View {
@ViewBuilder
private func heroSection(book: Book) -> some View {
ZStack(alignment: .bottom) {
// Blurred cover background
AsyncCoverImage(url: book.cover)
// Blurred cover background use plain colour placeholder to avoid
// the rounded-rect loading indicator showing through the blur.
AsyncCoverImage(url: book.cover, isBackground: true)
.frame(maxWidth: .infinity)
.frame(height: 260)
.blur(radius: 20)
@@ -190,7 +191,7 @@ private struct ChapterRow: View {
let chapter: ChapterIndex
let isCurrent: Bool
var body: some View {
HStack {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("Chapter \(chapter.number)")
.font(.subheadline)
@@ -203,16 +204,19 @@ private struct ChapterRow: View {
.lineLimit(1)
}
}
Spacer()
Spacer(minLength: 12)
HStack(spacing: 6) {
if !chapter.dateLabel.isEmpty {
Text(chapter.dateLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
.fixedSize()
}
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal)
.padding(.vertical, 10)
.contentShape(Rectangle())

View File

@@ -82,7 +82,7 @@ struct ChapterReaderView: View {
VStack(alignment: .leading, spacing: 16) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text(content.chapter.title)
Text(content.chapter.title.strippingTrailingDate())
.font(.title2.bold())
if !content.chapter.dateLabel.isEmpty {
Text(content.chapter.dateLabel)
@@ -102,18 +102,19 @@ struct ChapterReaderView: View {
Divider()
// Prev / Next navigation
HStack {
HStack(spacing: 12) {
if let prev = content.prev {
NavigationLink(value: NavDestination.chapter(slug, prev)) {
Label("Ch.\(prev)", systemImage: "chevron.left")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
Spacer()
if let next = content.next {
NavigationLink(value: NavDestination.chapter(slug, next)) {
Label("Ch.\(next)", systemImage: "chevron.right")
.labelStyle(ReverseLabelStyle())
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
@@ -123,6 +124,12 @@ struct ChapterReaderView: View {
}
.padding(.vertical)
}
// Ensure the Prev/Next buttons clear the mini-player bar when it is visible.
.safeAreaInset(edge: .bottom) {
if audioPlayer.isActive {
Color.clear.frame(height: AppLayout.miniPlayerBarHeight)
}
}
}
// MARK: - Audio toolbar button

View File

@@ -48,14 +48,22 @@ struct BookCard: View {
struct AsyncCoverImage: View {
let url: String
/// When true the placeholder is a plain colour fill used for blurred hero backgrounds
/// so the rounded-rect loading indicator doesn't bleed through.
var isBackground: Bool = false
var body: some View {
KFImage(URL(string: url))
.resizable()
.placeholder {
if isBackground {
Color(.systemGray6)
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray5))
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
}
}
.scaledToFill()
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI
import Kingfisher // used directly for blurred background in FullPlayerView
// MARK: - Mini player bar (pinned above tab bar)
@@ -8,10 +9,10 @@ struct MiniPlayerView: View {
var body: some View {
VStack(spacing: 0) {
// Seek bar (thin line at top of bar)
// Progress line thin amber strip at the very top
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle().fill(Color(.systemGray4)).frame(height: 2)
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 2)
Rectangle()
.fill(Color.amber)
.frame(width: geo.size.width * progress, height: 2)
@@ -19,59 +20,67 @@ struct MiniPlayerView: View {
}
.frame(height: 2)
HStack(spacing: 12) {
// Cover thumbnail tap to open full player
HStack(spacing: 14) {
// Cover thumbnail
Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 6))
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
// Track info
VStack(alignment: .leading, spacing: 2) {
Text(audioPlayer.bookTitle)
.font(.caption.bold())
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(chapterLabel)
.font(.caption2)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture { showFullPlayer = true }
// Status spinner or playback controls
// Status indicator or playback control
Group {
switch audioPlayer.status {
case .generating:
ProgressView()
.scaleEffect(0.8)
.frame(width: 36)
.tint(.amber)
.scaleEffect(0.85)
.frame(width: 36, height: 36)
case .ready:
Button { audioPlayer.togglePlayPause() } label: {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.title2)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
case .error:
Image(systemName: "exclamationmark.circle").foregroundStyle(.red)
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.red)
.frame(width: 36, height: 36)
default:
EmptyView()
Color.clear.frame(width: 36, height: 36)
}
}
// Dismiss
Button { audioPlayer.stop() } label: {
Image(systemName: "xmark").font(.caption.bold())
Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.secondary)
.frame(width: 30, height: 30)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 0))
.shadow(color: .black.opacity(0.12), radius: 4, y: -2)
.shadow(color: .black.opacity(0.15), radius: 6, y: -2)
}
private var progress: CGFloat {
@@ -80,9 +89,10 @@ struct MiniPlayerView: View {
}
private var chapterLabel: String {
audioPlayer.chapterTitle.isEmpty
let raw = audioPlayer.chapterTitle.isEmpty
? "Chapter \(audioPlayer.chapter)"
: audioPlayer.chapterTitle
return raw.strippingTrailingDate()
}
}
@@ -93,93 +103,203 @@ struct FullPlayerView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(spacing: 28) {
// Cover art
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 200, height: 290)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(radius: 10)
ZStack {
// Background: blurred cover art
GeometryReader { geo in
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.clipped()
.blur(radius: 40, opaque: true)
.overlay(Color.black.opacity(0.55))
.ignoresSafeArea()
}
.ignoresSafeArea()
// Titles
VStack(spacing: 4) {
Text(audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle)
.font(.headline)
// Content
VStack(spacing: 0) {
// Navigation bar row
HStack {
Spacer()
Text("Now Playing")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
Spacer()
Button {
dismiss()
} label: {
Text("Done")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.white.opacity(0.15), in: Capsule())
}
}
.padding(.horizontal, 24)
.padding(.top, 20)
Spacer(minLength: 0)
// Cover art
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.1))
.overlay(
Image(systemName: "book.closed")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.4))
)
}
.scaledToFill()
.frame(width: 260, height: 260)
.clipShape(RoundedRectangle(cornerRadius: 18))
.shadow(color: .black.opacity(0.5), radius: 24, y: 12)
.padding(.horizontal, 48)
Spacer(minLength: 0)
// Title block
VStack(spacing: 6) {
Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate())
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineLimit(2)
Text(audioPlayer.bookTitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
}
.padding(.horizontal)
.padding(.horizontal, 32)
.padding(.top, 28)
// Seek bar
VStack(spacing: 4) {
Slider(
VStack(spacing: 6) {
PlayerSlider(
value: Binding(
get: { audioPlayer.currentTime },
set: { audioPlayer.seek(to: $0) }
),
in: 0...max(audioPlayer.duration, 1)
range: 0...max(audioPlayer.duration, 1)
)
.tint(.amber)
HStack {
Text(formatTime(audioPlayer.currentTime))
Spacer()
Text(formatTime(audioPlayer.duration))
}
.font(.caption2)
.foregroundStyle(.secondary)
.font(.caption.monospacedDigit())
.foregroundStyle(.white.opacity(0.5))
}
.padding(.horizontal)
.padding(.horizontal, 28)
.padding(.top, 24)
// Controls row
HStack(spacing: 40) {
// Controls
HStack(spacing: 0) {
// skip back 15s
Button { audioPlayer.skip(by: -15) } label: {
Image(systemName: "gobackward.15").font(.title)
}
Button { audioPlayer.togglePlayPause() } label: {
Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.amber)
}
Button { audioPlayer.skip(by: 30) } label: {
Image(systemName: "goforward.30").font(.title)
}
Image(systemName: "gobackward.15")
.font(.system(size: 26, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
// Speed + auto-next row
// play / pause
Button { audioPlayer.togglePlayPause() } label: {
ZStack {
Circle()
.fill(Color.amber)
.frame(width: 72, height: 72)
.shadow(color: Color.amber.opacity(0.45), radius: 14, y: 4)
if audioPlayer.status == .generating {
ProgressView()
.tint(.white)
.scaleEffect(1.1)
} else {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(.white)
.offset(x: audioPlayer.isPlaying ? 0 : 2)
}
}
}
.buttonStyle(.plain)
.disabled(audioPlayer.status == .generating)
// skip forward 30s
Button { audioPlayer.skip(by: 30) } label: {
Image(systemName: "goforward.30")
.font(.system(size: 26, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.top, 32)
// Speed + Auto-next
HStack(spacing: 20) {
Menu {
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { s in
Button("\(s, specifier: "%.2g")×") { audioPlayer.setSpeed(s) }
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(.subheadline.bold())
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5), in: Capsule())
}
Toggle(isOn: $audioPlayer.autoNext) {
Label("Auto-next", systemImage: "forward.end")
.font(.subheadline)
}
.toggleStyle(.button)
.tint(.amber)
.font(.subheadline.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.white.opacity(0.18), in: Capsule())
}
Spacer()
// Auto-next toggle
Button {
audioPlayer.autoNext.toggle()
} label: {
HStack(spacing: 6) {
Image(systemName: "forward.end")
.font(.system(size: 13, weight: .semibold))
Text("Auto-next")
.font(.subheadline.weight(.semibold))
}
.padding(.top, 24)
.navigationTitle("Now Playing")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.55))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
audioPlayer.autoNext
? Color.amber.opacity(0.18)
: Color.white.opacity(0.1),
in: Capsule()
)
.overlay(
Capsule()
.stroke(
audioPlayer.autoNext ? Color.amber.opacity(0.5) : Color.clear,
lineWidth: 1
)
)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 28)
.padding(.top, 28)
Spacer(minLength: 32)
}
}
}
@@ -190,3 +310,59 @@ struct FullPlayerView: View {
return "\(s / 60):\(String(format: "%02d", s % 60))"
}
}
// MARK: - Custom seek slider
// A thicker, rounded-thumb slider that matches the amber design language.
struct PlayerSlider: View {
@Binding var value: Double
let range: ClosedRange<Double>
@State private var isDragging = false
var body: some View {
GeometryReader { geo in
let width = geo.size.width
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 : 16
let trackHeight: CGFloat = isDragging ? 5 : 4
ZStack(alignment: .leading) {
// Track
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: trackHeight)
// Fill
Capsule()
.fill(Color.amber)
.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)
.offset(x: max(0, filled - thumbSize / 2))
.animation(.spring(response: 0.2), value: isDragging)
}
.frame(height: 28) // generous touch target
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
isDragging = 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
}
)
}
.frame(height: 28)
}
}