Favourites tab, artist cover change, ArtistCoverStore
This commit is contained in:
parent
1009f86baa
commit
b1b7d4b695
5 changed files with 383 additions and 31 deletions
|
|
@ -761,7 +761,23 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
seek(to: duration * pct)
|
||||
}
|
||||
|
||||
func toggleShuffle() { shuffleEnabled.toggle() }
|
||||
func toggleShuffle() {
|
||||
shuffleEnabled.toggle()
|
||||
if !queue.isEmpty {
|
||||
if shuffleEnabled {
|
||||
// Save current song, shuffle the rest
|
||||
let current = queue[queueIndex]
|
||||
var rest = queue
|
||||
rest.remove(at: queueIndex)
|
||||
rest.shuffle()
|
||||
queue = [current] + rest
|
||||
queueIndex = 0
|
||||
} else {
|
||||
// Unshuffle — restore original order would need storing it.
|
||||
// For now, keep current order but ensure current song stays in place.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cycleRepeat() {
|
||||
switch repeatMode {
|
||||
|
|
@ -792,6 +808,31 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
play(song: song)
|
||||
}
|
||||
|
||||
/// Move a song in the queue (for drag reorder)
|
||||
func moveQueueItem(from source: IndexSet, to destination: Int) {
|
||||
let oldIndex = queueIndex
|
||||
let currentId = queue.indices.contains(oldIndex) ? queue[oldIndex].id : nil
|
||||
|
||||
queue.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
// Maintain queueIndex pointing to the currently playing song
|
||||
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
|
||||
queueIndex = newIdx
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a song from the queue
|
||||
func removeFromQueue(at offsets: IndexSet) {
|
||||
let currentId = queue.indices.contains(queueIndex) ? queue[queueIndex].id : nil
|
||||
queue.remove(atOffsets: offsets)
|
||||
|
||||
if let id = currentId, let newIdx = queue.firstIndex(where: { $0.id == id }) {
|
||||
queueIndex = newIdx
|
||||
} else {
|
||||
queueIndex = min(queueIndex, max(queue.count - 1, 0))
|
||||
}
|
||||
}
|
||||
|
||||
func playInstantMix(basedOn song: Song) {
|
||||
Task {
|
||||
let similar = try? await ServerManager.shared.client.getSimilarSongs2(id: song.id, count: 50)
|
||||
|
|
|
|||
|
|
@ -206,6 +206,52 @@ class AlbumCoverStore: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Artist Cover Store (custom artist photos)
|
||||
|
||||
class ArtistCoverStore: ObservableObject {
|
||||
static let shared = ArtistCoverStore()
|
||||
|
||||
@Published var updateTrigger = UUID()
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
private let coverDir: URL
|
||||
|
||||
private init() {
|
||||
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
coverDir = docs.appendingPathComponent("ArtistCovers", isDirectory: true)
|
||||
try? fileManager.createDirectory(at: coverDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
private func coverURL(for artistId: String) -> URL {
|
||||
let safe = artistId.replacingOccurrences(of: "/", with: "_")
|
||||
return coverDir.appendingPathComponent("\(safe).jpg")
|
||||
}
|
||||
|
||||
func hasCover(for artistId: String) -> Bool {
|
||||
fileManager.fileExists(atPath: coverURL(for: artistId).path)
|
||||
}
|
||||
|
||||
func loadCover(for artistId: String) -> UIImage? {
|
||||
let url = coverURL(for: artistId)
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
func saveCover(_ image: UIImage, for artistId: String) {
|
||||
let url = coverURL(for: artistId)
|
||||
if let data = image.jpegData(compressionQuality: 0.85) {
|
||||
try? data.write(to: url, options: .atomic)
|
||||
DispatchQueue.main.async { self.updateTrigger = UUID() }
|
||||
}
|
||||
}
|
||||
|
||||
func removeCover(for artistId: String) {
|
||||
let url = coverURL(for: artistId)
|
||||
try? fileManager.removeItem(at: url)
|
||||
DispatchQueue.main.async { self.updateTrigger = UUID() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cached Image Loader (ObservableObject per-view)
|
||||
|
||||
class CachedImageLoader: ObservableObject {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct MyMusicView: View {
|
||||
@EnvironmentObject var serverManager: ServerManager
|
||||
|
|
@ -14,6 +15,7 @@ struct MyMusicView: View {
|
|||
@State private var playlists: [Playlist] = []
|
||||
@State private var artists: [ArtistIndex] = []
|
||||
@State private var genres: [Genre] = []
|
||||
@State private var favouriteSongs: [Song] = []
|
||||
@State private var isLoading = true
|
||||
@State private var sortMode: SortMode = .recentlyAdded
|
||||
@State private var showServerPicker = false
|
||||
|
|
@ -34,10 +36,17 @@ struct MyMusicView: View {
|
|||
@State private var singleEditAlbumId: String?
|
||||
@State private var showSingleAlbumEditor = false
|
||||
|
||||
// Artist cover picker
|
||||
@State private var showArtistCoverPicker = false
|
||||
@State private var artistCoverPickerItem: PhotosPickerItem?
|
||||
@State private var artistCoverTargetId: String?
|
||||
@ObservedObject private var artistCoverStore = ArtistCoverStore.shared
|
||||
|
||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
enum SortMode: String, CaseIterable {
|
||||
case recentlyAdded = "Recently Added"
|
||||
case favourites = "Favourites"
|
||||
case artists = "Artists"
|
||||
case albums = "Albums"
|
||||
case songs = "Songs"
|
||||
|
|
@ -55,13 +64,19 @@ struct MyMusicView: View {
|
|||
sortMode = mode
|
||||
searchText = ""
|
||||
}) {
|
||||
Text(mode.rawValue)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(sortMode == mode ? accentPink : Color.white.opacity(0.1))
|
||||
.foregroundColor(sortMode == mode ? .white : .gray)
|
||||
.cornerRadius(16)
|
||||
HStack(spacing: 4) {
|
||||
if mode == .favourites {
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
Text(mode == .favourites ? "Favourites" : mode.rawValue)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(sortMode == mode ? accentPink : Color.white.opacity(0.1))
|
||||
.foregroundColor(sortMode == mode ? .white : .gray)
|
||||
.cornerRadius(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +94,7 @@ struct MyMusicView: View {
|
|||
VStack(alignment: .leading, spacing: 0) {
|
||||
switch sortMode {
|
||||
case .recentlyAdded: recentlyAddedTab
|
||||
case .favourites: favouritesTab
|
||||
case .artists: artistsTab
|
||||
case .albums: albumsTab
|
||||
case .songs: songsTab
|
||||
|
|
@ -370,6 +386,107 @@ struct MyMusicView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Favourites Tab
|
||||
|
||||
private var favouritesTab: some View {
|
||||
let filtered = searchText.isEmpty ? favouriteSongs : favouriteSongs.filter {
|
||||
$0.title.localizedCaseInsensitiveContains(searchText) ||
|
||||
($0.artist ?? "").localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
// Header buttons
|
||||
if !favouriteSongs.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: downloadAllFavourites) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
Text("Download All")
|
||||
}
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(accentPink)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(accentPink.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
if WatchConnectivityManager.shared.isWatchAvailable {
|
||||
Button(action: sendAllFavouritesToWatch) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "applewatch.and.arrow.forward")
|
||||
Text("Send to Watch")
|
||||
}
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(accentPink)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(accentPink.opacity(0.15))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(favouriteSongs.count) songs")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
// Song list with swipe-to-delete
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(filtered.enumerated()), id: \.element.id) { index, song in
|
||||
songRow(song: song, index: index, allSongs: filtered)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
removeFavourite(song)
|
||||
} label: {
|
||||
Label("Unfavourite", systemImage: "heart.slash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.08))
|
||||
.padding(.leading, 72)
|
||||
}
|
||||
|
||||
if filtered.isEmpty && !isLoading {
|
||||
emptyState(searchText.isEmpty ? "No favourites yet — star songs to see them here" : "No matches")
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFavourite(_ song: Song) {
|
||||
Task {
|
||||
try? await serverManager.client.unstar(id: song.id)
|
||||
await MainActor.run {
|
||||
favouriteSongs.removeAll { $0.id == song.id }
|
||||
LibraryCache.shared.save(favouriteSongs, key: "starred_songs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAllFavourites() {
|
||||
guard let server = serverManager.activeServer else { return }
|
||||
for song in favouriteSongs {
|
||||
if !offlineManager.isSongDownloaded(song.id) {
|
||||
offlineManager.downloadSong(song, server: server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAllFavouritesToWatch() {
|
||||
for song in favouriteSongs {
|
||||
_ = WatchConnectivityManager.shared.sendSongToWatch(song)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Artists Tab
|
||||
|
||||
private var artistsTab: some View {
|
||||
|
|
@ -382,9 +499,17 @@ struct MyMusicView: View {
|
|||
ForEach(filtered) { artist in
|
||||
NavigationLink(destination: ArtistDetailView(artistId: artist.id)) {
|
||||
HStack(spacing: 14) {
|
||||
AsyncCoverArt(coverArtId: artist.coverArt, size: 48)
|
||||
.frame(width: 48, height: 48)
|
||||
.clipShape(Circle())
|
||||
if let customImage = artistCoverStore.loadCover(for: artist.id) {
|
||||
Image(uiImage: customImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 48, height: 48)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
AsyncCoverArt(coverArtId: artist.coverArt, size: 48)
|
||||
.frame(width: 48, height: 48)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
Text(artist.name)
|
||||
.font(.system(size: 16))
|
||||
|
|
@ -405,6 +530,22 @@ struct MyMusicView: View {
|
|||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
artistCoverTargetId = artist.id
|
||||
showArtistCoverPicker = true
|
||||
}) {
|
||||
Label("Change Artist Cover", systemImage: "photo.on.rectangle")
|
||||
}
|
||||
|
||||
if artistCoverStore.hasCover(for: artist.id) {
|
||||
Button(role: .destructive, action: {
|
||||
artistCoverStore.removeCover(for: artist.id)
|
||||
}) {
|
||||
Label("Remove Custom Cover", systemImage: "xmark.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.1))
|
||||
|
|
@ -416,6 +557,22 @@ struct MyMusicView: View {
|
|||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.photosPicker(
|
||||
isPresented: $showArtistCoverPicker,
|
||||
selection: $artistCoverPickerItem,
|
||||
matching: .images
|
||||
)
|
||||
.onChange(of: artistCoverPickerItem) { _, newItem in
|
||||
guard let item = newItem, let targetId = artistCoverTargetId else { return }
|
||||
Task {
|
||||
if let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) {
|
||||
artistCoverStore.saveCover(image, for: targetId)
|
||||
}
|
||||
artistCoverPickerItem = nil
|
||||
artistCoverTargetId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Albums Tab (grid with selection)
|
||||
|
|
@ -775,6 +932,7 @@ struct MyMusicView: View {
|
|||
if artists.isEmpty, let cached = cache.loadArtists() { artists = cached }
|
||||
if let cached = cache.load([Genre].self, key: "genres") { genres = cached }
|
||||
if let cached = cache.load([Album].self, key: "all_albums") { allAlbums = cached }
|
||||
if favouriteSongs.isEmpty, let cached = cache.load([Song].self, key: "starred_songs") { favouriteSongs = cached }
|
||||
|
||||
hadCache = !recentAlbums.isEmpty || !playlists.isEmpty || !artists.isEmpty
|
||||
|
||||
|
|
@ -789,9 +947,10 @@ struct MyMusicView: View {
|
|||
async let allAlbumsReq = client.getAlbumList2(type: "alphabeticalByName", size: 100, offset: 0)
|
||||
async let genresReq = client.getGenres()
|
||||
async let songsReq = client.search3(query: "", songCount: 100, songOffset: 0)
|
||||
async let starredReq = client.getStarred2()
|
||||
|
||||
let (albums, playlistList, artistList, albumsFull, genreList, searchResult) = try await (
|
||||
albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq
|
||||
let (albums, playlistList, artistList, albumsFull, genreList, searchResult, starred) = try await (
|
||||
albumsReq, playlistsReq, artistsReq, allAlbumsReq, genresReq, songsReq, starredReq
|
||||
)
|
||||
|
||||
cache.cacheAlbums(albums)
|
||||
|
|
@ -799,6 +958,9 @@ struct MyMusicView: View {
|
|||
cache.cacheArtists(artistList)
|
||||
cache.save(genreList, key: "genres")
|
||||
cache.save(albumsFull, key: "all_albums")
|
||||
if let starredSongs = starred?.song {
|
||||
cache.save(starredSongs, key: "starred_songs")
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.recentAlbums = albums
|
||||
|
|
@ -811,6 +973,9 @@ struct MyMusicView: View {
|
|||
self.allSongs = searchResult?.song ?? []
|
||||
self.songOffset = 100
|
||||
self.hasMoreSongs = (searchResult?.song?.count ?? 0) >= 100
|
||||
if let starredSongs = starred?.song {
|
||||
self.favouriteSongs = starredSongs
|
||||
}
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1104,34 +1104,101 @@ struct AddToPlaylistSheet: View {
|
|||
// MARK: - Queue View
|
||||
struct QueueView: View {
|
||||
@EnvironmentObject var audioPlayer: AudioPlayer
|
||||
@State private var editMode: EditMode = .active
|
||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in
|
||||
HStack(spacing: 12) {
|
||||
if index == audioPlayer.queueIndex {
|
||||
Image(systemName: "waveform").font(.system(size: 12))
|
||||
.foregroundColor(accentPink).frame(width: 20)
|
||||
} else {
|
||||
Text("\(index + 1)").font(.system(size: 13))
|
||||
.foregroundColor(.gray).frame(width: 20)
|
||||
// Currently playing header
|
||||
if let song = audioPlayer.currentSong {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(accentPink)
|
||||
.frame(width: 20)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(song.title)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(accentPink)
|
||||
.lineLimit(1)
|
||||
Text(song.artist ?? "")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text(song.durationFormatted)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(song.title).font(.system(size: 15))
|
||||
.foregroundColor(index == audioPlayer.queueIndex ? accentPink : .white)
|
||||
Text(song.artist ?? "").font(.system(size: 12)).foregroundColor(.gray)
|
||||
}
|
||||
Spacer()
|
||||
Text(song.durationFormatted).font(.system(size: 13)).foregroundColor(.gray)
|
||||
} header: {
|
||||
Text("Now Playing")
|
||||
}
|
||||
}
|
||||
|
||||
// Up next
|
||||
let upNext = Array(audioPlayer.queue.enumerated()).filter { $0.offset != audioPlayer.queueIndex }
|
||||
|
||||
Section {
|
||||
ForEach(Array(audioPlayer.queue.enumerated()), id: \.element.id) { index, song in
|
||||
if index != audioPlayer.queueIndex {
|
||||
HStack(spacing: 12) {
|
||||
Text("\(index + 1)")
|
||||
.font(.system(size: 13, design: .monospaced))
|
||||
.foregroundColor(.gray)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(song.title)
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Text(song.artist ?? "")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text(song.durationFormatted)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
audioPlayer.play(song: song, at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onMove(perform: audioPlayer.moveQueueItem)
|
||||
.onDelete(perform: audioPlayer.removeFromQueue)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Up Next")
|
||||
Spacer()
|
||||
Text("\(audioPlayer.queue.count) songs")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { audioPlayer.play(song: song, at: index) }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Up Next")
|
||||
.environment(\.editMode, $editMode)
|
||||
.navigationTitle("Queue")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
Button(action: { audioPlayer.toggleShuffle() }) {
|
||||
Image(systemName: "shuffle")
|
||||
.foregroundColor(audioPlayer.shuffleEnabled ? accentPink : .gray)
|
||||
}
|
||||
Button(action: { audioPlayer.cycleRepeat() }) {
|
||||
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
|
||||
.foregroundColor(audioPlayer.repeatMode != .off ? accentPink : .gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -623,6 +623,39 @@ struct VisualizerSettingsView: View {
|
|||
Text("Viscosity is the most important slider — it controls how quickly the wave reacts. 0.15–0.25 = heavy, slow liquid (cinematic). 0.5 = responsive. 0.8+ = snappy EQ meter.\n\nSensitivity multiplies the incoming audio. Push up if the wave looks flat.\n\nBase multiplier is a second gain stage for FFT data. Higher values reveal quiet passages.\n\nFrequency cutoff limits how many FFT bins are used. At 80 (default), mostly bass/mids. At 150+, treble detail like hi-hats shows up.\n\nReal Audio Analysis uses FFT on downloaded songs. Streams always use simulated animation.\n\nFPS drops to 24 automatically in Low Power Mode.")
|
||||
}
|
||||
|
||||
// Smart DJ Crossfade
|
||||
Section {
|
||||
let crossfade = SmartCrossfadeManager.shared
|
||||
Toggle("Smart Crossfade", isOn: Binding(
|
||||
get: { crossfade.isEnabled },
|
||||
set: { crossfade.isEnabled = $0 }
|
||||
)).tint(pink)
|
||||
|
||||
if crossfade.isEnabled {
|
||||
HStack {
|
||||
Text("Duration")
|
||||
Spacer()
|
||||
Text("\(String(format: "%.1f", crossfade.crossfadeDuration))s")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { crossfade.crossfadeDuration },
|
||||
set: { crossfade.crossfadeDuration = $0 }
|
||||
),
|
||||
in: 1...10,
|
||||
step: 0.5
|
||||
).tint(pink)
|
||||
|
||||
Toggle("Skip Silence", isOn: Binding(
|
||||
get: { crossfade.skipSilence },
|
||||
set: { crossfade.skipSilence = $0 }
|
||||
)).tint(pink)
|
||||
}
|
||||
} header: { Text("CROSSFADE") } footer: {
|
||||
Text("Smart Crossfade uses Companion API profiles to blend songs seamlessly. Duration controls how long the fade lasts. Skip Silence jumps past leading/trailing silence.")
|
||||
}
|
||||
|
||||
// Presets
|
||||
Section {
|
||||
Button(action: applyDeepOcean) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue