2078 lines
85 KiB
Swift
2078 lines
85 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 var radioCoverStoreObserver: 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)
|
|
}
|
|
// Re-extract when a radio cover changes
|
|
radioCoverStoreObserver = RadioCoverStore.shared.$updateTrigger
|
|
.dropFirst()
|
|
.sink { [weak self] _ in
|
|
guard let self = self else { return }
|
|
self.lastCoverArtId = nil // force re-extract on next call
|
|
}
|
|
}
|
|
|
|
/// Extract colors directly from a UIImage — used for radio stations whose
|
|
/// custom cover lives in RadioCoverStore rather than AlbumCoverStore.
|
|
func extractDirect(from image: UIImage, id: String) {
|
|
guard id != lastCoverArtId else { return }
|
|
lastCoverArtId = id
|
|
let colors = Self.dominantColors(from: image)
|
|
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)
|
|
}
|
|
|
|
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
|
|
|
|
// isDraggingSlider and dragPosition moved into NowPlayingSeekBar —
|
|
// they only affect the seek bar child, not NowPlayingView.body
|
|
@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 shazamResult: String?
|
|
@State private var shazamArtworkURL: URL?
|
|
@State private var shazamPreviewURL: URL?
|
|
@State private var isShazaming = false
|
|
@State private var showShazamResult = false
|
|
@State private var showLyricsSearch = false
|
|
@State private var showLyricsEditor = false
|
|
@StateObject private var albumColors = AlbumColorExtractor.shared
|
|
@StateObject private var radioBuffer = RadioStreamBuffer.shared
|
|
@ObservedObject private var visSettings = VisualizerSettings.shared
|
|
@ObservedObject private var lyricsManager = LyricsManager.shared
|
|
|
|
@State private var dragOffset: CGFloat = 0
|
|
|
|
// playbackTime/playbackDuration moved into NowPlayingSeekBar.
|
|
// NowPlayingView.body must not read currentTime — it causes the entire view
|
|
// to re-evaluate 10x/second even when hidden off screen (offset 1500pt).
|
|
|
|
@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"
|
|
}
|
|
|
|
/// Routes color extraction to RadioCoverStore for radio stations with custom covers,
|
|
/// otherwise falls through to the standard AlbumColorExtractor path.
|
|
private func extractAlbumColors() {
|
|
if isRadio,
|
|
let song = audioPlayer.currentSong,
|
|
let customImg = RadioCoverStore.shared.loadCover(for: song.id) {
|
|
albumColors.extractDirect(from: customImg, id: song.id)
|
|
} else {
|
|
albumColors.extract(from: audioPlayer.currentSong?.coverArt)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
let _ = DebugLogger.shared.trackViewBodies ? ViewBodyTracker.shared.record("NowPlayingView") : false
|
|
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])
|
|
}
|
|
.sheet(isPresented: $showLyricsSearch) {
|
|
LyricsSearchSheet(initialQuery: "\(audioPlayer.currentSong?.artist ?? "") \(audioPlayer.currentSong?.title ?? "")")
|
|
}
|
|
.sheet(isPresented: $showLyricsEditor) {
|
|
if let lyrics = lyricsManager.currentLyrics {
|
|
LyricsEditorView(lyrics: lyrics, songPath: audioPlayer.currentSong?.path)
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .openLyricsSearch)) { _ in
|
|
showLyricsSearch = true
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .openLyricsEditor)) { _ in
|
|
showLyricsEditor = true
|
|
}
|
|
.onAppear {
|
|
dragOffset = 0
|
|
isStarred = audioPlayer.currentSong?.starred != nil
|
|
extractAlbumColors()
|
|
}
|
|
.onChange(of: audioPlayer.currentSong?.id) { _, _ in
|
|
isStarred = audioPlayer.currentSong?.starred != nil
|
|
extractAlbumColors()
|
|
}
|
|
.onChange(of: RadioCoverStore.shared.updateTrigger) { _, _ in
|
|
// Radio cover changed — re-extract if a radio station is playing
|
|
if isRadio { extractAlbumColors() }
|
|
}
|
|
.onChange(of: audioPlayer.currentSong?.starred) { _, newVal in
|
|
isStarred = newVal != nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Portrait Layout
|
|
|
|
private var portraitLayout: some View {
|
|
VStack(spacing: 0) {
|
|
topBar
|
|
Spacer()
|
|
|
|
// Album art OR lyrics overlay
|
|
if lyricsManager.isLyricsVisible {
|
|
lyricsOverlay
|
|
.frame(height: 340)
|
|
.transition(.opacity)
|
|
} else {
|
|
albumArt(size: 300)
|
|
.transition(.opacity)
|
|
}
|
|
|
|
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 {
|
|
let height = isLandscape
|
|
? geo.size.height * min(visSettings.nowPlayingHeightPct + 0.1, 0.7)
|
|
: geo.size.height * visSettings.nowPlayingHeightPct
|
|
|
|
if visSettings.enabled && visSettings.nowPlayingEnabled {
|
|
VStack {
|
|
Spacer()
|
|
MitsuhaVisualizerView(
|
|
isPlaying: audioPlayer.isPlaying,
|
|
isSongLoaded: audioPlayer.currentSong != nil,
|
|
songId: audioPlayer.currentSong?.id,
|
|
accentColor: accentPink,
|
|
isVisible: isPresented
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: height)
|
|
.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
|
|
// Route through OptimisticActionQueue for offline
|
|
// resilience — direct try? calls silently lose the
|
|
// action when Tailscale is reconnecting (AUDIT-057)
|
|
if song.starred != nil {
|
|
OptimisticActionQueue.shared.unstar(songId: song.id)
|
|
} else {
|
|
OptimisticActionQueue.shared.star(songId: 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: - Lyrics Overlay
|
|
|
|
/// Replaces the cover art area when lyrics are active.
|
|
/// The visualizer continues behind — a blur panel fades in where lyrics text starts.
|
|
private var lyricsOverlay: some View {
|
|
ZStack {
|
|
// Blur panel: .ultraThinMaterial with a gradient mask so the top
|
|
// fades to transparent (visualizer shows through), bottom is opaque
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
.mask(
|
|
VStack(spacing: 0) {
|
|
LinearGradient(
|
|
colors: [.clear, .white],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
.frame(height: 50)
|
|
Color.white
|
|
}
|
|
)
|
|
|
|
// Lyrics content on top of the blur
|
|
LyricsOverlayView()
|
|
}
|
|
}
|
|
|
|
// 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(.npTitle)
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Text(audioPlayer.currentSong?.artist ?? "")
|
|
.font(.npArtist)
|
|
.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)
|
|
}
|
|
.accessibilityLabel(isStarred ? "Unfavourite" : "Favourite")
|
|
.accessibilityValue(isStarred ? "Added to favourites" : "Not in favourites")
|
|
}
|
|
}
|
|
.padding(.horizontal, isLandscape ? 20 : 30)
|
|
}
|
|
|
|
// MARK: - Progress Bar
|
|
|
|
private var progressBar: some View {
|
|
VStack(spacing: 6) {
|
|
if isRecordedRadio {
|
|
// Feature 4: recorded radio uses the standard seek bar, not the buffer bar
|
|
normalProgressBar
|
|
} else if isLiveRadio {
|
|
radioProgressBar
|
|
} else {
|
|
normalProgressBar
|
|
}
|
|
}
|
|
.padding(.horizontal, isLandscape ? 20 : 30)
|
|
}
|
|
|
|
// Convenience flags used across progress bar and transport
|
|
private var isRecordedRadio: Bool { isRadio && !audioPlayer.isRadioStream }
|
|
private var isLiveRadio: Bool { isRadio && audioPlayer.isRadioStream }
|
|
|
|
private var normalProgressBar: some View {
|
|
// NowPlayingSeekBar owns its own @ObservedObject audioPlayer reference.
|
|
// Only this child re-evaluates at 10Hz — NowPlayingView.body stays quiet.
|
|
NowPlayingSeekBar()
|
|
}
|
|
|
|
private var radioProgressBar: some View {
|
|
NowPlayingSeekBar(radioBuffer: radioBuffer, accentColor: accentPink)
|
|
}
|
|
|
|
// MARK: - Transport Controls
|
|
|
|
private var transportControls: some View {
|
|
let iconSize: CGFloat = isLandscape ? 24 : 30
|
|
let playSize: CGFloat = isLandscape ? 34 : 42
|
|
|
|
// Dynamic disable states (Feature 2)
|
|
let bufferSec = radioBuffer.estimatedBufferSeconds
|
|
let currentPos: TimeInterval = audioPlayer.isPlayingFromBuffer ? audioPlayer.currentTime : bufferSec
|
|
let secondsBehindLive = bufferSec - currentPos
|
|
let canRewind30 = !radioBuffer.isHLSStream && bufferSec >= 30
|
|
let canForward15 = !radioBuffer.isHLSStream && secondsBehindLive >= 15
|
|
|
|
// Feature 4: recorded radio also gets timeshift transport (30s/15s), not prev/next
|
|
let showTimeshiftLeft = (isLiveRadio && radioBuffer.isBuffering) || isRecordedRadio
|
|
let showTimeshiftRight = showTimeshiftLeft
|
|
|
|
return VStack(spacing: 4) {
|
|
if isLiveRadio {
|
|
liveIndicator.padding(.bottom, 4)
|
|
}
|
|
|
|
HStack(spacing: 0) {
|
|
Spacer()
|
|
|
|
// Left button
|
|
if showTimeshiftLeft {
|
|
Button(action: {
|
|
if isRecordedRadio {
|
|
audioPlayer.seek(to: max(0, audioPlayer.currentTime - 30))
|
|
} else {
|
|
audioPlayer.radioSkip(by: -30)
|
|
}
|
|
}) {
|
|
Image(systemName: "gobackward.30")
|
|
.font(.system(size: iconSize))
|
|
.foregroundColor(canRewind30 || isRecordedRadio ? .white : .gray.opacity(0.35))
|
|
}
|
|
.frame(width: 60, height: 50)
|
|
.disabled(isLiveRadio && !canRewind30)
|
|
.accessibilityLabel("Rewind 30 seconds")
|
|
} else if !isLiveRadio {
|
|
Button(action: { audioPlayer.previous() }) {
|
|
Image(systemName: "backward.fill")
|
|
.font(.system(size: iconSize)).foregroundColor(.white)
|
|
}
|
|
.frame(width: 60, height: 50)
|
|
.accessibilityLabel("Previous track")
|
|
} 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)
|
|
.accessibilityLabel(audioPlayer.isPlaying ? "Pause" : "Play")
|
|
|
|
Spacer()
|
|
|
|
// Right button
|
|
if showTimeshiftRight {
|
|
Button(action: {
|
|
if isRecordedRadio {
|
|
audioPlayer.seek(to: min(audioPlayer.duration, audioPlayer.currentTime + 15))
|
|
} else {
|
|
audioPlayer.radioSkip(by: 15)
|
|
}
|
|
}) {
|
|
Image(systemName: "goforward.15")
|
|
.font(.system(size: iconSize))
|
|
.foregroundColor(canForward15 || isRecordedRadio ? .white : .gray.opacity(0.35))
|
|
}
|
|
.frame(width: 60, height: 50)
|
|
.disabled(isLiveRadio && !canForward15)
|
|
.accessibilityLabel("Forward 15 seconds")
|
|
} else if !isLiveRadio {
|
|
Button(action: { audioPlayer.next() }) {
|
|
Image(systemName: "forward.fill")
|
|
.font(.system(size: iconSize)).foregroundColor(.white)
|
|
}
|
|
.frame(width: 60, height: 50)
|
|
.accessibilityLabel("Next track")
|
|
} else {
|
|
Color.clear.frame(width: 60, height: 50)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// REC button — repurposed from LIVE now that buffering is automatic.
|
|
/// HLS stream → greyed "LIVE" pill with slash icon, non-tappable
|
|
/// Not buffering yet → greyed pill (brief transient while auto-buffer starts)
|
|
/// Buffering, idle → grey "REC" pill, tap to start recording
|
|
/// Recording active → solid red pill with pulsing glow, shows elapsed time, tap to stop
|
|
@State private var livePulse = false
|
|
|
|
private var liveIndicator: some View {
|
|
let isHLS = radioBuffer.isHLSStream
|
|
let buffering = radioBuffer.isBuffering
|
|
let recording = radioBuffer.isRecording
|
|
|
|
return Button(action: {
|
|
guard !isHLS, buffering else { return }
|
|
audioPlayer.toggleRecording()
|
|
}) {
|
|
HStack(spacing: 5) {
|
|
if isHLS {
|
|
Image(systemName: "slash.circle")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundColor(.gray.opacity(0.5))
|
|
} else if recording {
|
|
Circle()
|
|
.fill(Color.red)
|
|
.frame(width: 7, height: 7)
|
|
} else {
|
|
Image(systemName: "record.circle")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundColor(buffering ? .white : .gray.opacity(0.4))
|
|
}
|
|
Text(recording ? radioBuffer.recordingTimeFormatted : "REC")
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundColor(isHLS ? .gray.opacity(0.4) : recording ? .white : buffering ? .white.opacity(0.7) : .gray)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(recording
|
|
? Color.red.opacity(0.85)
|
|
: isHLS || !buffering
|
|
? Color.white.opacity(0.04)
|
|
: Color.white.opacity(0.06)
|
|
)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(
|
|
recording ? Color.red.opacity(0.9) : Color.clear,
|
|
lineWidth: 1.5
|
|
)
|
|
)
|
|
.shadow(
|
|
color: recording ? Color.red.opacity(livePulse ? 0.7 : 0.2) : .clear,
|
|
radius: livePulse ? 14 : 6
|
|
)
|
|
.animation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true), value: livePulse)
|
|
.overlay(alignment: .bottom) {
|
|
if isHLS {
|
|
Text("HLS — no timeshift")
|
|
.font(.system(size: 9))
|
|
.foregroundColor(.gray.opacity(0.5))
|
|
.offset(y: 18)
|
|
}
|
|
}
|
|
}
|
|
.disabled(isHLS || !buffering)
|
|
.onAppear { livePulse = true }
|
|
.onChange(of: recording) { _, val in livePulse = val }
|
|
}
|
|
|
|
// 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()
|
|
// Lyrics toggle
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
lyricsManager.isLyricsVisible.toggle()
|
|
}
|
|
}) {
|
|
Image(systemName: "quote.bubble")
|
|
.font(.system(size: isLandscape ? 14 : 16))
|
|
.foregroundColor(lyricsManager.isLyricsVisible ? accentPink : .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,
|
|
albumArtist: song.albumArtist,
|
|
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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, albumArtist: nil,
|
|
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.
|
|
|
|
// MARK: - ShazamRecognizer (AVPlayer buffer tap — no microphone required)
|
|
///
|
|
/// Architecture:
|
|
/// AVPlayerItem → MTAudioProcessingTap (C callback) → analysisQueue
|
|
/// analysisQueue → AVAudioConverter (→ 16kHz mono PCM) → SHSession.matchStreamingBuffer
|
|
///
|
|
/// The tap is installed as an AVMutableAudioMix on the current player item once the
|
|
/// player is playing. For non-AVPlayer paths (local engine) we fall back to the
|
|
/// legacy microphone approach.
|
|
class ShazamRecognizer: NSObject, ObservableObject, SHSessionDelegate {
|
|
static let shared = ShazamRecognizer()
|
|
|
|
@Published var isRecognizing = false
|
|
|
|
private var session: SHSession?
|
|
private var completion: ((ShazamMatch?) -> Void)?
|
|
private var timeoutTask: Task<Void, Never>?
|
|
private var hasDeliveredResult = false
|
|
|
|
// Tap state — uses shared AudioTapProcessor (no longer owns its own tap)
|
|
private var converter: AVAudioConverter?
|
|
private var sourceFormat: AVAudioFormat?
|
|
private let targetFormat = AVAudioFormat(standardFormatWithSampleRate: 16_000, channels: 1)!
|
|
private let analysisQueue = DispatchQueue(label: "com.navidrome.shazam", qos: .userInitiated)
|
|
|
|
// Legacy mic fallback
|
|
private var audioEngine: AVAudioEngine?
|
|
|
|
private override init() { super.init() }
|
|
|
|
// MARK: - Public entry point
|
|
|
|
func recognize(completion: @escaping (ShazamMatch?) -> Void) {
|
|
guard !isRecognizing else { return }
|
|
stopAll()
|
|
|
|
isRecognizing = true
|
|
hasDeliveredResult = false
|
|
self.completion = completion
|
|
|
|
let shSession = SHSession()
|
|
shSession.delegate = self
|
|
session = shSession
|
|
|
|
DebugLogger.shared.log("Shazam: starting recognition", category: "Audio")
|
|
|
|
// Use the shared AudioTapProcessor — piggyback on FFT tap if active,
|
|
// or install a new one. Fixes the old bug where Shazam's own tap
|
|
// would overwrite the FFT visualizer tap (or vice versa).
|
|
if AudioPlayer.shared.currentPlayerItem != nil {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
|
|
let tapProc = AudioTapProcessor.shared
|
|
// Ensure the shared tap is installed (may already be active for FFT)
|
|
if tapProc.sourceFormat == nil,
|
|
let item = AudioPlayer.shared.currentPlayerItem {
|
|
let _ = await tapProc.installTap(on: item)
|
|
}
|
|
|
|
// tapPrepare callback fires async on the audio thread after installTap.
|
|
// Wait briefly for sourceFormat to be populated — without this,
|
|
// sourceFormat is often nil and Shazam falls back to mic unnecessarily.
|
|
if tapProc.sourceFormat == nil {
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
}
|
|
|
|
if let srcFormat = tapProc.sourceFormat {
|
|
self.sourceFormat = srcFormat
|
|
self.converter = AVAudioConverter(from: srcFormat, to: self.targetFormat)
|
|
|
|
// Subscribe to the shared tap — receives buffers from render thread
|
|
tapProc.shazamHandler = { [weak self] bufferList, frameCount in
|
|
guard let self else { return }
|
|
self.processTapBuffer(bufferList, frameCount: frameCount)
|
|
}
|
|
self.startTimeout(seconds: 15)
|
|
DebugLogger.shared.log("Shazam: subscribed to shared audio tap (\(Int(srcFormat.sampleRate))Hz)", category: "Audio")
|
|
} else {
|
|
DebugLogger.shared.log("Shazam: shared tap has no format — mic fallback", category: "Audio")
|
|
self.startMicFallback()
|
|
}
|
|
}
|
|
} else {
|
|
startMicFallback()
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared Tap Consumer
|
|
|
|
private func removeTap() {
|
|
// Unsubscribe from the shared tap — do NOT remove the audioMix
|
|
// because the FFT visualizer may still be using it.
|
|
AudioTapProcessor.shared.shazamHandler = nil
|
|
converter = nil
|
|
sourceFormat = nil
|
|
}
|
|
|
|
/// Called from the shared tap callback — dispatches to analysis queue to avoid blocking render thread.
|
|
func processTapBuffer(_ bufferList: UnsafeMutablePointer<AudioBufferList>,
|
|
frameCount: CMItemCount) {
|
|
guard let session, let conv = converter,
|
|
let srcFmt = sourceFormat else { return }
|
|
|
|
analysisQueue.async { [weak self] in
|
|
guard let self else { return }
|
|
self.convertAndMatch(bufferList: bufferList,
|
|
frameCount: frameCount,
|
|
converter: conv,
|
|
sourceFormat: srcFmt,
|
|
session: session)
|
|
}
|
|
}
|
|
|
|
private func convertAndMatch(
|
|
bufferList: UnsafeMutablePointer<AudioBufferList>,
|
|
frameCount: CMItemCount,
|
|
converter: AVAudioConverter,
|
|
sourceFormat: AVAudioFormat,
|
|
session: SHSession
|
|
) {
|
|
let capacity = AVAudioFrameCount(frameCount)
|
|
guard capacity > 0 else { return }
|
|
|
|
// Wrap the raw AudioBufferList in an AVAudioPCMBuffer
|
|
guard let srcBuffer = AVAudioPCMBuffer(pcmFormat: sourceFormat,
|
|
frameCapacity: capacity) else { return }
|
|
srcBuffer.frameLength = capacity
|
|
|
|
let abl = UnsafeMutableAudioBufferListPointer(bufferList)
|
|
for (i, buf) in abl.enumerated() {
|
|
guard i < Int(sourceFormat.channelCount) else { break }
|
|
let dst = srcBuffer.floatChannelData?[i]
|
|
let src = buf.mData?.assumingMemoryBound(to: Float.self)
|
|
let n = Int(buf.mDataByteSize) / MemoryLayout<Float>.size
|
|
if let dst, let src { dst.initialize(from: src, count: n) }
|
|
}
|
|
|
|
// Output buffer for the converted audio
|
|
let outputCapacity = AVAudioFrameCount(Double(capacity) *
|
|
targetFormat.sampleRate / sourceFormat.sampleRate) + 16
|
|
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat,
|
|
frameCapacity: outputCapacity) else { return }
|
|
|
|
var error: NSError?
|
|
var inputConsumed = false
|
|
converter.convert(to: outBuffer, error: &error) { _, inputStatus in
|
|
if inputConsumed {
|
|
inputStatus.pointee = .noDataNow
|
|
return nil
|
|
}
|
|
inputConsumed = true
|
|
inputStatus.pointee = .haveData
|
|
return srcBuffer
|
|
}
|
|
|
|
if let error {
|
|
DebugLogger.shared.log("Shazam: converter error \(error)", category: "Audio")
|
|
return
|
|
}
|
|
|
|
if outBuffer.frameLength > 0 {
|
|
session.matchStreamingBuffer(outBuffer, at: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Microphone fallback (local engine / engine path)
|
|
|
|
private func startMicFallback() {
|
|
DebugLogger.shared.log("Shazam: falling back to microphone", category: "Audio")
|
|
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)", category: "Audio")
|
|
deliverResult(nil); return
|
|
}
|
|
|
|
let engine = AVAudioEngine()
|
|
audioEngine = engine
|
|
let inputNode = engine.inputNode
|
|
let bus: AVAudioNodeBus = 0
|
|
let nativeFmt = inputNode.outputFormat(forBus: bus)
|
|
guard nativeFmt.sampleRate > 0, nativeFmt.channelCount > 0 else {
|
|
deliverResult(nil); return
|
|
}
|
|
guard let shSession = session else { deliverResult(nil); return }
|
|
|
|
inputNode.installTap(onBus: bus, bufferSize: 2048, format: nativeFmt) { [weak shSession] buf, time in
|
|
shSession?.matchStreamingBuffer(buf, at: time)
|
|
}
|
|
do {
|
|
engine.prepare(); try engine.start()
|
|
} catch {
|
|
deliverResult(nil); return
|
|
}
|
|
startTimeout(seconds: 12)
|
|
}
|
|
|
|
// MARK: - Timeout
|
|
|
|
private func startTimeout(seconds: Int) {
|
|
timeoutTask = Task { @MainActor [weak self] in
|
|
try? await Task.sleep(for: .seconds(seconds))
|
|
guard let self, !self.hasDeliveredResult else { return }
|
|
DebugLogger.shared.log("Shazam: timeout after \(seconds)s", 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 ?? ""
|
|
DebugLogger.shared.log("Shazam: matched '\(title)' by '\(artist)'", category: "Audio")
|
|
|
|
var previewURL: URL?
|
|
if let firstSong = item.songs.first,
|
|
let preview = firstSong.previewAssets?.first {
|
|
previewURL = preview.url
|
|
}
|
|
DispatchQueue.main.async {
|
|
self.deliverResult(ShazamMatch(
|
|
title: title, artist: artist,
|
|
artworkURL: item.artworkURL,
|
|
previewURL: previewURL,
|
|
appleMusicURL: item.appleMusicURL
|
|
))
|
|
}
|
|
}
|
|
|
|
func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: (any Error)?) {
|
|
if let error {
|
|
DebugLogger.shared.log("Shazam: segment no match — \(error)", category: "Audio")
|
|
}
|
|
}
|
|
|
|
// MARK: - Cleanup
|
|
|
|
private func deliverResult(_ result: ShazamMatch?) {
|
|
guard !hasDeliveredResult else { return }
|
|
hasDeliveredResult = true
|
|
stopAll()
|
|
isRecognizing = false
|
|
completion?(result)
|
|
completion = nil
|
|
}
|
|
|
|
private func stopAll() {
|
|
timeoutTask?.cancel(); timeoutTask = nil
|
|
removeTap()
|
|
let usedMic = audioEngine != nil
|
|
if let engine = audioEngine {
|
|
if engine.inputNode.numberOfInputs > 0 {
|
|
engine.inputNode.removeTap(onBus: 0)
|
|
}
|
|
engine.stop()
|
|
audioEngine = nil
|
|
}
|
|
session = nil
|
|
|
|
// Restore audio session only if we used the mic fallback
|
|
if usedMic {
|
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
|
try? AVAudioSession.sharedInstance().setActive(true)
|
|
}
|
|
}
|
|
|
|
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
|
|
@State private var shareURL: String?
|
|
@State private var isCreatingShare = false
|
|
@State private var shareError: String?
|
|
@State private var showCopiedToast = false
|
|
|
|
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")
|
|
}
|
|
|
|
// Share Link
|
|
Section("SHARE") {
|
|
if let url = shareURL {
|
|
HStack {
|
|
Text(url)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(accentPink)
|
|
.lineLimit(2)
|
|
Spacer()
|
|
Button(action: {
|
|
UIPasteboard.general.string = url
|
|
withAnimation { showCopiedToast = true }
|
|
Task {
|
|
try? await Task.sleep(for: .seconds(2))
|
|
withAnimation { showCopiedToast = false }
|
|
}
|
|
}) {
|
|
Image(systemName: showCopiedToast ? "checkmark" : "doc.on.clipboard")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(showCopiedToast ? .green : accentPink)
|
|
}
|
|
}
|
|
|
|
// System share sheet
|
|
if let shareLink = URL(string: url) {
|
|
ShareLink(item: shareLink) {
|
|
HStack {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.font(.system(size: 14))
|
|
Text("Share via…")
|
|
.font(.system(size: 14))
|
|
}
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
} else if isCreatingShare {
|
|
HStack {
|
|
ProgressView().tint(accentPink).scaleEffect(0.8)
|
|
Text("Creating share link…")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray)
|
|
.padding(.leading, 8)
|
|
}
|
|
} else if let error = shareError {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(error)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.red)
|
|
Button("Try Again") {
|
|
Task { await createShareLink() }
|
|
}
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
} else {
|
|
Button(action: { Task { await createShareLink() } }) {
|
|
HStack {
|
|
Image(systemName: "link.badge.plus")
|
|
.font(.system(size: 14))
|
|
Text("Create Share Link")
|
|
.font(.system(size: 14))
|
|
}
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Get Info")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") { dismiss() }.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func createShareLink() async {
|
|
isCreatingShare = true
|
|
shareError = nil
|
|
do {
|
|
// Expires in 7 days (ms since epoch)
|
|
let expires = Int64((Date().timeIntervalSince1970 + 7 * 86400) * 1000)
|
|
let share = try await ServerManager.shared.client.createShare(
|
|
id: song.id,
|
|
description: "\(song.title) — \(song.artist ?? "Unknown")",
|
|
expires: expires
|
|
)
|
|
if let url = share?.url {
|
|
shareURL = url
|
|
} else {
|
|
shareError = "Server returned no share URL. Sharing may be disabled."
|
|
}
|
|
} catch {
|
|
shareError = error.localizedDescription
|
|
}
|
|
isCreatingShare = false
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|