Features: - Dual-AVPlayer Smart DJ crossfade with LUFS normalization - Mitsuha-style FFT visualizer (real-time + offline pre-computed) - Companion API integration (Smart DJ, tag editing, vis frames) - Offline-first SyncEngine with delta sync and album detail pre-caching - Audio pre-fetcher for gapless queue playback - Optimistic action queue (star/unstar with background retry) - ShazamKit recognition with MusicKit preview playback - Radio streaming with HLS/PLS/M3U support and buffer seek - Watch app with Crown Sequencer and Ultra speaker support - Batch metadata editing with album_artist fix for split albums - Cache-first UI pattern across all views - NWPathMonitor offline detection with reactive song greying
1629 lines
64 KiB
Swift
1629 lines
64 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?
|
|
|
|
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
|
|
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
|
|
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
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// Visualizer behind content (portrait) — extends to edges
|
|
if !isLandscape && visSettings.enabled && visSettings.nowPlayingEnabled {
|
|
VStack {
|
|
Spacer()
|
|
MitsuhaVisualizerView(
|
|
isPlaying: audioPlayer.isPlaying,
|
|
accentColor: accentPink
|
|
)
|
|
.frame(height: screenGeo.size.height * visSettings.nowPlayingHeightPct)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
|
|
// Visualizer behind content (landscape) — extends to edges
|
|
if isLandscape && visSettings.enabled && visSettings.nowPlayingEnabled {
|
|
VStack(spacing: 0) {
|
|
Spacer()
|
|
MitsuhaVisualizerView(
|
|
isPlaying: audioPlayer.isPlaying,
|
|
accentColor: accentPink
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: screenGeo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7))
|
|
.allowsHitTesting(false)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
|
|
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: $showVisualizerSettings) { VisualizerSettingsView() }
|
|
.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: $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: - Background
|
|
|
|
private var backgroundGradient: some View {
|
|
ZStack {
|
|
// Layer 1: Deep black base
|
|
Color.black.ignoresSafeArea()
|
|
|
|
// Layer 2: Blurred album art mist — must be clipped to prevent layout overflow
|
|
if let art = albumColors.currentImage {
|
|
GeometryReader { geo in
|
|
Image(uiImage: art)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
.frame(width: geo.size.width, height: geo.size.height)
|
|
.clipped()
|
|
.blur(radius: 85, opaque: true)
|
|
.opacity(0.5)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
List {
|
|
if !isRadio {
|
|
Section {
|
|
sheetButton("Instant Mix", icon: "wand.and.stars") {
|
|
showEllipsisMenu = false
|
|
if let song = audioPlayer.currentSong {
|
|
audioPlayer.playInstantMix(basedOn: song)
|
|
}
|
|
}
|
|
sheetButton("Add to Playlist...", icon: "text.badge.plus") {
|
|
showEllipsisMenu = false
|
|
Task {
|
|
availablePlaylists = (try? await serverManager.client.getPlaylists()) ?? []
|
|
showPlaylistPicker = true
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
if audioPlayer.currentSong?.albumId != nil {
|
|
sheetButton("Go to Album", icon: "square.stack") {
|
|
showEllipsisMenu = false
|
|
if let albumId = audioPlayer.currentSong?.albumId {
|
|
NotificationCenter.default.post(
|
|
name: .navigateToAlbum, object: nil,
|
|
userInfo: ["albumId": albumId]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if audioPlayer.currentSong?.artistId != nil {
|
|
sheetButton("Go to Artist", icon: "music.mic") {
|
|
showEllipsisMenu = false
|
|
if let artistId = audioPlayer.currentSong?.artistId {
|
|
NotificationCenter.default.post(
|
|
name: .navigateToArtist, object: nil,
|
|
userInfo: ["artistId": artistId]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if let pid = audioPlayer.sourcePlaylistId,
|
|
let pname = audioPlayer.sourcePlaylistName {
|
|
sheetButton("Show in \(pname)", icon: "music.note.list") {
|
|
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]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let song = audioPlayer.currentSong {
|
|
Section {
|
|
if offlineManager.isSongDownloaded(song.id) {
|
|
sheetButton("Remove Download", icon: "trash", role: .destructive) {
|
|
showEllipsisMenu = false
|
|
offlineManager.removeSong(song.id)
|
|
}
|
|
if WatchConnectivityManager.shared.isWatchAvailable {
|
|
sheetButton("Send to Watch", icon: "applewatch.and.arrow.forward") {
|
|
showEllipsisMenu = false
|
|
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
|
|
}
|
|
}
|
|
} else {
|
|
sheetButton("Download", icon: "arrow.down.circle") {
|
|
showEllipsisMenu = false
|
|
if let server = serverManager.activeServer {
|
|
offlineManager.downloadSong(song, server: server)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
sheetButton("Get Info", icon: "info.circle") {
|
|
showEllipsisMenu = false
|
|
showGetInfo = true
|
|
}
|
|
|
|
if CompanionSettings.shared.isEnabled {
|
|
sheetButton("Edit Tags", icon: "tag") {
|
|
showEllipsisMenu = false
|
|
showTrackEditor = true
|
|
}
|
|
}
|
|
|
|
sheetButton("Visualizer Settings", icon: "waveform") {
|
|
showEllipsisMenu = false
|
|
showVisualizerSettings = true
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.navigationTitle("Options")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") { showEllipsisMenu = false }
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sheetButton(_ title: String, icon: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View {
|
|
Button(role: role, action: action) {
|
|
Label(title, systemImage: icon)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in
|
|
HStack(spacing: 12) {
|
|
if index == audioPlayer.queueIndex {
|
|
Image(systemName: "waveform").font(.system(size: 12))
|
|
.foregroundColor(accentPink).frame(width: 20)
|
|
} else {
|
|
Text("\(index + 1)").font(.system(size: 13))
|
|
.foregroundColor(.gray).frame(width: 20)
|
|
}
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(song.title).font(.system(size: 15))
|
|
.foregroundColor(index == audioPlayer.queueIndex ? accentPink : .white)
|
|
Text(song.artist ?? "").font(.system(size: 12)).foregroundColor(.gray)
|
|
}
|
|
Spacer()
|
|
Text(song.durationFormatted).font(.system(size: 13)).foregroundColor(.gray)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { audioPlayer.play(song: song, at: index) }
|
|
}
|
|
}
|
|
.navigationTitle("Up Next")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, style: .date)
|
|
.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))
|
|
}
|
|
}
|