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