import SwiftUI import AVFoundation import MediaPlayer import Combine import UniformTypeIdentifiers import AVKit import PhotosUI // MARK: - Модель трека struct Track: Identifiable, Hashable { let id = UUID() let url: URL let title: String let artist: String func hash(into hasher: inout Hasher) { hasher.combine(url) } } // MARK: - Модель строки текста struct LyricLine: Identifiable, Codable { var id = UUID() var time: TimeInterval var text: String } // MARK: - Модель ответа от LRCLIB struct LRCLibSearchResult: Codable { let id: Int? let trackName: String? let artistName: String? let syncedLyrics: String? let plainLyrics: String? } // MARK: - Перечисления enum RepeatMode: Int, CaseIterable { case none = 0 case all = 1 case one = 2 var iconName: String { switch self { case .none: return "repeat" case .all: return "repeat" case .one: return "repeat.1" } } } enum AppTheme: Int, CaseIterable { case system = 0 case light = 1 case dark = 2 var colorScheme: ColorScheme? { switch self { case .system: return nil case .light: return .light case .dark: return .dark } } var title: String { switch self { case .system: return "Системная" case .light: return "Светлая" case .dark: return "Темная" } } } // MARK: - Гибридный плеер с эквалайзером и автоматическим фолбеком class AVAudioPlayerWithEQ: NSObject, AVAudioPlayerDelegate { private var engine: AVAudioEngine? private var playerNode: AVAudioPlayerNode? private var eqNode: AVAudioUnitEQ? private var audioFile: AVAudioFile? private var fallbackPlayer: AVAudioPlayer? private var useFallback: Bool = false var url: URL? var onFinishPlaying: (() -> Void)? private var seekTime: TimeInterval = 0 private var pausedTime: TimeInterval = 0 private var totalDuration: TimeInterval = 0 private var sampleRate: Double = 44100 private var totalFrames: AVAudioFramePosition = 0 private var isSeeking = false var volume: Float = 1.0 { didSet { if useFallback { fallbackPlayer?.volume = volume } else { playerNode?.volume = volume } } } var isPlaying: Bool { if useFallback { return fallbackPlayer?.isPlaying ?? false } else { return playerNode?.isPlaying ?? false } } var currentTime: TimeInterval { get { if useFallback { return fallbackPlayer?.currentTime ?? 0 } else { guard let playerNode = playerNode else { return pausedTime } if let nodeTime = playerNode.lastRenderTime, let playerTime = playerNode.playerTime(forNodeTime: nodeTime) { let currentSecs = Double(playerTime.sampleTime) / playerTime.sampleRate let computed = min(seekTime + currentSecs, totalDuration) pausedTime = computed return computed } return pausedTime } } set { seek(to: newValue) } } var duration: TimeInterval { if useFallback { return fallbackPlayer?.duration ?? 0 } else { return totalDuration } } init(contentsOf url: URL) throws { self.url = url super.init() do { self.engine = AVAudioEngine() self.playerNode = AVAudioPlayerNode() self.eqNode = AVAudioUnitEQ(numberOfBands: 10) let file = try AVAudioFile(forReading: url) self.audioFile = file self.totalDuration = Double(file.length) / file.fileFormat.sampleRate self.sampleRate = file.fileFormat.sampleRate self.totalFrames = file.length guard let engine = engine, let playerNode = playerNode, let eqNode = eqNode else { throw NSError(domain: "AVAudioPlayerWithEQ", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to create nodes"]) } engine.attach(playerNode) engine.attach(eqNode) let frequencies: [Float] = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000] let isEqEnabled = UserDefaults.standard.bool(forKey: "eq_enabled") for i in 0..<10 { let band = eqNode.bands[i] band.frequency = frequencies[i] band.filterType = .parametric band.bypass = !isEqEnabled let savedGain = Float(UserDefaults.standard.double(forKey: "eq_band_\(i)")) band.gain = savedGain } let format = file.processingFormat engine.connect(playerNode, to: eqNode, format: format) engine.connect(eqNode, to: engine.mainMixerNode, format: format) try engine.start() self.useFallback = false scheduleFile() } catch { print("AVAudioEngine setup failed, falling back to AVAudioPlayer: \(error)") self.useFallback = true self.fallbackPlayer = try AVAudioPlayer(contentsOf: url) self.fallbackPlayer?.delegate = self self.fallbackPlayer?.prepareToPlay() } } func play() { if useFallback { fallbackPlayer?.play() } else { if let engine = engine, !engine.isRunning { try? engine.start() } playerNode?.play() } } func pause() { if useFallback { fallbackPlayer?.pause() } else { pausedTime = currentTime playerNode?.pause() } } func stop() { if useFallback { fallbackPlayer?.stop() } else { playerNode?.stop() engine?.stop() } } func seek(to time: TimeInterval) { if useFallback { fallbackPlayer?.currentTime = time } else { guard let playerNode = playerNode, let file = audioFile else { return } let wasPlaying = playerNode.isPlaying isSeeking = true playerNode.stop() seekTime = max(0, min(time, totalDuration)) pausedTime = seekTime let startFrame = AVAudioFramePosition(seekTime * sampleRate) let framesToPlay = AVAudioFrameCount(totalFrames - startFrame) if framesToPlay > 100 { if #available(iOS 15.0, *) { playerNode.scheduleSegment(file, startingFrame: startFrame, frameCount: framesToPlay, at: nil, completionCallbackType: .dataPlayedBack) { [weak self] _ in guard let self = self, !self.isSeeking else { return } DispatchQueue.main.async { self.onFinishPlaying?() } } } else { playerNode.scheduleSegment(file, startingFrame: startFrame, frameCount: framesToPlay, at: nil) { [weak self] in guard let self = self, !self.isSeeking else { return } DispatchQueue.main.async { self.onFinishPlaying?() } } } } isSeeking = false if wasPlaying { playerNode.play() } } } private func scheduleFile() { guard let playerNode = playerNode, let file = audioFile else { return } if #available(iOS 15.0, *) { playerNode.scheduleFile(file, at: nil, completionCallbackType: .dataPlayedBack) { [weak self] _ in guard let self = self, !self.isSeeking else { return } DispatchQueue.main.async { self.onFinishPlaying?() } } } else { playerNode.scheduleFile(file, at: nil) { [weak self] in guard let self = self, !self.isSeeking else { return } DispatchQueue.main.async { self.onFinishPlaying?() } } } } func updateEQSettings() { guard !useFallback, let eqNode = eqNode else { return } let isEqEnabled = UserDefaults.standard.bool(forKey: "eq_enabled") for i in 0..<10 { let band = eqNode.bands[i] band.bypass = !isEqEnabled let savedGain = Float(UserDefaults.standard.double(forKey: "eq_band_\(i)")) band.gain = savedGain } } func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { if flag { self.onFinishPlaying?() } } } struct EQPreset: Identifiable, Hashable { var id: String { name } let name: String let gains: [Double] } let eqPresets: [EQPreset] = [ EQPreset(name: "Обычный (Flat)", gains: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]), EQPreset(name: "Усиление баса", gains: [8.0, 6.5, 5.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]), EQPreset(name: "Усиление вокала", gains: [-3.0, -2.0, -1.0, 1.0, 3.0, 4.5, 4.0, 3.0, 1.5, -1.0]), EQPreset(name: "Акустика", gains: [3.5, 3.0, 1.5, 2.0, 1.0, 1.5, 2.5, 2.0, 1.5, 1.0]), EQPreset(name: "Классика", gains: [5.0, 4.0, 3.0, 2.0, -1.0, -1.0, 0.0, 2.0, 3.5, 4.5]), EQPreset(name: "Электроника", gains: [5.0, 4.0, 2.0, 0.0, -2.0, 2.5, 1.5, 2.0, 4.0, 5.5]), EQPreset(name: "Джаз", gains: [4.0, 3.0, 1.5, 2.0, -1.5, -1.5, 0.0, 1.5, 3.0, 4.0]), EQPreset(name: "Поп", gains: [-2.0, -1.5, 0.0, 2.5, 4.5, 4.0, 2.5, 0.0, -1.5, -2.0]), EQPreset(name: "Рок", gains: [5.0, 4.0, -2.0, -4.0, -1.5, 1.5, 3.5, 4.5, 5.0, 5.0]) ] class LyricsManager: ObservableObject { static let shared = LyricsManager() private let storageKey = "trackLyrics_v1" @Published private var store: [String: [LyricLine]] = [:] init() { load() } func lyrics(for url: URL) -> [LyricLine] { store[url.lastPathComponent] ?? [] } func hasLyrics(for url: URL) -> Bool { !(store[url.lastPathComponent]?.isEmpty ?? true) } func setLyrics(_ lines: [LyricLine], for url: URL) { store[url.lastPathComponent] = lines save() objectWillChange.send() } func deleteLyrics(for url: URL) { store.removeValue(forKey: url.lastPathComponent) save() } func parseLRC(_ content: String) -> [LyricLine] { var lines: [LyricLine] = [] let timeRx = try! NSRegularExpression(pattern: #"\[(\d{2}):(\d{2})[\.:](\d{2,3})\]"#) let metaRx = try! NSRegularExpression(pattern: #"^\[[a-zA-Z]+:.*\]$"#) for raw in content.components(separatedBy: "\n") { let t = raw.trimmingCharacters(in: .whitespacesAndNewlines) if t.isEmpty { continue } let nsT = t as NSString let r = NSRange(t.startIndex..., in: t) if metaRx.firstMatch(in: t, range: r) != nil { continue } let matches = timeRx.matches(in: t, range: r) if matches.isEmpty { if !t.hasPrefix("[") { lines.append(LyricLine(time: -1, text: t)) } continue } let lastEnd = matches.last!.range.location + matches.last!.range.length let text = nsT.substring(from: lastEnd).trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { continue } for m in matches { let mm = Double(nsT.substring(with: m.range(at: 1))) ?? 0 let ss = Double(nsT.substring(with: m.range(at: 2))) ?? 0 let ms = Double(nsT.substring(with: m.range(at: 3))) ?? 0 let div = nsT.substring(with: m.range(at: 3)).count == 3 ? 1000.0 : 100.0 lines.append(LyricLine(time: mm * 60 + ss + ms / div, text: text)) } } return lines.sorted { let a = $0.time < 0 ? Double.infinity : $0.time let b = $1.time < 0 ? Double.infinity : $1.time return a < b } } func lrcText(for lines: [LyricLine]) -> String { lines.map { l in guard l.time >= 0 else { return l.text } let m = Int(l.time) / 60 let s = Int(l.time) % 60 let ms = Int((l.time - Double(Int(l.time))) * 100) return String(format: "[%02d:%02d.%02d]%@", m, s, ms, l.text) }.joined(separator: "\n") } private func save() { if let data = try? JSONEncoder().encode(store) { UserDefaults.standard.set(data, forKey: storageKey) } } private func load() { guard let d = UserDefaults.standard.data(forKey: storageKey), let dict = try? JSONDecoder().decode([String: [LyricLine]].self, from: d) else { return } store = dict } func fetchLyricsFromLRCLib(title: String, artist: String) async -> String? { var cleanTitle = title for ext in [".mp3", ".m4a", ".wav", ".flac", ".aac"] { cleanTitle = cleanTitle.replacingOccurrences(of: ext, with: "", options: .caseInsensitive) } let cleanArtist = artist == "Локальный файл" ? "" : artist var components = URLComponents(string: "https://lrclib.net/api/search")! components.queryItems = [ URLQueryItem(name: "track_name", value: cleanTitle), URLQueryItem(name: "artist_name", value: cleanArtist) ].filter { !($0.value?.trimmingCharacters(in: .whitespaces).isEmpty ?? true) } guard let url = components.url else { return nil } var request = URLRequest(url: url) request.setValue("SwiftUIMusicPlayer/1.0", forHTTPHeaderField: "User-Agent") do { let (data, _) = try await URLSession.shared.data(for: request) let results = try JSONDecoder().decode([LRCLibSearchResult].self, from: data) if let bestMatch = results.first { return bestMatch.syncedLyrics ?? bestMatch.plainLyrics } } catch { print("LRCLib Network Error: \(error)") } return nil } } class PlaybackTime: ObservableObject { static let shared = PlaybackTime() @Published var currentTime: TimeInterval = 0 } // MARK: - Модель и менеджер пользовательских метаданных трека struct TrackMetadataOverride: Codable { var title: String var artist: String } class TrackMetadataManager: ObservableObject { static let shared = TrackMetadataManager() private let storageKey = "trackMetadataOverrides_v1" @Published private var store: [String: TrackMetadataOverride] = [:] init() { load() } func override(for url: URL) -> TrackMetadataOverride? { store[url.lastPathComponent] } func hasOverride(for url: URL) -> Bool { store[url.lastPathComponent] != nil } func setMetadata(title: String, artist: String, for url: URL) { let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedArtist = artist.trimmingCharacters(in: .whitespacesAndNewlines) store[url.lastPathComponent] = TrackMetadataOverride( title: trimmedTitle.isEmpty ? url.lastPathComponent : trimmedTitle, artist: trimmedArtist.isEmpty ? "Локальный файл" : trimmedArtist ) save() objectWillChange.send() } func resetMetadata(for url: URL) { store.removeValue(forKey: url.lastPathComponent) save() objectWillChange.send() } private func save() { if let data = try? JSONEncoder().encode(store) { UserDefaults.standard.set(data, forKey: storageKey) } } private func load() { guard let d = UserDefaults.standard.data(forKey: storageKey), let dict = try? JSONDecoder().decode([String: TrackMetadataOverride].self, from: d) else { return } store = dict } } // MARK: - Менеджер пользовательских обложек треков class ArtworkManager: ObservableObject { static let shared = ArtworkManager() private var cache: [String: UIImage] = [:] private var artworkDirectory: URL { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let dir = docs.appendingPathComponent("CustomArtwork", isDirectory: true) if !FileManager.default.fileExists(atPath: dir.path) { try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } return dir } private func fileURL(for trackURL: URL) -> URL { artworkDirectory.appendingPathComponent(trackURL.lastPathComponent + ".jpg") } func hasCustomArtwork(for trackURL: URL) -> Bool { FileManager.default.fileExists(atPath: fileURL(for: trackURL).path) } func artwork(for trackURL: URL) -> UIImage? { let key = trackURL.lastPathComponent if let cached = cache[key] { return cached } guard let data = try? Data(contentsOf: fileURL(for: trackURL)), let image = UIImage(data: data) else { return nil } cache[key] = image return image } func setArtwork(_ image: UIImage, for trackURL: URL) { guard let data = image.jpegData(compressionQuality: 0.85) else { return } try? data.write(to: fileURL(for: trackURL), options: .atomic) cache[trackURL.lastPathComponent] = image objectWillChange.send() } func removeArtwork(for trackURL: URL) { try? FileManager.default.removeItem(at: fileURL(for: trackURL)) cache.removeValue(forKey: trackURL.lastPathComponent) objectWillChange.send() } } class MusicManager: NSObject, ObservableObject { static let shared = MusicManager() @Published var audioPlayer: AVAudioPlayerWithEQ? @Published var tracks: [Track] = [] private var shuffledTracks: [Track] = [] @Published var isPlaying = false @Published var currentTrackIndex = 0 @Published var volume: Float = 0.7 { didSet { applyVolumeChange() } } @Published var shuffleOn = false { didSet { updateShuffledList() } } @Published var repeatMode: RepeatMode = .none @Published var currentArtwork: UIImage? @Published var gradientColors: [Color] = [Color(.systemGray5), Color(.systemGray3)] @Published var trackDuration: TimeInterval = 0 var currentTime: TimeInterval { get { PlaybackTime.shared.currentTime } set { PlaybackTime.shared.currentTime = newValue } } @Published var currentTitle: String = "" @Published var currentArtist: String = "" private var timer: AnyCancellable? private var cancellables = Set() private let systemVolumeView = MPVolumeView(frame: .zero) var currentList: [Track] { return shuffleOn ? shuffledTracks : tracks } var currentTrack: Track? { guard !currentList.isEmpty && currentTrackIndex < currentList.count else { return nil } return currentList[currentTrackIndex] } override init() { super.init() setupSession() setupRemoteCommands() loadTracksFromDisk() setupVolumeObservation() setupInterruptionObserving() DispatchQueue.main.async { self.syncVolumeWithCurrentMode() } } func setupSession() { do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio) try AVAudioSession.sharedInstance().setActive(true) } catch { print("Session error: \(error)") } } func loadTracksFromDisk() { let fileManager = FileManager.default guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return } do { let content = try fileManager.contentsOfDirectory(at: docs, includingPropertiesForKeys: nil) let audioFiles = content.filter { ["mp3", "m4a", "wav"].contains($0.pathExtension.lowercased()) } var loadedTracks: [Track] = [] for url in audioFiles { let asset = AVAsset(url: url) var title = url.lastPathComponent var artist = "Локальный файл" for item in asset.metadata { guard let key = item.commonKey else { continue } if key == .commonKeyTitle { title = item.stringValue ?? title } if key == .commonKeyArtist { artist = item.stringValue ?? artist } } if let override = TrackMetadataManager.shared.override(for: url) { title = override.title artist = override.artist } loadedTracks.append(Track(url: url, title: title, artist: artist)) } DispatchQueue.main.async { self.tracks = loadedTracks.sorted(by: { $0.title < $1.title }) self.updateShuffledList() } } catch { print("Disk error: \(error)") } } func originalMetadata(for url: URL) -> (title: String, artist: String) { let asset = AVAsset(url: url) var title = url.lastPathComponent var artist = "Локальный файл" for item in asset.metadata { guard let key = item.commonKey else { continue } if key == .commonKeyTitle { title = item.stringValue ?? title } if key == .commonKeyArtist { artist = item.stringValue ?? artist } } return (title, artist) } func updateMetadata(title: String, artist: String, for url: URL) { TrackMetadataManager.shared.setMetadata(title: title, artist: artist, for: url) loadTracksFromDisk() if audioPlayer?.url == url { let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedArtist = artist.trimmingCharacters(in: .whitespacesAndNewlines) currentTitle = trimmedTitle.isEmpty ? url.lastPathComponent : trimmedTitle currentArtist = trimmedArtist.isEmpty ? "Локальный файл" : trimmedArtist updateNowPlaying() } } func resetMetadata(for url: URL) { TrackMetadataManager.shared.resetMetadata(for: url) loadTracksFromDisk() if audioPlayer?.url == url { let original = originalMetadata(for: url) currentTitle = original.title currentArtist = original.artist updateNowPlaying() } } func updateArtwork(_ image: UIImage, for url: URL) { ArtworkManager.shared.setArtwork(image, for: url) if audioPlayer?.url == url { currentArtwork = image gradientColors = image.extractGradientColors() updateNowPlaying() } } func removeCustomArtwork(for url: URL) { ArtworkManager.shared.removeArtwork(for: url) if audioPlayer?.url == url { fetchArtwork(for: url) updateNowPlaying() } } func importTracks(from urls: [URL]) { let fileManager = FileManager.default guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return } var lrcURLs: [URL] = [] var audioURLs: [URL] = [] for url in urls { if url.pathExtension.lowercased() == "lrc" { lrcURLs.append(url) } else { audioURLs.append(url) } } for url in audioURLs { let accessing = url.startAccessingSecurityScopedResource() let dest = docs.appendingPathComponent(url.lastPathComponent) try? fileManager.copyItem(at: url, to: dest) if accessing { url.stopAccessingSecurityScopedResource() } } for lrcURL in lrcURLs { let accessing = lrcURL.startAccessingSecurityScopedResource() defer { if accessing { lrcURL.stopAccessingSecurityScopedResource() } } guard let content = try? String(contentsOf: lrcURL, encoding: .utf8) else { continue } let baseName = lrcURL.deletingPathExtension().lastPathComponent if let matchURL = findAudioURL(for: baseName, in: docs) { let lines = LyricsManager.shared.parseLRC(content) LyricsManager.shared.setLyrics(lines, for: matchURL) } } loadTracksFromDisk() } private func findAudioURL(for baseName: String, in directory: URL) -> URL? { for ext in ["mp3", "m4a", "wav", "flac", "aac"] { let url = directory.appendingPathComponent(baseName + "." + ext) if FileManager.default.fileExists(atPath: url.path) { return url } } return nil } func deleteTrack(at offsets: IndexSet) { offsets.forEach { try? FileManager.default.removeItem(at: tracks[$0].url) } loadTracksFromDisk() } func deleteTrack(track: Track) { try? FileManager.default.removeItem(at: track.url) loadTracksFromDisk() } func deleteAllTracks() { audioPlayer?.stop() isPlaying = false for track in tracks { try? FileManager.default.removeItem(at: track.url) } DispatchQueue.main.async { self.tracks.removeAll() self.shuffledTracks.removeAll() self.currentArtwork = nil self.gradientColors = [Color(.systemGray5), Color(.systemGray4), Color(.systemGray3)] self.currentTitle = "" self.currentArtist = "" self.currentTime = 0 self.trackDuration = 0 self.updateNowPlaying() } } private func updateShuffledList() { shuffledTracks = tracks.shuffled() if let playingURL = audioPlayer?.url, let newIndex = shuffledTracks.firstIndex(where: { $0.url == playingURL }) { currentTrackIndex = newIndex } } func playTrack(at index: Int, in list: [Track]? = nil) { let activeList = list ?? currentList guard !activeList.isEmpty else { return } let safeIndex = (index < 0) ? 0 : (index >= activeList.count ? 0 : index) currentTrackIndex = safeIndex let track = activeList[currentTrackIndex] do { audioPlayer?.onFinishPlaying = nil // Clear old callback securely audioPlayer?.stop() // Prevent ghostly double playback audioPlayer = try AVAudioPlayerWithEQ(contentsOf: track.url) audioPlayer?.onFinishPlaying = { [weak self] in guard let self = self else { return } if self.repeatMode == .one { self.seek(to: 0) self.audioPlayer?.play() self.isPlaying = true } else if self.repeatMode == .all || self.currentTrackIndex < self.currentList.count - 1 { self.nextTrack() } else { self.isPlaying = false self.updateNowPlaying() } } if !UserDefaults.standard.bool(forKey: "controlSystemVolume") { audioPlayer?.volume = volume } else { audioPlayer?.volume = 1.0 } audioPlayer?.play() isPlaying = true trackDuration = audioPlayer?.duration ?? 0 self.currentTitle = track.title self.currentArtist = track.artist fetchArtwork(for: track.url) startTimer() updateNowPlaying() } catch { print("Play error: \(error)") } } func resume() { guard audioPlayer?.isPlaying != true else { updateNowPlaying() return } if currentTime >= trackDuration - 0.5 && trackDuration > 0 { seek(to: 0) } audioPlayer?.play() isPlaying = true startTimer() updateNowPlaying() } func pause() { guard audioPlayer?.isPlaying == true else { updateNowPlaying() return } audioPlayer?.pause() isPlaying = false updateNowPlaying() } func playPause() { if audioPlayer?.isPlaying == true { pause() } else { resume() } } func nextTrack() { if currentList.isEmpty { return } playTrack(at: (currentTrackIndex + 1) % currentList.count) } func prevTrack() { if currentList.isEmpty { return } if (audioPlayer?.currentTime ?? 0) > 3.0 { seek(to: 0); return } playTrack(at: (currentTrackIndex - 1 + currentList.count) % currentList.count) } func seek(to time: TimeInterval) { audioPlayer?.seek(to: time) currentTime = time updateNowPlaying() } func toggleShuffle() { shuffleOn.toggle() } func toggleRepeat() { repeatMode = RepeatMode(rawValue: (repeatMode.rawValue + 1) % RepeatMode.allCases.count) ?? .none } private func startTimer() { timer?.cancel() var tickCount = 0 timer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect().sink { [weak self] _ in guard let self = self else { return } PlaybackTime.shared.currentTime = self.audioPlayer?.currentTime ?? 0 tickCount += 1 if tickCount % 20 == 0 { self.syncNowPlayingIfNeeded() } } } private func syncNowPlayingIfNeeded() { let actuallyPlaying = audioPlayer?.isPlaying ?? false if actuallyPlaying != isPlaying { isPlaying = actuallyPlaying } updateNowPlaying() } private func fetchArtwork(for url: URL) { self.currentArtwork = nil if let custom = ArtworkManager.shared.artwork(for: url) { self.currentArtwork = custom self.gradientColors = custom.extractGradientColors() return } let asset = AVAsset(url: url) for item in asset.metadata where item.commonKey == .commonKeyArtwork { if let data = item.dataValue, let img = UIImage(data: data) { self.currentArtwork = img self.gradientColors = img.extractGradientColors() } } if self.currentArtwork == nil { self.gradientColors = [Color(.systemGray5), Color(.systemGray4), Color(.systemGray3)] } } func updateNowPlaying() { var info: [String: Any] = [ MPMediaItemPropertyTitle: currentTitle, MPMediaItemPropertyArtist: currentArtist, MPNowPlayingInfoPropertyElapsedPlaybackTime: audioPlayer?.currentTime ?? 0, MPMediaItemPropertyPlaybackDuration: trackDuration, MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0 ] if let image = currentArtwork { info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in return image } } MPNowPlayingInfoCenter.default().nowPlayingInfo = info MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused } func setupRemoteCommands() { let center = MPRemoteCommandCenter.shared() center.playCommand.addTarget { [weak self] _ in self?.resume() return .success } center.pauseCommand.addTarget { [weak self] _ in self?.pause() return .success } center.nextTrackCommand.addTarget { [weak self] _ in self?.nextTrack(); return .success } center.previousTrackCommand.addTarget { [weak self] _ in self?.prevTrack(); return .success } } private func setupInterruptionObserving() { NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification) .sink { [weak self] notification in self?.handleInterruption(notification) } .store(in: &cancellables) NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) .sink { [weak self] _ in self?.syncNowPlayingIfNeeded() } .store(in: &cancellables) } private func handleInterruption(_ notification: Notification) { guard let info = notification.userInfo, let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .began: isPlaying = false updateNowPlaying() case .ended: var shouldResume = false if let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt { shouldResume = AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume) } do { try AVAudioSession.sharedInstance().setActive(true) } catch { print("Не удалось повторно активировать аудиосессию: \(error)") } if shouldResume { audioPlayer?.play() isPlaying = true } else { isPlaying = false } updateNowPlaying() @unknown default: break } } private func applyVolumeChange() { let controlSystem = UserDefaults.standard.bool(forKey: "controlSystemVolume") if controlSystem { setSystemVolume(volume) } else { audioPlayer?.volume = volume } } func syncVolumeWithCurrentMode() { let controlSystem = UserDefaults.standard.bool(forKey: "controlSystemVolume") if controlSystem { self.volume = AVAudioSession.sharedInstance().outputVolume audioPlayer?.volume = 1.0 } else { if let player = audioPlayer { self.volume = player.volume } } } private func setSystemVolume(_ value: Float) { let slider = systemVolumeView.subviews.first(where: { $0 is UISlider }) as? UISlider DispatchQueue.main.async { slider?.value = value } } private func setupVolumeObservation() { NotificationCenter.default.publisher(for: NSNotification.Name("AVSystemController_SystemVolumeDidChangeNotification")) .sink { [weak self] notification in guard let self = self else { return } if UserDefaults.standard.bool(forKey: "controlSystemVolume") { if let volumeValue = notification.userInfo?["AVSystemController_AudioVolumeNotificationParameter"] as? Float { DispatchQueue.main.async { self.volume = volumeValue } } } } .store(in: &cancellables) } func applyEQChanges() { audioPlayer?.updateEQSettings() } } // MARK: - Вертикальный ползунок эквалайзера в стиле аудио-консоли struct VerticalSlider: View { @Binding var value: Double var range: ClosedRange = -12...12 let label: String var body: some View { VStack(spacing: 8) { Text(String(format: "%+.1f", value)) .font(.system(size: 10, weight: .bold, design: .monospaced)) .foregroundColor(.gray) .frame(width: 48) GeometryReader { geo in let height = geo.size.height let pct = CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) ZStack(alignment: .bottom) { Capsule() .fill(Color.primary.opacity(0.1)) .frame(width: 6) Capsule() .fill(Color.red) .frame(width: 6, height: max(0, min(height, height * pct))) Circle() .fill(Color.white) .frame(width: 18, height: 18) .shadow(color: Color.black.opacity(0.25), radius: 2) .offset(y: -height * pct + 9) } .frame(maxWidth: .infinity) .contentShape(Rectangle()) .padding(.vertical, 9) .animation(.interactiveSpring(response: 0.35, dampingFraction: 0.75), value: value) .gesture( DragGesture(minimumDistance: 0) .onChanged { gesture in let touchY = gesture.location.y let percent = 1 - min(max(touchY / height, 0), 1) value = Double(percent) * (range.upperBound - range.lowerBound) + range.lowerBound } ) } .frame(height: 140) Text(label) .font(.system(size: 10, weight: .bold, design: .rounded)) .foregroundColor(.primary) } .frame(width: 44) } } // MARK: - Экран настройки эквалайзера struct EqualizerSettingsView: View { @AppStorage("eq_enabled") private var isEqEnabled: Bool = false @AppStorage("selected_eq_preset") private var selectedPresetName: String = "Обычный (Flat)" @AppStorage("appTheme") private var appTheme: AppTheme = .system @AppStorage("eq_band_0") private var band0: Double = 0.0 @AppStorage("eq_band_1") private var band1: Double = 0.0 @AppStorage("eq_band_2") private var band2: Double = 0.0 @AppStorage("eq_band_3") private var band3: Double = 0.0 @AppStorage("eq_band_4") private var band4: Double = 0.0 @AppStorage("eq_band_5") private var band5: Double = 0.0 @AppStorage("eq_band_6") private var band6: Double = 0.0 @AppStorage("eq_band_7") private var band7: Double = 0.0 @AppStorage("eq_band_8") private var band8: Double = 0.0 @AppStorage("eq_band_9") private var band9: Double = 0.0 private let frequencies = ["32Гц", "64Гц", "125Гц", "250Гц", "500Гц", "1кГц", "2кГц", "4кГц", "8кГц", "16кГц"] private func bandBinding(for index: Int) -> Binding { Binding( get: { switch index { case 0: return band0; case 1: return band1; case 2: return band2 case 3: return band3; case 4: return band4; case 5: return band5 case 6: return band6; case 7: return band7; case 8: return band8 case 9: return band9; default: return 0.0 } }, set: { newValue in switch index { case 0: band0 = newValue; case 1: band1 = newValue; case 2: band2 = newValue case 3: band3 = newValue; case 4: band4 = newValue; case 5: band5 = newValue case 6: band6 = newValue; case 7: band7 = newValue; case 8: band8 = newValue case 9: band9 = newValue; default: break } selectedPresetName = "Вручную" MusicManager.shared.applyEQChanges() } ) } var body: some View { Form { Section { Toggle("Включить эквалайзер", isOn: $isEqEnabled) .onChange(of: isEqEnabled) { _ in MusicManager.shared.applyEQChanges() } } if isEqEnabled { Section(header: Text("Параметрические полосы")) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(0..<10, id: \.self) { index in VerticalSlider(value: bandBinding(for: index), label: frequencies[index]) } } .padding(.vertical, 10) } } Section(header: Text("Популярные пресеты")) { List { ForEach(eqPresets) { preset in HStack { Text(preset.name) .font(.body) .foregroundColor(.primary) Spacer() if selectedPresetName == preset.name { Image(systemName: "checkmark") .foregroundColor(.red) .font(.subheadline.bold()) } } .contentShape(Rectangle()) .onTapGesture { applyPreset(preset) } } } } } } .navigationTitle("Эквалайзер") .navigationBarTitleDisplayMode(.inline) .preferredColorScheme(appTheme.colorScheme) } private func applyPreset(_ preset: EQPreset) { withAnimation { selectedPresetName = preset.name band0 = preset.gains[0]; band1 = preset.gains[1]; band2 = preset.gains[2] band3 = preset.gains[3]; band4 = preset.gains[4]; band5 = preset.gains[5] band6 = preset.gains[6]; band7 = preset.gains[7]; band8 = preset.gains[8] band9 = preset.gains[9] } MusicManager.shared.applyEQChanges() } } // MARK: - Синхронизированный просмотр текста struct LyricsView: View { let lyrics: [LyricLine] let currentTime: TimeInterval var isImmersive: Bool = false var onSeek: ((TimeInterval) -> Void)? = nil var onScroll: (() -> Void)? = nil var onTap: (() -> Void)? = nil @AppStorage("lyricsFontSize") private var lyricsFontSize: Double = 34.0 @AppStorage("karaokeModeEnabled") private var karaokeModeEnabled: Bool = true @State private var isUserScrolling = false @State private var resetTask: DispatchWorkItem? = nil private var activeIndex: Int? { guard lyrics.contains(where: { $0.time >= 0 }) else { return nil } var result: Int? = nil for (i, line) in lyrics.enumerated() where line.time >= 0 && line.time <= currentTime { result = i } return result } private var displayIndex: Int? { isUserScrolling ? nil : activeIndex } private func opacity(for i: Int) -> Double { guard let cur = displayIndex else { return 0.65 } switch abs(i - cur) { case 0: return 1.0 case 1: return 0.5 case 2: return 0.3 default: return 0.15 } } private func blurRadius(for i: Int) -> CGFloat { guard let cur = displayIndex else { return 0 } switch abs(i - cur) { case 0: return 0 case 1: return 0.8 case 2: return 1.5 default: return 2.2 } } private func scale(for i: Int) -> CGFloat { guard let cur = displayIndex else { return 1 } switch abs(i - cur) { case 0: return 1.08 default: return 1.0 } } private func progress(for index: Int) -> Double { let currentLineTime = lyrics[index].time let nextLineTime: Double if index + 1 < lyrics.count, lyrics[index + 1].time >= 0 { nextLineTime = lyrics[index + 1].time } else { nextLineTime = currentLineTime + 8.0 } let duration = min(nextLineTime - currentLineTime, 8.0) guard duration > 0 else { return 1.0 } let p = (currentTime - currentLineTime) / duration return max(0, p) } private func karaokeText(for line: String, progress: Double, isActiveLine: Bool) -> Text { guard isActiveLine && karaokeModeEnabled else { return Text(line).foregroundColor(.primary) } let chars = Array(line) let total = Double(max(1, chars.count)) var result = Text("") for (index, char) in chars.enumerated() { let start = Double(index) / total let end = Double(index + 1) / total let opacity: Double if progress <= start { opacity = 0.35 } else if progress >= end { opacity = 1.0 } else { let fillRatio = (progress - start) / (end - start) opacity = 0.35 + (0.65 * fillRatio) } result = result + Text(String(char)).foregroundColor(Color.primary.opacity(opacity)) } return result } private func karaokeGlowText(for line: String, progress: Double) -> Text { let chars = Array(line) let total = Double(max(1, chars.count)) var result = Text("") for (index, char) in chars.enumerated() { let start = Double(index) / total let end = Double(index + 1) / total let center = (start + end) / 2.0 let glowSpread = max(0.15, 2.5 / total) let distance = abs(progress - center) let opacity: Double = distance < glowSpread ? 1.0 - (distance / glowSpread) : 0.0 result = result + Text(String(char)).foregroundColor(Color.primary.opacity(opacity)) } return result } private func scheduleReset() { resetTask?.cancel() let task = DispatchWorkItem { withAnimation(.easeInOut(duration: 0.4)) { isUserScrolling = false } } resetTask = task DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: task) } var body: some View { GeometryReader { geo in ScrollViewReader { proxy in ScrollView(showsIndicators: false) { LazyVStack(spacing: 24) { ForEach(Array(lyrics.enumerated()), id: \.element.id) { i, line in let isActive = displayIndex == i let currentProgress = progress(for: i) ZStack { karaokeText(for: line.text, progress: currentProgress, isActiveLine: isActive) if isActive && karaokeModeEnabled { karaokeGlowText(for: line.text, progress: currentProgress) .blur(radius: 7) .opacity(0.85) .scaleEffect(1.02) } } .font(.system(size: CGFloat(lyricsFontSize), weight: .heavy, design: .rounded)) .lineSpacing(4) .multilineTextAlignment(.leading) .padding(.horizontal, 30) .opacity(opacity(for: i)) .blur(radius: blurRadius(for: i)) .scaleEffect(scale(for: i), anchor: .leading) .frame(maxWidth: .infinity, alignment: .leading) .id(line.id) .animation(.spring(response: 0.38, dampingFraction: 0.78), value: displayIndex) .onTapGesture { guard line.time >= 0 else { onTap?() return } onSeek?(line.time) onTap?() resetTask?.cancel() withAnimation(.easeInOut(duration: 0.3)) { isUserScrolling = false } } } } .padding(.vertical, geo.size.height * 0.4) // Динамический отступ вместо статичного .frame(maxWidth: .infinity) } .background(Color.clear.contentShape(Rectangle()).onTapGesture { onTap?() }) .simultaneousGesture( DragGesture(minimumDistance: 8) .onChanged { _ in onScroll?() resetTask?.cancel() if !isUserScrolling { withAnimation(.easeInOut(duration: 0.25)) { isUserScrolling = true } } } .onEnded { _ in onScroll?() scheduleReset() } ) .onChange(of: activeIndex) { newIndex in guard !isUserScrolling, let idx = newIndex, lyrics.indices.contains(idx) else { return } withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { proxy.scrollTo(lyrics[idx].id, anchor: .center) } } .onChange(of: isUserScrolling) { scrolling in guard !scrolling, let idx = activeIndex, lyrics.indices.contains(idx) else { return } withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { proxy.scrollTo(lyrics[idx].id, anchor: .center) } } } } } } // MARK: - Редактор текста struct LyricsEditorView: View { @Environment(\.dismiss) var dismiss @AppStorage("appTheme") private var appTheme: AppTheme = .system @ObservedObject var lyricsManager = LyricsManager.shared let trackURL: URL let trackTitle: String let trackArtist: String @State private var editorText: String = "" @State private var showLRCPicker = false @State private var showDeleteAlert = false @State private var importBanner: String? = nil @State private var isFetchingOnline = false init(trackURL: URL, trackTitle: String, trackArtist: String) { self.trackURL = trackURL self.trackTitle = trackTitle self.trackArtist = trackArtist UITextView.appearance().backgroundColor = .clear } var body: some View { NavigationView { VStack(spacing: 0) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { Button(action: fetchOnline) { if isFetchingOnline { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: .white)) .frame(width: 20, height: 20) } else { Label("Найти в сети", systemImage: "network") } } .font(.subheadline.weight(.medium)) .foregroundColor(.white) .padding(.horizontal, 14).padding(.vertical, 8) .background(Color.blue) .cornerRadius(10) .disabled(isFetchingOnline) Button(action: { showLRCPicker = true }) { Label("Импорт LRC", systemImage: "doc.badge.plus") .font(.subheadline.weight(.medium)) .foregroundColor(.white) .padding(.horizontal, 14).padding(.vertical, 8) .background(Color.red) .cornerRadius(10) } if !editorText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Button(action: { showDeleteAlert = true }) { Image(systemName: "trash") .foregroundColor(.white) .padding(9) .background(Color.gray.opacity(0.5)) .cornerRadius(10) } } Spacer() if editorText.contains("[") && editorText.contains(":") { Label("LRC", systemImage: "timer") .font(.caption.weight(.semibold)) .foregroundColor(.orange) .padding(.horizontal, 8).padding(.vertical, 4) .background(Color.orange.opacity(0.12)) .cornerRadius(6) } } .padding(.horizontal) } .padding(.vertical, 12) if let banner = importBanner { HStack(spacing: 6) { Image(systemName: "info.circle.fill").foregroundColor(.blue) Text(banner).font(.caption).foregroundColor(.blue) Spacer() } .padding(.horizontal).padding(.bottom, 8) .transition(.opacity) } Divider() ZStack(alignment: .topLeading) { if editorText.isEmpty { Text("Введите текст песни вручную, или вставьте LRC-содержимое с временными метками вида [01:23.45]…") .foregroundColor(.gray.opacity(0.6)) .font(.body) .padding(.horizontal, 18).padding(.top, 14) } TextEditor(text: $editorText) .font(.body) .padding(.horizontal, 12).padding(.top, 6) .background(Color.clear) } .background(Color(UIColor.systemBackground)) } .navigationTitle(trackTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Отмена") { dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { Button(action: saveAndDismiss) { Text("Сохранить").fontWeight(.bold) } } } .alert("Очистить текст?", isPresented: $showDeleteAlert) { Button("Отмена", role: .cancel) {} Button("Очистить", role: .destructive) { editorText = "" lyricsManager.deleteLyrics(for: trackURL) } } .fileImporter( isPresented: $showLRCPicker, allowedContentTypes: [UTType(filenameExtension: "lrc") ?? .plainText], allowsMultipleSelection: false ) { result in importLRCFile(result: result) } } .onAppear { let lines = lyricsManager.lyrics(for: trackURL) editorText = lines.isEmpty ? "" : lyricsManager.lrcText(for: lines) } .preferredColorScheme(appTheme.colorScheme) } private func saveAndDismiss() { let trimmed = editorText.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { lyricsManager.deleteLyrics(for: trackURL) } else if trimmed.contains("[") { let parsed = lyricsManager.parseLRC(trimmed) if parsed.contains(where: { $0.time >= 0 }) { lyricsManager.setLyrics(parsed, for: trackURL) } else { savePlainText(trimmed) } } else { savePlainText(trimmed) } dismiss() } private func savePlainText(_ text: String) { let lines = text.components(separatedBy: "\n") .map { LyricLine(time: -1, text: $0) } lyricsManager.setLyrics(lines, for: trackURL) } private func importLRCFile(result: Result<[URL], Error>) { guard case .success(let urls) = result, let url = urls.first else { return } let accessing = url.startAccessingSecurityScopedResource() defer { if accessing { url.stopAccessingSecurityScopedResource() } } guard let content = try? String(contentsOf: url, encoding: .utf8) else { return } let lines = lyricsManager.parseLRC(content) lyricsManager.setLyrics(lines, for: trackURL) editorText = lyricsManager.lrcText(for: lines) withAnimation { importBanner = "Импортирован: \(url.lastPathComponent)" } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { withAnimation { importBanner = nil } } } private func fetchOnline() { isFetchingOnline = true Task { let foundLyrics = await lyricsManager.fetchLyricsFromLRCLib(title: trackTitle, artist: trackArtist) await MainActor.run { isFetchingOnline = false if let newText = foundLyrics, !newText.isEmpty { editorText = newText withAnimation { importBanner = "Текст успешно загружен!" } } else { withAnimation { importBanner = "К сожалению, ничего не найдено 😔" } } DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { withAnimation { importBanner = nil } } } } } } // MARK: - Редактор метаданных трека struct MetadataEditorView: View { @Environment(\.dismiss) var dismiss @AppStorage("appTheme") private var appTheme: AppTheme = .system @ObservedObject var manager = MusicManager.shared @ObservedObject var metadataManager = TrackMetadataManager.shared @ObservedObject var artworkManager = ArtworkManager.shared let trackURL: URL @State private var editedTitle: String = "" @State private var editedArtist: String = "" @State private var previewArtwork: UIImage? = nil @State private var showResetAlert = false @State private var showImagePicker = false private var isSaveDisabled: Bool { editedTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var body: some View { NavigationView { Form { Section(header: Text("Обложка")) { HStack(spacing: 16) { ZStack { RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.15)) if let image = previewArtwork { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) } else { Image(systemName: "music.note") .foregroundColor(.secondary) } } .frame(width: 60, height: 60) .clipShape(RoundedRectangle(cornerRadius: 8)) .clipped() VStack(alignment: .leading, spacing: 10) { Button(action: { showImagePicker = true }) { Label("Выбрать из галереи", systemImage: "photo") } if artworkManager.hasCustomArtwork(for: trackURL) { Button(role: .destructive, action: removeArtwork) { Label("Удалить обложку", systemImage: "trash") } } } .font(.subheadline) Spacer() } } Section(header: Text("Метаданные трека")) { TextField("Название", text: $editedTitle) TextField("Исполнитель", text: $editedArtist) } Section(header: Text("Файл")) { InfoRow(title: "Имя файла", value: trackURL.lastPathComponent) InfoRow(title: "Формат", value: trackURL.pathExtension.uppercased()) } if metadataManager.hasOverride(for: trackURL) { Section { Button(role: .destructive, action: { showResetAlert = true }) { Label("Сбросить к оригинальным данным", systemImage: "arrow.counterclockwise") } } } } .navigationTitle("Редактировать метаданные") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Отмена") { dismiss() } } ToolbarItem(placement: .navigationBarTrailing) { Button(action: saveAndDismiss) { Text("Сохранить").fontWeight(.bold) } .disabled(isSaveDisabled) } } .alert("Сбросить метаданные?", isPresented: $showResetAlert) { Button("Отмена", role: .cancel) {} Button("Сбросить", role: .destructive) { manager.resetMetadata(for: trackURL) let original = manager.originalMetadata(for: trackURL) editedTitle = original.title editedArtist = original.artist dismiss() } } message: { Text("Название и исполнитель вернутся к значениям, прочитанным из файла.") } .sheet(isPresented: $showImagePicker) { ImagePicker { image in previewArtwork = image manager.updateArtwork(image, for: trackURL) } } } .onAppear { if let override = metadataManager.override(for: trackURL) { editedTitle = override.title editedArtist = override.artist } else { let original = manager.originalMetadata(for: trackURL) editedTitle = original.title editedArtist = original.artist } loadPreviewArtwork() } .preferredColorScheme(appTheme.colorScheme) } private func saveAndDismiss() { manager.updateMetadata(title: editedTitle, artist: editedArtist, for: trackURL) dismiss() } private func removeArtwork() { manager.removeCustomArtwork(for: trackURL) loadPreviewArtwork() } private func loadPreviewArtwork() { if let custom = artworkManager.artwork(for: trackURL) { previewArtwork = custom return } previewArtwork = nil DispatchQueue.global(qos: .userInitiated).async { let asset = AVAsset(url: trackURL) for item in asset.metadata where item.commonKey == .commonKeyArtwork { if let data = item.dataValue, let img = UIImage(data: data) { DispatchQueue.main.async { self.previewArtwork = img } return } } } } } struct TrackRow: View { let track: Track let isCurrent: Bool let isPlaying: Bool @State private var artwork: UIImage? @ObservedObject private var lyricsManager = LyricsManager.shared var body: some View { HStack(spacing: 16) { ZStack { RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.15)) if let image = artwork { Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) } else { Image(systemName: "music.note").foregroundColor(.gray).font(.system(size: 18)) } if lyricsManager.hasLyrics(for: track.url) { VStack { HStack { Spacer() Image(systemName: "quote.bubble.fill") .font(.system(size: 8, weight: .bold)) .foregroundColor(.white) .padding(4) .background(Color.black.opacity(0.6)) .clipShape(Circle()) } Spacer() } .padding(3) } } .frame(width: 50, height: 50).cornerRadius(8).clipped() .shadow(color: Color.black.opacity(0.1), radius: 3, y: 2) VStack(alignment: .leading, spacing: 3) { Text(track.title) .font(.system(size: 16, weight: isCurrent ? .semibold : .regular)) .foregroundColor(isCurrent ? .red : .primary) .lineLimit(1) Text(track.artist) .font(.system(size: 14)) .foregroundColor(.secondary) .lineLimit(1) } Spacer() if isCurrent && isPlaying { Image(systemName: "waveform").foregroundColor(.red) } } .padding(.vertical, 4) .onAppear { if let custom = ArtworkManager.shared.artwork(for: track.url) { self.artwork = custom return } DispatchQueue.global(qos: .userInteractive).async { let asset = AVAsset(url: track.url) for item in asset.metadata where item.commonKey == .commonKeyArtwork { if let data = item.dataValue, let img = UIImage(data: data) { DispatchQueue.main.async { self.artwork = img } } } } } } } struct MiniPlayer: View { @ObservedObject var manager = MusicManager.shared @Binding var showFullPlayer: Bool var body: some View { VStack(spacing: 0) { Divider().background(Color.primary.opacity(0.1)) HStack(spacing: 14) { ZStack { if let image = manager.currentArtwork { Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) } else { Color.primary.opacity(0.1) Image(systemName: "music.note").foregroundColor(.gray) } } .frame(width: 48, height: 48).cornerRadius(6).clipped() .shadow(color: manager.gradientColors.first?.opacity(0.4) ?? .black.opacity(0.2), radius: 5, y: 3) VStack(alignment: .leading, spacing: 2) { Text(manager.currentTitle) .font(.system(size: 15, weight: .semibold, design: .rounded)) .lineLimit(1) Text(manager.currentArtist) .font(.system(size: 13, weight: .regular)) .foregroundColor(.secondary) .lineLimit(1) } Spacer() HStack(spacing: 20) { Button(action: manager.playPause) { Image(systemName: manager.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 22)) } Button(action: manager.nextTrack) { Image(systemName: "forward.fill") .font(.system(size: 22)) } } .foregroundColor(.primary) } .padding(.horizontal, 20).frame(height: 64) .padding(.bottom, 8) } .background(BlurView(style: .systemChromeMaterial).ignoresSafeArea(edges: .bottom)) .onTapGesture { showFullPlayer = true } } } struct ImmersiveBottomBar: View { @ObservedObject var manager: MusicManager var body: some View { HStack(spacing: 14) { if let image = manager.currentArtwork { Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) .frame(width: 48, height: 48).cornerRadius(6) } else { RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.2)) .frame(width: 48, height: 48) .overlay(Image(systemName: "music.note").foregroundColor(.white.opacity(0.5))) } VStack(alignment: .leading, spacing: 2) { Text(manager.currentTitle) .font(.system(size: 16, weight: .bold)) .foregroundColor(.white) .lineLimit(1) Text(manager.currentArtist) .font(.system(size: 14)) .foregroundColor(.white.opacity(0.7)) .lineLimit(1) } Spacer() Button(action: manager.playPause) { Image(systemName: manager.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 38)) .foregroundColor(.white) } } .padding(.horizontal, 20) .padding(.vertical, 14) .background(Color.black.opacity(0.4).edgesIgnoringSafeArea(.bottom)) .background(BlurView(style: .systemMaterialDark).edgesIgnoringSafeArea(.bottom)) } } // MARK: - Главный экран struct ContentView: View { @StateObject var manager = MusicManager.shared @AppStorage("appTheme") private var appTheme: AppTheme = .system @State private var showFilePicker = false @State private var showFullPlayer = false @State private var showSettings = false @State private var searchText = "" @State private var shareURL: URL? @State private var isFetchingMassLyrics = false @State private var processedTracksCount = 0 @State private var totalTracksToFetch = 0 @State private var foundLyricsCount = 0 @State private var showMassFetchResult = false var body: some View { ZStack(alignment: .bottom) { NavigationView { ZStack { Color(UIColor.systemBackground).ignoresSafeArea() if manager.tracks.isEmpty { ZStack { VStack(spacing: 16) { Image(systemName: "music.note.list") .font(.system(size: 70, weight: .light)) .foregroundColor(.secondary) Text("Медиатека пуста") .font(.title2.weight(.semibold)) .foregroundColor(.primary) Text("Добавьте музыку, нажав на '+'") .font(.subheadline) .foregroundColor(.secondary) } VStack { Spacer() Text("by aswer.") .font(.system(size: 10, weight: .regular, design: .rounded)) .foregroundColor(.secondary.opacity(0.4)) .padding(.bottom, 20) } } } else { List { ForEach(manager.tracks.filter { track in if searchText.isEmpty { return true } let matchTitle = track.title.localizedCaseInsensitiveContains(searchText) let matchArtist = track.artist.localizedCaseInsensitiveContains(searchText) return matchTitle || matchArtist }, id: \.self) { track in TrackRow(track: track, isCurrent: manager.audioPlayer?.url == track.url, isPlaying: manager.isPlaying) .contentShape(Rectangle()) .onTapGesture { manager.shuffleOn = false if let i = manager.tracks.firstIndex(of: track) { manager.playTrack(at: i, in: manager.tracks) } } .contextMenu { Button(action: { shareURL = track.url }) { Label("Поделиться", systemImage: "square.and.arrow.up") } Button(role: .destructive, action: { manager.deleteTrack(track: track) }) { Label("Удалить", systemImage: "trash") } } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { manager.deleteTrack(track: track) } label: { Label("Удалить", systemImage: "trash") } Button { shareURL = track.url } label: { Label("Поделиться", systemImage: "square.and.arrow.up") }.tint(.blue) } .listRowBackground(Color.clear) .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 4, trailing: 20)) .listRowSeparator(.visible) } .onDelete(perform: manager.deleteTrack) Color.clear .frame(height: 85) .listRowBackground(Color.clear) .listRowSeparator(.hidden) Text("by aswer.") .font(.system(size: 10, weight: .regular, design: .rounded)) .foregroundColor(.secondary.opacity(0.4)) .frame(maxWidth: .infinity, alignment: .center) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .padding(.bottom, 20) } .listStyle(.plain) .disabled(isFetchingMassLyrics) } if isFetchingMassLyrics { ZStack { Color.black.opacity(0.4).ignoresSafeArea() BlurView(style: .systemThinMaterialDark).ignoresSafeArea() VStack(spacing: 24) { ProgressView() .scaleEffect(1.8) .progressViewStyle(CircularProgressViewStyle(tint: .white)) VStack(spacing: 8) { Text("Ищем тексты...") .font(.headline) .foregroundColor(.white) Text("\(processedTracksCount) из \(totalTracksToFetch)") .font(.subheadline) .foregroundColor(.white.opacity(0.7)) } } .padding(40) .background(Color(UIColor.secondarySystemBackground).opacity(0.15)) .cornerRadius(24) } .zIndex(100) } } .navigationTitle("Медиатека") .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 16) { Button(action: { showFilePicker = true }) { Image(systemName: "plus.circle.fill") .foregroundColor(.red) .font(.title3) } Menu { Button(action: fetchLyricsForAll) { Label("Найти тексты для всех", systemImage: "text.magnifyingglass") } Button(action: { showSettings = true }) { Label("Настройки", systemImage: "gearshape") } } label: { Image(systemName: "ellipsis.circle.fill") .foregroundColor(.red) .font(.title3) } } } } .alert("Результаты поиска", isPresented: $showMassFetchResult) { Button("Завершить поиск", role: .cancel) { } } message: { if totalTracksToFetch == 0 { Text("Для всех твоих треков тексты уже есть.") } else { Text("Обработано треков: \(totalTracksToFetch)\nУспешно найдено: \(foundLyricsCount)\nНе найдено : \(totalTracksToFetch - foundLyricsCount)") } } } .navigationViewStyle(StackNavigationViewStyle()) if !manager.tracks.isEmpty { MiniPlayer(showFullPlayer: $showFullPlayer) } } .preferredColorScheme(appTheme.colorScheme) .fullScreenCover(isPresented: $showFullPlayer) { FullPlayerView() } .sheet(isPresented: $showSettings) { SettingsView() } .sheet(item: $shareURL) { url in ActivityViewController(activityItems: [url]) } .fileImporter( isPresented: $showFilePicker, allowedContentTypes: [.audio, UTType(filenameExtension: "lrc") ?? .plainText], allowsMultipleSelection: true ) { res in if case .success(let urls) = res { manager.importTracks(from: urls) } } } private func fetchLyricsForAll() { let tracksToProcess = manager.tracks.filter { !LyricsManager.shared.hasLyrics(for: $0.url) } guard !tracksToProcess.isEmpty else { totalTracksToFetch = 0 foundLyricsCount = 0 showMassFetchResult = true return } isFetchingMassLyrics = true totalTracksToFetch = tracksToProcess.count processedTracksCount = 0 foundLyricsCount = 0 Task { for track in tracksToProcess { let foundLyrics = await LyricsManager.shared.fetchLyricsFromLRCLib(title: track.title, artist: track.artist) if let newText = foundLyrics, !newText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let trimmed = newText.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.contains("[") { let parsed = LyricsManager.shared.parseLRC(trimmed) if parsed.contains(where: { $0.time >= 0 }) { LyricsManager.shared.setLyrics(parsed, for: track.url) } else { let lines = trimmed.components(separatedBy: "\n").map { LyricLine(time: -1, text: $0) } LyricsManager.shared.setLyrics(lines, for: track.url) } } else { let lines = trimmed.components(separatedBy: "\n").map { LyricLine(time: -1, text: $0) } LyricsManager.shared.setLyrics(lines, for: track.url) } await MainActor.run { foundLyricsCount += 1 } } await MainActor.run { processedTracksCount += 1 } } await MainActor.run { isFetchingMassLyrics = false showMassFetchResult = true } } } } extension URL: Identifiable { public var id: String { self.absoluteString } } // MARK: - Окно настроек struct SettingsView: View { @AppStorage("appTheme") private var appTheme: AppTheme = .system @AppStorage("controlSystemVolume") private var controlSystemVolume: Bool = false @AppStorage("eq_enabled") private var isEqEnabled: Bool = false @AppStorage("selected_eq_preset") private var selectedPresetName: String = "Обычный (Flat)" @AppStorage("karaokeModeEnabled") private var karaokeModeEnabled: Bool = true @AppStorage("autoImmersiveLyricsEnabled") private var autoImmersiveLyricsEnabled: Bool = true @AppStorage("autoImmersiveLyricsTimeout") private var autoImmersiveLyricsTimeout: Double = 3.0 @AppStorage("lyricsFontSize") private var lyricsFontSize: Double = 34.0 @AppStorage("useSimpleBackground") private var useSimpleBackground: Bool = false @Environment(\.dismiss) var dismiss @State private var showingClearAlert = false @ObservedObject var manager = MusicManager.shared var body: some View { NavigationView { Form { Section(header: Text("Внешний вид")) { Picker("Тема оформления", selection: $appTheme) { ForEach(AppTheme.allCases, id: \.self) { theme in Text(theme.title).tag(theme) } } .pickerStyle(SegmentedPickerStyle()) Toggle(isOn: $useSimpleBackground) { VStack(alignment: .leading, spacing: 4) { Text("Упрощенный фон плеера") Text("Отключает тяжелую анимацию и показывает статичную размытую обложку") .font(.caption) .foregroundColor(.gray) } } } Section(header: Text("Звуковые эффекты")) { NavigationLink(destination: EqualizerSettingsView()) { HStack { Label("Эквалайзер", systemImage: "slider.horizontal.3") .foregroundColor(.red) Spacer() Text(isEqEnabled ? selectedPresetName : "Выкл.") .foregroundColor(.gray) } } } Section(header: Text("Текст песни")) { Toggle("Караоке-режим", isOn: $karaokeModeEnabled) Toggle("Иммерсивный режим", isOn: $autoImmersiveLyricsEnabled) if autoImmersiveLyricsEnabled { VStack(alignment: .leading, spacing: 6) { Text("Задержка включения: \(Int(autoImmersiveLyricsTimeout)) сек.") .font(.body) Slider(value: $autoImmersiveLyricsTimeout, in: 1...15, step: 1) .accentColor(.red) Text("Через какое время бездействия скрывать плеер и оставлять только текст") .font(.caption) .foregroundColor(.gray) } .padding(.vertical, 4) } VStack(alignment: .leading, spacing: 6) { Text("Размер шрифта: \(Int(lyricsFontSize)) пт") .font(.body) Slider(value: $lyricsFontSize, in: 18...48, step: 2) .accentColor(.red) Text("Настройте размер отображаемого текста на экране воспроизведения") .font(.caption) .foregroundColor(.gray) } .padding(.vertical, 4) } Section(header: Text("Громкость")) { Toggle(isOn: $controlSystemVolume) { VStack(alignment: .leading, spacing: 4) { Text("Системная громкость iPhone") Text("Управлять общей громкостью устройства вместо громкости внутри плеера") .font(.caption) .foregroundColor(.gray) } } .onChange(of: controlSystemVolume) { _ in manager.syncVolumeWithCurrentMode() } } Section(header: Text("Управление")) { Button(role: .destructive, action: { showingClearAlert = true }) { Text("Очистить медиатеку") } } } .navigationTitle("Настройки") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Готово") { dismiss() } } } .alert("Очистить медиатеку?", isPresented: $showingClearAlert) { Button("Отмена", role: .cancel) { } Button("Удалить", role: .destructive) { manager.deleteAllTracks() dismiss() } } message: { Text("Вы точно хотите удалить все треки? Это действие нельзя отменить.") } } .preferredColorScheme(appTheme.colorScheme) } } // MARK: - Извлечение доминирующих цветов из обложки extension UIImage { func extractGradientColors() -> [Color] { let side = 16 var raw = [UInt8](repeating: 0, count: side * side * 4) guard let ctx = CGContext( data: &raw, width: side, height: side, bitsPerComponent: 8, bytesPerRow: side * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ), let cgImg = self.cgImage else { return [Color(red: 0.1, green: 0.1, blue: 0.2), Color(red: 0.2, green: 0.1, blue: 0.3)] } ctx.draw(cgImg, in: CGRect(x: 0, y: 0, width: side, height: side)) let pts: [(Int,Int)] = [ (1,1), (14,1), (7,7), (1,14), (14,14), (7,1), (7,14) ] return pts.map { (x, y) in let off = (y * side + x) * 4 let r = CGFloat(raw[off]) / 255 let g = CGFloat(raw[off+1]) / 255 let b = CGFloat(raw[off+2]) / 255 var h: CGFloat = 0, s: CGFloat = 0, br: CGFloat = 0, a: CGFloat = 0 UIColor(red: r, green: g, blue: b, alpha: 1) .getHue(&h, saturation: &s, brightness: &br, alpha: &a) return Color(UIColor( hue: h, saturation: min(s * 1.5, 1.0), brightness: max(min(br, 0.7), 0.25), alpha: 1 )) } } } // MARK: - Анимированный градиентный фон в стиле Apple Music struct AppleMusicGradientBackground: View { let colors: [Color] var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in AnimatedBlobCanvas( colors: colors, time: timeline.date.timeIntervalSinceReferenceDate ) } .ignoresSafeArea() } } private struct AnimatedBlobCanvas: View { let colors: [Color] let time: Double private let configs: [(Double, Double, Double, Double, Double)] = [ (0.41, 0.29, 0.00, 0.00, 1.15), (0.57, 0.38, 2.09, 1.57, 1.00), (0.33, 0.63, 4.19, 0.79, 1.25), (0.69, 0.44, 1.05, 3.14, 0.90), (0.48, 0.71, 3.14, 2.36, 1.05), (0.62, 0.52, 5.24, 4.71, 0.95), (0.36, 0.59, 2.62, 1.05, 1.10), ] var body: some View { GeometryReader { geo in let w = geo.size.width let h = geo.size.height let base = max(w, h) * 0.50 ZStack { Color.black.opacity(0.4) colors.first.map { $0.opacity(0.80) } ForEach(0.. = 0...1 @State private var isDragging: Bool = false var body: some View { GeometryReader { geo in let trackHeight: CGFloat = isDragging ? 8 : 4 let pct = CGFloat((value - range.lowerBound) / max(range.upperBound - range.lowerBound, 0.001)) ZStack(alignment: .leading) { Capsule() .fill(Color.white.opacity(0.2)) .frame(height: trackHeight) Capsule() .fill(Color.white.opacity(isDragging ? 0.9 : 0.7)) .frame(width: max(0, min(geo.size.width, geo.size.width * pct)), height: trackHeight) } .position(x: geo.size.width / 2, y: geo.size.height / 2) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isDragging) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { v in isDragging = true let percent = min(max(v.location.x / geo.size.width, 0), 1) value = Double(percent) * (range.upperBound - range.lowerBound) + range.lowerBound } .onEnded { _ in isDragging = false } ) } .frame(height: 24) } } struct VolumeSliderView: View { @Binding var value: Float var body: some View { HStack(spacing: 12) { Image(systemName: "speaker.fill") .font(.caption) .foregroundColor(.white.opacity(0.6)) GeometryReader { geo in let pct = CGFloat(value) ZStack(alignment: .leading) { Capsule() .fill(Color.white.opacity(0.2)) .frame(height: 5) Capsule() .fill(Color.white) .frame(width: max(0, min(geo.size.width, geo.size.width * pct)), height: 5) } .position(x: geo.size.width / 2, y: geo.size.height / 2) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { v in let percent = min(max(v.location.x / geo.size.width, 0), 1) value = Float(percent) } ) } .frame(height: 20) Image(systemName: "speaker.wave.3.fill") .font(.caption) .foregroundColor(.white.opacity(0.6)) } } } struct FullPlayerView: View { @Environment(\.dismiss) var dismiss @AppStorage("autoImmersiveLyricsEnabled") private var autoImmersiveLyricsEnabled: Bool = true @AppStorage("autoImmersiveLyricsTimeout") private var autoImmersiveLyricsTimeout: Double = 3.0 @AppStorage("useSimpleBackground") private var useSimpleBackground: Bool = false @ObservedObject var manager = MusicManager.shared @ObservedObject var lyricsManager = LyricsManager.shared @ObservedObject var playbackTime = PlaybackTime.shared @State private var showFileInfo = false @State private var showShareSheet = false @State private var showLyrics = false @State private var showLyricsEditor = false @State private var showMetadataEditor = false @State private var isImmersiveLyrics = false @State private var idleTask: DispatchWorkItem? = nil private func resetIdleTimer() { idleTask?.cancel() guard showLyrics else { if isImmersiveLyrics { withAnimation { isImmersiveLyrics = false } } return } guard autoImmersiveLyricsEnabled else { return } let task = DispatchWorkItem { if self.showLyrics { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { self.isImmersiveLyrics = true } } } idleTask = task DispatchQueue.main.asyncAfter(deadline: .now() + autoImmersiveLyricsTimeout, execute: task) } private func handleTap() { if isImmersiveLyrics { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { isImmersiveLyrics = false } } resetIdleTimer() } private func handleScroll() { if showLyrics && !isImmersiveLyrics && autoImmersiveLyricsEnabled { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { isImmersiveLyrics = true } } resetIdleTimer() } var body: some View { GeometryReader { geo in let isLandscape = geo.size.width > geo.size.height let isSmall = min(geo.size.height, geo.size.width) < 670 ZStack { // Фоновая подложка Color.black.ignoresSafeArea() // Градиенты или обложка на фоне if useSimpleBackground { GeometryReader { bgGeo in if let image = manager.currentArtwork { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) .frame(width: bgGeo.size.width, height: bgGeo.size.height) .blur(radius: 80) .clipped() .opacity(0.6) .animation(.easeInOut(duration: 1.0), value: manager.currentArtwork) } else { LinearGradient( colors: manager.gradientColors, startPoint: .topLeading, endPoint: .bottomTrailing ) .opacity(0.6) .animation(.easeInOut(duration: 1.0), value: manager.gradientColors) } } .ignoresSafeArea() } else { AppleMusicGradientBackground(colors: manager.gradientColors) .animation(.easeInOut(duration: 1.5), value: manager.currentTrackIndex) } BlurView(style: .systemUltraThinMaterialDark).opacity(0.40).ignoresSafeArea() // Главный контейнер VStack(spacing: 0) { // Капсула-Grabber (только если не иммерсивный режим) if !isImmersiveLyrics { Capsule() .fill(Color.white.opacity(0.3)) .frame(width: 36, height: 5) .padding(.top, 8) } if isLandscape { // ГОРИЗОНТАЛЬНАЯ ОРИЕНТАЦИЯ HStack(spacing: 0) { artworkAndLyricsSection(geo: geo, isLandscape: true, isSmall: isSmall) .frame(maxWidth: .infinity) .padding(.horizontal, isImmersiveLyrics ? 0 : 20) if !isImmersiveLyrics { playerControlsSection(isLandscape: true, isSmall: isSmall) .frame(maxWidth: .infinity) } } } else { // ВЕРТИКАЛЬНАЯ ОРИЕНТАЦИЯ artworkAndLyricsSection(geo: geo, isLandscape: false, isSmall: isSmall) .padding(.top, isImmersiveLyrics ? 0 : (isSmall ? 10 : 30)) .padding(.horizontal, isImmersiveLyrics ? 0 : (isSmall ? 24 : 20)) if !isImmersiveLyrics { playerControlsSection(isLandscape: false, isSmall: isSmall) } } } .frame(width: geo.size.width) // Нижний бар для иммерсивного режима if isImmersiveLyrics { VStack { Spacer() ImmersiveBottomBar(manager: manager) .transition(.move(edge: .bottom).combined(with: .opacity)) } } } .frame(width: geo.size.width, height: geo.size.height) } .preferredColorScheme(.dark) .gesture(DragGesture().onEnded { if $0.translation.height > 100 { dismiss() } }) .sheet(isPresented: $showFileInfo) { if let currentUrl = manager.audioPlayer?.url { FileInfoView(url: currentUrl, duration: manager.trackDuration) } } .sheet(isPresented: $showShareSheet) { if let currentTrack = manager.currentTrack { ActivityViewController(activityItems: [currentTrack.url]) } } .sheet(isPresented: $showLyricsEditor) { if let track = manager.currentTrack { LyricsEditorView(trackURL: track.url, trackTitle: track.title, trackArtist: track.artist) } } .sheet(isPresented: $showMetadataEditor) { if let track = manager.currentTrack { MetadataEditorView(trackURL: track.url) } } .onChange(of: manager.currentTrackIndex) { _ in withAnimation { showLyrics = false isImmersiveLyrics = false } } .onChange(of: autoImmersiveLyricsEnabled) { isEnabled in if !isEnabled { idleTask?.cancel() withAnimation { isImmersiveLyrics = false } } else { resetIdleTimer() } } .onChange(of: showLyrics) { _ in resetIdleTimer() } .onAppear { manager.syncVolumeWithCurrentMode() resetIdleTimer() } } // Блок с обложкой или текстами @ViewBuilder private func artworkAndLyricsSection(geo: GeometryProxy, isLandscape: Bool, isSmall: Bool) -> some View { let trackURL = manager.currentTrack?.url let lyrics = trackURL.map { lyricsManager.lyrics(for: $0) } ?? [] let boxSize = isLandscape ? geo.size.height * 0.75 : (isSmall ? geo.size.width * 0.75 : geo.size.width * 0.92) ZStack { if showLyrics { Group { if lyrics.isEmpty { VStack(spacing: 16) { Image(systemName: "quote.bubble") .font(.system(size: 46)) .foregroundColor(.white.opacity(0.35)) Text("Текст отсутствует") .font(.headline) .foregroundColor(.white.opacity(0.6)) Button(action: { showLyricsEditor = true }) { Label("Добавить текст", systemImage: "plus") .font(.subheadline.weight(.medium)) .foregroundColor(.white) .padding(.horizontal, 18).padding(.vertical, 10) .background(Color.white.opacity(0.15)) .cornerRadius(10) } } } else { LyricsView( lyrics: lyrics, currentTime: playbackTime.currentTime, isImmersive: isImmersiveLyrics, onSeek: { manager.seek(to: $0) }, onScroll: { handleScroll() }, onTap: { handleTap() } ) } } .transition(.opacity) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.white.opacity(0.1)) if let image = manager.currentArtwork { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) } else { Image(systemName: "music.note") .font(.system(size: isSmall ? 60 : 100)) .foregroundColor(.white.opacity(0.5)) } } .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .scaleEffect(manager.isPlaying ? 1.0 : 0.85) .animation(.spring(response: 0.6, dampingFraction: 0.65), value: manager.isPlaying) .transition(.opacity) .frame(width: boxSize, height: boxSize) // Жестко фиксируем обложку квадратом } } // Вместо общего aspectRatio и жестких фреймов позволяем LyricsView полностью заполнить доступное место .frame( maxWidth: (showLyrics || isImmersiveLyrics) ? .infinity : boxSize, maxHeight: (showLyrics || isImmersiveLyrics) ? .infinity : boxSize ) .animation(.spring(response: 0.55, dampingFraction: 0.82), value: isImmersiveLyrics) .animation(.spring(response: 0.55, dampingFraction: 0.82), value: showLyrics) .cornerRadius(isImmersiveLyrics ? 0 : 12) .overlay(alignment: .topTrailing) { if isImmersiveLyrics { Button(action: handleTap) { Image(systemName: "xmark.circle.fill") .font(.system(size: 28)) .foregroundColor(.white.opacity(0.6)) .padding(24) .padding(.top, isSmall ? 0 : 10) } } else if showLyrics && !lyrics.isEmpty { Button(action: { showLyricsEditor = true }) { Image(systemName: "pencil.circle.fill") .font(.system(size: 26)) .foregroundColor(.white.opacity(0.7)) .padding(10) } } } .gesture( DragGesture().onEnded { value in guard !showLyrics else { return } if value.translation.width < -50 { withAnimation { manager.nextTrack() } } else if value.translation.width > 50 { withAnimation { manager.prevTrack() } } } ) } // Блок с элементами управления плеером @ViewBuilder private func playerControlsSection(isLandscape: Bool, isSmall: Bool) -> some View { VStack(spacing: 0) { VStack(spacing: isLandscape ? 16 : (isSmall ? 22 : 34)) { // Info & More Button HStack(alignment: .center) { VStack(alignment: .leading, spacing: 2) { Text(manager.currentTitle) .font(.system(size: isSmall ? 20 : 24, weight: .bold, design: .rounded)) .lineLimit(1) .minimumScaleFactor(0.8) .foregroundColor(.white) Text(manager.currentArtist) .font(.system(size: isSmall ? 16 : 18, weight: .medium, design: .rounded)) .foregroundColor(.white.opacity(0.6)) .lineLimit(1) .minimumScaleFactor(0.8) } Spacer() Menu { Button(action: { showFileInfo = true }) { Label("Свойства файла", systemImage: "info.circle") } Button(action: { if manager.currentTrack != nil { showShareSheet = true } }) { Label("Поделиться", systemImage: "square.and.arrow.up") } Button(action: { if manager.currentTrack != nil { showMetadataEditor = true } }) { Label("Редактировать метаданные", systemImage: "tag") } Button(action: { if manager.currentTrack != nil { showLyricsEditor = true } }) { Label("Редактировать текст", systemImage: "pencil") } } label: { Image(systemName: "ellipsis.circle.fill") .font(.system(size: 24)) .foregroundColor(.white.opacity(0.7)) .padding(.leading, 10) } } .padding(.horizontal, isLandscape ? 15 : 30) // Native Scrubber VStack(spacing: 6) { NativePlaybackSlider( value: Binding( get: { playbackTime.currentTime }, set: { playbackTime.currentTime = $0; manager.seek(to: $0) } ), range: 0...max(manager.trackDuration, 1) ) HStack { Text(formatTime(playbackTime.currentTime)) Spacer() Text(formatTime(manager.trackDuration)) } .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundColor(.white.opacity(0.5)) } .padding(.horizontal, isLandscape ? 15 : 30) // Playback Controls HStack(spacing: isLandscape ? 40 : (isSmall ? 45 : 65)) { Button(action: manager.prevTrack) { Image(systemName: "backward.fill") .font(.system(size: isSmall ? 28 : 36)) } Button(action: manager.playPause) { Image(systemName: manager.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: isSmall ? 44 : 54)) } Button(action: manager.nextTrack) { Image(systemName: "forward.fill") .font(.system(size: isSmall ? 28 : 36)) } }.foregroundColor(.white) // Volume Bar VolumeSliderView(value: Binding( get: { manager.volume }, set: { manager.volume = $0 } )) .padding(.horizontal, isLandscape ? 20 : 35) // Bottom Actions Bar HStack(spacing: 0) { Button(action: { withAnimation(.easeInOut(duration: 0.35)) { showLyrics.toggle() } }) { Image(systemName: "quote.bubble") .font(.system(size: 20, weight: .semibold)) .foregroundColor(showLyrics ? .white : .white.opacity(0.6)) .frame(maxWidth: .infinity) } AirPlayView().frame(maxWidth: .infinity, maxHeight: 35) Button(action: manager.toggleRepeat) { Image(systemName: manager.repeatMode.iconName) .font(.system(size: 20, weight: .semibold)) .foregroundColor(manager.repeatMode == .none ? .white.opacity(0.6) : .white) .frame(maxWidth: .infinity) } Button(action: manager.toggleShuffle) { Image(systemName: "shuffle") .font(.system(size: 20, weight: .semibold)) .foregroundColor(manager.shuffleOn ? .white : .white.opacity(0.6)) .frame(maxWidth: .infinity) } } .padding(.horizontal, 10) } .padding(.top, isLandscape ? 10 : (isSmall ? 14 : 20)) .padding(.bottom, isLandscape ? 20 : (isSmall ? 25 : 45)) } .transition(.opacity) } func formatTime(_ t: TimeInterval) -> String { guard t.isFinite && !t.isNaN else { return "00:00" } let m = Int(t) / 60 let s = Int(t) % 60 return String(format: "%02d:%02d", m, s) } } // MARK: - Экран информации о файле struct FileInfoView: View { @AppStorage("appTheme") private var appTheme: AppTheme = .system let url: URL let duration: TimeInterval @Environment(\.dismiss) var dismiss @State private var fileSize: String = "Вычисляется..." @State private var showShareSheet = false var body: some View { NavigationView { List { Section(header: Text("Основная информация")) { InfoRow(title: "Имя файла", value: url.lastPathComponent) InfoRow(title: "Формат", value: url.pathExtension.uppercased()) InfoRow(title: "Размер", value: fileSize) InfoRow(title: "Длительность", value: formatTime(duration)) } Section(header: Text("Расположение файла")) { Text(url.path).font(.caption2).foregroundColor(.gray) } Section { Button(action: { showShareSheet = true }) { HStack { Spacer() Label("Поделиться файлом", systemImage: "square.and.arrow.up") .foregroundColor(.red) Spacer() } } } } .navigationTitle("Свойства трека") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Готово") { dismiss() } } } .onAppear(perform: calculateFileSize) .sheet(isPresented: $showShareSheet) { ActivityViewController(activityItems: [url]) } } .preferredColorScheme(appTheme.colorScheme) } private func calculateFileSize() { if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attrs[.size] as? Int64 { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useMB, .useKB] formatter.countStyle = .file fileSize = formatter.string(fromByteCount: size) } else { fileSize = "Неизвестно" } } private func formatTime(_ t: TimeInterval) -> String { guard t.isFinite && !t.isNaN else { return "00:00" } let m = Int(t) / 60 let s = Int(t) % 60 return String(format: "%02d:%02d", m, s) } } struct InfoRow: View { let title: String let value: String var body: some View { HStack(alignment: .top) { Text(title).foregroundColor(.gray) Spacer() Text(value).multilineTextAlignment(.trailing) } } } // MARK: - Системные обертки struct BlurView: UIViewRepresentable { var style: UIBlurEffect.Style func makeUIView(context: Context) -> UIVisualEffectView { UIVisualEffectView(effect: UIBlurEffect(style: style)) } func updateUIView(_ uiView: UIVisualEffectView, context: Context) {} } struct ActivityViewController: UIViewControllerRepresentable { var activityItems: [Any] var applicationActivities: [UIActivity]? = nil func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIActivityViewController { let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities) return controller } func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext) {} } struct ImagePicker: UIViewControllerRepresentable { var onImagePicked: (UIImage) -> Void @Environment(\.presentationMode) private var presentationMode func makeUIViewController(context: Context) -> PHPickerViewController { var config = PHPickerConfiguration() config.filter = .images let picker = PHPickerViewController(configuration: config) picker.delegate = context.coordinator return picker } func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, PHPickerViewControllerDelegate { let parent: ImagePicker init(_ parent: ImagePicker) { self.parent = parent } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { parent.presentationMode.wrappedValue.dismiss() guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } provider.loadObject(ofClass: UIImage.self) { image, _ in if let uiImage = image as? UIImage { DispatchQueue.main.async { self.parent.onImagePicked(uiImage) } } } } } } struct AirPlayView: UIViewRepresentable { func makeUIView(context: Context) -> AVRoutePickerView { let view = AVRoutePickerView() view.activeTintColor = .red view.tintColor = .white return view } func updateUIView(_ uiView: AVRoutePickerView, context: Context) { // Обновление не требуется } }