From db06e3ecdc64734c55ecfaeb8fb50492c3bf05b6 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 8 Mar 2026 00:09:24 +0500 Subject: [PATCH] Enhance iOS player controls and layout --- ios/LibNovel/LibNovel/App/RootTabView.swift | 3 +- .../LibNovel/Extensions/String+App.swift | 2 +- .../Services/AudioPlayerService.swift | 85 +++++++++- .../LibNovel/Views/Player/PlayerViews.swift | 153 +++++++++++++++--- 4 files changed, 213 insertions(+), 30 deletions(-) diff --git a/ios/LibNovel/LibNovel/App/RootTabView.swift b/ios/LibNovel/LibNovel/App/RootTabView.swift index 5dea898..9fda84e 100644 --- a/ios/LibNovel/LibNovel/App/RootTabView.swift +++ b/ios/LibNovel/LibNovel/App/RootTabView.swift @@ -49,7 +49,8 @@ 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, max(tabBarHeight - 6, 0)) + .padding(.horizontal, 12) + .padding(.bottom, max(tabBarHeight - 2, 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/Extensions/String+App.swift b/ios/LibNovel/LibNovel/Extensions/String+App.swift index 6bcb7a9..49b0c82 100644 --- a/ios/LibNovel/LibNovel/Extensions/String+App.swift +++ b/ios/LibNovel/LibNovel/Extensions/String+App.swift @@ -36,5 +36,5 @@ 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 + static let miniPlayerBarHeight: CGFloat = 56 } diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift index df34be2..968a9bb 100644 --- a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -34,6 +34,8 @@ final class AudioPlayerService: ObservableObject { @Published var autoNext: Bool = false @Published var nextChapter: Int? = nil + @Published var sleepTimerOption: SleepTimerOption = .off + @Published var nextPrefetchStatus: NextPrefetchStatus = .none @Published var nextAudioURL: String = "" @Published var nextPrefetchedChapter: Int? = nil @@ -56,6 +58,9 @@ final class AudioPlayerService: ObservableObject { private var generationTask: Task? private var prefetchTask: Task? + private var sleepTimer: Timer? + private var sleepRemainingChapters: Int? + // Cached cover image — downloaded once per chapter load, reused on every // updateNowPlaying() call so we don't re-download on every play/pause/seek. private var cachedCoverArtwork: MPMediaItemArtwork? @@ -148,6 +153,49 @@ final class AudioPlayerService: ObservableObject { duration = 0 audioURL = "" status = .idle + clearSleepTimerIfNeeded() + } + + func playChapter(number: Int) { + guard !slug.isEmpty else { return } + let title = chapters.first(where: { $0.number == number })?.title ?? "" + let next = chapters.first(where: { $0.number > number })?.number + load( + slug: slug, + chapter: number, + chapterTitle: title, + bookTitle: bookTitle, + coverURL: coverURL, + voice: voice, + speed: speed, + chapters: chapters, + nextChapter: next + ) + } + + func setSleepTimer(_ option: SleepTimerOption) { + sleepTimer?.invalidate() + sleepTimer = nil + sleepRemainingChapters = nil + sleepTimerOption = option + + switch option { + case .off: + break + case .endOfChapter: + sleepRemainingChapters = 1 + case .chapters(let count): + sleepRemainingChapters = max(1, count) + case .minutes(let minutes): + let seconds = max(60, minutes * 60) + sleepTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(seconds), repeats: false) { [weak self] _ in + Task { @MainActor in + self?.stop() + self?.sleepTimerOption = .off + self?.sleepRemainingChapters = nil + } + } + } } // MARK: - Audio generation @@ -309,22 +357,48 @@ final class AudioPlayerService: ObservableObject { playerItem = nil } + private func clearSleepTimerIfNeeded() { + sleepTimer?.invalidate() + sleepTimer = nil + sleepRemainingChapters = nil + sleepTimerOption = .off + } + private func handlePlaybackFinished() { isPlaying = false + var shouldAutoNext = autoNext + var didTriggerSleepStop = false + if let remaining = sleepRemainingChapters { + let updated = remaining - 1 + if updated <= 0 { + sleepRemainingChapters = nil + sleepTimerOption = .off + didTriggerSleepStop = true + shouldAutoNext = false + } else { + sleepRemainingChapters = updated + } + } + + if didTriggerSleepStop { + stop() + return + } + guard let next = nextChapter else { return } // Always notify the view that the chapter finished (it may update UI). NotificationCenter.default.post( name: .audioDidFinishChapter, object: nil, - userInfo: ["next": next, "autoNext": autoNext] + userInfo: ["next": next, "autoNext": shouldAutoNext] ) // If autoNext is on, load the next chapter internally right away. // We already have the metadata in `chapters`, so we can reconstruct // everything without waiting for the view to navigate. - guard autoNext else { return } + guard shouldAutoNext else { return } let nextTitle = chapters.first(where: { $0.number == next })?.title ?? "" let nextNextChapter = chapters.first(where: { $0.number > next })?.number @@ -460,6 +534,13 @@ enum AudioPlayerStatus: Equatable { } } +enum SleepTimerOption: Equatable { + case off + case endOfChapter + case chapters(Int) + case minutes(Int) +} + extension Notification.Name { static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter") } diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index 12a3c3c..536bc7f 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -1,5 +1,6 @@ import SwiftUI import Kingfisher // used directly for blurred background in FullPlayerView +import AVKit // MARK: - Mini player bar (pinned above tab bar) @@ -7,13 +8,13 @@ struct MiniPlayerView: View { @Binding var showFullPlayer: Bool @EnvironmentObject var audioPlayer: AudioPlayerService - private let capsuleRadius: CGFloat = 24 + private let capsuleRadius: CGFloat = 22 /// Live drag offset while the user is swiping up (negative = moving up). @State private var dragOffset: CGFloat = 0 var body: some View { - VStack(spacing: 6) { + VStack(spacing: 4) { // Progress line — thin amber strip at the very top GeometryReader { geo in ZStack(alignment: .leading) { @@ -29,8 +30,8 @@ struct MiniPlayerView: View { // Cover thumbnail Button { showFullPlayer = true } label: { AsyncCoverImage(url: audioPlayer.coverURL) - .frame(width: 46, height: 46) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) } .buttonStyle(.plain) @@ -53,21 +54,21 @@ struct MiniPlayerView: View { case .generating: ProgressView() .tint(.amber) - .scaleEffect(0.85) - .frame(width: 34, height: 34) + .scaleEffect(0.8) + .frame(width: 32, height: 32) case .ready: Button { audioPlayer.togglePlayPause() } label: { Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill") - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) - .frame(width: 34, height: 34) + .frame(width: 32, height: 32) .background(.white.opacity(0.12), in: Circle()) } .buttonStyle(.plain) case .error: Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.red) - .frame(width: 34, height: 34) + .frame(width: 32, height: 32) default: EmptyView() } @@ -78,17 +79,17 @@ struct MiniPlayerView: View { Image(systemName: "xmark") .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white.opacity(0.7)) - .frame(width: 28, height: 28) + .frame(width: 26, height: 26) .background(.white.opacity(0.12), in: Circle()) } .buttonStyle(.plain) } .padding(.horizontal, 16) - .padding(.vertical, 6) + .padding(.vertical, 4) } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .frame(maxWidth: 520) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .frame(maxWidth: .infinity) .background( RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous) .fill(.ultraThinMaterial) @@ -153,18 +154,30 @@ struct FullPlayerView: View { @State private var isShuffled = false @State private var isRepeating = false + @State private var showChapterList = false var body: some View { ZStack { - 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() + // Dynamic background derived from cover art colors. + ZStack { + KFImage(URL(string: audioPlayer.coverURL)) + .resizable() + .scaledToFill() + .blur(radius: 60) + .saturation(1.4) + .overlay(Color.black.opacity(0.35)) + .ignoresSafeArea() + + LinearGradient( + colors: [ + Color.black.opacity(0.05), + Color.black.opacity(0.55) + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + } VStack(spacing: 0) { // Drag handle pill — visual cue that you can swipe down to close @@ -296,12 +309,26 @@ struct FullPlayerView: View { // Utility row HStack(spacing: 30) { - Button {} label: { Image(systemName: "airplayaudio") } + AirPlayRoutePicker() + .frame(width: 24, height: 24) 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") } + Button { showChapterList = true } label: { Image(systemName: "list.bullet") } + Menu { + sleepTimerButton(label: "Off", option: .off) + sleepTimerButton(label: "End of Chapter", option: .endOfChapter) + sleepTimerButton(label: "2 chapters", option: .chapters(2)) + sleepTimerButton(label: "3 chapters", option: .chapters(3)) + sleepTimerButton(label: "4 chapters", option: .chapters(4)) + sleepTimerButton(label: "5 chapters", option: .chapters(5)) + Divider() + sleepTimerButton(label: "20 mins", option: .minutes(20)) + sleepTimerButton(label: "30 mins", option: .minutes(30)) + sleepTimerButton(label: "1 hour", option: .minutes(60)) + } label: { + Image(systemName: "moon.zzz") + } } .font(.system(size: 18, weight: .semibold)) .foregroundStyle(.white.opacity(0.75)) @@ -310,6 +337,26 @@ struct FullPlayerView: View { Spacer(minLength: 26) } } + .sheet(isPresented: $showChapterList) { + ChapterListSheet( + chapters: audioPlayer.chapters, + current: audioPlayer.chapter, + onSelect: { chapter in + audioPlayer.playChapter(number: chapter) + } + ) + } + } + + @ViewBuilder + private func sleepTimerButton(label: String, option: SleepTimerOption) -> some View { + if audioPlayer.sleepTimerOption == option { + Button { audioPlayer.setSleepTimer(option) } label: { + Label(label, systemImage: "checkmark") + } + } else { + Button(label) { audioPlayer.setSleepTimer(option) } + } } private func formatTime(_ seconds: Double) -> String { @@ -374,3 +421,57 @@ struct PlayerSlider: View { .frame(height: 28) } } + +struct AirPlayRoutePicker: UIViewRepresentable { + func makeUIView(context: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + view.prioritizesVideoDevices = false + view.activeTintColor = UIColor.white + view.tintColor = UIColor.white.withAlphaComponent(0.7) + return view + } + + func updateUIView(_ uiView: AVRoutePickerView, context: Context) {} +} + +struct ChapterListSheet: View { + let chapters: [ChapterIndexBrief] + let current: Int + let onSelect: (Int) -> Void + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + if chapters.isEmpty { + Text("No chapters available") + .foregroundStyle(.secondary) + } else { + ForEach(chapters, id: \.self) { chapter in + Button { + onSelect(chapter.number) + dismiss() + } label: { + HStack(spacing: 12) { + Text("Ch. \(chapter.number)") + .font(.subheadline.weight(.semibold)) + Text(chapter.title.strippingTrailingDate()) + .font(.subheadline) + .lineLimit(1) + Spacer() + if chapter.number == current { + Image(systemName: "speaker.wave.2.fill") + .foregroundStyle(.amber) + } + } + } + .buttonStyle(.plain) + } + } + } + .navigationTitle("Chapters") + .navigationBarTitleDisplayMode(.inline) + } + } +}