NavidromeApp/Widget/NowPlayingWidgetViews.swift
Dallas Groot 3c28413af8 bug fixes and improvements
Gap 1: Lock Screen seek bar drift — nowPlayingSyncTimer (5s timer that
pushes elapsed time to MPNowPlayingInfoCenter) was created in
playWithAVPlayer but never restarted after a background/foreground
cycle. Now invalidated in suspendVisTimers() and recreated in
resumeVisTimers(). The crossfade path benefits too — it never went
through playWithAVPlayer so the timer was never created at all for
crossfade sessions.
Gap 2: Prefetcher re-downloads after restructure — AudioPreFetcher
used isSongDownloaded(song.id) (exact ID match). After a Companion
restructure changes IDs, songs already downloaded were re-fetched.
Changed to isSongAvailableOffline(song) which falls back to
title/artist/duration matching.
Gap 3: Unbounded image disk cache — trimDiskCache() only ran on app
launch. A long browsing session could push well past the 200MB limit.
Now storeToDisk increments a write counter and triggers trim every 50
writes. The counter lives on ioQueue (serial) so no lock needed.
Gap 4: Custom cover art in widget — WidgetBridge cached blur keyed by
coverArtId. Custom covers don’t change the ID, so the bridge skipped
the update. Now pushWidgetState() passes "custom_\(id)" as the key
when AlbumCoverStore has a custom image. Same album’s songs still
share the key → blur is reused, not redone. When custom is removed,
key reverts to the bare ID → re-blurs with server art.
2026-04-12 16:16:32 -07:00

499 lines
19 KiB
Swift

import SwiftUI
import WidgetKit
import AppIntents
//
// NowPlayingWidgetViews.swift
// TARGET: Widget extension only.
//
// SwiftUI views for small, medium, and large widgets.
// Features:
// Blurred album art background with light/dark scrim
// Interactive play/pause, next, previous via AppIntents
// Progress bar with left/right tap zones for ±15s seek
// "Up Next" queue in large widget
// Graceful idle state when nothing is playing
//
// MARK: - Accent Color
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
// MARK: - Size Router
struct NowPlayingWidgetView: View {
let entry: NowPlayingEntry
@Environment(\.widgetFamily) var family
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
// Blurred album art background
backgroundLayer
// Dark/light scrim for text readability
scrimOverlay
// Content
switch family {
case .systemSmall: SmallWidgetContent(entry: entry)
case .systemMedium: MediumWidgetContent(entry: entry)
case .systemLarge: LargeWidgetContent(entry: entry)
default: MediumWidgetContent(entry: entry)
}
}
}
// MARK: - Background
@ViewBuilder
private var backgroundLayer: some View {
if let data = entry.blurredArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
// Fallback gradient when no art is available
LinearGradient(
colors: colorScheme == .dark
? [Color(white: 0.12), Color(white: 0.08)]
: [Color(white: 0.92), Color(white: 0.85)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
private var scrimOverlay: some View {
Rectangle().fill(
colorScheme == .dark
? Color.black.opacity(0.55)
: Color.white.opacity(0.50)
)
}
}
//
// MARK: - SMALL WIDGET
//
struct SmallWidgetContent: View {
let entry: NowPlayingEntry
@Environment(\.colorScheme) var colorScheme
private var primaryColor: Color { colorScheme == .dark ? .white : .black }
private var secondaryColor: Color { colorScheme == .dark ? Color(white: 0.7) : Color(white: 0.35) }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Album art + song info
HStack(spacing: 10) {
coverArt(size: 48)
VStack(alignment: .leading, spacing: 2) {
Text(entry.songTitle)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(primaryColor)
.lineLimit(2)
Text(entry.artist)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
}
Spacer(minLength: 0)
}
Spacer(minLength: 0)
// Progress bar
ProgressBarView(entry: entry, height: 3, showTimes: false)
// Play/pause button centered
HStack {
Spacer()
Button(intent: PlayPauseIntent()) {
Image(systemName: entry.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22, weight: .medium))
.foregroundStyle(primaryColor)
}
.buttonStyle(.plain)
Spacer()
}
}
.padding(14)
}
@ViewBuilder
private func coverArt(size: CGFloat) -> some View {
if let data = entry.coverArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
} else {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(white: colorScheme == .dark ? 0.2 : 0.8))
.frame(width: size, height: size)
.overlay(
Image(systemName: "music.note")
.font(.system(size: size * 0.35))
.foregroundStyle(Color(white: 0.5))
)
}
}
}
//
// MARK: - MEDIUM WIDGET
//
struct MediumWidgetContent: View {
let entry: NowPlayingEntry
@Environment(\.colorScheme) var colorScheme
private var primaryColor: Color { colorScheme == .dark ? .white : .black }
private var secondaryColor: Color { colorScheme == .dark ? Color(white: 0.7) : Color(white: 0.35) }
var body: some View {
HStack(spacing: 14) {
// Album art
coverArt(size: 90)
// Right side: info + progress + controls
VStack(alignment: .leading, spacing: 0) {
// Song info
Text(entry.songTitle)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(primaryColor)
.lineLimit(1)
Text(entry.artist)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
.padding(.bottom, 2)
Spacer(minLength: 0)
// Progress bar with times and seek tap zones
ProgressBarView(entry: entry, height: 4, showTimes: true)
.padding(.bottom, 8)
// Transport controls
HStack(spacing: 0) {
Spacer(minLength: 0)
transportButton(
icon: "backward.fill",
size: 18,
intent: PreviousTrackIntent()
)
Spacer(minLength: 0)
transportButton(
icon: entry.isPlaying ? "pause.fill" : "play.fill",
size: 26,
intent: PlayPauseIntent()
)
Spacer(minLength: 0)
transportButton(
icon: "forward.fill",
size: 18,
intent: NextTrackIntent()
)
Spacer(minLength: 0)
}
}
}
.padding(14)
}
@ViewBuilder
private func coverArt(size: CGFloat) -> some View {
if let data = entry.coverArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
} else {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(white: colorScheme == .dark ? 0.2 : 0.8))
.frame(width: size, height: size)
.overlay(
Image(systemName: "music.note")
.font(.system(size: size * 0.3))
.foregroundStyle(Color(white: 0.5))
)
}
}
private func transportButton<I: AppIntent>(icon: String, size: CGFloat, intent: I) -> some View {
Button(intent: intent) {
Image(systemName: icon)
.font(.system(size: size, weight: .medium))
.foregroundStyle(primaryColor)
.frame(width: 44, height: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
//
// MARK: - LARGE WIDGET
//
struct LargeWidgetContent: View {
let entry: NowPlayingEntry
@Environment(\.colorScheme) var colorScheme
private var primaryColor: Color { colorScheme == .dark ? .white : .black }
private var secondaryColor: Color { colorScheme == .dark ? Color(white: 0.7) : Color(white: 0.35) }
private var tertiaryColor: Color { colorScheme == .dark ? Color(white: 0.45) : Color(white: 0.55) }
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 4)
// Centered album art
coverArt(size: 140)
.padding(.bottom, 12)
// Song info
Text(entry.songTitle)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(primaryColor)
.lineLimit(1)
.padding(.bottom, 1)
Text(entry.artist)
.font(.system(size: 14, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
if !entry.album.isEmpty {
Text(entry.album)
.font(.system(size: 12, weight: .regular))
.foregroundStyle(tertiaryColor)
.lineLimit(1)
.padding(.top, 1)
}
Spacer(minLength: 8)
// Progress bar with times and seek tap zones
ProgressBarView(entry: entry, height: 4, showTimes: true)
.padding(.horizontal, 8)
.padding(.bottom, 10)
// Transport controls
HStack(spacing: 0) {
Spacer()
transportButton(icon: "backward.fill", size: 20, intent: PreviousTrackIntent())
Spacer()
transportButton(
icon: entry.isPlaying ? "pause.fill" : "play.fill",
size: 30,
intent: PlayPauseIntent()
)
Spacer()
transportButton(icon: "forward.fill", size: 20, intent: NextTrackIntent())
Spacer()
}
.padding(.bottom, 10)
// Up Next divider + queue
if !entry.queueNext.isEmpty {
Divider()
.background(tertiaryColor.opacity(0.3))
.padding(.horizontal, 8)
VStack(alignment: .leading, spacing: 4) {
Text("UP NEXT")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(tertiaryColor)
.tracking(1.2)
.padding(.top, 6)
.padding(.leading, 4)
ForEach(Array(entry.queueNext.prefix(3).enumerated()), id: \.offset) { idx, item in
HStack(spacing: 6) {
Text("\(idx + 2).")
.font(.system(size: 12, weight: .medium).monospacedDigit())
.foregroundStyle(accentPink)
.frame(width: 18, alignment: .trailing)
Text(item.title)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(primaryColor)
.lineLimit(1)
Text("\(item.artist)")
.font(.system(size: 12, weight: .regular))
.foregroundStyle(secondaryColor)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.leading, 4)
}
}
}
Spacer(minLength: 4)
}
.padding(14)
}
@ViewBuilder
private func coverArt(size: CGFloat) -> some View {
if let data = entry.coverArtData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.15), lineWidth: 0.5)
)
.shadow(color: .black.opacity(0.4), radius: 12, x: 0, y: 6)
} else {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(white: colorScheme == .dark ? 0.2 : 0.8))
.frame(width: size, height: size)
.overlay(
Image(systemName: "music.note")
.font(.system(size: size * 0.25))
.foregroundStyle(Color(white: 0.5))
)
}
}
private func transportButton<I: AppIntent>(icon: String, size: CGFloat, intent: I) -> some View {
Button(intent: intent) {
Image(systemName: icon)
.font(.system(size: size, weight: .medium))
.foregroundStyle(primaryColor)
.frame(width: 50, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
//
// MARK: - PROGRESS BAR (shared component)
//
struct ProgressBarView: View {
let entry: NowPlayingEntry
let height: CGFloat
let showTimes: Bool
@Environment(\.colorScheme) var colorScheme
private var trackColor: Color {
colorScheme == .dark ? Color(white: 0.3) : Color(white: 0.7)
}
private var timeColor: Color {
colorScheme == .dark ? Color(white: 0.6) : Color(white: 0.4)
}
var body: some View {
VStack(spacing: 4) {
// Progress track with segmented tap-to-seek
GeometryReader { geo in
ZStack(alignment: .leading) {
// Background track
Capsule()
.fill(trackColor)
.frame(height: height)
// Filled progress
Capsule()
.fill(accentPink)
.frame(width: max(geo.size.width * entry.progress, height), height: height)
// Seek tap zones 10 segments, each seeks to that fraction
HStack(spacing: 0) {
ForEach(0..<10, id: \.self) { i in
let fraction = (Double(i) + 0.5) / 10.0
Button(intent: SeekToIntent(fraction: fraction)) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.frame(height: max(height, 28)) // generous tap target
}
}
.frame(height: max(height, 28))
// Time labels
if showTimes {
HStack {
Text(entry.currentTimeFormatted)
.font(.system(size: 10, weight: .medium).monospacedDigit())
.foregroundStyle(timeColor)
Spacer()
Text(entry.remainingTimeFormatted)
.font(.system(size: 10, weight: .medium).monospacedDigit())
.foregroundStyle(timeColor)
}
}
}
}
}
// MARK: - Previews
#if DEBUG
#Preview("Small — Dark", as: .systemSmall) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: .now, songTitle: "This Is A Test", artist: "Armin van Buuren",
album: "Imagine", isPlaying: true, currentTime: 83, duration: 210,
coverArtData: nil, blurredArtData: nil,
queueNext: [], hasData: true
)
}
#Preview("Medium — Dark", as: .systemMedium) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: .now, songTitle: "This Is A Test", artist: "Armin van Buuren",
album: "Imagine", isPlaying: true, currentTime: 83, duration: 210,
coverArtData: nil, blurredArtData: nil,
queueNext: [], hasData: true
)
}
#Preview("Large — Dark", as: .systemLarge) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry(
date: .now, songTitle: "This Is A Test", artist: "Armin van Buuren",
album: "Imagine", isPlaying: true, currentTime: 83, duration: 210,
coverArtData: nil, blurredArtData: nil,
queueNext: [
WidgetQueueItem(title: "Blah Blah Blah", artist: "Armin van Buuren"),
WidgetQueueItem(title: "In And Out of Love", artist: "Armin van Buuren"),
],
hasData: true
)
}
#endif