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: View { let colors: WidgetColors var flush: Bool = false @ViewBuilder var content: () -> Content var body: some View { let radius: CGFloat = flush ? 0 : 16 let outerPad: CGFloat = flush ? 0 : 8 content() .padding(flush ? 14 : 12) .background( RoundedRectangle(cornerRadius: radius, style: .continuous) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: radius, style: .continuous) .fill(colors.accent.opacity(0.06)) ) .overlay( RoundedRectangle(cornerRadius: radius, style: .continuous) .stroke(.white.opacity(0.12), lineWidth: flush ? 0 : 0.5) ) ) .padding(outerPad) } } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // 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.. 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 cover art — real data from queue, fallback to accent placeholder Group { if let data = item.coverArtData, let img = UIImage(data: data) { Image(uiImage: img) .resizable() .aspectRatio(contentMode: .fill) } else { ZStack { colors.accent.opacity(0.2) Image(systemName: "music.note") .font(.system(size: 11, weight: .light)) .foregroundStyle(.white.opacity(0.2)) } } } .frame(width: 32, height: 32) .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) 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) } } } // 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