NavidromeApp/Widget/NowPlayingWidget.swift
Dallas Groot cea4e3868e Seek bar glitch — The time observer was reporting from the outgoing player (near 100%) for the first half, then jumping to the incoming player (near 0%) at 50%. Now it reports from the incoming player as soon as crossfade begins. The user hears the new song fading in — the seek bar matches.
Metadata/art/colors delayed — currentSong, artwork, widget colors, and lyrics only updated after finalizeCrossfade. Now a songHandoff callback fires at the crossfade midpoint (50%) — updates everything mid-fade so the UI transitions with the audio. Scrobble of the outgoing song also fires here (before currentSong changes).
Visualizer stale data — Old song’s vis frames stayed in the buffer during the first half of crossfade. Now visualizerHandoff zeros offlineVisBuffer + _audioLevels before loading the new song’s data. Clean slate → simulation fills the gap → real vis takes over.
The needsNextTrack callback (post-finalize) now only handles player swap, observer re-registration, and queue persistence — no redundant metadata/scrobble work.
2026-04-14 00:27:10 -07:00

184 lines
6.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import WidgetKit
import SwiftUI
//
// NowPlayingWidget.swift
// TARGET: Widget extension only.
//
// Defines the widget bundle, timeline provider, and timeline entry.
// The provider reads from WidgetSharedState (App Group UserDefaults)
// and generates entries with projected playback positions so the
// progress bar advances even between reloads.
//
// MARK: - Widget Bundle
@main
struct NavidromeWidgetBundle: WidgetBundle {
var body: some Widget {
NowPlayingWidget()
}
}
// MARK: - Timeline Entry
struct NowPlayingEntry: TimelineEntry {
let date: Date
// Song metadata
let songTitle: String
let artist: String
let album: String
// Playback state
let isPlaying: Bool
let currentTime: TimeInterval
let duration: TimeInterval
// Images (raw JPEG data decoded in the view)
let coverArtData: Data?
let blurredArtData: Data?
// Up Next queue
let queueNext: [WidgetQueueItem]
// v2: waveform, adaptive colors, crossfade
let waveformSamples: [Float] // 40 peaks 0.01.0
let accentColorHex: String? // dominant color e.g. "E74C3C"
let secondaryColorHex: String? // lighter variant for text
let crossfadeAt: TimeInterval // 0 = no crossfade data
// Whether any data has ever been written
let hasData: Bool
// MARK: - Computed
var progress: Double {
guard duration > 0 else { return 0 }
return min(max(currentTime / duration, 0), 1)
}
var currentTimeFormatted: String { formatTime(currentTime) }
var remainingTimeFormatted: String {
"-\(formatTime(max(duration - currentTime, 0)))"
}
/// Seconds until crossfade triggers. Nil if no crossfade data or already past.
var crossfadeCountdown: TimeInterval? {
guard crossfadeAt > 0, crossfadeAt > currentTime else { return nil }
let remaining = crossfadeAt - currentTime
return remaining < 120 ? remaining : nil // only show if < 2 min
}
var crossfadeCountdownFormatted: String? {
guard let cd = crossfadeCountdown else { return nil }
return "in \(formatTime(cd))"
}
private func formatTime(_ t: TimeInterval) -> String {
let total = Int(max(t, 0))
return String(format: "%d:%02d", total / 60, total % 60)
}
// MARK: - Placeholder
static let placeholder = NowPlayingEntry(
date: .now,
songTitle: "Not Playing",
artist: "NavidromePlayer",
album: "",
isPlaying: false,
currentTime: 0,
duration: 1,
coverArtData: nil,
blurredArtData: nil,
queueNext: [],
waveformSamples: [],
accentColorHex: nil,
secondaryColorHex: nil,
crossfadeAt: 0,
hasData: false
)
}
// MARK: - Timeline Provider
struct NowPlayingProvider: TimelineProvider {
func placeholder(in context: Context) -> NowPlayingEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (NowPlayingEntry) -> Void) {
completion(buildEntry(at: .now))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<NowPlayingEntry>) -> Void) {
let state = WidgetSharedState.shared
let now = Date()
if state.isPlaying && state.duration > 0 {
var entries: [NowPlayingEntry] = []
let elapsed = now.timeIntervalSince(state.lastUpdatedDate)
let baseTime = state.currentTime + elapsed
for i in 0..<10 {
let offset = Double(i) * 30.0
let entryDate = now.addingTimeInterval(offset)
let projectedTime = min(baseTime + offset, state.duration)
entries.append(makeEntry(at: entryDate, projectedTime: projectedTime, state: state))
}
completion(Timeline(entries: entries, policy: .after(now.addingTimeInterval(300))))
} else {
let entry = buildEntry(at: now)
completion(Timeline(entries: [entry], policy: .after(now.addingTimeInterval(15 * 60))))
}
}
private func buildEntry(at date: Date) -> NowPlayingEntry {
let state = WidgetSharedState.shared
var projectedTime = state.currentTime
if state.isPlaying && state.duration > 0 {
let elapsed = date.timeIntervalSince(state.lastUpdatedDate)
projectedTime = min(state.currentTime + elapsed, state.duration)
}
return makeEntry(at: date, projectedTime: projectedTime, state: state)
}
private func makeEntry(at date: Date, projectedTime: TimeInterval, state: WidgetSharedState) -> NowPlayingEntry {
NowPlayingEntry(
date: date,
songTitle: state.songTitle.isEmpty ? "Not Playing" : state.songTitle,
artist: state.artist.isEmpty ? "NavidromePlayer" : state.artist,
album: state.album,
isPlaying: state.isPlaying,
currentTime: projectedTime,
duration: state.duration,
coverArtData: state.coverArtData,
blurredArtData: state.blurredArtData,
queueNext: state.queueNext,
waveformSamples: state.waveformSamples,
accentColorHex: state.accentColorHex,
secondaryColorHex: state.secondaryColorHex,
crossfadeAt: state.crossfadeAt,
hasData: state.hasData
)
}
}
// MARK: - Widget Definition
struct NowPlayingWidget: Widget {
let kind = "NowPlayingWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: NowPlayingProvider()) { entry in
NowPlayingWidgetView(entry: entry)
.containerBackground(.clear, for: .widget)
}
.configurationDisplayName("Now Playing")
.description("Control playback and see what's playing.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}