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
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:
@@ -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 */,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
ios/LibNovel/LibNovel/Extensions/String+App.swift
Normal file
40
ios/LibNovel/LibNovel/Extensions/String+App.swift
Normal 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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user