diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift index 37143d0..5dea898 100644 --- a/ios/LibNovel/LibNovel/App/RootTabView.swift +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -23,19 +23,19 @@ struct RootTabView: View { ZStack(alignment: .bottom) { TabView(selection: $selectedTab) { HomeView() - .tabItem { Label("Home", systemImage: "house.fill") } + .tabItem { Label("Home", systemImage: "house") } .tag(Tab.home) LibraryView() - .tabItem { Label("Library", systemImage: "books.vertical.fill") } + .tabItem { Label("Library", systemImage: "books.vertical") } .tag(Tab.library) BrowseView() - .tabItem { Label("Discover", systemImage: "globe") } + .tabItem { Label("Discover", systemImage: "sparkles") } .tag(Tab.browse) ProfileView() - .tabItem { Label("Profile", systemImage: "person.fill") } + .tabItem { Label("Profile", systemImage: "person.crop.circle") } .tag(Tab.profile) } // Reserve space for the mini-player above the tab bar so scroll content @@ -49,7 +49,7 @@ struct RootTabView: View { // Mini-player pinned above the tab bar (hidden while full player is open) if audioPlayer.isActive && !showFullPlayer { MiniPlayerView(showFullPlayer: $showFullPlayer) - .padding(.bottom, tabBarHeight) + .padding(.bottom, max(tabBarHeight - 6, 0)) .transition(.move(edge: .bottom).combined(with: .opacity)) .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) } diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index fdbd7c2..12a3c3c 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -7,28 +7,30 @@ struct MiniPlayerView: View { @Binding var showFullPlayer: Bool @EnvironmentObject var audioPlayer: AudioPlayerService + private let capsuleRadius: CGFloat = 24 + /// Live drag offset while the user is swiping up (negative = moving up). @State private var dragOffset: CGFloat = 0 var body: some View { - VStack(spacing: 0) { + VStack(spacing: 6) { // 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() + Capsule().fill(Color.white.opacity(0.12)).frame(height: 2) + Capsule() .fill(Color.amber) .frame(width: geo.size.width * progress, height: 2) } } .frame(height: 2) - HStack(spacing: 14) { + HStack(spacing: 12) { // Cover thumbnail Button { showFullPlayer = true } label: { AsyncCoverImage(url: audioPlayer.coverURL) - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 46, height: 46) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } .buttonStyle(.plain) @@ -39,7 +41,7 @@ struct MiniPlayerView: View { .lineLimit(1) Text(chapterLabel) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(.white.opacity(0.65)) .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) @@ -52,19 +54,20 @@ struct MiniPlayerView: View { ProgressView() .tint(.amber) .scaleEffect(0.85) - .frame(width: 36, height: 36) + .frame(width: 34, height: 34) 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) + Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .background(.white.opacity(0.12), in: Circle()) } .buttonStyle(.plain) case .error: Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.red) - .frame(width: 36, height: 36) + .frame(width: 34, height: 34) default: EmptyView() } @@ -73,17 +76,34 @@ struct MiniPlayerView: View { // Dismiss Button { audioPlayer.stop() } label: { Image(systemName: "xmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(.secondary) - .frame(width: 30, height: 30) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 28, height: 28) + .background(.white.opacity(0.12), in: Circle()) } .buttonStyle(.plain) } .padding(.horizontal, 16) - .padding(.vertical, 10) + .padding(.vertical, 6) } - .background(.ultraThinMaterial) - .shadow(color: .black.opacity(0.15), radius: 6, y: -2) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(maxWidth: 520) + .background( + RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous) + .fill(.ultraThinMaterial) + .overlay(Color.black.opacity(0.18)) + ) + .overlay( + RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .overlay( + RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous) + .stroke(Color.white.opacity(0.06), lineWidth: 1) + .blur(radius: 0.8) + ) + .shadow(color: .black.opacity(0.25), radius: 16, y: 8) // Follow finger upward while dragging, spring back or expand on release .offset(y: min(dragOffset, 0)) .gesture( @@ -131,69 +151,82 @@ struct FullPlayerView: View { /// Called when the view wants to close itself (Done button or drag-to-dismiss). var onDismiss: () -> Void = {} + @State private var isShuffled = false + @State private var isRepeating = false + var body: some View { ZStack { - // ── Background: blurred cover art ────────────────────────────── - GeometryReader { geo in - KFImage(URL(string: audioPlayer.coverURL)) - .resizable() - .scaledToFill() - .frame(width: geo.size.width, height: geo.size.height) - .clipped() - .blur(radius: 40, opaque: true) - .overlay(Color.black.opacity(0.55)) - .ignoresSafeArea() - } + LinearGradient( + colors: [ + Color(red: 73 / 255, green: 55 / 255, blue: 102 / 255), + Color(red: 34 / 255, green: 24 / 255, blue: 49 / 255) + ], + startPoint: .top, + endPoint: .bottom + ) .ignoresSafeArea() - // ── Content ──────────────────────────────────────────────────── VStack(spacing: 0) { // Drag handle pill — visual cue that you can swipe down to close Capsule() .fill(Color.white.opacity(0.35)) - .frame(width: 36, height: 4) + .frame(width: 46, height: 5) .padding(.top, 12) - .padding(.bottom, 8) - - Spacer(minLength: 0) + .padding(.bottom, 14) // Cover art KFImage(URL(string: audioPlayer.coverURL)) .resizable() .placeholder { - RoundedRectangle(cornerRadius: 18) - .fill(.white.opacity(0.1)) + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(.white.opacity(0.12)) .overlay( - Image(systemName: "book.closed") - .font(.system(size: 48)) - .foregroundStyle(.white.opacity(0.4)) + Image(systemName: "music.note") + .font(.system(size: 52)) + .foregroundStyle(.white.opacity(0.35)) ) } .scaledToFill() - .frame(width: 260, height: 260) - .clipShape(RoundedRectangle(cornerRadius: 18)) - .shadow(color: .black.opacity(0.5), radius: 24, y: 12) - .padding(.horizontal, 48) - - Spacer(minLength: 0) + .frame(width: 310, height: 310) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .shadow(color: .black.opacity(0.45), radius: 26, y: 16) + .padding(.horizontal, 32) // Title block - VStack(spacing: 6) { + VStack(spacing: 8) { Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate()) .font(.title3.weight(.bold)) .foregroundStyle(.white) .multilineTextAlignment(.center) .lineLimit(2) Text(audioPlayer.bookTitle) - .font(.subheadline) - .foregroundStyle(.white.opacity(0.65)) + .font(.title3.weight(.regular)) + .foregroundStyle(.white.opacity(0.7)) .lineLimit(1) } .padding(.horizontal, 32) - .padding(.top, 28) + .padding(.top, 22) + + // Actions row + HStack(spacing: 22) { + Button {} label: { + Image(systemName: "heart") + .font(.system(size: 20, weight: .regular)) + } + Button {} label: { + Image(systemName: "star") + .font(.system(size: 20, weight: .regular)) + } + Button {} label: { + Image(systemName: "ellipsis") + .font(.system(size: 20, weight: .semibold)) + } + } + .foregroundStyle(.white.opacity(0.9)) + .padding(.top, 18) // Seek bar - VStack(spacing: 6) { + VStack(spacing: 8) { PlayerSlider( value: Binding( get: { audioPlayer.currentTime }, @@ -204,40 +237,41 @@ struct FullPlayerView: View { HStack { Text(formatTime(audioPlayer.currentTime)) Spacer() - Text(formatTime(audioPlayer.duration)) + Text("-\(formatTime(max(audioPlayer.duration - audioPlayer.currentTime, 0)))") } .font(.caption.monospacedDigit()) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(.white.opacity(0.65)) } .padding(.horizontal, 28) - .padding(.top, 24) + .padding(.top, 18) // Controls - HStack(spacing: 0) { - // ← skip back 15s + HStack(spacing: 26) { + Button { isShuffled.toggle() } label: { + Image(systemName: "shuffle") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(isShuffled ? .white : .white.opacity(0.45)) + } + Button { audioPlayer.skip(by: -15) } label: { Image(systemName: "gobackward.15") - .font(.system(size: 26, weight: .regular)) - .foregroundStyle(.white.opacity(0.9)) - .frame(maxWidth: .infinity) + .font(.system(size: 24, weight: .regular)) + .foregroundStyle(.white) } - .buttonStyle(.plain) - // play / pause Button { audioPlayer.togglePlayPause() } label: { ZStack { Circle() - .fill(Color.amber) + .fill(Color.white) .frame(width: 72, height: 72) - .shadow(color: Color.amber.opacity(0.45), radius: 14, y: 4) + .shadow(color: .black.opacity(0.3), radius: 10, y: 6) if audioPlayer.status == .generating { ProgressView() - .tint(.white) - .scaleEffect(1.1) + .tint(Color.black.opacity(0.7)) } else { Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 28, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(Color.black.opacity(0.85)) .offset(x: audioPlayer.isPlaying ? 0 : 2) } } @@ -245,76 +279,35 @@ struct FullPlayerView: View { .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)) - .foregroundStyle(.white.opacity(0.9)) - .frame(maxWidth: .infinity) + .font(.system(size: 24, weight: .regular)) + .foregroundStyle(.white) + } + + Button { isRepeating.toggle() } label: { + Image(systemName: "repeat") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(isRepeating ? .white : .white.opacity(0.45)) } - .buttonStyle(.plain) } .padding(.horizontal, 20) - .padding(.top, 32) + .padding(.top, 18) - // Speed + Auto-next - HStack(spacing: 20) { - Menu { - ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in - Button { - audioPlayer.setSpeed(s) - } label: { - if s == audioPlayer.speed { - Label("\(s, specifier: "%.2g")×", systemImage: "checkmark") - } else { - Text("\(s, specifier: "%.2g")×") - } - } - } - } 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 - ) - ) - } - .buttonStyle(.plain) + // Utility row + HStack(spacing: 30) { + Button {} label: { Image(systemName: "airplayaudio") } + Button {} label: { Image(systemName: "gearshape") } + Button { onDismiss() } label: { Image(systemName: "chevron.down") } + Button {} label: { Image(systemName: "quote.bubble") } + Button {} label: { Image(systemName: "list.bullet") } + Button {} label: { Image(systemName: "moon.zzz") } } - .padding(.horizontal, 28) - .padding(.top, 28) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white.opacity(0.75)) + .padding(.top, 24) - Spacer(minLength: 32) + Spacer(minLength: 26) } } }