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
@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
/// Track horizontal drag for seek gesture
@State private var seekDragOffset: CGFloat = 0
@State private var isSeeking: Bool = false
var body: some View {
HStack(spacing: 16) {
// Cover thumbnail with rounded corners
Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 12))
ZStack {
// Interactive progress bar as background (full bleed behind content)
GeometryReader { geo in
ZStack(alignment: .leading) {
// Background track - full pill shape
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)
// 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
// Content layer
HStack(spacing: 16) {
// Status indicator or play/pause control
Group {
switch audioPlayer.status {
case .generating:
ProgressView()
.tint(.white)
.scaleEffect(1.0)
.frame(width: 44, height: 44)
case .ready:
Button { audioPlayer.togglePlayPause() } label: {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24, weight: .semibold))
// Cover thumbnail with rounded corners
Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
// 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 - 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)
.frame(width: 44, height: 44)
.frame(width: 40, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 24))
.foregroundStyle(.red)
.frame(width: 44, height: 44)
default:
EmptyView()
.disabled(audioPlayer.prevChapter == nil)
.opacity(audioPlayer.prevChapter == nil ? 0.4 : 1.0)
}
}
// Skip forward button
if audioPlayer.status == .ready {
Button {
if let next = audioPlayer.nextChapter {
// Navigate to next chapter
NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
// Status indicator or play/pause control
Group {
switch audioPlayer.status {
case .generating:
ProgressView()
.tint(.white)
.scaleEffect(1.0)
.frame(width: 44, height: 44)
case .ready:
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")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
// Show small loading indicator if next chapter is being prefetched
if audioPlayer.nextPrefetchStatus == .prefetching {
VStack {
Spacer()
HStack {
}
// Next chapter button
if audioPlayer.status == .ready {
Button {
if let next = audioPlayer.nextChapter {
NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
}
} 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()
ProgressView()
.scaleEffect(0.5)
.tint(.amber)
.padding(2)
.background(Circle().fill(.black.opacity(0.6)))
HStack {
Spacer()
ProgressView()
.scaleEffect(0.5)
.tint(.amber)
.padding(2)
.background(Circle().fill(.black.opacity(0.6)))
}
}
}
}
.frame(width: 40, height: 40)
.contentShape(Rectangle())
}
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.buttonStyle(.plain)
.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(
ZStack {
// Dark rounded background (pill-shaped with full circular ends)
RoundedRectangle(cornerRadius: 40)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 40)
.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)
}
// Dark rounded background (pill-shaped with full circular ends)
RoundedRectangle(cornerRadius: 40)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 40)
.fill(Color.black.opacity(0.3))
)
)
.frame(height: 80)
.padding(.horizontal, 12)
.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)
// 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)
.scaleEffect(dragOffset > 0 ? max(0.95, 1.0 - (dragOffset / 800)) : 1.0)
.gesture(
.simultaneousGesture(
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in
if value.translation.height < 0 {
// Upward swipe: rubberband resistance (opens full player)
dragOffset = value.translation.height * 0.4
} else {
// Downward swipe: less resistance for easier dismiss
dragOffset = value.translation.height * 0.8
// Only handle vertical drags (not horizontal seeks)
if abs(value.translation.height) > abs(value.translation.width) {
if value.translation.height < 0 {
// Upward swipe: rubberband resistance (opens full player)
dragOffset = value.translation.height * 0.4
} else {
// Downward swipe: less resistance for easier dismiss
dragOffset = value.translation.height * 0.8
}
}
}
.onEnded { value in
@@ -190,6 +228,10 @@ struct MiniPlayerView: View {
guard audioPlayer.duration > 0 else { return 0 }
return CGFloat(audioPlayer.currentTime / audioPlayer.duration)
}
private var seekProgress: CGFloat {
return seekDragOffset
}
private var chapterLabel: String {
let raw = audioPlayer.chapterTitle.isEmpty