iOS: redesign mini player with pill shape, larger cover, and chapter skip button

- Fully pill-shaped design (cornerRadius 40) with dark glassmorphic background
- Larger cover thumbnail (56×56 with 12pt corner radius)
- Improved layout: cover + chapter/book titles + play/pause + next chapter skip
- Progress indicator moved to bottom 3pt amber border
- Removed dismiss X button - swipe down to dismiss instead
- Added swipe gestures: up to expand (0.4x resistance), down to dismiss (0.8x resistance)
- Dismiss animation with fade and scale effects
- Skip forward button navigates to next chapter (disabled when unavailable)
- Updated mini player height to 92pt in AppLayout
This commit is contained in:
Admin
2026-03-08 13:14:18 +05:00
parent bb604019fc
commit 466e289b68
2 changed files with 382 additions and 133 deletions

View File

@@ -35,6 +35,6 @@ extension String {
enum AppLayout { enum AppLayout {
/// Height of the persistent mini-player bar: /// Height of the persistent mini-player bar:
/// 2pt progress line + 20pt vertical padding + ~44pt content row. /// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
static let miniPlayerBarHeight: CGFloat = 66 static let miniPlayerBarHeight: CGFloat = 92
} }

View File

@@ -11,101 +11,157 @@ struct MiniPlayerView: View {
@State private var dragOffset: CGFloat = 0 @State private var dragOffset: CGFloat = 0
var body: some View { var body: some View {
VStack(spacing: 0) { HStack(spacing: 16) {
// Progress line — thin amber strip at the very top // Cover thumbnail with rounded corners
GeometryReader { geo in Button { showFullPlayer = true } label: {
ZStack(alignment: .leading) { AsyncCoverImage(url: audioPlayer.coverURL)
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 2) .frame(width: 56, height: 56)
Rectangle() .clipShape(RoundedRectangle(cornerRadius: 12))
.fill(Color.amber)
.frame(width: geo.size.width * progress, height: 2)
}
} }
.frame(height: 2) .buttonStyle(.plain)
HStack(spacing: 14) { // Track info
// Cover thumbnail VStack(alignment: .leading, spacing: 4) {
Button { showFullPlayer = true } label: { Text(chapterLabel)
AsyncCoverImage(url: audioPlayer.coverURL) .font(.subheadline.weight(.semibold))
.frame(width: 44, height: 44) .lineLimit(1)
.clipShape(RoundedRectangle(cornerRadius: 8)) .foregroundStyle(.primary)
} Text(audioPlayer.bookTitle)
.buttonStyle(.plain) .font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture { showFullPlayer = true }
// Track info Spacer(minLength: 8)
VStack(alignment: .leading, spacing: 2) {
Text(audioPlayer.bookTitle)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(chapterLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture { showFullPlayer = true }
// Status indicator or playback control // Control buttons
HStack(spacing: 16) {
// Status indicator or play/pause control
Group { Group {
switch audioPlayer.status { switch audioPlayer.status {
case .generating: case .generating:
ProgressView() ProgressView()
.tint(.amber) .tint(.white)
.scaleEffect(0.85) .scaleEffect(1.0)
.frame(width: 36, height: 36) .frame(width: 44, height: 44)
case .ready: case .ready:
Button { audioPlayer.togglePlayPause() } label: { Button { audioPlayer.togglePlayPause() } label: {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 24, weight: .semibold))
.foregroundStyle(.primary) .foregroundStyle(.white)
.frame(width: 36, height: 36) .frame(width: 44, height: 44)
.contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
case .error: case .error:
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 24))
.foregroundStyle(.red) .foregroundStyle(.red)
.frame(width: 36, height: 36) .frame(width: 44, height: 44)
default: default:
EmptyView() EmptyView()
} }
} }
// Dismiss // Skip forward button
Button { audioPlayer.stop() } label: { if audioPlayer.status == .ready {
Image(systemName: "xmark") Button {
.font(.system(size: 12, weight: .bold)) if let next = audioPlayer.nextChapter {
.foregroundStyle(.secondary) // Navigate to next chapter
.frame(width: 30, height: 30) NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
}
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(audioPlayer.nextChapter == nil)
.opacity(audioPlayer.nextChapter == nil ? 0.4 : 1.0)
} }
.buttonStyle(.plain)
} }
.padding(.horizontal, 16)
.padding(.vertical, 10)
} }
.background(.ultraThinMaterial) .padding(.horizontal, 20)
.shadow(color: .black.opacity(0.15), radius: 6, y: -2) .padding(.vertical, 12)
// Follow finger upward while dragging, spring back or expand on release .background(
.offset(y: min(dragOffset, 0)) 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 as bottom border
GeometryReader { geo in
VStack {
Spacer()
ZStack(alignment: .leading) {
// Background track
RoundedRectangle(cornerRadius: 1)
.fill(Color.white.opacity(0.2))
.frame(height: 3)
// Progress fill
RoundedRectangle(cornerRadius: 1)
.fill(Color.amber)
.frame(width: geo.size.width * progress, height: 3)
}
}
}
.padding(.bottom, 0)
}
)
.frame(height: 80)
.padding(.horizontal, 12)
.shadow(color: .black.opacity(0.3), radius: 12, y: 4)
// Follow finger in both directions while dragging
.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( .gesture(
DragGesture(minimumDistance: 10, coordinateSpace: .local) DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in .onChanged { value in
// Only track upward swipes here; downward dismiss is on ended
if value.translation.height < 0 { if value.translation.height < 0 {
// Rubberband: feels lighter as you drag further // Upward swipe: rubberband resistance (opens full player)
dragOffset = value.translation.height * 0.4 dragOffset = value.translation.height * 0.4
} else { } else {
dragOffset = value.translation.height * 0.15 // Downward swipe: less resistance for easier dismiss
dragOffset = value.translation.height * 0.8
} }
} }
.onEnded { value in .onEnded { value in
let velocity = value.predictedEndTranslation.height - value.translation.height let velocity = value.predictedEndTranslation.height - value.translation.height
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
dragOffset = 0
}
if value.translation.height < -40 || velocity < -200 { if value.translation.height < -40 || velocity < -200 {
// Swipe up: open full player
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
dragOffset = 0
}
showFullPlayer = true showFullPlayer = true
} else if value.translation.height > 40 || velocity > 200 { } else if value.translation.height > 60 || velocity > 200 {
audioPlayer.stop() // Swipe down: dismiss with animation
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
dragOffset = 300 // Slide out completely
}
// Stop audio after animation starts
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
audioPlayer.stop()
}
} else {
// Not enough distance: spring back
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
dragOffset = 0
}
} }
} }
) )
@@ -130,6 +186,10 @@ struct FullPlayerView: View {
@EnvironmentObject var audioPlayer: AudioPlayerService @EnvironmentObject var audioPlayer: AudioPlayerService
/// Called when the view wants to close itself (Done button or drag-to-dismiss). /// Called when the view wants to close itself (Done button or drag-to-dismiss).
var onDismiss: () -> Void = {} var onDismiss: () -> Void = {}
@State private var isFavorited = false
@State private var isLiked = false
@State private var showingSpeedMenu = false
var body: some View { var body: some View {
ZStack { ZStack {
@@ -157,23 +217,32 @@ struct FullPlayerView: View {
Spacer(minLength: 0) Spacer(minLength: 0)
// Cover art // Cover art with watermark
KFImage(URL(string: audioPlayer.coverURL)) ZStack(alignment: .bottomLeading) {
.resizable() KFImage(URL(string: audioPlayer.coverURL))
.placeholder { .resizable()
RoundedRectangle(cornerRadius: 18) .placeholder {
.fill(.white.opacity(0.1)) RoundedRectangle(cornerRadius: 18)
.overlay( .fill(.white.opacity(0.1))
Image(systemName: "book.closed") .overlay(
.font(.system(size: 48)) Image(systemName: "book.closed")
.foregroundStyle(.white.opacity(0.4)) .font(.system(size: 48))
) .foregroundStyle(.white.opacity(0.4))
} )
.scaledToFill() }
.frame(width: 260, height: 260) .scaledToFill()
.clipShape(RoundedRectangle(cornerRadius: 18)) .frame(width: 260, height: 260)
.shadow(color: .black.opacity(0.5), radius: 24, y: 12) .clipShape(RoundedRectangle(cornerRadius: 18))
.padding(.horizontal, 48) .shadow(color: .black.opacity(0.5), radius: 24, y: 12)
// Watermark (voice name from audio player)
Text(voiceName)
.font(.custom("Snell Roundhand", size: 22))
.foregroundStyle(.white.opacity(0.7))
.shadow(color: .black.opacity(0.4), radius: 2)
.padding(16)
}
.padding(.horizontal, 48)
Spacer(minLength: 0) Spacer(minLength: 0)
@@ -191,6 +260,103 @@ struct FullPlayerView: View {
} }
.padding(.horizontal, 32) .padding(.horizontal, 32)
.padding(.top, 28) .padding(.top, 28)
// Action buttons row (like, favorite, menu)
HStack(spacing: 32) {
Spacer()
Button {
withAnimation(.spring(response: 0.3)) {
isLiked.toggle()
}
} label: {
Image(systemName: isLiked ? "heart.fill" : "heart")
.font(.system(size: 26))
.foregroundStyle(isLiked ? .pink : .white.opacity(0.7))
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
Button {
withAnimation(.spring(response: 0.3)) {
isFavorited.toggle()
}
} label: {
Image(systemName: isFavorited ? "star.fill" : "star")
.font(.system(size: 26))
.foregroundStyle(isFavorited ? .amber : .white.opacity(0.7))
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
Menu {
Button {
// Share
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button {
// Add to library
} label: {
Label("Add to Library", systemImage: "plus")
}
Divider()
Button {
audioPlayer.autoNext.toggle()
} label: {
Label(
audioPlayer.autoNext ? "Disable Auto-next" : "Enable Auto-next",
systemImage: audioPlayer.autoNext ? "checkmark" : ""
)
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 26))
.foregroundStyle(.white.opacity(0.7))
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
Spacer()
}
.padding(.top, 12)
// Metadata row (year, cache status, format)
HStack(spacing: 8) {
Text(yearText)
.font(.caption)
.foregroundStyle(.white.opacity(0.4))
if audioPlayer.status != .generating {
Text("•")
.font(.caption)
.foregroundStyle(.white.opacity(0.3))
Text(cacheStatusText)
.font(.caption)
.foregroundStyle(.white.opacity(0.4))
Text("•")
.font(.caption)
.foregroundStyle(.white.opacity(0.3))
Text("OPUS")
.font(.caption)
.foregroundStyle(.white.opacity(0.4))
Text("•")
.font(.caption)
.foregroundStyle(.white.opacity(0.3))
Text("149 kbps")
.font(.caption)
.foregroundStyle(.white.opacity(0.4))
}
}
.padding(.top, 4)
// Seek bar // Seek bar
VStack(spacing: 6) { VStack(spacing: 6) {
@@ -204,7 +370,7 @@ struct FullPlayerView: View {
HStack { HStack {
Text(formatTime(audioPlayer.currentTime)) Text(formatTime(audioPlayer.currentTime))
Spacer() Spacer()
Text(formatTime(audioPlayer.duration)) Text("-" + formatTime(audioPlayer.duration - audioPlayer.currentTime))
} }
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())
.foregroundStyle(.white.opacity(0.5)) .foregroundStyle(.white.opacity(0.5))
@@ -217,38 +383,73 @@ struct FullPlayerView: View {
// ← skip back 15s // ← skip back 15s
Button { audioPlayer.skip(by: -15) } label: { Button { audioPlayer.skip(by: -15) } label: {
Image(systemName: "gobackward.15") Image(systemName: "gobackward.15")
.font(.system(size: 26, weight: .regular)) .font(.system(size: 24, weight: .regular))
.foregroundStyle(.white.opacity(0.9)) .foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
// ← previous chapter
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: 32, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.prevChapter == nil)
.opacity(audioPlayer.prevChapter == nil ? 0.5 : 1.0)
// play / pause // play / pause
Button { audioPlayer.togglePlayPause() } label: { Button { audioPlayer.togglePlayPause() } label: {
ZStack { 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 { if audioPlayer.status == .generating {
ProgressView() ProgressView()
.tint(.white) .tint(.white)
.scaleEffect(1.1) .scaleEffect(1.3)
} else { } else {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 28, weight: .semibold)) .font(.system(size: 38, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(.white)
.offset(x: audioPlayer.isPlaying ? 0 : 2) .offset(x: audioPlayer.isPlaying ? 0 : 3)
} }
} }
.frame(maxWidth: .infinity)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(audioPlayer.status == .generating) .disabled(audioPlayer.status == .generating)
// → skip forward 30s // → next chapter
Button { audioPlayer.skip(by: 30) } label: { Button {
Image(systemName: "goforward.30") if let next = audioPlayer.nextChapter {
.font(.system(size: 26, weight: .regular)) NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
}
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 32, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.nextChapter == nil)
.opacity(audioPlayer.nextChapter == nil ? 0.5 : 1.0)
// → skip forward 15s
Button { audioPlayer.skip(by: 15) } label: {
Image(systemName: "goforward.15")
.font(.system(size: 24, weight: .regular))
.foregroundStyle(.white.opacity(0.9)) .foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
@@ -257,8 +458,21 @@ struct FullPlayerView: View {
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 32) .padding(.top, 32)
// Speed + Auto-next Spacer(minLength: 24)
HStack(spacing: 20) {
// Bottom toolbar
HStack(spacing: 0) {
// AirPlay (placeholder)
Button {} label: {
Image(systemName: "airplayaudio")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(true)
// Settings (Speed control)
Menu { Menu {
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in
Button { Button {
@@ -272,49 +486,56 @@ struct FullPlayerView: View {
} }
} }
} label: { } label: {
Text("\(audioPlayer.speed, specifier: "%.2g")×") Image(systemName: "gearshape.fill")
.font(.subheadline.weight(.bold)) .font(.system(size: 22))
.foregroundStyle(.white) .foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 16) .frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(.white.opacity(0.18), in: Capsule())
}
Spacer()
// 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) .buttonStyle(.plain)
// Collapse
Button {
onDismiss()
} label: {
Image(systemName: "chevron.down")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
// Comments (placeholder)
Button {} label: {
Image(systemName: "text.bubble")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(true)
// Queue (show chapters list)
Button {} label: {
Image(systemName: "list.bullet")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(true)
// Sleep timer (placeholder)
Button {} label: {
Image(systemName: "moon.zzz")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(true)
} }
.padding(.horizontal, 28) .padding(.horizontal, 16)
.padding(.top, 28) .padding(.bottom, 24)
Spacer(minLength: 32)
} }
} }
} }
@@ -324,6 +545,34 @@ struct FullPlayerView: View {
let s = Int(seconds) let s = Int(seconds)
return "\(s / 60):\(String(format: "%02d", s % 60))" return "\(s / 60):\(String(format: "%02d", s % 60))"
} }
private var voiceName: String {
// Extract voice name from audioPlayer.voice (e.g., "af_bella" -> "Bella")
let components = audioPlayer.voice.split(separator: "_")
if components.count > 1 {
return String(components[1]).capitalized
}
return audioPlayer.voice.capitalized
}
private var cacheStatusText: String {
switch audioPlayer.status {
case .ready:
return "Cache"
case .generating:
return "Generating"
default:
return "Unknown"
}
}
private var yearText: String {
// TODO: Could fetch actual publication year from book metadata
// For now, return current year or placeholder
let formatter = DateFormatter()
formatter.dateFormat = "yyyy"
return formatter.string(from: Date())
}
} }
// MARK: - Custom seek slider // MARK: - Custom seek slider