NavidromeApp/iOS/Views/NowPlaying/NowPlayingView.swift
2026-04-04 18:02:14 -07:00

1819 lines
66 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
import AVKit
import AVFoundation
import UIKit
import Combine
import ShazamKit
import MusicKit
// MARK: - Album Art Color Extractor
class AlbumColorExtractor: ObservableObject {
static let shared = AlbumColorExtractor()
```
@Published var primaryColor: Color = .pink
@Published var secondaryColor: Color = .blue
@Published var isLoaded = false
@Published var currentImage: UIImage?
/// UIKit status bar style derived from album brightness.
/// Kept separate from preferredColorScheme so NowPlayingView stays dark-mode
/// while only the status bar icons flip for very bright album art.
@Published var preferredStatusBarStyle: UIStatusBarStyle = .lightContent
private var lastCoverArtId: String?
private var coverStoreObserver: AnyCancellable?
private init() {
// Re-extract colors when custom album cover changes
coverStoreObserver = AlbumCoverStore.shared.$updateTrigger
.dropFirst()
.sink { [weak self] _ in
guard let self = self, let id = self.lastCoverArtId else { return }
self.lastCoverArtId = nil // force re-extract
self.extract(from: id)
}
}
func extract(from coverArtId: String?) {
guard let id = coverArtId, id != lastCoverArtId else { return }
lastCoverArtId = id
// Check user-set custom cover first
if let customImage = AlbumCoverStore.shared.loadCover(for: id) {
let colors = Self.dominantColors(from: customImage)
self.primaryColor = colors.0
self.secondaryColor = colors.1
self.currentImage = customImage
self.isLoaded = true
self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0)
return
}
guard let url = ServerManager.shared.client.coverArtURL(id: id, size: 100) else { return }
// Check cache
if let cached = ImageCache.shared.cachedImage(for: url) {
let colors = Self.dominantColors(from: cached)
self.primaryColor = colors.0
self.secondaryColor = colors.1
self.currentImage = cached
self.isLoaded = true
self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0)
return
}
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { return }
ImageCache.shared.store(image, for: url)
let colors = Self.dominantColors(from: image)
await MainActor.run {
withAnimation(.easeInOut(duration: 1.0)) {
self.primaryColor = colors.0
self.secondaryColor = colors.1
self.currentImage = image
self.isLoaded = true
}
self.preferredStatusBarStyle = Self.statusBarStyle(for: colors.0)
}
} catch { }
}
}
/// Derives UIStatusBarStyle from a dominant album color.
/// High threshold (0.88) because the NowPlaying background always applies
/// Color.black.opacity(0.4) over the art only genuinely white/near-white
/// albums ever need dark icons.
private static func statusBarStyle(for color: Color) -> UIStatusBarStyle {
var brightness: CGFloat = 0
UIColor(color).getHue(nil, saturation: nil, brightness: &brightness, alpha: nil)
return brightness > 0.88 ? .darkContent : .lightContent
}
static func dominantColors(from image: UIImage) -> (Color, Color) {
guard let cgImage = image.cgImage else { return (.pink, .blue) }
let width = min(cgImage.width, 50)
let height = min(cgImage.height, 50)
let bytesPerPixel = 4
let bytesPerRow = width * bytesPerPixel
var pixelData = [UInt8](repeating: 0, count: width * height * bytesPerPixel)
guard let context = CGContext(
data: &pixelData,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return (.pink, .blue) }
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
// Sample pixels and bucket by hue
var colorBuckets: [(r: CGFloat, g: CGFloat, b: CGFloat, count: Int)] = Array(repeating: (0, 0, 0, 0), count: 12)
let totalPixels = width * height
let step = max(1, totalPixels / 200) // sample ~200 pixels
for i in stride(from: 0, to: totalPixels, by: step) {
let offset = i * bytesPerPixel
let r = CGFloat(pixelData[offset]) / 255.0
let g = CGFloat(pixelData[offset + 1]) / 255.0
let b = CGFloat(pixelData[offset + 2]) / 255.0
// Skip very dark or very light pixels
let brightness = (r + g + b) / 3.0
if brightness < 0.1 || brightness > 0.9 { continue }
// Skip low saturation (grayish)
let maxC = max(r, g, b)
let minC = min(r, g, b)
let saturation = maxC > 0 ? (maxC - minC) / maxC : 0
if saturation < 0.15 { continue }
// Bucket by hue (12 buckets = 30° each)
var hue: CGFloat = 0
if maxC == minC {
hue = 0
} else if maxC == r {
hue = (g - b) / (maxC - minC)
if hue < 0 { hue += 6 }
} else if maxC == g {
hue = 2 + (b - r) / (maxC - minC)
} else {
hue = 4 + (r - g) / (maxC - minC)
}
let bucket = Int(hue / 6.0 * 12.0) % 12
colorBuckets[bucket].r += r
colorBuckets[bucket].g += g
colorBuckets[bucket].b += b
colorBuckets[bucket].count += 1
}
// Sort buckets by count, pick top 2
let sorted = colorBuckets.enumerated()
.filter { $0.element.count > 0 }
.sorted { $0.element.count > $1.element.count }
func colorFromBucket(_ b: (r: CGFloat, g: CGFloat, b: CGFloat, count: Int)) -> Color {
let c = CGFloat(b.count)
return Color(red: b.r / c, green: b.g / c, blue: b.b / c)
}
let primary = sorted.count > 0 ? colorFromBucket(sorted[0].element) : Color.pink
// Pick secondary that's at least 2 buckets away from primary for contrast
var secondary = Color.blue
if sorted.count > 1 {
let primaryIdx = sorted[0].offset
for s in sorted.dropFirst() {
let dist = min(abs(s.offset - primaryIdx), 12 - abs(s.offset - primaryIdx))
if dist >= 2 {
secondary = colorFromBucket(s.element)
break
}
}
if secondary == .blue && sorted.count > 1 {
secondary = colorFromBucket(sorted[1].element)
}
}
return (primary, secondary)
}
```
}
// MARK: - Now Playing View (landscape-adaptive)
struct NowPlayingView: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var serverManager: ServerManager
@EnvironmentObject var offlineManager: OfflineManager
@Binding var isPresented: Bool
```
@State private var isDraggingSlider = false
@State private var dragPosition: Double = 0
@State private var showQueue = false
@State private var showVisualizerSettings = false
@State private var showPlaylistPicker = false
@State private var showGetInfo = false
@State private var showTrackEditor = false
@State private var showEllipsisMenu = false
@State private var isStarred = false
@State private var availablePlaylists: [Playlist] = []
@State private var isRecording = false
@State private var shazamResult: String?
@State private var shazamArtworkURL: URL?
@State private var shazamPreviewURL: URL?
@State private var isShazaming = false
@State private var showShazamResult = false
@StateObject private var albumColors = AlbumColorExtractor.shared
@StateObject private var radioBuffer = RadioStreamBuffer.shared
@ObservedObject private var visSettings = VisualizerSettings.shared
@State private var dragOffset: CGFloat = 0
// Local time polling avoids @Published currentTime triggering global redraws
@State private var playbackTime: TimeInterval = 0
@State private var playbackDuration: TimeInterval = 0
private let timePoller = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
@Environment(\.horizontalSizeClass) private var hSizeClass
@Environment(\.verticalSizeClass) private var vSizeClass
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var isLandscape: Bool {
vSizeClass == .compact
}
/// Whether the current song is a radio stream
private var isRadio: Bool {
audioPlayer.isRadioStream || audioPlayer.currentSong?.album == "Radio"
}
var body: some View {
GeometryReader { screenGeo in
ZStack {
visualizerLayer(geo: screenGeo)
if isLandscape {
landscapeLayout
} else {
portraitLayout
}
}
} // end GeometryReader
.background { backgroundGradient }
.offset(y: dragOffset)
.gesture(
DragGesture(minimumDistance: 30, coordinateSpace: .local)
.onChanged { value in
let isVertical = abs(value.translation.height) > abs(value.translation.width)
if value.translation.height > 0 && isVertical {
dragOffset = value.translation.height
}
}
.onEnded { value in
if value.translation.height > 120 || value.velocity.height > 800 {
// Dismiss the parent ZStack transition handles the slide-out
withAnimation(.easeInOut(duration: 0.35)) {
dragOffset = 0
isPresented = false
}
} else {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
dragOffset = 0
}
}
}
)
.preferredColorScheme(.dark)
.sheet(isPresented: $showQueue) {
if isRadio {
RadioRecordingsView(stationName: audioPlayer.currentSong?.title ?? "Radio")
} else {
QueueView()
}
}
.sheet(isPresented: $showGetInfo) {
if let song = audioPlayer.currentSong {
SongInfoSheet(song: song)
}
}
.sheet(isPresented: $showPlaylistPicker) {
AddToPlaylistSheet(
songId: audioPlayer.currentSong?.id,
playlists: availablePlaylists
)
}
.sheet(isPresented: $showTrackEditor) {
if let song = audioPlayer.currentSong {
TrackEditorView(song: song)
}
}
.sheet(isPresented: $showVisualizerSettings) {
VisualizerSettingsView()
}
.sheet(isPresented: $showShazamResult) {
ShazamResultSheet(
title: shazamResult ?? "",
artworkURL: shazamArtworkURL,
previewURL: shazamPreviewURL
)
.presentationDetents([.medium])
}
.sheet(isPresented: $showEllipsisMenu) {
ellipsisActionsSheet
.presentationDetents([.medium, .large])
}
.onAppear {
dragOffset = 0
isStarred = audioPlayer.currentSong?.starred != nil
albumColors.extract(from: audioPlayer.currentSong?.coverArt)
}
.onChange(of: audioPlayer.currentSong?.id) { _, _ in
isStarred = audioPlayer.currentSong?.starred != nil
albumColors.extract(from: audioPlayer.currentSong?.coverArt)
}
.onChange(of: audioPlayer.currentSong?.starred) { _, newVal in
isStarred = newVal != nil
}
.onReceive(timePoller) { _ in
playbackTime = audioPlayer.currentTime
playbackDuration = audioPlayer.duration
}
}
// MARK: - Portrait Layout
private var portraitLayout: some View {
VStack(spacing: 0) {
topBar
Spacer()
albumArt(size: 300)
Spacer()
songInfo
progressBar.padding(.top, 16)
transportControls.padding(.top, 20)
bottomControls.padding(.top, 12).padding(.bottom, 30)
}
}
// MARK: - Landscape Layout
private var landscapeLayout: some View {
GeometryReader { geo in
HStack(spacing: 0) {
// Left: Album art centered
VStack {
Spacer()
albumArt(size: min(200, geo.size.height - 40))
Spacer()
}
.frame(width: geo.size.width * 0.4)
// Right: Controls padded below Dynamic Island
VStack(spacing: 6) {
HStack {
Button(action: {
withAnimation(.easeInOut(duration: 0.35)) {
isPresented = false
}
}) {
Image(systemName: "chevron.down")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
}
Spacer()
ellipsisMenu
}
.padding(.horizontal, 16)
songInfo
progressBar.padding(.top, 6)
transportControls.padding(.top, 8)
bottomControls.padding(.top, 4).padding(.bottom, 8)
}
.frame(width: geo.size.width * 0.6)
}
}
}
// MARK: - Visualizer Layer
@ViewBuilder
private func visualizerLayer(geo: GeometryProxy) -> some View {
if visSettings.enabled && visSettings.nowPlayingEnabled {
if isLandscape {
VStack(spacing: 0) {
Spacer()
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
.frame(maxWidth: .infinity)
.frame(height: geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7))
.allowsHitTesting(false)
}
.ignoresSafeArea()
} else {
VStack {
Spacer()
MitsuhaVisualizerView(isPlaying: audioPlayer.isPlaying, accentColor: accentPink)
.frame(height: geo.size.height * visSettings.nowPlayingHeightPct)
.allowsHitTesting(false)
}
.ignoresSafeArea()
}
}
}
// MARK: - Background
private var backgroundGradient: some View {
ZStack {
// Layer 1: Deep black base
Color.black.ignoresSafeArea()
// Layer 2: Blurred album art with crossfade on song change
if let art = albumColors.currentImage {
GeometryReader { geo in
Image(uiImage: art)
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.clipped()
.blur(radius: 60, opaque: true)
.overlay(Color.black.opacity(0.4))
}
.ignoresSafeArea()
.id(audioPlayer.currentSong?.albumId ?? audioPlayer.currentSong?.id ?? "none")
.transition(.opacity)
}
// Layer 3: Radial vignette from dominant color
RadialGradient(
gradient: Gradient(colors: [
albumColors.primaryColor.opacity(0.4),
Color.black.opacity(0.85)
]),
center: .center,
startRadius: 80,
endRadius: 500
)
.ignoresSafeArea()
}
.animation(.easeInOut(duration: 0.6), value: audioPlayer.currentSong?.albumId)
}
// MARK: - Top Bar (portrait only)
private var topBar: some View {
HStack {
Button(action: {
withAnimation(.easeInOut(duration: 0.35)) {
isPresented = false
}
}) {
Image(systemName: "chevron.down")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
.frame(width: 44, height: 44)
Spacer()
VStack(spacing: 2) {
Text("PLAYING FROM")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.gray)
.tracking(1)
Text(audioPlayer.currentSong?.album ?? "")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
}
.onTapGesture {
if let albumId = audioPlayer.currentSong?.albumId {
NotificationCenter.default.post(
name: .navigateToAlbum,
object: nil,
userInfo: ["albumId": albumId]
)
}
}
Spacer()
ellipsisMenu
}
.padding(.horizontal, 8)
.padding(.top, 8)
}
private var ellipsisMenu: some View {
Button(action: { showEllipsisMenu = true }) {
Image(systemName: "ellipsis")
.font(.system(size: 18))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
/// Actions sheet replaces Menu to avoid UIContextMenuInteraction conflicts
/// with the drag-to-dismiss gesture. Uses a custom sheet instead of confirmationDialog
/// so we can show icons and dividers.
private var ellipsisActionsSheet: some View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
// Visualizer Settings (centered, full width)
Button(action: {
showEllipsisMenu = false
showVisualizerSettings = true
}) {
HStack(spacing: 8) {
Image(systemName: "waveform")
.font(.system(size: 18))
.foregroundColor(accentPink)
Text("Visualizer Settings")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
}
.frame(maxWidth: .infinity, minHeight: 44)
.background(Color.white.opacity(0.08))
.cornerRadius(12)
}
.padding(.horizontal, 16)
if !isRadio {
// Row 1: Instant Mix | Add to Playlist
gridRow(
left: ("Instant Mix", "wand.and.stars", {
showEllipsisMenu = false
if let song = audioPlayer.currentSong {
audioPlayer.playInstantMix(basedOn: song)
}
}),
right: ("Add to Playlist", "text.badge.plus", {
showEllipsisMenu = false
Task {
availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? []
showPlaylistPicker = true
}
})
)
// Row 2: Go to Album | Go to Artist
gridRow(
left: ("Go to Album", "square.stack", {
showEllipsisMenu = false
if let albumId = audioPlayer.currentSong?.albumId {
NotificationCenter.default.post(name: .navigateToAlbum, object: nil, userInfo: ["albumId": albumId])
}
}),
right: ("Go to Artist", "music.mic", {
showEllipsisMenu = false
if let artistId = audioPlayer.currentSong?.artistId {
NotificationCenter.default.post(name: .navigateToArtist, object: nil, userInfo: ["artistId": artistId])
}
})
)
// Row 3: Download/Remove | Send to Watch (always visible)
if let song = audioPlayer.currentSong {
let isDownloaded = offlineManager.isSongDownloaded(song.id)
let isOnWatch = WatchConnectivityManager.shared.isSongOnWatch(song.id)
gridRow(
left: isDownloaded
? ("Remove Download", "trash", { showEllipsisMenu = false; offlineManager.removeSong(song.id) })
: ("Download", "arrow.down.circle", {
showEllipsisMenu = false
if let server = serverManager.activeServer { offlineManager.downloadSong(song, server: server) }
}),
right: (
isOnWatch ? "On Watch ✓" : "Send to Watch",
isOnWatch ? "applewatch.checkmark" : "applewatch.and.arrow.forward",
{
showEllipsisMenu = false
if !isOnWatch {
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
}
}
)
)
// Row 3b: Favourite toggle
gridRow(
left: (
song.starred != nil ? "Unfavourite" : "Favourite",
song.starred != nil ? "heart.slash.fill" : "heart",
{
showEllipsisMenu = false
Task {
if song.starred != nil {
try? await serverManager.client.unstar(id: song.id)
} else {
try? await serverManager.client.star(id: song.id)
}
}
}
),
right: nil
)
}
Divider().padding(.horizontal, 16)
}
// Row 4: Get Info | Edit Tags
gridRow(
left: ("Get Info", "info.circle", {
showEllipsisMenu = false
showGetInfo = true
}),
right: CompanionSettings.shared.isEnabled
? ("Edit Tags", "tag", {
showEllipsisMenu = false
showTrackEditor = true
})
: nil
)
// Playlist link if playing from one
if let pid = audioPlayer.sourcePlaylistId,
let pname = audioPlayer.sourcePlaylistName {
Button(action: {
showEllipsisMenu = false
withAnimation(.easeInOut(duration: 0.35)) { isPresented = false }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: .navigateToPlaylist, object: nil, userInfo: ["playlistId": pid])
}
}) {
Label("Show in \(pname)", systemImage: "music.note.list")
.font(.system(size: 14))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.white.opacity(0.08))
.cornerRadius(12)
}
.padding(.horizontal, 16)
}
Spacer()
}
.padding(.top, 20)
}
.background(Color(white: 0.1))
.navigationTitle("Options")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { showEllipsisMenu = false }
.foregroundColor(accentPink)
}
}
}
}
/// Two-button grid row for the options sheet
private func gridRow(
left: (String, String, () -> Void),
right: (String, String, () -> Void)?
) -> some View {
HStack(spacing: 12) {
gridButton(title: left.0, icon: left.1, action: left.2)
if let r = right {
gridButton(title: r.0, icon: r.1, action: r.2)
} else {
Color.clear.frame(maxWidth: .infinity, minHeight: 70)
}
}
.padding(.horizontal, 16)
}
private func gridButton(title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(accentPink)
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.white)
.multilineTextAlignment(.center)
.lineLimit(2)
}
.frame(maxWidth: .infinity, minHeight: 70)
.background(Color.white.opacity(0.08))
.cornerRadius(12)
}
}
// MARK: - Album Art
private func albumArt(size: CGFloat) -> some View {
Group {
if let song = audioPlayer.currentSong,
song.album == "Radio",
let img = RadioCoverStore.shared.loadCover(for: song.id) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
AsyncCoverArt(coverArtId: audioPlayer.currentSong?.coverArt, size: Int(size) * 2)
}
}
.frame(width: size, height: size)
.cornerRadius(8)
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
}
// MARK: - Song Info
private var songInfo: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(audioPlayer.currentSong?.title ?? "Not Playing")
.font(.system(size: isLandscape ? 16 : 20, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
Text(audioPlayer.currentSong?.artist ?? "")
.font(.system(size: isLandscape ? 13 : 16))
.foregroundColor(accentPink)
.lineLimit(1)
.onTapGesture {
if let artistId = audioPlayer.currentSong?.artistId {
NotificationCenter.default.post(
name: .navigateToArtist,
object: nil,
userInfo: ["artistId": artistId]
)
}
}
// Show Shazam result if available
if let result = shazamResult {
Text(result)
.font(.system(size: isLandscape ? 11 : 13))
.foregroundColor(.white.opacity(0.7))
.lineLimit(2)
.transition(.opacity)
}
}
Spacer()
if !isRadio {
// Heart/star for regular music
Button(action: toggleStar) {
Image(systemName: isStarred ? "heart.fill" : "heart")
.font(.system(size: isLandscape ? 18 : 22))
.foregroundColor(isStarred ? accentPink : .gray)
}
}
}
.padding(.horizontal, isLandscape ? 20 : 30)
}
// MARK: - Progress Bar
private var progressBar: some View {
VStack(spacing: 6) {
if isRadio {
// Radio always shows LIVE indicator, buffer bar when available
radioProgressBar
} else {
// Normal song progress bar
normalProgressBar
}
}
.padding(.horizontal, isLandscape ? 20 : 30)
}
private var normalProgressBar: some View {
VStack(spacing: 6) {
SiriSeekBar(
value: Binding(
get: {
guard playbackDuration > 0 else { return 0 }
return playbackTime / playbackDuration
},
set: { _ in }
),
onSeek: { pct in
isDraggingSlider = false
audioPlayer.seekToPercent(pct)
},
onDragChanged: { pct in
isDraggingSlider = true
dragPosition = pct
}
)
HStack {
Text(formatTime(isDraggingSlider ? playbackDuration * dragPosition : playbackTime))
.font(.system(size: 11)).foregroundColor(.gray)
Spacer()
Text("-" + formatTime(playbackDuration - (isDraggingSlider ? playbackDuration * dragPosition : playbackTime)))
.font(.system(size: 11)).foregroundColor(.gray)
}
}
}
private var radioProgressBar: some View {
VStack(spacing: 6) {
if radioBuffer.isHLSStream || !radioBuffer.isBuffering {
// HLS or no buffer static thin bar, no seek
Capsule().fill(Color.white.opacity(0.15)).frame(height: 4)
} else {
// Buffered radio scrubbable seek bar
GeometryReader { geo in
let bufferSec = radioBuffer.estimatedBufferSeconds
let maxSec = radioBuffer.maxBufferDuration
let fillPct = min(bufferSec / maxSec, 1.0)
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.15)).frame(height: 4)
Capsule().fill(accentPink.opacity(0.35))
.frame(width: geo.size.width * fillPct, height: 4)
if isDraggingSlider {
Circle().fill(Color.white)
.frame(width: 14, height: 14)
.offset(x: geo.size.width * dragPosition - 7)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { v in
isDraggingSlider = true
let pct = min(max(v.location.x / geo.size.width, 0), fillPct)
dragPosition = pct
}
.onEnded { v in
isDraggingSlider = false
let pct = min(max(v.location.x / geo.size.width, 0), fillPct)
let seekTime = maxSec * pct
audioPlayer.radioSeekBack(to: seekTime)
}
)
}
.frame(height: 14)
// Time labels
HStack {
if isDraggingSlider {
let scrubTime = radioBuffer.maxBufferDuration * dragPosition
Text(formatTime(scrubTime))
.font(.system(size: 11)).foregroundColor(.gray)
} else {
Text(formatTime(radioBuffer.bufferedDuration))
.font(.system(size: 11)).foregroundColor(.gray)
}
Spacer()
if !radioBuffer.isLive {
// "Back to live" tap on time label
Button(action: { audioPlayer.radioGoLive() }) {
Text("Go Live")
.font(.system(size: 11, weight: .medium))
.foregroundColor(accentPink)
}
}
}
}
}
}
// MARK: - Transport Controls
private var transportControls: some View {
let iconSize: CGFloat = isLandscape ? 24 : 30
let playSize: CGFloat = isLandscape ? 34 : 42
let isRecordedRadio = isRadio && !audioPlayer.isRadioStream
let isLiveRadio = isRadio && audioPlayer.isRadioStream
return VStack(spacing: 4) {
// LIVE indicator above transport only for live radio streams
if isLiveRadio {
liveIndicator
.padding(.bottom, 4)
}
HStack(spacing: 0) {
Spacer()
if isRecordedRadio {
Button(action: {
audioPlayer.seek(to: max(0, audioPlayer.currentTime - 5))
}) {
Image(systemName: "gobackward.5").font(.system(size: iconSize)).foregroundColor(.white)
}.frame(width: 60, height: 50)
} else if !isLiveRadio {
Button(action: { audioPlayer.previous() }) {
Image(systemName: "backward.fill").font(.system(size: iconSize)).foregroundColor(.white)
}.frame(width: 60, height: 50)
} else {
Color.clear.frame(width: 60, height: 50)
}
Spacer()
Button(action: { audioPlayer.togglePlayPause() }) {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: playSize)).foregroundColor(.white)
}.frame(width: 70, height: 60)
Spacer()
if isRecordedRadio {
Button(action: {
audioPlayer.seek(to: min(audioPlayer.duration, audioPlayer.currentTime + 5))
}) {
Image(systemName: "goforward.5").font(.system(size: iconSize)).foregroundColor(.white)
}.frame(width: 60, height: 50)
} else if !isLiveRadio {
Button(action: { audioPlayer.next() }) {
Image(systemName: "forward.fill").font(.system(size: iconSize)).foregroundColor(.white)
}.frame(width: 60, height: 50)
} else {
Color.clear.frame(width: 60, height: 50)
}
Spacer()
}
}
}
/// Glowing LIVE indicator red box when buffering active, gray when not
private var liveIndicator: some View {
Button(action: {
if radioBuffer.isBuffering && !radioBuffer.isLive {
audioPlayer.radioGoLive()
} else if !radioBuffer.isBuffering {
if let url = audioPlayer.radioStreamURL {
RadioStreamBuffer.shared.startBuffering(
url: url,
stationName: audioPlayer.currentSong?.title ?? "Radio"
)
}
}
}) {
HStack(spacing: 5) {
Circle()
.fill(radioBuffer.isBuffering ? Color.red : Color.gray.opacity(0.5))
.frame(width: 7, height: 7)
Text("LIVE")
.font(.system(size: 12, weight: .bold))
.foregroundColor(radioBuffer.isBuffering ? .white : .gray)
}
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(radioBuffer.isBuffering ? Color.red.opacity(0.25) : Color.white.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(radioBuffer.isBuffering ? Color.red.opacity(0.6) : Color.clear, lineWidth: 1)
)
.shadow(color: radioBuffer.isBuffering ? Color.red.opacity(0.4) : .clear, radius: 8)
}
}
// MARK: - Bottom Controls
private var bottomControls: some View {
HStack {
if isRadio {
// Shazam button
Button(action: shazamCurrentAudio) {
Image(systemName: isShazaming ? "waveform" : "shazam.logo")
.font(.system(size: isLandscape ? 14 : 16))
.foregroundColor(isShazaming ? accentPink : .gray)
.symbolEffect(.pulse, isActive: isShazaming)
}.frame(width: 40, height: 40)
Spacer()
// Recordings list
Button(action: { showQueue = true }) {
Image(systemName: "recordingtape").font(.system(size: isLandscape ? 14 : 16)).foregroundColor(.gray)
}.frame(width: 40, height: 40)
Spacer()
AirPlayButton().frame(width: 40, height: 40)
} else {
// Normal music controls
Button(action: { audioPlayer.toggleShuffle() }) {
Image(systemName: "shuffle").font(.system(size: isLandscape ? 14 : 16))
.foregroundColor(audioPlayer.shuffleEnabled ? accentPink : .gray)
}.frame(width: 40, height: 40)
Spacer()
Button(action: { audioPlayer.cycleRepeat() }) {
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
.font(.system(size: isLandscape ? 14 : 16))
.foregroundColor(audioPlayer.repeatMode != .off ? accentPink : .gray)
}.frame(width: 40, height: 40)
Spacer()
Button(action: { showQueue = true }) {
Image(systemName: "list.bullet").font(.system(size: isLandscape ? 14 : 16)).foregroundColor(.gray)
}.frame(width: 40, height: 40)
Spacer()
AirPlayButton().frame(width: 40, height: 40)
}
}
.padding(.horizontal, isLandscape ? 20 : 30)
}
// MARK: - Helpers
private func formatTime(_ seconds: TimeInterval) -> String {
let secs = Int(max(0, seconds))
return String(format: "%d:%02d", secs / 60, secs % 60)
}
private func toggleStar() {
guard let song = audioPlayer.currentSong else { return }
let wasStarred = isStarred
isStarred.toggle()
Task {
do {
if wasStarred {
try await serverManager.client.unstar(id: song.id)
} else {
try await serverManager.client.star(id: song.id)
}
// Rebuild song with updated starred field so it persists across dismiss/reopen
await MainActor.run {
let newStarred: String? = wasStarred ? nil : "true"
let updated = Song(
id: song.id, parent: song.parent, isDir: song.isDir,
title: song.title, album: song.album, artist: song.artist,
track: song.track, year: song.year, genre: song.genre,
coverArt: song.coverArt, size: song.size,
contentType: song.contentType, suffix: song.suffix,
transcodedContentType: song.transcodedContentType,
transcodedSuffix: song.transcodedSuffix,
duration: song.duration, bitRate: song.bitRate,
path: song.path, playCount: song.playCount,
discNumber: song.discNumber, created: song.created,
albumId: song.albumId, artistId: song.artistId,
type: song.type, starred: newStarred,
bpm: song.bpm, musicBrainzId: song.musicBrainzId
)
audioPlayer.currentSong = updated
// Also update in queue
if let idx = audioPlayer.queue.firstIndex(where: { $0.id == song.id }) {
audioPlayer.queue[idx] = updated
}
}
} catch {
await MainActor.run { isStarred = wasStarred }
}
}
}
// MARK: - Shazam
private func shazamCurrentAudio() {
guard !isShazaming else { return }
isShazaming = true
shazamResult = "Listening..."
shazamArtworkURL = nil
shazamPreviewURL = nil
ShazamRecognizer.shared.recognize { result in
DispatchQueue.main.async {
isShazaming = false
if let match = result {
shazamResult = match.displayText
shazamArtworkURL = match.artworkURL
shazamPreviewURL = match.previewURL
showShazamResult = true
// Auto-clear text after 10 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
if shazamResult == match.displayText { shazamResult = nil }
}
} else {
shazamResult = "No match found"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
if shazamResult == "No match found" { shazamResult = nil }
}
}
}
}
}
// MARK: - Recording
private func toggleRecording() {
if isRecording {
_ = radioBuffer.stopRecording()
isRecording = false
} else {
radioBuffer.startRecording()
isRecording = true
}
}
```
}
/// AirPlay route picker wrapped in a container UIView to prevent _UIReparentingView warnings.
/// The AVRoutePickerView creates internal subviews that conflict with SwiftUIs UIHostingController
/// hierarchy. Wrapping in a plain UIView keeps the reparenting inside our container.
struct AirPlayButton: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let container = UIView()
container.backgroundColor = .clear
```
let picker = AVRoutePickerView()
picker.activeTintColor = UIColor(red: 1.0, green: 0.176, blue: 0.333, alpha: 1.0)
picker.tintColor = .gray
picker.prioritizesVideoDevices = false
picker.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(picker)
NSLayoutConstraint.activate([
picker.centerXAnchor.constraint(equalTo: container.centerXAnchor),
picker.centerYAnchor.constraint(equalTo: container.centerYAnchor),
picker.widthAnchor.constraint(equalTo: container.widthAnchor),
picker.heightAnchor.constraint(equalTo: container.heightAnchor)
])
return container
}
func updateUIView(_ uiView: UIView, context: Context) {}
```
}
// MARK: - Add to Playlist Sheet
struct AddToPlaylistSheet: View {
let songId: String?
let playlists: [Playlist]
@Environment(.dismiss) private var dismiss
```
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
List {
if playlists.isEmpty {
Text("No playlists found")
.foregroundColor(.gray)
} else {
ForEach(playlists) { playlist in
Button(action: { addToPlaylist(playlist) }) {
HStack(spacing: 12) {
AsyncCoverArt(coverArtId: playlist.coverArt, size: 44)
.frame(width: 44, height: 44)
.cornerRadius(4)
VStack(alignment: .leading, spacing: 2) {
Text(playlist.name)
.font(.system(size: 15))
.foregroundColor(.white)
Text("\(playlist.songCount ?? 0) songs")
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "plus.circle")
.foregroundColor(accentPink)
}
}
}
}
}
.navigationTitle("Add to Playlist")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") { dismiss() }
.foregroundColor(accentPink)
}
}
}
}
private func addToPlaylist(_ playlist: Playlist) {
guard let songId = songId else { return }
Task {
try? await ServerManager.shared.client.updatePlaylist(
id: playlist.id,
songIdsToAdd: [songId]
)
await MainActor.run { dismiss() }
}
}
```
}
// MARK: - Queue View
struct QueueView: View {
@EnvironmentObject var audioPlayer: AudioPlayer
@EnvironmentObject var offlineManager: OfflineManager
@ObservedObject private var libraryCache = LibraryCache.shared
@State private var editMode: EditMode = .active
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
```
var body: some View {
NavigationStack {
List {
// Currently playing header
if let song = audioPlayer.currentSong {
Section {
HStack(spacing: 12) {
Image(systemName: "waveform")
.font(.system(size: 14))
.foregroundColor(accentPink)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15, weight: .medium))
.foregroundColor(accentPink)
.lineLimit(1)
Text(song.artist ?? "")
.font(.system(size: 12))
.foregroundColor(.gray)
.lineLimit(1)
}
Spacer()
Text(song.durationFormatted)
.font(.system(size: 13))
.foregroundColor(.gray)
}
} header: {
Text("Now Playing")
}
}
Section {
ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in
if index != audioPlayer.queueIndex {
let available = offlineManager.isSongDownloaded(song.id) || libraryCache.isServerAvailable
HStack(spacing: 12) {
Text("\(index + 1)")
.font(.system(size: 13, design: .monospaced))
.foregroundColor(available ? .gray : .gray.opacity(0.35))
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(song.title)
.font(.system(size: 15))
.foregroundColor(available ? .white : .gray.opacity(0.4))
.lineLimit(1)
Text(song.artist ?? "")
.font(.system(size: 12))
.foregroundColor(available ? .gray : .gray.opacity(0.3))
.lineLimit(1)
}
Spacer()
if offlineManager.isSongDownloaded(song.id) {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 11))
.foregroundColor(.green.opacity(0.6))
}
Text(song.durationFormatted)
.font(.system(size: 13))
.foregroundColor(available ? .gray : .gray.opacity(0.35))
}
.contentShape(Rectangle())
.onTapGesture {
if available { audioPlayer.play(song: song, at: index) }
}
}
}
.onMove(perform: audioPlayer.moveQueueItem)
.onDelete(perform: audioPlayer.removeFromQueue)
} header: {
HStack {
Text("Up Next")
Spacer()
Text("\(audioPlayer.queue.count) songs")
.font(.caption)
.foregroundColor(.gray)
}
}
}
.environment(\.editMode, $editMode)
.navigationTitle("Queue")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 16) {
Button(action: { audioPlayer.toggleShuffle() }) {
Image(systemName: "shuffle")
.foregroundColor(audioPlayer.shuffleEnabled ? accentPink : .gray)
}
Button(action: { audioPlayer.cycleRepeat() }) {
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
.foregroundColor(audioPlayer.repeatMode != .off ? accentPink : .gray)
}
}
}
}
}
}
```
}
// MARK: - Radio Recordings View
struct RadioRecordingsView: View {
let stationName: String
@State private var recordings: [RecordingFile] = []
@State private var playingURL: URL?
@Environment(.dismiss) private var dismiss
```
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
struct RecordingFile: Identifiable {
let id = UUID()
let url: URL
let name: String
let date: Date
let size: String
let duration: String
}
var body: some View {
NavigationStack {
Group {
if recordings.isEmpty {
VStack(spacing: 12) {
Image(systemName: "recordingtape")
.font(.system(size: 36))
.foregroundColor(.gray)
Text("No recordings yet")
.font(.system(size: 16))
.foregroundColor(.gray)
Text("Tap REC to record this station")
.font(.system(size: 13))
.foregroundColor(.gray.opacity(0.7))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
ForEach(recordings) { rec in
HStack(spacing: 12) {
Button(action: { playRecording(rec) }) {
Image(systemName: playingURL == rec.url ? "stop.circle.fill" : "play.circle.fill")
.font(.system(size: 28))
.foregroundColor(playingURL == rec.url ? .red : accentPink)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 3) {
Text(rec.name)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
HStack(spacing: 8) {
Text(rec.date, format: .dateTime.month(.wide).day().year())
.font(.system(size: 11))
.foregroundColor(.gray)
Text(rec.size)
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
Spacer()
// Share button
ShareLink(item: rec.url) {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 14))
.foregroundColor(.gray)
}
}
.padding(.vertical, 4)
}
.onDelete(perform: deleteRecordings)
}
}
}
.navigationTitle("Recordings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }
.foregroundColor(accentPink)
}
}
.onAppear { loadRecordings() }
}
}
private func loadRecordings() {
let fm = FileManager.default
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first!
let dir = docs.appendingPathComponent("RadioRecordings", isDirectory: true)
guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey]) else {
return
}
let safeName = stationName.replacingOccurrences(of: "/", with: "-")
recordings = files
.filter { $0.lastPathComponent.hasPrefix(safeName) || stationName == "Radio" }
.compactMap { url in
guard let attrs = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
let date = attrs.contentModificationDate,
let size = attrs.fileSize else { return nil }
let sizeStr = ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)
let name = url.deletingPathExtension().lastPathComponent
return RecordingFile(url: url, name: name, date: date, size: sizeStr, duration: "")
}
.sorted { $0.date > $1.date }
}
private func playRecording(_ rec: RecordingFile) {
if playingURL == rec.url {
AudioPlayer.shared.stop()
playingURL = nil
} else {
let song = Song(
id: rec.id.uuidString, parent: nil, isDir: nil,
title: rec.name, album: "Recording",
artist: stationName,
track: nil, year: nil, genre: nil, coverArt: nil,
size: nil, contentType: nil, suffix: nil,
transcodedContentType: nil, transcodedSuffix: nil,
duration: 0, bitRate: nil, path: nil, playCount: nil,
discNumber: nil, created: nil, albumId: nil, artistId: nil,
type: nil, starred: nil, bpm: nil, musicBrainzId: nil
)
AudioPlayer.shared.playRadio(song: song, streamURL: rec.url)
playingURL = rec.url
}
}
private func deleteRecordings(at offsets: IndexSet) {
for idx in offsets {
try? FileManager.default.removeItem(at: recordings[idx].url)
}
recordings.remove(atOffsets: offsets)
}
```
}
// MARK: - Rounded Corner Shape Helper
struct RoundedCornerShape: Shape {
let corners: UIRectCorner
let radius: CGFloat
func path(in rect: CGRect) -> Path {
Path(UIBezierPath(roundedRect: rect, byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)).cgPath)
}
}
// MARK: - Shazam Match Result
// MARK: - Shazam Match Result
struct ShazamMatch {
let title: String
let artist: String
let artworkURL: URL?
let previewURL: URL?
let appleMusicURL: URL?
```
var displayText: String {
artist.isEmpty ? title : "\(title)\(artist)"
}
```
}
// MARK: - Shazam Recognizer
/// Uses ShazamKit + AVAudioEngine mic tap for audio recognition.
class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
static let shared = ShazamRecognizer()
```
@Published var isRecognizing = false
private var session: SHSession?
private var audioEngine: AVAudioEngine?
private var completion: ((ShazamMatch?) -> Void)?
private var timeoutTask: Task<Void, Never>?
private var hasDeliveredResult = false
private override init() { super.init() }
/// Start recognition via microphone using matchStreamingBuffer
func recognize(completion: @escaping (ShazamMatch?) -> Void) {
guard !isRecognizing else { return }
stopListening()
isRecognizing = true
hasDeliveredResult = false
self.completion = completion
DebugLogger.shared.log("Shazam: starting recognition", category: "Audio")
// Configure audio session for simultaneous playback + mic
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playAndRecord, mode: .default, options: [
.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers
])
try audioSession.setActive(true)
} catch {
DebugLogger.shared.log("Shazam: audio session failed: \(error.localizedDescription)", category: "Audio")
deliverResult(nil)
return
}
// Create SHSession for Shazam catalog matching
let shSession = SHSession()
shSession.delegate = self
session = shSession
// Dedicated audio engine for mic capture
let engine = AVAudioEngine()
audioEngine = engine
let inputNode = engine.inputNode
let bus: AVAudioNodeBus = 0
let nativeFormat = inputNode.outputFormat(forBus: bus)
guard nativeFormat.sampleRate > 0, nativeFormat.channelCount > 0 else {
DebugLogger.shared.log("Shazam: invalid mic format (sr=\(nativeFormat.sampleRate))", category: "Audio")
deliverResult(nil)
return
}
DebugLogger.shared.log("Shazam: mic \(Int(nativeFormat.sampleRate))Hz, \(nativeFormat.channelCount)ch", category: "Audio")
// Install tap feed buffers directly to matchStreamingBuffer
// Per Apple docs: "Use matchStreamingBuffer when you are generating
// the audio buffers and passing them into the framework."
// This is simpler and more reliable than manual SHSignatureGenerator.
inputNode.installTap(onBus: bus, bufferSize: 2048, format: nativeFormat) { [weak shSession] buffer, time in
shSession?.matchStreamingBuffer(buffer, at: time)
}
do {
engine.prepare()
try engine.start()
DebugLogger.shared.log("Shazam: engine started, listening...", category: "Audio")
} catch {
DebugLogger.shared.log("Shazam: engine start failed: \(error.localizedDescription)", category: "Audio")
deliverResult(nil)
return
}
// Timeout after 12 seconds if no match
timeoutTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(12))
if !self.hasDeliveredResult {
DebugLogger.shared.log("Shazam: timeout — no match after 12s", category: "Audio")
self.deliverResult(nil)
}
}
}
// MARK: - SHSessionDelegate
func session(_ session: SHSession, didFind match: SHMatch) {
guard let item = match.mediaItems.first else { return }
let title = item.title ?? "Unknown"
let artist = item.artist ?? ""
let artworkURL = item.artworkURL
let appleMusicURL = item.appleMusicURL
DebugLogger.shared.log("Shazam: matched '\(title)' by '\(artist)'", category: "Audio")
// Extract preview URL from SHMediaItem.songs MusicKit bridge.
// SHMediaItem.songs returns MusicKit Song objects directly.
// Song.previewAssets contains the 30s DRM-free preview URL.
var previewURL: URL? = nil
if let firstSong = item.songs.first,
let preview = firstSong.previewAssets?.first {
previewURL = preview.url
DebugLogger.shared.log("Shazam: preview URL from songs bridge", category: "Audio")
}
DispatchQueue.main.async {
let result = ShazamMatch(
title: title,
artist: artist,
artworkURL: artworkURL,
previewURL: previewURL,
appleMusicURL: appleMusicURL
)
self.deliverResult(result)
}
}
func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: (any Error)?) {
// matchStreamingBuffer keeps trying automatically don't stop here.
// The timeout task handles giving up.
if let error = error {
DebugLogger.shared.log("Shazam: segment no match — \(error.localizedDescription)", category: "Audio")
}
}
// MARK: - Cleanup
private func deliverResult(_ result: ShazamMatch?) {
guard !hasDeliveredResult else { return }
hasDeliveredResult = true
stopListening()
isRecognizing = false
completion?(result)
completion = nil
}
private func stopListening() {
timeoutTask?.cancel()
timeoutTask = nil
if let engine = audioEngine {
engine.inputNode.removeTap(onBus: 0)
engine.stop()
audioEngine = nil
}
session = nil
// Restore playback audio session
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
try AVAudioSession.sharedInstance().setActive(true)
} catch {
DebugLogger.shared.log("Shazam: restore session failed: \(error.localizedDescription)", category: "Audio")
}
}
func cancel() {
DebugLogger.shared.log("Shazam: cancelled", category: "Audio")
deliverResult(nil)
}
```
}
// MARK: - Shazam Result Sheet
struct ShazamResultSheet: View {
let title: String
let artworkURL: URL?
let previewURL: URL?
```
@Environment(\.dismiss) private var dismiss
@State private var previewPlayer: AVPlayer?
@State private var isPlayingPreview = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
VStack(spacing: 20) {
// Artwork
if let url = artworkURL {
AsyncImage(url: url) { image in
image.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.08))
.overlay(ProgressView().tint(accentPink))
}
.frame(width: 180, height: 180)
.cornerRadius(12)
.shadow(color: .black.opacity(0.4), radius: 12, y: 6)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.08))
.frame(width: 180, height: 180)
.overlay(
Image(systemName: "shazam.logo")
.font(.system(size: 50))
.foregroundColor(.gray)
)
}
// Title
Text(title)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
// Preview button
if let previewURL = previewURL {
Button(action: { togglePreview(url: previewURL) }) {
HStack(spacing: 8) {
Image(systemName: isPlayingPreview ? "pause.fill" : "play.fill")
.font(.system(size: 14))
Text(isPlayingPreview ? "Stop Preview" : "Play 30s Preview")
.font(.system(size: 14, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(accentPink)
.cornerRadius(20)
}
}
Spacer()
}
.padding(.top, 24)
.frame(maxWidth: .infinity)
.background(Color(white: 0.1))
.onDisappear {
previewPlayer?.pause()
previewPlayer = nil
}
}
private func togglePreview(url: URL) {
if isPlayingPreview {
previewPlayer?.pause()
isPlayingPreview = false
} else {
if previewPlayer == nil {
previewPlayer = AVPlayer(url: url)
} else {
previewPlayer?.replaceCurrentItem(with: AVPlayerItem(url: url))
}
previewPlayer?.play()
isPlayingPreview = true
}
}
```
}
// MARK: - Song Info Sheet
struct SongInfoSheet: View {
let song: Song
@Environment(.dismiss) private var dismiss
```
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
var body: some View {
NavigationStack {
List {
Section("TRACK") {
infoRow("Title", song.title)
infoRow("Artist", song.artist ?? "Unknown")
infoRow("Album", song.album ?? "Unknown")
if let track = song.track { infoRow("Track #", "\(track)") }
if let disc = song.discNumber { infoRow("Disc #", "\(disc)") }
if let year = song.year { infoRow("Year", "\(year)") }
if let genre = song.genre { infoRow("Genre", genre) }
}
Section("FILE") {
if let dur = song.duration, dur > 0 { infoRow("Duration", song.durationFormatted) }
if let bitRate = song.bitRate { infoRow("Bit Rate", "\(bitRate) kbps") }
if let suffix = song.suffix { infoRow("Format", suffix.uppercased()) }
if let contentType = song.contentType { infoRow("Content Type", contentType) }
if let size = song.size {
infoRow("File Size", ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
}
if let path = song.path { infoRow("Path", path) }
}
Section("METADATA") {
infoRow("Song ID", song.id)
if let albumId = song.albumId { infoRow("Album ID", albumId) }
if let artistId = song.artistId { infoRow("Artist ID", artistId) }
if let playCount = song.playCount { infoRow("Play Count", "\(playCount)") }
if let created = song.created { infoRow("Added", created) }
if let bpm = song.bpm { infoRow("BPM", "\(bpm)") }
infoRow("Starred", song.starred != nil ? "Yes" : "No")
let isDownloaded = OfflineManager.shared.isSongDownloaded(song.id)
infoRow("Downloaded", isDownloaded ? "Yes" : "No")
}
}
.navigationTitle("Get Info")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.foregroundColor(accentPink)
}
}
}
}
private func infoRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label).foregroundColor(.gray)
Spacer()
Text(value)
.foregroundColor(.white)
.multilineTextAlignment(.trailing)
.lineLimit(2)
}
.font(.system(size: 14))
}
```
}