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.
499 lines
19 KiB
Swift
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
|