Add mini-player, per-row podcast button, and resume-from-position
Some checks failed
CI / build-and-deploy (push) Failing after 9s
Some checks failed
CI / build-and-deploy (push) Failing after 9s
- MiniPlayerView: persistent bar above tab bar, shows title/progress, play/pause and close buttons, slides in when audio starts - PodcastPlayerViewModel lifted to BookmarksViewModel so it persists across sheet dismissals — audio keeps playing in background - Headphone button on every bookmark row (filled = cached, outline = not) - start() is idempotent: re-tapping same article resumes without restart - stop() saves playback position before teardown (PodcastProgress) - setupPlayer restores saved position on next open - BrowserView keeps its own local VM (stopOnDismiss: true) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct BookmarkRow: View {
|
||||
let bookmark: Bookmark
|
||||
var readingProgress: Double = 0
|
||||
var onPodcast: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -29,6 +30,18 @@ struct BookmarkRow: View {
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let onPodcast {
|
||||
Button(action: onPodcast) {
|
||||
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if let summary = bookmark.aiSummary {
|
||||
@@ -76,6 +89,10 @@ struct BookmarkRow: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var podcastCached: Bool {
|
||||
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: bookmark.url).path)
|
||||
}
|
||||
|
||||
private var effectiveTags: [String] {
|
||||
let base = bookmark.tagNames
|
||||
let ai = bookmark.aiTags ?? []
|
||||
|
||||
@@ -10,17 +10,28 @@ struct BookmarksView: View {
|
||||
@State private var showAddBookmark = false
|
||||
@State private var editingBookmark: Bookmark?
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
@State private var podcastBookmark: Bookmark?
|
||||
@State private var showFullPlayer = false
|
||||
@State private var readingProgress: [String: Double] = ReadingProgress.all()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark, readingProgress: readingProgress[bookmark.url] ?? 0)
|
||||
.onTapGesture {
|
||||
browsingBookmark = bookmark
|
||||
BookmarkRow(
|
||||
bookmark: bookmark,
|
||||
readingProgress: readingProgress[bookmark.url] ?? 0,
|
||||
onPodcast: {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: bookmark.url,
|
||||
articleTitle: bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
}
|
||||
)
|
||||
.onTapGesture {
|
||||
browsingBookmark = bookmark
|
||||
}
|
||||
.onAppear { maybeLoadMore(bookmark) }
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
@@ -62,7 +73,12 @@ struct BookmarksView: View {
|
||||
Label("Open in Safari", systemImage: "safari")
|
||||
}
|
||||
Button {
|
||||
podcastBookmark = bookmark
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: bookmark.url,
|
||||
articleTitle: bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
} label: {
|
||||
Label("Convert to Podcast", systemImage: "headphones")
|
||||
}
|
||||
@@ -133,9 +149,19 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if viewModel.enrichmentProgress > 0 {
|
||||
enrichmentBanner
|
||||
VStack(spacing: 8) {
|
||||
if viewModel.podcastPlayer.player != nil {
|
||||
MiniPlayerView(vm: viewModel.podcastPlayer) {
|
||||
showFullPlayer = true
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
if viewModel.enrichmentProgress > 0 {
|
||||
enrichmentBanner
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: 0.3), value: viewModel.podcastPlayer.player != nil)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
@@ -158,8 +184,14 @@ struct BookmarksView: View {
|
||||
.onChange(of: browsingBookmark) { _, new in
|
||||
if new == nil { readingProgress = ReadingProgress.all() }
|
||||
}
|
||||
.sheet(item: $podcastBookmark) { bookmark in
|
||||
PodcastPlayerView(articleUrl: bookmark.url, claude: viewModel.claude)
|
||||
.sheet(isPresented: $showFullPlayer) {
|
||||
PodcastPlayerView(
|
||||
vm: viewModel.podcastPlayer,
|
||||
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
||||
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
||||
claude: viewModel.claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
@@ -193,7 +225,6 @@ struct BookmarksView: View {
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func maybeLoadMore(_ bookmark: Bookmark) {
|
||||
|
||||
@@ -17,6 +17,7 @@ final class BookmarksViewModel {
|
||||
|
||||
private let api: LinkdingAPI
|
||||
let claude = ClaudeService()
|
||||
let podcastPlayer = PodcastPlayerViewModel()
|
||||
private var enrichTask: Task<Void, Never>?
|
||||
private let cacheFileUrl: URL
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ struct BrowserView: View {
|
||||
@State private var state: BrowserState
|
||||
@State private var readerMode = false
|
||||
@State private var showPodcast = false
|
||||
@State private var podcastVM = PodcastPlayerViewModel()
|
||||
|
||||
init(url: URL, title: String, claude: ClaudeService) {
|
||||
self.initialURL = url
|
||||
@@ -290,7 +291,13 @@ struct BrowserView: View {
|
||||
if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) }
|
||||
}
|
||||
.sheet(isPresented: $showPodcast) {
|
||||
PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude)
|
||||
PodcastPlayerView(
|
||||
vm: podcastVM,
|
||||
articleUrl: (state.currentURL ?? initialURL).absoluteString,
|
||||
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
|
||||
claude: claude,
|
||||
stopOnDismiss: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,27 @@ import SwiftUI
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
|
||||
// MARK: - Playback position persistence
|
||||
|
||||
enum PodcastProgress {
|
||||
private static let key = "podcastProgress"
|
||||
|
||||
static func get(url: String) -> Double {
|
||||
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0
|
||||
}
|
||||
|
||||
static func set(url: String, time: Double, duration: Double) {
|
||||
var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
|
||||
let fraction = time / max(duration, 1)
|
||||
if fraction > 0.97 || time < 10 {
|
||||
store.removeValue(forKey: url)
|
||||
} else {
|
||||
store[url] = time
|
||||
}
|
||||
UserDefaults.standard.set(store, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewModel
|
||||
|
||||
@Observable
|
||||
@@ -19,19 +40,31 @@ final class PodcastPlayerViewModel {
|
||||
var isPlaying = false
|
||||
var currentTime: Double = 0
|
||||
var duration: Double = 1
|
||||
var currentArticleUrl: String = ""
|
||||
var currentArticleTitle: String = ""
|
||||
|
||||
private var pollingTask: Task<Void, Never>?
|
||||
private var timeObserver: Any?
|
||||
private var currentTitle: String = "Marks Podcast"
|
||||
private var currentPodcastTitle: String = "Marks Podcast"
|
||||
|
||||
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) {
|
||||
// Idempotent — don't restart the same article if already playing
|
||||
if currentArticleUrl == articleUrl && player != nil { return }
|
||||
|
||||
// Teardown previous episode (saves its position)
|
||||
if player != nil { stop() }
|
||||
|
||||
currentArticleUrl = articleUrl
|
||||
currentArticleTitle = articleTitle
|
||||
|
||||
func start(articleUrl: String, claude: ClaudeService) {
|
||||
// Play from cache if already generated
|
||||
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
|
||||
if FileManager.default.fileExists(atPath: cached.path) {
|
||||
setupPlayer(url: cached, title: "Podcast")
|
||||
return
|
||||
}
|
||||
|
||||
phase = .generating(progress: 0, label: "Starting…")
|
||||
|
||||
pollingTask = Task {
|
||||
do {
|
||||
let jobId = try await claude.generatePodcast(url: articleUrl)
|
||||
@@ -54,13 +87,35 @@ final class PodcastPlayerViewModel {
|
||||
try await Task.sleep(for: .seconds(3))
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// sheet dismissed — clean exit
|
||||
} catch {
|
||||
phase = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if !currentArticleUrl.isEmpty && currentTime > 10 {
|
||||
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
|
||||
}
|
||||
pollingTask?.cancel()
|
||||
player?.pause()
|
||||
if let obs = timeObserver { player?.removeTimeObserver(obs) }
|
||||
player = nil
|
||||
isPlaying = false
|
||||
currentTime = 0
|
||||
duration = 1
|
||||
currentArticleUrl = ""
|
||||
currentArticleTitle = ""
|
||||
currentPodcastTitle = "Marks Podcast"
|
||||
phase = .generating(progress: 0, label: "Starting…")
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
let c = MPRemoteCommandCenter.shared()
|
||||
c.playCommand.removeTarget(nil)
|
||||
c.pauseCommand.removeTarget(nil)
|
||||
c.changePlaybackPositionCommand.removeTarget(nil)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
guard let player else { return }
|
||||
if isPlaying { player.pause() } else { player.play() }
|
||||
@@ -74,21 +129,8 @@ final class PodcastPlayerViewModel {
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func teardown() {
|
||||
pollingTask?.cancel()
|
||||
player?.pause()
|
||||
if let obs = timeObserver { player?.removeTimeObserver(obs) }
|
||||
player = nil
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
let c = MPRemoteCommandCenter.shared()
|
||||
c.playCommand.removeTarget(nil)
|
||||
c.pauseCommand.removeTarget(nil)
|
||||
c.changePlaybackPositionCommand.removeTarget(nil)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private func setupPlayer(url: URL, title: String) {
|
||||
currentTitle = title
|
||||
currentPodcastTitle = title
|
||||
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
|
||||
options: [.allowBluetoothA2DP])
|
||||
@@ -101,6 +143,12 @@ final class PodcastPlayerViewModel {
|
||||
Task {
|
||||
if let d = try? await item.asset.load(.duration), d.isNumeric {
|
||||
duration = max(d.seconds, 1)
|
||||
// Restore saved position
|
||||
let saved = PodcastProgress.get(url: currentArticleUrl)
|
||||
if saved > 10 {
|
||||
p.seek(to: CMTime(seconds: saved, preferredTimescale: 600))
|
||||
currentTime = saved
|
||||
}
|
||||
updateNowPlaying()
|
||||
}
|
||||
}
|
||||
@@ -122,17 +170,11 @@ final class PodcastPlayerViewModel {
|
||||
let c = MPRemoteCommandCenter.shared()
|
||||
c.playCommand.isEnabled = true
|
||||
c.playCommand.addTarget { [weak self] _ in
|
||||
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.addTarget { [weak self] _ in
|
||||
self?.player?.pause()
|
||||
self?.isPlaying = false
|
||||
self?.updateNowPlaying()
|
||||
return .success
|
||||
self?.player?.pause(); self?.isPlaying = false; self?.updateNowPlaying(); return .success
|
||||
}
|
||||
c.changePlaybackPositionCommand.isEnabled = true
|
||||
c.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
@@ -145,8 +187,8 @@ final class PodcastPlayerViewModel {
|
||||
|
||||
private func updateNowPlaying() {
|
||||
var info: [String: Any] = [:]
|
||||
info[MPMediaItemPropertyTitle] = currentTitle
|
||||
info[MPMediaItemPropertyArtist] = "Marks"
|
||||
info[MPMediaItemPropertyTitle] = currentPodcastTitle
|
||||
info[MPMediaItemPropertyArtist] = currentArticleTitle.isEmpty ? "Marks" : currentArticleTitle
|
||||
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
|
||||
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||
if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration }
|
||||
@@ -165,14 +207,16 @@ final class PodcastPlayerViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
// MARK: - Full player sheet
|
||||
|
||||
struct PodcastPlayerView: View {
|
||||
let vm: PodcastPlayerViewModel
|
||||
let articleUrl: String
|
||||
let articleTitle: String
|
||||
let claude: ClaudeService
|
||||
var stopOnDismiss: Bool = false
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var vm = PodcastPlayerViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -188,10 +232,22 @@ struct PodcastPlayerView: View {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
if vm.player != nil {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { vm.stop(); dismiss() } label: {
|
||||
Image(systemName: "stop.circle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { vm.start(articleUrl: articleUrl, claude: claude) }
|
||||
.onDisappear { vm.teardown() }
|
||||
.onAppear {
|
||||
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
|
||||
}
|
||||
.onDisappear {
|
||||
if stopOnDismiss { vm.stop() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -285,3 +341,86 @@ struct PodcastPlayerView: View {
|
||||
return String(format: "%d:%02d", total / 60, total % 60)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mini player
|
||||
|
||||
struct MiniPlayerView: View {
|
||||
let vm: PodcastPlayerViewModel
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying)
|
||||
.frame(width: 22)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text(progressLabel)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
vm.togglePlayPause()
|
||||
} label: {
|
||||
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
vm.stop()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(alignment: .bottom) {
|
||||
// Progress bar along bottom edge
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.frame(width: geo.size.width * progressFraction, height: 3)
|
||||
}
|
||||
.frame(height: 3)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, x: 0, y: 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
private var progressFraction: Double {
|
||||
guard vm.duration > 1 else { return 0 }
|
||||
return min(vm.currentTime / vm.duration, 1)
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard vm.duration > 1 else {
|
||||
if case .generating(_, let label) = vm.phase { return label }
|
||||
return "Generating…"
|
||||
}
|
||||
let remaining = vm.duration - vm.currentTime
|
||||
let mins = Int(remaining) / 60
|
||||
return mins > 0 ? "\(mins) min left" : "Almost done"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user