diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj index 93357fc..d7e5c03 100644 --- a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj +++ b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj @@ -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 = ""; }; 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = ""; }; 9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = ""; }; + B2C3D4E5F67890123456789A /* String+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+App.swift"; sourceTree = ""; }; B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = ""; }; B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; @@ -274,6 +276,7 @@ children = ( 9D83BB88C4306BE7A4F947CB /* Color+App.swift */, 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */, + B2C3D4E5F67890123456789A /* String+App.swift */, ); path = Extensions; sourceTree = ""; @@ -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 */, diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift index 1af25f6..f1b2308 100644 --- a/ios/LibNovel/LibNovel/App/RootTabView.swift +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -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) } } diff --git a/ios/LibNovel/LibNovel/Extensions/String+App.swift b/ios/LibNovel/LibNovel/Extensions/String+App.swift new file mode 100644 index 0000000..6bcb7a9 --- /dev/null +++ b/ios/LibNovel/LibNovel/Extensions/String+App.swift @@ -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 +} diff --git a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index d90c80b..27a4f38 100644 --- a/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/LibNovel/LibNovel/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,14 +1,14 @@ { - "images": [ + "images" : [ { - "filename": "icon-1024.png", - "idiom": "universal", - "platform": "ios", - "size": "1024x1024" + "filename" : "icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" } ], - "info": { - "author": "xcode", - "version": 1 + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift index a2bbd7d..384e416 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -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,15 +204,18 @@ private struct ChapterRow: View { .lineLimit(1) } } - Spacer() - if !chapter.dateLabel.isEmpty { - Text(chapter.dateLabel) + 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) } - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.tertiary) } .padding(.horizontal) .padding(.vertical, 10) diff --git a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift index a95dd79..e7723b3 100644 --- a/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift +++ b/ios/LibNovel/LibNovel/Views/ChapterReader/ChapterReaderView.swift @@ -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 diff --git a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift index de9d521..261b324 100644 --- a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift +++ b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift @@ -48,13 +48,21 @@ 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 { - RoundedRectangle(cornerRadius: 10) - .fill(Color(.systemGray5)) - .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + if isBackground { + Color(.systemGray6) + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray5)) + .overlay(Image(systemName: "book.closed").foregroundStyle(.secondary)) + } } .scaledToFill() } diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index 1ab0e3e..a293f15 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -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 - switch audioPlayer.status { - case .generating: - ProgressView() - .scaleEffect(0.8) - .frame(width: 36) - case .ready: - Button { audioPlayer.togglePlayPause() } label: { - Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - .font(.title2) + // Status indicator or playback control + Group { + switch audioPlayer.status { + case .generating: + ProgressView() + .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(.system(size: 18, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.red) + .frame(width: 36, height: 36) + default: + Color.clear.frame(width: 36, height: 36) } - .buttonStyle(.plain) - case .error: - Image(systemName: "exclamationmark.circle").foregroundStyle(.red) - default: - EmptyView() } // 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) + Image(systemName: "gobackward.15") + .font(.system(size: 26, weight: .regular)) + .foregroundStyle(.white.opacity(0.9)) + .frame(maxWidth: .infinity) } - 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) - } - } - .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) { 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()) + .font(.subheadline.weight(.bold)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .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() - } - .padding(.top, 24) - .navigationTitle("Now Playing") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } + // 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)) + } + .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 + + @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) + } +}