feat(podcasts): #6 quick wins — tab badge, remember speed, sleep timer, persist queue
- Unread badge on the Podcasts tab via a new observable PodcastLibrary store that mirrors PodcastIndex; all played/remove/generation mutations route through it so the count stays live. - Persist playback speed across sessions (UserDefaults), applied on setup. - Sleep timer (15/30/45 min or end-of-episode) with a moon menu in the full player; end-of-episode stops instead of auto-advancing. - Persist the current episode + Up Next queue and restore a *paused* session on launch; save position when backgrounding. Audio session is only claimed on actual playback so a restored session never interrupts other audio. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,7 @@
|
|||||||
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; };
|
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; };
|
||||||
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
||||||
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; };
|
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; };
|
||||||
|
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */; };
|
||||||
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
||||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
||||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; };
|
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; };
|
||||||
@@ -113,6 +114,7 @@
|
|||||||
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
|
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
|
||||||
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
||||||
|
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = "<group>"; };
|
||||||
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
|
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
|
||||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
|
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||||
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
|
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
|
||||||
@@ -258,6 +260,7 @@
|
|||||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
||||||
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
|
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
|
||||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
||||||
|
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
|
||||||
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
|
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
|
||||||
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
|
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
|
||||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
|
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
|
||||||
@@ -538,6 +541,7 @@
|
|||||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
||||||
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
|
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
|
||||||
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
|
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
|
||||||
|
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */,
|
||||||
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
|
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
|
||||||
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */,
|
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */,
|
||||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ struct MainContainer: View {
|
|||||||
@State private var showDeepLinkPlayer = false
|
@State private var showDeepLinkPlayer = false
|
||||||
@State private var selectedTab: AppTab = .bookmarks
|
@State private var selectedTab: AppTab = .bookmarks
|
||||||
@State private var router = IntentRouter.shared
|
@State private var router = IntentRouter.shared
|
||||||
|
@State private var library = PodcastLibrary.shared
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||||
@@ -58,6 +59,7 @@ struct MainContainer: View {
|
|||||||
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
|
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
|
||||||
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
|
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
|
||||||
}
|
}
|
||||||
|
.badge(library.unplayedCount)
|
||||||
Tab(value: AppTab.search, role: .search) {
|
Tab(value: AppTab.search, role: .search) {
|
||||||
SearchView(viewModel: viewModel)
|
SearchView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,11 @@ struct MainContainer: View {
|
|||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleDeepLink(url)
|
handleDeepLink(url)
|
||||||
}
|
}
|
||||||
.task { applyPendingIntent(); viewModel.processPendingPodcastRequests() }
|
.task {
|
||||||
|
applyPendingIntent()
|
||||||
|
viewModel.processPendingPodcastRequests()
|
||||||
|
viewModel.podcastPlayer.restoreSession(claude: viewModel.claude)
|
||||||
|
}
|
||||||
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
|
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
|
||||||
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
|
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
|
||||||
.onChange(of: scenePhase) { old, new in
|
.onChange(of: scenePhase) { old, new in
|
||||||
@@ -75,6 +81,11 @@ struct MainContainer: View {
|
|||||||
viewModel.processPendingPodcastRequests()
|
viewModel.processPendingPodcastRequests()
|
||||||
Task { await viewModel.load() }
|
Task { await viewModel.load() }
|
||||||
}
|
}
|
||||||
|
// Persist playback position + session when leaving the foreground so a
|
||||||
|
// relaunch can resume where you left off.
|
||||||
|
if new == .background {
|
||||||
|
viewModel.podcastPlayer.saveProgress()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $deepLinkBrowser) { item in
|
.sheet(item: $deepLinkBrowser) { item in
|
||||||
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
|
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ final class PodcastGenerationManager {
|
|||||||
title: status.title, parentBookmarkUrl: parentBookmarkUrl)
|
title: status.title, parentBookmarkUrl: parentBookmarkUrl)
|
||||||
let finalTitle = status.title ?? title
|
let finalTitle = status.title ?? title
|
||||||
finish(articleUrl)
|
finish(articleUrl)
|
||||||
|
PodcastLibrary.shared.reload() // new episode → refresh list + badge
|
||||||
Analytics.track("podcast.bg_completed", ["url": articleUrl])
|
Analytics.track("podcast.bg_completed", ["url": articleUrl])
|
||||||
onReady?(url, finalTitle)
|
onReady?(url, finalTitle)
|
||||||
return
|
return
|
||||||
|
|||||||
31
Marks/Services/PodcastLibrary.swift
Normal file
31
Marks/Services/PodcastLibrary.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Observable, in-memory mirror of `PodcastIndex` so SwiftUI updates immediately
|
||||||
|
/// on changes — the Podcasts tab badge and the library list both read from here.
|
||||||
|
/// Route podcast mutations through this store so the unplayed badge stays in sync.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class PodcastLibrary {
|
||||||
|
static let shared = PodcastLibrary()
|
||||||
|
|
||||||
|
private(set) var entries: [PodcastEntry] = []
|
||||||
|
|
||||||
|
var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } }
|
||||||
|
var played: [PodcastEntry] { entries.filter { $0.isPlayed } }
|
||||||
|
var unplayedCount: Int { entries.reduce(0) { $0 + ($1.isPlayed ? 0 : 1) } }
|
||||||
|
|
||||||
|
private init() { entries = PodcastIndex.all() }
|
||||||
|
|
||||||
|
func reload() { entries = PodcastIndex.all() }
|
||||||
|
|
||||||
|
func setPlayed(_ articleUrl: String, _ played: Bool) {
|
||||||
|
PodcastIndex.setPlayed(articleUrl: articleUrl, played)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ articleUrl: String) {
|
||||||
|
PodcastIndex.remove(articleUrl: articleUrl)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,10 +42,21 @@ final class PodcastPlayerViewModel {
|
|||||||
var duration: Double = 1
|
var duration: Double = 1
|
||||||
var currentArticleUrl: String = ""
|
var currentArticleUrl: String = ""
|
||||||
var currentArticleTitle: String = ""
|
var currentArticleTitle: String = ""
|
||||||
var playbackSpeed: Float = 1.0
|
var playbackSpeed: Float = PodcastPlayerViewModel.savedSpeed
|
||||||
|
|
||||||
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
|
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
|
||||||
|
|
||||||
|
/// Sleep-timer selection. `.off` when disabled.
|
||||||
|
enum SleepTimer: Hashable { case off, minutes(Int), endOfEpisode }
|
||||||
|
var sleepTimer: SleepTimer = .off
|
||||||
|
|
||||||
|
private static let speedKey = "podcastPlaybackSpeed"
|
||||||
|
private static let sessionKey = "podcastSession"
|
||||||
|
static var savedSpeed: Float {
|
||||||
|
let v = UserDefaults.standard.double(forKey: speedKey)
|
||||||
|
return v > 0 ? Float(v) : 1.0
|
||||||
|
}
|
||||||
|
|
||||||
var isGenerating: Bool {
|
var isGenerating: Bool {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .generating, .downloading: return true
|
case .generating, .downloading: return true
|
||||||
@@ -59,6 +70,7 @@ final class PodcastPlayerViewModel {
|
|||||||
private var lastClaude: ClaudeService?
|
private var lastClaude: ClaudeService?
|
||||||
|
|
||||||
private var pollingTask: Task<Void, Never>?
|
private var pollingTask: Task<Void, Never>?
|
||||||
|
private var sleepTask: Task<Void, Never>?
|
||||||
private var timeObserver: Any?
|
private var timeObserver: Any?
|
||||||
private var endObserver: Any?
|
private var endObserver: Any?
|
||||||
private var interruptionObserver: Any?
|
private var interruptionObserver: Any?
|
||||||
@@ -124,10 +136,14 @@ final class PodcastPlayerViewModel {
|
|||||||
// `start` calls `stop`, which clears the queue — set it afterwards.
|
// `start` calls `stop`, which clears the queue — set it afterwards.
|
||||||
start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude)
|
start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude)
|
||||||
queue = Array(entries.dropFirst())
|
queue = Array(entries.dropFirst())
|
||||||
|
persistSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
queue = []
|
queue = []
|
||||||
|
clearSession()
|
||||||
|
sleepTask?.cancel(); sleepTask = nil
|
||||||
|
sleepTimer = .off
|
||||||
if !currentArticleUrl.isEmpty && currentTime > 10 {
|
if !currentArticleUrl.isEmpty && currentTime > 10 {
|
||||||
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
|
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
|
||||||
}
|
}
|
||||||
@@ -161,6 +177,7 @@ final class PodcastPlayerViewModel {
|
|||||||
if isPlaying {
|
if isPlaying {
|
||||||
player.pause()
|
player.pause()
|
||||||
} else {
|
} else {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true) // may be a restored, paused session
|
||||||
player.rate = playbackSpeed
|
player.rate = playbackSpeed
|
||||||
}
|
}
|
||||||
isPlaying.toggle()
|
isPlaying.toggle()
|
||||||
@@ -183,16 +200,90 @@ final class PodcastPlayerViewModel {
|
|||||||
|
|
||||||
func setPlaybackSpeed(_ speed: Float) {
|
func setPlaybackSpeed(_ speed: Float) {
|
||||||
playbackSpeed = speed
|
playbackSpeed = speed
|
||||||
|
UserDefaults.standard.set(Double(speed), forKey: Self.speedKey)
|
||||||
if isPlaying { player?.rate = speed }
|
if isPlaying { player?.rate = speed }
|
||||||
updateNowPlaying()
|
updateNowPlaying()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupPlayer(url: URL, title: String) {
|
// MARK: - Sleep timer
|
||||||
|
|
||||||
|
func setSleepTimer(_ timer: SleepTimer) {
|
||||||
|
sleepTimer = timer
|
||||||
|
sleepTask?.cancel(); sleepTask = nil
|
||||||
|
guard case .minutes(let m) = timer else { return } // .endOfEpisode handled at finish
|
||||||
|
sleepTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(Double(m) * 60))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self?.fireSleepTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fireSleepTimer() {
|
||||||
|
player?.pause()
|
||||||
|
isPlaying = false
|
||||||
|
sleepTimer = .off
|
||||||
|
sleepTask = nil
|
||||||
|
updateNowPlaying()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session persistence (survives app relaunch)
|
||||||
|
|
||||||
|
private struct SavedSession: Codable {
|
||||||
|
let currentUrl: String
|
||||||
|
let currentTitle: String
|
||||||
|
let podcastTitle: String
|
||||||
|
let queue: [PodcastEntry]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the current episode + queue so relaunch can restore a paused session.
|
||||||
|
func persistSession() {
|
||||||
|
guard !currentArticleUrl.isEmpty else { clearSession(); return }
|
||||||
|
let session = SavedSession(currentUrl: currentArticleUrl,
|
||||||
|
currentTitle: currentArticleTitle,
|
||||||
|
podcastTitle: currentPodcastTitle,
|
||||||
|
queue: queue)
|
||||||
|
if let data = try? JSONEncoder().encode(session) {
|
||||||
|
UserDefaults.standard.set(data, forKey: Self.sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearSession() {
|
||||||
|
UserDefaults.standard.removeObject(forKey: Self.sessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the current playback position + session (call when backgrounding).
|
||||||
|
func saveProgress() {
|
||||||
|
if !currentArticleUrl.isEmpty && currentTime > 10 {
|
||||||
|
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
|
||||||
|
}
|
||||||
|
persistSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a previously-playing episode into a **paused** player on launch,
|
||||||
|
/// with its queue intact. No-op if nothing is saved or the audio is gone.
|
||||||
|
func restoreSession(claude: ClaudeService) {
|
||||||
|
guard currentArticleUrl.isEmpty, player == nil else { return }
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: Self.sessionKey),
|
||||||
|
let session = try? JSONDecoder().decode(SavedSession.self, from: data) else { return }
|
||||||
|
let cached = ClaudeService.cachedPodcastURL(for: session.currentUrl)
|
||||||
|
guard FileManager.default.fileExists(atPath: cached.path) else { clearSession(); return }
|
||||||
|
lastClaude = claude
|
||||||
|
currentArticleUrl = session.currentUrl
|
||||||
|
currentArticleTitle = session.currentTitle
|
||||||
|
queue = session.queue
|
||||||
|
setupPlayer(url: cached, title: session.podcastTitle, autoPlay: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPlayer(url: URL, title: String, autoPlay: Bool = true) {
|
||||||
currentPodcastTitle = title
|
currentPodcastTitle = title
|
||||||
|
// A freshly generated episode may have just landed in the index.
|
||||||
|
PodcastLibrary.shared.reload()
|
||||||
|
|
||||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
|
||||||
options: [.allowBluetoothA2DP])
|
options: [.allowBluetoothA2DP])
|
||||||
try? AVAudioSession.sharedInstance().setActive(true)
|
// Only claim the audio session when actually playing — a restored, paused
|
||||||
|
// session on launch must not interrupt the user's other audio.
|
||||||
|
if autoPlay { try? AVAudioSession.sharedInstance().setActive(true) }
|
||||||
|
|
||||||
let item = AVPlayerItem(url: url)
|
let item = AVPlayerItem(url: url)
|
||||||
let p = AVPlayer(playerItem: item)
|
let p = AVPlayer(playerItem: item)
|
||||||
@@ -220,19 +311,26 @@ final class PodcastPlayerViewModel {
|
|||||||
let finished = self.currentArticleUrl
|
let finished = self.currentArticleUrl
|
||||||
PodcastIndex.setPlayed(articleUrl: finished, true)
|
PodcastIndex.setPlayed(articleUrl: finished, true)
|
||||||
PodcastProgress.set(url: finished, time: 0, duration: self.duration)
|
PodcastProgress.set(url: finished, time: 0, duration: self.duration)
|
||||||
|
PodcastLibrary.shared.reload()
|
||||||
|
|
||||||
if !self.queue.isEmpty {
|
// Sleep timer set to "end of episode" stops here — no auto-advance.
|
||||||
|
let stopForSleep = self.sleepTimer == .endOfEpisode
|
||||||
|
if stopForSleep { self.sleepTimer = .off }
|
||||||
|
|
||||||
|
if !stopForSleep, !self.queue.isEmpty {
|
||||||
let next = self.queue.removeFirst()
|
let next = self.queue.removeFirst()
|
||||||
let remaining = self.queue
|
let remaining = self.queue
|
||||||
let claude = self.lastClaude ?? ClaudeService()
|
let claude = self.lastClaude ?? ClaudeService()
|
||||||
self.start(articleUrl: next.articleUrl,
|
self.start(articleUrl: next.articleUrl,
|
||||||
articleTitle: next.title ?? "", claude: claude)
|
articleTitle: next.title ?? "", claude: claude)
|
||||||
self.queue = remaining // `start` cleared it via `stop`
|
self.queue = remaining // `start` cleared it via `stop`
|
||||||
|
self.persistSession()
|
||||||
} else {
|
} else {
|
||||||
self.isPlaying = false
|
self.isPlaying = false
|
||||||
self.currentTime = 0
|
self.currentTime = 0
|
||||||
self.player?.seek(to: .zero)
|
self.player?.seek(to: .zero)
|
||||||
self.updateNowPlaying()
|
self.updateNowPlaying()
|
||||||
|
self.persistSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,14 +371,20 @@ final class PodcastPlayerViewModel {
|
|||||||
setupRemoteCommands()
|
setupRemoteCommands()
|
||||||
phase = .ready(title: title)
|
phase = .ready(title: title)
|
||||||
updateNowPlaying()
|
updateNowPlaying()
|
||||||
p.rate = playbackSpeed
|
if autoPlay {
|
||||||
isPlaying = true
|
p.rate = playbackSpeed
|
||||||
|
isPlaying = true
|
||||||
|
} else {
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
persistSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupRemoteCommands() {
|
private func setupRemoteCommands() {
|
||||||
let c = MPRemoteCommandCenter.shared()
|
let c = MPRemoteCommandCenter.shared()
|
||||||
c.playCommand.isEnabled = true
|
c.playCommand.isEnabled = true
|
||||||
c.playCommand.addTarget { [weak self] _ in
|
c.playCommand.addTarget { [weak self] _ in
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success
|
self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success
|
||||||
}
|
}
|
||||||
c.pauseCommand.isEnabled = true
|
c.pauseCommand.isEnabled = true
|
||||||
@@ -366,6 +470,26 @@ struct PodcastPlayerView: View {
|
|||||||
vm.currentArticleUrl.isEmpty ? articleUrl : vm.currentArticleUrl
|
vm.currentArticleUrl.isEmpty ? articleUrl : vm.currentArticleUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sleepActive: Bool { vm.sleepTimer != .off }
|
||||||
|
private var sleepLabel: String {
|
||||||
|
switch vm.sleepTimer {
|
||||||
|
case .off: return "Sleep"
|
||||||
|
case .minutes(let m): return "\(m) min"
|
||||||
|
case .endOfEpisode: return "End"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sleepButton(_ title: String, _ option: PodcastPlayerViewModel.SleepTimer) -> some View {
|
||||||
|
Button { vm.setSleepTimer(option) } label: {
|
||||||
|
if vm.sleepTimer == option {
|
||||||
|
Label(title, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -484,32 +608,49 @@ struct PodcastPlayerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Menu {
|
HStack(spacing: 12) {
|
||||||
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
|
Menu {
|
||||||
Button {
|
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
|
||||||
vm.setPlaybackSpeed(speed)
|
Button {
|
||||||
} label: {
|
vm.setPlaybackSpeed(speed)
|
||||||
let label = speed == 1.0 ? "1× (Normal)" : "\(String(format: "%g", speed))×"
|
} label: {
|
||||||
if speed == vm.playbackSpeed {
|
let label = speed == 1.0 ? "1× (Normal)" : "\(String(format: "%g", speed))×"
|
||||||
Label(label, systemImage: "checkmark")
|
if speed == vm.playbackSpeed {
|
||||||
} else {
|
Label(label, systemImage: "checkmark")
|
||||||
Text(label)
|
} else {
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
|
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.glassEffect(in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
sleepButton("Off", .off)
|
||||||
|
sleepButton("15 Minutes", .minutes(15))
|
||||||
|
sleepButton("30 Minutes", .minutes(30))
|
||||||
|
sleepButton("45 Minutes", .minutes(45))
|
||||||
|
sleepButton("End of Episode", .endOfEpisode)
|
||||||
|
} label: {
|
||||||
|
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(sleepActive ? Color.blue : .secondary)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.glassEffect(in: Capsule())
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
|
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 7)
|
|
||||||
.glassEffect(in: Capsule())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 28) {
|
HStack(spacing: 28) {
|
||||||
Button {
|
Button {
|
||||||
isPlayed.toggle()
|
isPlayed.toggle()
|
||||||
PodcastIndex.setPlayed(articleUrl: activeUrl, isPlayed)
|
PodcastLibrary.shared.setPlayed(activeUrl, isPlayed)
|
||||||
} label: {
|
} label: {
|
||||||
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
|
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
|
||||||
systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle")
|
systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle")
|
||||||
@@ -657,13 +798,13 @@ struct PodcastLibraryView: View {
|
|||||||
let vm: PodcastPlayerViewModel
|
let vm: PodcastPlayerViewModel
|
||||||
let claude: ClaudeService
|
let claude: ClaudeService
|
||||||
|
|
||||||
@State private var entries: [PodcastEntry] = []
|
@State private var library = PodcastLibrary.shared
|
||||||
@State private var showFullPlayer = false
|
@State private var showFullPlayer = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if entries.isEmpty {
|
if library.entries.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Podcasts",
|
"No Podcasts",
|
||||||
systemImage: "headphones",
|
systemImage: "headphones",
|
||||||
@@ -716,14 +857,14 @@ struct PodcastLibraryView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { entries = PodcastIndex.all() }
|
.onAppear { library.reload() }
|
||||||
// When the current episode changes (finished, advanced, or stopped),
|
// When the current episode changes (finished, advanced, or stopped),
|
||||||
// re-read the index so played items move into the Played section.
|
// re-read the index so played items move into the Played section.
|
||||||
.onChange(of: vm.currentArticleUrl) { _, _ in entries = PodcastIndex.all() }
|
.onChange(of: vm.currentArticleUrl) { _, _ in library.reload() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } }
|
private var unplayed: [PodcastEntry] { library.unplayed }
|
||||||
private var played: [PodcastEntry] { entries.filter { $0.isPlayed } }
|
private var played: [PodcastEntry] { library.played }
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func row(for entry: PodcastEntry) -> some View {
|
private func row(for entry: PodcastEntry) -> some View {
|
||||||
@@ -800,10 +941,7 @@ struct PodcastLibraryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {
|
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {
|
||||||
PodcastIndex.setPlayed(articleUrl: entry.articleUrl, played)
|
library.setPlayed(entry.articleUrl, played)
|
||||||
if let i = entries.firstIndex(where: { $0.articleUrl == entry.articleUrl }) {
|
|
||||||
entries[i].playedAt = played ? Date() : nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isCurrent(_ entry: PodcastEntry) -> Bool {
|
private func isCurrent(_ entry: PodcastEntry) -> Bool {
|
||||||
@@ -819,8 +957,7 @@ struct PodcastLibraryView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func deleteEntry(_ entry: PodcastEntry) {
|
private func deleteEntry(_ entry: PodcastEntry) {
|
||||||
PodcastIndex.remove(articleUrl: entry.articleUrl)
|
library.remove(entry.articleUrl)
|
||||||
entries.removeAll { $0.articleUrl == entry.articleUrl }
|
|
||||||
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user