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

View File

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

View File

@@ -82,7 +82,7 @@ struct ChapterReaderView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Header // Header
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(content.chapter.title) Text(content.chapter.title.strippingTrailingDate())
.font(.title2.bold()) .font(.title2.bold())
if !content.chapter.dateLabel.isEmpty { if !content.chapter.dateLabel.isEmpty {
Text(content.chapter.dateLabel) Text(content.chapter.dateLabel)
@@ -102,18 +102,19 @@ struct ChapterReaderView: View {
Divider() Divider()
// Prev / Next navigation // Prev / Next navigation
HStack { HStack(spacing: 12) {
if let prev = content.prev { if let prev = content.prev {
NavigationLink(value: NavDestination.chapter(slug, prev)) { NavigationLink(value: NavDestination.chapter(slug, prev)) {
Label("Ch.\(prev)", systemImage: "chevron.left") Label("Ch.\(prev)", systemImage: "chevron.left")
.frame(maxWidth: .infinity)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
Spacer()
if let next = content.next { if let next = content.next {
NavigationLink(value: NavDestination.chapter(slug, next)) { NavigationLink(value: NavDestination.chapter(slug, next)) {
Label("Ch.\(next)", systemImage: "chevron.right") Label("Ch.\(next)", systemImage: "chevron.right")
.labelStyle(ReverseLabelStyle()) .labelStyle(ReverseLabelStyle())
.frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(.amber) .tint(.amber)
@@ -123,6 +124,12 @@ struct ChapterReaderView: View {
} }
.padding(.vertical) .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 // MARK: - Audio toolbar button

View File

@@ -48,14 +48,22 @@ struct BookCard: View {
struct AsyncCoverImage: View { struct AsyncCoverImage: View {
let url: String 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 { var body: some View {
KFImage(URL(string: url)) KFImage(URL(string: url))
.resizable() .resizable()
.placeholder { .placeholder {
if isBackground {
Color(.systemGray6)
} else {
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray5)) .fill(Color(.systemGray5))
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
} }
}
.scaledToFill() .scaledToFill()
} }
} }

View File

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