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
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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(.horizontal, 10)
|
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.frame(maxWidth: 520)
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.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 {
|
||||||
|
// 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(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Color(red: 73 / 255, green: 55 / 255, blue: 102 / 255),
|
Color.black.opacity(0.05),
|
||||||
Color(red: 34 / 255, green: 24 / 255, blue: 49 / 255)
|
Color.black.opacity(0.55)
|
||||||
],
|
],
|
||||||
startPoint: .top,
|
startPoint: .top,
|
||||||
endPoint: .bottom
|
endPoint: .bottom
|
||||||
)
|
)
|
||||||
.ignoresSafeArea()
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user