1819 lines
66 KiB
Swift
1819 lines
66 KiB
Swift
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 SwiftUI’s 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))
|
||
}
|
||
```
|
||
|
||
}
|