NavidromeApp/Widget/NowPlayingWidgetViews.swift

621 lines
24 KiB
Swift

import SwiftUI
import WidgetKit
import AppIntents
//
// NowPlayingWidgetViews.swift
// TARGET: Widget extension only.
//
// v2 redesign: glassmorphism, waveform scrubber, color-adaptive theming,
// crossfade countdown, Up Next queue (large).
//
// MARK: - Color Helpers
extension Color {
/// Create a Color from a hex string like "E74C3C".
init(hex: String) {
var h = hex.trimmingCharacters(in: .whitespacesAndNewlines)
if h.hasPrefix("#") { h = String(h.dropFirst()) }
guard h.count == 6, let val = UInt64(h, radix: 16) else {
self = .pink
return
}
self.init(
red: Double((val >> 16) & 0xFF) / 255.0,
green: Double((val >> 8) & 0xFF) / 255.0,
blue: Double( val & 0xFF) / 255.0
)
}
}
// MARK: - Adaptive Colors from Entry
struct WidgetColors {
let accent: Color
let secondary: Color
let textPrimary: Color
let textSecondary: Color
let textTertiary: Color
let barUnplayed: Color
init(entry: NowPlayingEntry) {
if let hex = entry.accentColorHex {
accent = Color(hex: hex)
} else {
accent = Color(red: 0.91, green: 0.30, blue: 0.24) // default pink-red
}
if let hex = entry.secondaryColorHex {
secondary = Color(hex: hex)
} else {
secondary = .white.opacity(0.7)
}
textPrimary = .white
textSecondary = secondary.opacity(0.9)
textTertiary = .white.opacity(0.4)
barUnplayed = .white.opacity(0.18)
}
}
// MARK: - Main View (dispatches by family)
struct NowPlayingWidgetView: View {
let entry: NowPlayingEntry
@Environment(\.widgetFamily) var family
var body: some View {
let colors = WidgetColors(entry: entry)
ZStack {
// Layer 0: Blurred album art background
backgroundLayer
// Dark scrim for contrast
Color.black.opacity(0.28)
// Layer 1: Glass panel + content
switch family {
case .systemSmall:
SmallContent(entry: entry, colors: colors)
case .systemMedium:
MediumContent(entry: entry, colors: colors)
case .systemLarge:
LargeContent(entry: entry, colors: colors)
default:
MediumContent(entry: entry, colors: colors)
}
}
}
@ViewBuilder
private var backgroundLayer: some View {
if let data = entry.blurredArtData, let img = UIImage(data: data) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
LinearGradient(
colors: [Color(hex: entry.accentColorHex ?? "3A1A1A"), .black],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
}
//
// MARK: - Glass Panel Container
//
struct GlassPanel<Content: View>: View {
let colors: WidgetColors
@ViewBuilder var content: () -> Content
var body: some View {
content()
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(colors.accent.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(.white.opacity(0.12), lineWidth: 0.5)
)
)
.padding(8)
}
}
//
// MARK: - Waveform Bar (Canvas + SeekToIntent overlay)
//
struct WaveformBar: View {
let samples: [Float]
let progress: Double
let accentColor: Color
let unplayedColor: Color
let barHeight: CGFloat
private let barCount = 40
private let seekSegments = 20
var body: some View {
GeometryReader { geo in
let playedCount = Int(Double(barCount) * progress)
ZStack {
// Waveform canvas
Canvas { ctx, size in
let effectiveSamples = samples.isEmpty
? (0..<barCount).map { _ in Float(0.3) }
: samples
let totalBars = min(effectiveSamples.count, barCount)
let spacing: CGFloat = 1.5
let bw = max((size.width - CGFloat(totalBars - 1) * spacing) / CGFloat(totalBars), 1.5)
for i in 0..<totalBars {
let amp = CGFloat(effectiveSamples[i])
let h = max(amp * (barHeight - 2), 2)
let x = CGFloat(i) * (bw + spacing)
let y = barHeight - h
let rect = CGRect(x: x, y: y, width: bw, height: h)
let rr = Path(roundedRect: rect, cornerRadius: 1)
let color: Color = i < playedCount ? accentColor : unplayedColor
ctx.fill(rr, with: .color(color))
}
}
.frame(height: barHeight)
// Seek tap zones 20 segments over the waveform
HStack(spacing: 0) {
ForEach(0..<seekSegments, id: \.self) { i in
let fraction = (Double(i) + 0.5) / Double(seekSegments)
Button(intent: SeekToIntent(fraction: fraction)) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.frame(height: max(barHeight, 28))
}
}
.frame(height: max(barHeight, 28))
}
}
//
// MARK: - Transport Controls
//
struct TransportControls: View {
let isPlaying: Bool
let iconSize: CGFloat
let playSize: CGFloat
var body: some View {
HStack(spacing: iconSize * 1.5) {
// Previous
Button(intent: PreviousTrackIntent()) {
Image(systemName: "backward.fill")
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
.buttonStyle(.plain)
// Play / Pause
Button(intent: PlayPauseIntent()) {
ZStack {
Circle()
.fill(.white.opacity(0.15))
.frame(width: playSize, height: playSize)
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: playSize * 0.4, weight: .bold))
.foregroundStyle(.white)
.offset(x: isPlaying ? 0 : 1) // optical center for play triangle
}
}
.buttonStyle(.plain)
// Next
Button(intent: NextTrackIntent()) {
Image(systemName: "forward.fill")
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
.buttonStyle(.plain)
}
}
}
//
// MARK: - Cover Art View
//
struct CoverArtView: View {
let data: Data?
let size: CGFloat
var body: some View {
Group {
if let data = data, let img = UIImage(data: data) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
ZStack {
Color.white.opacity(0.08)
Image(systemName: "music.note")
.font(.system(size: size * 0.35, weight: .light))
.foregroundStyle(.white.opacity(0.3))
}
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.15, style: .continuous))
.shadow(color: .black.opacity(0.35), radius: 6, y: 3)
}
}
//
// MARK: - SMALL WIDGET
//
struct SmallContent: View {
let entry: NowPlayingEntry
let colors: WidgetColors
var body: some View {
GlassPanel(colors: colors) {
VStack(spacing: 6) {
// Top: art + text
HStack(spacing: 10) {
CoverArtView(data: entry.coverArtData, size: 48)
VStack(alignment: .leading, spacing: 2) {
Text(entry.songTitle)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(colors.textPrimary)
.lineLimit(1)
Text(entry.artist)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(colors.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer(minLength: 0)
// Waveform
VStack(spacing: 3) {
WaveformBar(
samples: entry.waveformSamples,
progress: entry.progress,
accentColor: colors.accent,
unplayedColor: colors.barUnplayed,
barHeight: 18
)
HStack {
Text(entry.currentTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
Spacer()
Text(entry.remainingTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
}
}
// Controls
TransportControls(isPlaying: entry.isPlaying, iconSize: 14, playSize: 28)
}
}
}
}
//
// MARK: - MEDIUM WIDGET
//
struct MediumContent: View {
let entry: NowPlayingEntry
let colors: WidgetColors
var body: some View {
GlassPanel(colors: colors) {
VStack(spacing: 6) {
// Top row: art + song info
HStack(spacing: 14) {
CoverArtView(data: entry.coverArtData, size: 64)
VStack(alignment: .leading, spacing: 2) {
Text(entry.songTitle)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(colors.textPrimary)
.lineLimit(1)
Text(entry.artist)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(colors.textSecondary)
.lineLimit(1)
Text(entry.album)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(colors.textTertiary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// Waveform + times
VStack(spacing: 3) {
WaveformBar(
samples: entry.waveformSamples,
progress: entry.progress,
accentColor: colors.accent,
unplayedColor: colors.barUnplayed,
barHeight: 18
)
HStack {
Text(entry.currentTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
Spacer()
Text(entry.remainingTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
}
}
// Transport controls
TransportControls(isPlaying: entry.isPlaying, iconSize: 16, playSize: 30)
// Up Next footer with crossfade countdown
upNextFooter
}
}
}
@ViewBuilder
private var upNextFooter: some View {
let nextTrack = entry.queueNext.first
if nextTrack != nil || entry.crossfadeCountdownFormatted != nil {
HStack(spacing: 6) {
Text("")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.4))
if let track = nextTrack {
Text("Up Next: \(track.title) · \(track.artist)")
.font(.system(size: 10, weight: .regular))
.foregroundStyle(.white.opacity(0.4))
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 0)
if entry.isPlaying, let cd = entry.crossfadeCountdownFormatted {
Text(cd)
.font(.system(size: 9, weight: .semibold).monospacedDigit())
.foregroundStyle(colors.accent.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(.white.opacity(0.06))
)
} else if !entry.isPlaying {
Text("paused")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(colors.accent.opacity(0.7))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(.white.opacity(0.06))
)
}
}
}
}
}
//
// MARK: - LARGE WIDGET
//
struct LargeContent: View {
let entry: NowPlayingEntry
let colors: WidgetColors
var body: some View {
GlassPanel(colors: colors) {
VStack(spacing: 8) {
// Header: art + info + waveform
HStack(spacing: 16) {
CoverArtView(data: entry.coverArtData, size: 80)
VStack(alignment: .leading, spacing: 2) {
Text(entry.songTitle)
.font(.system(size: 17, weight: .bold))
.foregroundStyle(colors.textPrimary)
.lineLimit(1)
Text(entry.artist)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(colors.textSecondary)
.lineLimit(1)
Text(entry.album)
.font(.system(size: 11, weight: .regular))
.foregroundStyle(colors.textTertiary)
.lineLimit(1)
Spacer(minLength: 4)
// Waveform inside the header
VStack(spacing: 3) {
WaveformBar(
samples: entry.waveformSamples,
progress: entry.progress,
accentColor: colors.accent,
unplayedColor: colors.barUnplayed,
barHeight: 22
)
HStack {
Text(entry.currentTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
Spacer()
Text(entry.remainingTimeFormatted)
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(colors.textTertiary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// Transport controls
TransportControls(isPlaying: entry.isPlaying, iconSize: 18, playSize: 34)
// Divider
Rectangle()
.fill(.white.opacity(0.08))
.frame(height: 0.5)
.padding(.horizontal, 2)
// Queue section
VStack(alignment: .leading, spacing: 0) {
Text("UP NEXT")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.white.opacity(0.3))
.tracking(0.8)
.padding(.bottom, 6)
.padding(.leading, 2)
if entry.queueNext.isEmpty {
Text("No upcoming tracks")
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.25))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
} else {
ForEach(Array(entry.queueNext.prefix(3).enumerated()), id: \.offset) { idx, item in
queueRow(index: idx + 1, item: item)
}
}
}
.frame(maxHeight: .infinity, alignment: .top)
// Crossfade footer
crossfadeFooter
}
}
}
private func queueRow(index: Int, item: WidgetQueueItem) -> some View {
HStack(spacing: 10) {
Text("\(index)")
.font(.system(size: 11, weight: .medium).monospacedDigit())
.foregroundStyle(.white.opacity(0.3))
.frame(width: 14, alignment: .center)
// Mini art placeholder (no per-track art data in widget)
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(colors.accent.opacity(0.2))
.frame(width: 32, height: 32)
.overlay(
Image(systemName: "music.note")
.font(.system(size: 11, weight: .light))
.foregroundStyle(.white.opacity(0.2))
)
VStack(alignment: .leading, spacing: 1) {
Text(item.title)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
.lineLimit(1)
Text(item.artist)
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.4))
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(.white.opacity(0.03))
)
.padding(.bottom, 2)
}
@ViewBuilder
private var crossfadeFooter: some View {
if entry.isPlaying, let cd = entry.crossfadeCountdownFormatted {
HStack(spacing: 4) {
Text("")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.4))
Text("Crossfade \(cd)")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(colors.accent.opacity(0.7))
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 2)
} else if !entry.isPlaying {
HStack(spacing: 4) {
Text("")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.4))
Text("Paused")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(colors.accent.opacity(0.7))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 2)
}
}
}
// MARK: - Previews
#if DEBUG
#Preview("Small", as: .systemSmall) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry.placeholder
}
#Preview("Medium", as: .systemMedium) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry.placeholder
}
#Preview("Large", as: .systemLarge) {
NowPlayingWidget()
} timeline: {
NowPlayingEntry.placeholder
}
#endif