2970 lines
118 KiB
Swift
2970 lines
118 KiB
Swift
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<AnyCancellable>()
|
|
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<Double> = -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<Double> {
|
|
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..<min(configs.count, max(colors.count, 1)), id: \.self) { i in
|
|
let cfg = configs[i]
|
|
let color = colors[i % colors.count]
|
|
let xPos = w * 0.5 + w * 0.55 * CGFloat(sin(time * cfg.0 + cfg.2))
|
|
let yPos = h * 0.5 + h * 0.58 * CGFloat(sin(time * cfg.1 + cfg.3))
|
|
let r = base * CGFloat(cfg.4)
|
|
|
|
RadialGradient(
|
|
colors: [color, color.opacity(0.0)],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: r
|
|
)
|
|
.frame(width: r * 2, height: r * 2)
|
|
.position(x: xPos, y: yPos)
|
|
.blendMode(.screen)
|
|
}
|
|
}
|
|
.blur(radius: 60)
|
|
}
|
|
.clipped()
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Полноэкранный плеер
|
|
|
|
struct NativePlaybackSlider: View {
|
|
@Binding var value: Double
|
|
var range: ClosedRange<Double> = 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<ActivityViewController>) -> UIActivityViewController {
|
|
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities)
|
|
return controller
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext<ActivityViewController>) {}
|
|
}
|
|
|
|
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) {
|
|
// Обновление не требуется
|
|
}
|
|
}
|