iOS: add functional AirPlay and sleep timer to full player
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
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 6s
CI / Scraper / Test (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
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
AirPlay: - Replaced placeholder AirPlay button with AVRoutePickerView - Users can now cast audio to AirPlay devices - Icon changes color when AirPlay is active Sleep Timer: - Added sleep timer sheet with multiple options - Chapter-based: 1, 2, 3, or 4 chapters - Time-based: 20 mins, 40 mins, 1 hour, 2 hours - Moon icon fills when timer is active and turns amber - Timer stops playback when target is reached - Chapter-based timer tracks chapters played from start - Time-based timer uses async task with proper cancellation - Timer is cancelled when playback stops manually - SleepTimerOption enum supports both timer types
This commit is contained in:
@@ -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 prevChapter: Int? = nil
|
@Published var prevChapter: Int? = nil
|
||||||
|
|
||||||
|
@Published var sleepTimer: SleepTimerOption? = nil
|
||||||
|
|
||||||
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
|
||||||
@Published var nextAudioURL: String = ""
|
@Published var nextAudioURL: String = ""
|
||||||
@@ -61,6 +63,10 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
// 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?
|
||||||
private var cachedCoverURL: String = ""
|
private var cachedCoverURL: String = ""
|
||||||
|
|
||||||
|
// Sleep timer tracking
|
||||||
|
private var sleepTimerTask: Task<Void, Never>?
|
||||||
|
private var sleepTimerStartChapter: Int = 0
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
@@ -92,6 +98,11 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
self.nextPrefetchStatus = .none
|
self.nextPrefetchStatus = .none
|
||||||
self.nextAudioURL = ""
|
self.nextAudioURL = ""
|
||||||
self.nextPrefetchedChapter = nil
|
self.nextPrefetchedChapter = nil
|
||||||
|
|
||||||
|
// Reset sleep timer start chapter if it's a chapter-based timer
|
||||||
|
if case .chapters = sleepTimer {
|
||||||
|
sleepTimerStartChapter = chapter
|
||||||
|
}
|
||||||
|
|
||||||
status = .generating
|
status = .generating
|
||||||
generationProgress = 0
|
generationProgress = 0
|
||||||
@@ -141,6 +152,33 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
if isPlaying { player?.rate = Float(newSpeed) }
|
if isPlaying { player?.rate = Float(newSpeed) }
|
||||||
updateNowPlaying()
|
updateNowPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setSleepTimer(_ option: SleepTimerOption?) {
|
||||||
|
// Cancel existing timer
|
||||||
|
sleepTimerTask?.cancel()
|
||||||
|
sleepTimerTask = nil
|
||||||
|
|
||||||
|
sleepTimer = option
|
||||||
|
|
||||||
|
guard let option else { return }
|
||||||
|
|
||||||
|
// Start timer based on option
|
||||||
|
switch option {
|
||||||
|
case .chapters(let count):
|
||||||
|
sleepTimerStartChapter = chapter
|
||||||
|
// Monitor chapter changes in handlePlaybackFinished
|
||||||
|
|
||||||
|
case .minutes(let minutes):
|
||||||
|
sleepTimerTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(minutes) * 60 * 1_000_000_000)
|
||||||
|
guard let self, !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
self.stop()
|
||||||
|
self.sleepTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
player?.pause()
|
player?.pause()
|
||||||
@@ -150,6 +188,11 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
duration = 0
|
duration = 0
|
||||||
audioURL = ""
|
audioURL = ""
|
||||||
status = .idle
|
status = .idle
|
||||||
|
|
||||||
|
// Cancel sleep timer
|
||||||
|
sleepTimerTask?.cancel()
|
||||||
|
sleepTimerTask = nil
|
||||||
|
sleepTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio generation
|
// MARK: - Audio generation
|
||||||
@@ -315,6 +358,15 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
|
||||||
guard let next = nextChapter else { return }
|
guard let next = nextChapter else { return }
|
||||||
|
|
||||||
|
// Check chapter-based sleep timer
|
||||||
|
if case .chapters(let count) = sleepTimer {
|
||||||
|
let chaptersPlayed = chapter - sleepTimerStartChapter + 1
|
||||||
|
if chaptersPlayed >= count {
|
||||||
|
stop()
|
||||||
|
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(
|
||||||
@@ -465,6 +517,11 @@ enum AudioPlayerStatus: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SleepTimerOption: Equatable {
|
||||||
|
case chapters(Int) // Stop after N chapters
|
||||||
|
case minutes(Int) // Stop after N minutes
|
||||||
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
||||||
static let skipToNextChapter = Notification.Name("skipToNextChapter")
|
static let skipToNextChapter = Notification.Name("skipToNextChapter")
|
||||||
|
|||||||
@@ -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 // for AVRoutePickerView (AirPlay)
|
||||||
|
|
||||||
// MARK: - Mini player bar (pinned above tab bar)
|
// MARK: - Mini player bar (pinned above tab bar)
|
||||||
|
|
||||||
@@ -191,6 +192,7 @@ struct FullPlayerView: View {
|
|||||||
@State private var isLiked = false
|
@State private var isLiked = false
|
||||||
@State private var showingSpeedMenu = false
|
@State private var showingSpeedMenu = false
|
||||||
@State private var showingChaptersList = false
|
@State private var showingChaptersList = false
|
||||||
|
@State private var showingSleepTimer = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -463,15 +465,9 @@ struct FullPlayerView: View {
|
|||||||
|
|
||||||
// Bottom toolbar
|
// Bottom toolbar
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// AirPlay (placeholder)
|
// AirPlay
|
||||||
Button {} label: {
|
AirPlayButton()
|
||||||
Image(systemName: "airplayaudio")
|
.frame(maxWidth: .infinity)
|
||||||
.font(.system(size: 22))
|
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(true)
|
|
||||||
|
|
||||||
// Settings (Speed control)
|
// Settings (Speed control)
|
||||||
Menu {
|
Menu {
|
||||||
@@ -526,15 +522,16 @@ struct FullPlayerView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
// Sleep timer (placeholder)
|
// Sleep timer
|
||||||
Button {} label: {
|
Button {
|
||||||
Image(systemName: "moon.zzz")
|
showingSleepTimer = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: sleepTimerIcon)
|
||||||
.font(.system(size: 22))
|
.font(.system(size: 22))
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(true)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
@@ -565,6 +562,11 @@ struct FullPlayerView: View {
|
|||||||
.presentationDetents([.medium, .large])
|
.presentationDetents([.medium, .large])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingSleepTimer) {
|
||||||
|
SleepTimerSheet(audioPlayer: audioPlayer)
|
||||||
|
.presentationDetents([.height(500)])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatTime(_ seconds: Double) -> String {
|
private func formatTime(_ seconds: Double) -> String {
|
||||||
@@ -600,6 +602,115 @@ struct FullPlayerView: View {
|
|||||||
formatter.dateFormat = "yyyy"
|
formatter.dateFormat = "yyyy"
|
||||||
return formatter.string(from: Date())
|
return formatter.string(from: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sleepTimerIcon: String {
|
||||||
|
audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AirPlay Button
|
||||||
|
|
||||||
|
struct AirPlayButton: UIViewRepresentable {
|
||||||
|
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||||
|
let picker = AVRoutePickerView()
|
||||||
|
picker.tintColor = UIColor.white.withAlphaComponent(0.7)
|
||||||
|
picker.activeTintColor = UIColor(named: "AccentColor") ?? UIColor.systemOrange
|
||||||
|
picker.prioritizesVideoDevices = false
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sleep Timer Sheet
|
||||||
|
|
||||||
|
struct SleepTimerSheet: View {
|
||||||
|
@ObservedObject var audioPlayer: AudioPlayerService
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
audioPlayer.setSleepTimer(nil)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Off")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
if audioPlayer.sleepTimer == nil {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Chapter-based")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach([1, 2, 3, 4], id: \.self) { count in
|
||||||
|
Button {
|
||||||
|
audioPlayer.setSleepTimer(.chapters(count))
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("\(count) \(count == 1 ? "chapter" : "chapters")")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
if case .chapters(let c) = audioPlayer.sleepTimer, c == count {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach([20, 40, 60, 120], id: \.self) { minutes in
|
||||||
|
Button {
|
||||||
|
audioPlayer.setSleepTimer(.minutes(minutes))
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(formatTimerOption(minutes))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
if case .minutes(let m) = audioPlayer.sleepTimer, m == minutes {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(.amber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Time-based")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Sleep Timer")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTimerOption(_ minutes: Int) -> String {
|
||||||
|
if minutes < 60 {
|
||||||
|
return "\(minutes) mins"
|
||||||
|
} else {
|
||||||
|
let hours = minutes / 60
|
||||||
|
return "\(hours) \(hours == 1 ? "hour" : "hours")"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Chapters List Sheet
|
// MARK: - Chapters List Sheet
|
||||||
|
|||||||
Reference in New Issue
Block a user