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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user