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) -> 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() } }