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.
184 lines
6.3 KiB
Swift
184 lines
6.3 KiB
Swift
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.0–1.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()
|
||
}
|
||
}
|