Enhance iOS player controls and layout
Some checks failed
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 9s
CI / Scraper / Test (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped

This commit is contained in:
Admin
2026-03-08 00:09:24 +05:00
parent 3d9d8ab365
commit db06e3ecdc
4 changed files with 213 additions and 30 deletions

View File

@@ -49,7 +49,8 @@ struct RootTabView: View {
// Mini-player pinned above the tab bar (hidden while full player is open) // Mini-player pinned above the tab bar (hidden while full player is open)
if audioPlayer.isActive && !showFullPlayer { if audioPlayer.isActive && !showFullPlayer {
MiniPlayerView(showFullPlayer: $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)) .transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
} }

View File

@@ -36,5 +36,5 @@ 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. /// 2pt progress line + 20pt vertical padding + ~44pt content row.
static let miniPlayerBarHeight: CGFloat = 66 static let miniPlayerBarHeight: CGFloat = 56
} }

View File

@@ -34,6 +34,8 @@ final class AudioPlayerService: ObservableObject {
@Published var autoNext: Bool = false @Published var autoNext: Bool = false
@Published var nextChapter: Int? = nil @Published var nextChapter: Int? = nil
@Published var sleepTimerOption: SleepTimerOption = .off
@Published var nextPrefetchStatus: NextPrefetchStatus = .none @Published var nextPrefetchStatus: NextPrefetchStatus = .none
@Published var nextAudioURL: String = "" @Published var nextAudioURL: String = ""
@Published var nextPrefetchedChapter: Int? = nil @Published var nextPrefetchedChapter: Int? = nil
@@ -56,6 +58,9 @@ final class AudioPlayerService: ObservableObject {
private var generationTask: Task<Void, Never>? private var generationTask: Task<Void, Never>?
private var prefetchTask: Task<Void, Never>? private var prefetchTask: Task<Void, Never>?
private var sleepTimer: Timer?
private var sleepRemainingChapters: Int?
// Cached cover image downloaded once per chapter load, reused on every // Cached cover image downloaded once per chapter load, reused on every
// updateNowPlaying() call so we don't re-download on every play/pause/seek. // updateNowPlaying() call so we don't re-download on every play/pause/seek.
private var cachedCoverArtwork: MPMediaItemArtwork? private var cachedCoverArtwork: MPMediaItemArtwork?
@@ -148,6 +153,49 @@ final class AudioPlayerService: ObservableObject {
duration = 0 duration = 0
audioURL = "" audioURL = ""
status = .idle 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 // MARK: - Audio generation
@@ -309,22 +357,48 @@ final class AudioPlayerService: ObservableObject {
playerItem = nil playerItem = nil
} }
private func clearSleepTimerIfNeeded() {
sleepTimer?.invalidate()
sleepTimer = nil
sleepRemainingChapters = nil
sleepTimerOption = .off
}
private func handlePlaybackFinished() { private func handlePlaybackFinished() {
isPlaying = false 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 } guard let next = nextChapter else { return }
// Always notify the view that the chapter finished (it may update UI). // Always notify the view that the chapter finished (it may update UI).
NotificationCenter.default.post( NotificationCenter.default.post(
name: .audioDidFinishChapter, name: .audioDidFinishChapter,
object: nil, object: nil,
userInfo: ["next": next, "autoNext": autoNext] userInfo: ["next": next, "autoNext": shouldAutoNext]
) )
// If autoNext is on, load the next chapter internally right away. // If autoNext is on, load the next chapter internally right away.
// We already have the metadata in `chapters`, so we can reconstruct // We already have the metadata in `chapters`, so we can reconstruct
// everything without waiting for the view to navigate. // 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 nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
let nextNextChapter = chapters.first(where: { $0.number > next })?.number 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 { extension Notification.Name {
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter") static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
} }

View File

@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import Kingfisher // used directly for blurred background in FullPlayerView import Kingfisher // used directly for blurred background in FullPlayerView
import AVKit
// MARK: - Mini player bar (pinned above tab bar) // MARK: - Mini player bar (pinned above tab bar)
@@ -7,13 +8,13 @@ struct MiniPlayerView: View {
@Binding var showFullPlayer: Bool @Binding var showFullPlayer: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService @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). /// Live drag offset while the user is swiping up (negative = moving up).
@State private var dragOffset: CGFloat = 0 @State private var dragOffset: CGFloat = 0
var body: some View { var body: some View {
VStack(spacing: 6) { VStack(spacing: 4) {
// Progress line thin amber strip at the very top // Progress line thin amber strip at the very top
GeometryReader { geo in GeometryReader { geo in
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
@@ -29,8 +30,8 @@ struct MiniPlayerView: View {
// Cover thumbnail // Cover thumbnail
Button { showFullPlayer = true } label: { Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL) AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 46, height: 46) .frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -53,21 +54,21 @@ struct MiniPlayerView: View {
case .generating: case .generating:
ProgressView() ProgressView()
.tint(.amber) .tint(.amber)
.scaleEffect(0.85) .scaleEffect(0.8)
.frame(width: 34, height: 34) .frame(width: 32, height: 32)
case .ready: case .ready:
Button { audioPlayer.togglePlayPause() } label: { Button { audioPlayer.togglePlayPause() } label: {
Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill") Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(width: 34, height: 34) .frame(width: 32, height: 32)
.background(.white.opacity(0.12), in: Circle()) .background(.white.opacity(0.12), in: Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
case .error: case .error:
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.red) .foregroundStyle(.red)
.frame(width: 34, height: 34) .frame(width: 32, height: 32)
default: default:
EmptyView() EmptyView()
} }
@@ -78,17 +79,17 @@ struct MiniPlayerView: View {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 11, weight: .bold)) .font(.system(size: 11, weight: .bold))
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
.frame(width: 28, height: 28) .frame(width: 26, height: 26)
.background(.white.opacity(0.12), in: Circle()) .background(.white.opacity(0.12), in: Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 6) .padding(.vertical, 4)
} }
.padding(.horizontal, 10) .padding(.horizontal, 8)
.padding(.vertical, 4) .padding(.vertical, 3)
.frame(maxWidth: 520) .frame(maxWidth: .infinity)
.background( .background(
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous) RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
.fill(.ultraThinMaterial) .fill(.ultraThinMaterial)
@@ -153,18 +154,30 @@ struct FullPlayerView: View {
@State private var isShuffled = false @State private var isShuffled = false
@State private var isRepeating = false @State private var isRepeating = false
@State private var showChapterList = false
var body: some View { var body: some View {
ZStack { ZStack {
LinearGradient( // Dynamic background derived from cover art colors.
colors: [ ZStack {
Color(red: 73 / 255, green: 55 / 255, blue: 102 / 255), KFImage(URL(string: audioPlayer.coverURL))
Color(red: 34 / 255, green: 24 / 255, blue: 49 / 255) .resizable()
], .scaledToFill()
startPoint: .top, .blur(radius: 60)
endPoint: .bottom .saturation(1.4)
) .overlay(Color.black.opacity(0.35))
.ignoresSafeArea() .ignoresSafeArea()
LinearGradient(
colors: [
Color.black.opacity(0.05),
Color.black.opacity(0.55)
],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
}
VStack(spacing: 0) { VStack(spacing: 0) {
// Drag handle pill visual cue that you can swipe down to close // Drag handle pill visual cue that you can swipe down to close
@@ -296,12 +309,26 @@ struct FullPlayerView: View {
// Utility row // Utility row
HStack(spacing: 30) { HStack(spacing: 30) {
Button {} label: { Image(systemName: "airplayaudio") } AirPlayRoutePicker()
.frame(width: 24, height: 24)
Button {} label: { Image(systemName: "gearshape") } Button {} label: { Image(systemName: "gearshape") }
Button { onDismiss() } label: { Image(systemName: "chevron.down") } Button { onDismiss() } label: { Image(systemName: "chevron.down") }
Button {} label: { Image(systemName: "quote.bubble") } Button {} label: { Image(systemName: "quote.bubble") }
Button {} label: { Image(systemName: "list.bullet") } Button { showChapterList = true } label: { Image(systemName: "list.bullet") }
Button {} label: { Image(systemName: "moon.zzz") } 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)) .font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white.opacity(0.75)) .foregroundStyle(.white.opacity(0.75))
@@ -310,6 +337,26 @@ struct FullPlayerView: View {
Spacer(minLength: 26) 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 { private func formatTime(_ seconds: Double) -> String {
@@ -374,3 +421,57 @@ struct PlayerSlider: View {
.frame(height: 28) .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)
}
}
}