iOS: redesign mini player with prev button and interactive progress bar

- Added previous chapter button (matching next button style)
- Made progress bar interactive - now occupies full background of mini player
- Horizontal drag/tap on progress bar seeks audio (shows amber overlay while seeking)
- Progress bar now uses full pill shape as background instead of small bottom border
- Tightened button spacing (12pt instead of 16pt) to fit prev/next buttons
- Changed vertical drag to simultaneousGesture to not conflict with horizontal seek
- Buttons slightly smaller (40pt) to accommodate prev button in layout
This commit is contained in:
Admin
2026-03-08 13:49:40 +05:00
parent 9a05708019
commit 7efeee3fc2

View File

@@ -8,155 +8,193 @@ struct MiniPlayerView: View {
@Binding var showFullPlayer: Bool @Binding var showFullPlayer: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService @EnvironmentObject var audioPlayer: AudioPlayerService
/// Live drag offset while the user is swiping up (negative = moving up). /// Live drag offset while the user is swiping up/down (negative = moving up).
@State private var dragOffset: CGFloat = 0 @State private var dragOffset: CGFloat = 0
/// Track horizontal drag for seek gesture
@State private var seekDragOffset: CGFloat = 0
@State private var isSeeking: Bool = false
var body: some View { var body: some View {
HStack(spacing: 16) { ZStack {
// Cover thumbnail with rounded corners // Interactive progress bar as background (full bleed behind content)
Button { showFullPlayer = true } label: { GeometryReader { geo in
AsyncCoverImage(url: audioPlayer.coverURL) ZStack(alignment: .leading) {
.frame(width: 56, height: 56) // Background track - full pill shape
.clipShape(RoundedRectangle(cornerRadius: 12)) RoundedRectangle(cornerRadius: 40)
.fill(Color.white.opacity(0.2))
// Progress fill - rounded to match pill shape
RoundedRectangle(cornerRadius: 40)
.fill(Color.amber.opacity(0.3))
.frame(width: max(0, geo.size.width * (isSeeking ? seekProgress : progress)))
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isSeeking = true
let ratio = max(0, min(1, value.location.x / geo.size.width))
seekDragOffset = ratio
}
.onEnded { value in
let ratio = max(0, min(1, value.location.x / geo.size.width))
let newTime = audioPlayer.duration * ratio
audioPlayer.seek(to: newTime)
isSeeking = false
seekDragOffset = 0
}
)
} }
.buttonStyle(.plain)
// Content layer
// Track info
VStack(alignment: .leading, spacing: 4) {
Text(chapterLabel)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.foregroundStyle(.primary)
Text(audioPlayer.bookTitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture { showFullPlayer = true }
Spacer(minLength: 8)
// Control buttons
HStack(spacing: 16) { HStack(spacing: 16) {
// Status indicator or play/pause control // Cover thumbnail with rounded corners
Group { Button { showFullPlayer = true } label: {
switch audioPlayer.status { AsyncCoverImage(url: audioPlayer.coverURL)
case .generating: .frame(width: 56, height: 56)
ProgressView() .clipShape(RoundedRectangle(cornerRadius: 12))
.tint(.white) }
.scaleEffect(1.0) .buttonStyle(.plain)
.frame(width: 44, height: 44)
case .ready: // Track info
Button { audioPlayer.togglePlayPause() } label: { VStack(alignment: .leading, spacing: 4) {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") Text(chapterLabel)
.font(.system(size: 24, weight: .semibold)) .font(.subheadline.weight(.semibold))
.lineLimit(1)
.foregroundStyle(.primary)
Text(audioPlayer.bookTitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture { showFullPlayer = true }
Spacer(minLength: 8)
// Control buttons - compact group
HStack(spacing: 12) {
// Previous chapter button
if audioPlayer.status == .ready {
Button {
if let prev = audioPlayer.prevChapter {
NotificationCenter.default.post(
name: .skipToPrevChapter,
object: nil,
userInfo: ["prev": prev]
)
}
} label: {
Image(systemName: "backward.end.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 44, height: 44) .frame(width: 40, height: 40)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
case .error: .disabled(audioPlayer.prevChapter == nil)
Image(systemName: "exclamationmark.circle.fill") .opacity(audioPlayer.prevChapter == nil ? 0.4 : 1.0)
.font(.system(size: 24))
.foregroundStyle(.red)
.frame(width: 44, height: 44)
default:
EmptyView()
} }
}
// Status indicator or play/pause control
// Skip forward button Group {
if audioPlayer.status == .ready { switch audioPlayer.status {
Button { case .generating:
if let next = audioPlayer.nextChapter { ProgressView()
// Navigate to next chapter .tint(.white)
NotificationCenter.default.post( .scaleEffect(1.0)
name: .skipToNextChapter, .frame(width: 44, height: 44)
object: nil, case .ready:
userInfo: ["next": next] Button { audioPlayer.togglePlayPause() } label: {
) Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 24))
.foregroundStyle(.red)
.frame(width: 44, height: 44)
default:
EmptyView()
} }
} label: { }
ZStack {
Image(systemName: "forward.end.fill") // Next chapter button
.font(.system(size: 24, weight: .semibold)) if audioPlayer.status == .ready {
.foregroundStyle(.white) Button {
if let next = audioPlayer.nextChapter {
// Show small loading indicator if next chapter is being prefetched NotificationCenter.default.post(
if audioPlayer.nextPrefetchStatus == .prefetching { name: .skipToNextChapter,
VStack { object: nil,
Spacer() userInfo: ["next": next]
HStack { )
}
} label: {
ZStack {
Image(systemName: "forward.end.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
// Show small loading indicator if next chapter is being prefetched
if audioPlayer.nextPrefetchStatus == .prefetching {
VStack {
Spacer() Spacer()
ProgressView() HStack {
.scaleEffect(0.5) Spacer()
.tint(.amber) ProgressView()
.padding(2) .scaleEffect(0.5)
.background(Circle().fill(.black.opacity(0.6))) .tint(.amber)
.padding(2)
.background(Circle().fill(.black.opacity(0.6)))
}
} }
} }
} }
.frame(width: 40, height: 40)
.contentShape(Rectangle())
} }
.frame(width: 44, height: 44) .buttonStyle(.plain)
.contentShape(Rectangle()) .disabled(audioPlayer.nextChapter == nil)
.opacity(audioPlayer.nextChapter == nil ? 0.4 : 1.0)
} }
.buttonStyle(.plain)
.disabled(audioPlayer.nextChapter == nil)
.opacity(audioPlayer.nextChapter == nil ? 0.4 : 1.0)
} }
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
} }
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background( .background(
ZStack { // Dark rounded background (pill-shaped with full circular ends)
// Dark rounded background (pill-shaped with full circular ends) RoundedRectangle(cornerRadius: 40)
RoundedRectangle(cornerRadius: 40) .fill(.ultraThinMaterial)
.fill(.ultraThinMaterial) .overlay(
.overlay( RoundedRectangle(cornerRadius: 40)
RoundedRectangle(cornerRadius: 40) .fill(Color.black.opacity(0.3))
.fill(Color.black.opacity(0.3)) )
)
// Progress indicator - wraps around bottom corners of pill
GeometryReader { geo in
VStack {
Spacer()
ZStack(alignment: .leading) {
// Background track - rounded to match pill shape
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: 3)
// Progress fill - rounded to match pill shape
Capsule()
.fill(Color.amber)
.frame(width: max(3, geo.size.width * progress), height: 3)
}
.padding(.horizontal, 4) // Inset slightly from edges so it wraps nicely
}
}
.padding(.bottom, 2)
}
) )
.frame(height: 80) .frame(height: 80)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.shadow(color: .black.opacity(0.3), radius: 12, y: 4) .shadow(color: .black.opacity(0.3), radius: 12, y: 4)
// Follow finger in both directions while dragging // Follow finger in both directions while dragging vertically
.offset(y: dragOffset) .offset(y: dragOffset)
// Visual feedback: fade out and scale down slightly when dragging down to dismiss // Visual feedback: fade out and scale down slightly when dragging down to dismiss
.opacity(dragOffset > 0 ? max(0.3, 1.0 - (dragOffset / 200)) : 1.0) .opacity(dragOffset > 0 ? max(0.3, 1.0 - (dragOffset / 200)) : 1.0)
.scaleEffect(dragOffset > 0 ? max(0.95, 1.0 - (dragOffset / 800)) : 1.0) .scaleEffect(dragOffset > 0 ? max(0.95, 1.0 - (dragOffset / 800)) : 1.0)
.gesture( .simultaneousGesture(
DragGesture(minimumDistance: 10, coordinateSpace: .local) DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in .onChanged { value in
if value.translation.height < 0 { // Only handle vertical drags (not horizontal seeks)
// Upward swipe: rubberband resistance (opens full player) if abs(value.translation.height) > abs(value.translation.width) {
dragOffset = value.translation.height * 0.4 if value.translation.height < 0 {
} else { // Upward swipe: rubberband resistance (opens full player)
// Downward swipe: less resistance for easier dismiss dragOffset = value.translation.height * 0.4
dragOffset = value.translation.height * 0.8 } else {
// Downward swipe: less resistance for easier dismiss
dragOffset = value.translation.height * 0.8
}
} }
} }
.onEnded { value in .onEnded { value in
@@ -190,6 +228,10 @@ struct MiniPlayerView: View {
guard audioPlayer.duration > 0 else { return 0 } guard audioPlayer.duration > 0 else { return 0 }
return CGFloat(audioPlayer.currentTime / audioPlayer.duration) return CGFloat(audioPlayer.currentTime / audioPlayer.duration)
} }
private var seekProgress: CGFloat {
return seekDragOffset
}
private var chapterLabel: String { private var chapterLabel: String {
let raw = audioPlayer.chapterTitle.isEmpty let raw = audioPlayer.chapterTitle.isEmpty