504 lines
19 KiB
Swift
504 lines
19 KiB
Swift
import SwiftUI
|
|
import WidgetKit
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// 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
|
|
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 — invisible buttons overlaid on the bar
|
|
HStack(spacing: 0) {
|
|
// Left half → seek backward 15s
|
|
Button(intent: SeekBackwardIntent()) {
|
|
Color.clear
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// Right half → seek forward 15s
|
|
Button(intent: SeekForwardIntent()) {
|
|
Color.clear
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.frame(height: max(height, 24)) // ≥ 24pt tap target
|
|
}
|
|
}
|
|
.frame(height: max(height, 24))
|
|
|
|
// 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
|