From 7efeee3fc21a1709e32c670ee63e477d6f02b991 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 8 Mar 2026 13:49:40 +0500 Subject: [PATCH] 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 --- .../LibNovel/Views/Player/PlayerViews.swift | 278 ++++++++++-------- 1 file changed, 160 insertions(+), 118 deletions(-) diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index 8b8516e..d948c06 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -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