diff --git a/Shared/Audio/AudioPlayer.swift b/Shared/Audio/AudioPlayer.swift index 7b88526..055b81e 100644 --- a/Shared/Audio/AudioPlayer.swift +++ b/Shared/Audio/AudioPlayer.swift @@ -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) diff --git a/iOS/Views/Common/AsyncCoverArt.swift b/iOS/Views/Common/AsyncCoverArt.swift index e9d951e..dc2cbb8 100644 --- a/iOS/Views/Common/AsyncCoverArt.swift +++ b/iOS/Views/Common/AsyncCoverArt.swift @@ -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 { diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift index f720478..2513cf9 100644 --- a/iOS/Views/Library/MyMusicView.swift +++ b/iOS/Views/Library/MyMusicView.swift @@ -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 { diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index b1496ad..da4b91b 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -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) + } + } + } + } } } } diff --git a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift index 83fea85..1d7b7de 100644 --- a/iOS/Views/Visualizer/MitsuhaVisualizerView.swift +++ b/iOS/Views/Visualizer/MitsuhaVisualizerView.swift @@ -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) {