From 466e289b686a3d329e32e231f081886d99a00613 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 8 Mar 2026 13:14:18 +0500 Subject: [PATCH] iOS: redesign mini player with pill shape, larger cover, and chapter skip button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../LibNovel/Extensions/String+App.swift | 4 +- .../LibNovel/Views/Player/PlayerViews.swift | 511 +++++++++++++----- 2 files changed, 382 insertions(+), 133 deletions(-) diff --git a/ios/LibNovel/LibNovel/Extensions/String+App.swift b/ios/LibNovel/LibNovel/Extensions/String+App.swift index 6bcb7a9..18b0352 100644 --- a/ios/LibNovel/LibNovel/Extensions/String+App.swift +++ b/ios/LibNovel/LibNovel/Extensions/String+App.swift @@ -35,6 +35,6 @@ extension String { enum AppLayout { /// Height of the persistent mini-player bar: - /// 2pt progress line + 20pt vertical padding + ~44pt content row. - static let miniPlayerBarHeight: CGFloat = 66 + /// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin. + static let miniPlayerBarHeight: CGFloat = 92 } diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index fdbd7c2..7e3754b 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -11,101 +11,157 @@ struct MiniPlayerView: View { @State private var dragOffset: CGFloat = 0 var body: some View { - VStack(spacing: 0) { - // Progress line — thin amber strip at the very top - GeometryReader { geo in - ZStack(alignment: .leading) { - Rectangle().fill(Color.white.opacity(0.08)).frame(height: 2) - Rectangle() - .fill(Color.amber) - .frame(width: geo.size.width * progress, height: 2) - } + 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)) } - .frame(height: 2) + .buttonStyle(.plain) - HStack(spacing: 14) { - // Cover thumbnail - Button { showFullPlayer = true } label: { - AsyncCoverImage(url: audioPlayer.coverURL) - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .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 } - // Track info - 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 } + Spacer(minLength: 8) - // Status indicator or playback control + // Control buttons + HStack(spacing: 16) { + // Status indicator or play/pause control Group { switch audioPlayer.status { case .generating: ProgressView() - .tint(.amber) - .scaleEffect(0.85) - .frame(width: 36, height: 36) + .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: 18, weight: .semibold)) - .foregroundStyle(.primary) - .frame(width: 36, height: 36) + .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: 36, height: 36) + .frame(width: 44, height: 44) default: EmptyView() } } - // Dismiss - Button { audioPlayer.stop() } label: { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.secondary) - .frame(width: 30, height: 30) + // 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] + ) + } + } 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) - .shadow(color: .black.opacity(0.15), radius: 6, y: -2) - // Follow finger upward while dragging, spring back or expand on release - .offset(y: min(dragOffset, 0)) + .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 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( DragGesture(minimumDistance: 10, coordinateSpace: .local) .onChanged { value in - // Only track upward swipes here; downward dismiss is on ended 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 } else { - dragOffset = value.translation.height * 0.15 + // Downward swipe: less resistance for easier dismiss + dragOffset = value.translation.height * 0.8 } } .onEnded { value in 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 { + // Swipe up: open full player + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + dragOffset = 0 + } showFullPlayer = true - } else if value.translation.height > 40 || velocity > 200 { - audioPlayer.stop() + } else if value.translation.height > 60 || velocity > 200 { + // 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 /// Called when the view wants to close itself (Done button or drag-to-dismiss). var onDismiss: () -> Void = {} + + @State private var isFavorited = false + @State private var isLiked = false + @State private var showingSpeedMenu = false var body: some View { ZStack { @@ -157,23 +217,32 @@ struct FullPlayerView: View { Spacer(minLength: 0) - // Cover art - KFImage(URL(string: audioPlayer.coverURL)) - .resizable() - .placeholder { - RoundedRectangle(cornerRadius: 18) - .fill(.white.opacity(0.1)) - .overlay( - Image(systemName: "book.closed") - .font(.system(size: 48)) - .foregroundStyle(.white.opacity(0.4)) - ) - } - .scaledToFill() - .frame(width: 260, height: 260) - .clipShape(RoundedRectangle(cornerRadius: 18)) - .shadow(color: .black.opacity(0.5), radius: 24, y: 12) - .padding(.horizontal, 48) + // Cover art with watermark + ZStack(alignment: .bottomLeading) { + KFImage(URL(string: audioPlayer.coverURL)) + .resizable() + .placeholder { + RoundedRectangle(cornerRadius: 18) + .fill(.white.opacity(0.1)) + .overlay( + Image(systemName: "book.closed") + .font(.system(size: 48)) + .foregroundStyle(.white.opacity(0.4)) + ) + } + .scaledToFill() + .frame(width: 260, height: 260) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .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) @@ -191,6 +260,103 @@ struct FullPlayerView: View { } .padding(.horizontal, 32) .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 VStack(spacing: 6) { @@ -204,7 +370,7 @@ struct FullPlayerView: View { HStack { Text(formatTime(audioPlayer.currentTime)) Spacer() - Text(formatTime(audioPlayer.duration)) + Text("-" + formatTime(audioPlayer.duration - audioPlayer.currentTime)) } .font(.caption.monospacedDigit()) .foregroundStyle(.white.opacity(0.5)) @@ -217,38 +383,73 @@ struct FullPlayerView: View { // ← skip back 15s Button { audioPlayer.skip(by: -15) } label: { Image(systemName: "gobackward.15") - .font(.system(size: 26, weight: .regular)) + .font(.system(size: 24, weight: .regular)) .foregroundStyle(.white.opacity(0.9)) .frame(maxWidth: .infinity) } .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 Button { audioPlayer.togglePlayPause() } label: { 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 { ProgressView() .tint(.white) - .scaleEffect(1.1) + .scaleEffect(1.3) } else { Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 28, weight: .semibold)) + .font(.system(size: 38, weight: .bold)) .foregroundStyle(.white) - .offset(x: audioPlayer.isPlaying ? 0 : 2) + .offset(x: audioPlayer.isPlaying ? 0 : 3) } } + .frame(maxWidth: .infinity) } .buttonStyle(.plain) .disabled(audioPlayer.status == .generating) - // → skip forward 30s - Button { audioPlayer.skip(by: 30) } label: { - Image(systemName: "goforward.30") - .font(.system(size: 26, weight: .regular)) + // → next chapter + Button { + if let next = audioPlayer.nextChapter { + 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)) .frame(maxWidth: .infinity) } @@ -257,8 +458,21 @@ struct FullPlayerView: View { .padding(.horizontal, 20) .padding(.top, 32) - // Speed + Auto-next - HStack(spacing: 20) { + Spacer(minLength: 24) + + // 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 { ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in Button { @@ -272,49 +486,56 @@ struct FullPlayerView: View { } } } label: { - Text("\(audioPlayer.speed, specifier: "%.2g")×") - .font(.subheadline.weight(.bold)) - .foregroundStyle(.white) - .padding(.horizontal, 16) - .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 - ) - ) + Image(systemName: "gearshape.fill") + .font(.system(size: 22)) + .foregroundStyle(.white.opacity(0.7)) + .frame(maxWidth: .infinity) } .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(.top, 28) - - Spacer(minLength: 32) + .padding(.horizontal, 16) + .padding(.bottom, 24) } } } @@ -324,6 +545,34 @@ struct FullPlayerView: View { let s = Int(seconds) 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