diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..aee71cc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "sweetpad.build.xcodeWorkspacePath": "LittleRoute.xcodeproj/project.xcworkspace" +} \ No newline at end of file diff --git a/LittleRoute.xcodeproj/project.pbxproj b/LittleRoute.xcodeproj/project.pbxproj index eb3eb39..4e660af 100644 --- a/LittleRoute.xcodeproj/project.pbxproj +++ b/LittleRoute.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 28465EB02DA10E9600D0508D /* Music in Resources */ = {isa = PBXBuildFile; fileRef = 28465EAF2DA10E9600D0508D /* Music */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 28D3DCC22D00D87900CA0FC2 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -24,6 +28,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 28465EAF2DA10E9600D0508D /* Music */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Music; path = LittleRoute/Music; sourceTree = ""; }; 28D3DCAF2D00D87700CA0FC2 /* LittleRoute.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LittleRoute.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28D3DCC12D00D87900CA0FC2 /* LittleRouteTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LittleRouteTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 28D3DCCB2D00D87900CA0FC2 /* LittleRouteUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LittleRouteUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -75,6 +80,7 @@ 28D3DCA62D00D87700CA0FC2 = { isa = PBXGroup; children = ( + 28465EAF2DA10E9600D0508D /* Music */, 28D3DCB12D00D87700CA0FC2 /* LittleRoute */, 28D3DCC42D00D87900CA0FC2 /* LittleRouteTests */, 28D3DCCE2D00D87900CA0FC2 /* LittleRouteUITests */, @@ -212,6 +218,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 28465EB02DA10E9600D0508D /* Music in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/LittleRoute/Data Handling/AudioPlayerManager.swift b/LittleRoute/Data Handling/AudioPlayerManager.swift new file mode 100644 index 0000000..23c9763 --- /dev/null +++ b/LittleRoute/Data Handling/AudioPlayerManager.swift @@ -0,0 +1,252 @@ +// +// AudioPlayerManager.swift +// LittleRoute +// +// Created by Dean Nemphos on 5/7/25. +// + +import Foundation +import AVFoundation +import SwiftUI +import _SwiftData_SwiftUI + +class AudioPlayerManager: NSObject, ObservableObject, AVAudioPlayerDelegate { + + @Published var isPaused: Bool = false + @Published var isShuffled: Bool = false + @Published var songLength: TimeInterval = 0.0 // total length of the song + @Published var currentTime: TimeInterval = 0.0 // current playback time + @Published var currentContext: Context = .all + @Published var currentSong: Song? = nil // the currently playing song, if any + + private var audioPlayer: AVAudioPlayer? + private var currentIndex: Int = 0 // index of the current song in the queue + private var songQueue: [Song] = [] + private var playbackTimer: Timer? + + static let shared = AudioPlayerManager() + + // Computed property for progress (0.0 to 1.0) + var progress: Double { + guard songLength > 0 else { return 0.0 } + return currentTime / songLength + } + + // Load songs from the Music folder in the app bundle + public func loadSongsFromBundle(modelContext: ModelContext) -> [Song] { + guard let musicPath = Bundle.main.resourcePath?.appending("/Music") else { + print("Music folder not found") + return [] + } + + var loadedSongs: [Song] = [] + + do { + let fileManager = FileManager.default + let files = try fileManager.contentsOfDirectory(atPath: musicPath) + let mp3Files = files.filter { $0.hasSuffix(".mp3") } + + for fileName in mp3Files { + let songName = fileName.replacingOccurrences(of: ".mp3", with: "") + let title = songName // Use filename as title + + // Check if song already exists + let descriptor = FetchDescriptor(predicate: #Predicate { $0.songName == songName }) + let existingSongs = try? modelContext.fetch(descriptor) + + if let existing = existingSongs?.first { + loadedSongs.append(existing) + } else { + let newSong = Song(title: title, songName: songName, artist: "Unknown Artist", locations: ["All"], populationMin: 0, populationMax: 10000000) + modelContext.insert(newSong) + loadedSongs.append(newSong) + } + } + + try? modelContext.save() + print("Loaded \(mp3Files.count) songs from Music folder") + } catch { + print("Error loading songs: \(error)") + } + + return loadedSongs + } + + // all contexts the music will account for + enum Context: String { + case all = "All" + case gym = "Gyms" + case restaurant = "Restaurants" + case store = "Stores" + case park = "Parks" + case home = "Home" + case work = "Work" + case street = "Streets" + case driving = "Driving" + case beach = "Beaches" + case mountain = "Mountains" + case city = "Cities" + case town = "Towns" + case water = "Water" + } + + // MARK: Audio Playback Functions + // Play the music if not paused, pause the music if paused. ezpz + public func musicPlayPause() { + // If no audio is loaded and we have songs in queue, load the first one + if audioPlayer == nil && !songQueue.isEmpty { + currentSong = songQueue[currentIndex] + loadAudio(fileName: songQueue[currentIndex].songName) + } + + if audioPlayer != nil && audioPlayer!.isPlaying { + audioPlayer!.pause() + isPaused = true + stopPlaybackTimer() + } + else if audioPlayer != nil && !audioPlayer!.isPlaying { + audioPlayer!.play() + isPaused = false + startPlaybackTimer() + } + + print("Audio Player is now \(isPaused ? "paused" : "playing")") + } + + public func skip() { + guard !songQueue.isEmpty else { + print("No songs in queue") + return + } + + // Set the current index to the next song in the queue + // if on the last song, loop back to the start of the queue + currentIndex = (currentIndex + 1) % songQueue.count + currentSong = songQueue[currentIndex] + let nextSong = songQueue[currentIndex].songName + + loadAudio(fileName: nextSong) + audioPlayer?.play() + isPaused = false + startPlaybackTimer() + } + + public func previous() { + guard !songQueue.isEmpty else { + print("No songs in queue") + return + } + + // Same as the skip function but in reverse + currentIndex = (currentIndex - 1 + songQueue.count) % songQueue.count + currentSong = songQueue[currentIndex] + let previousSong = songQueue[currentIndex].songName + + loadAudio(fileName: previousSong) + audioPlayer?.play() + isPaused = false + startPlaybackTimer() + } + + // Toggle shuffle mode + // Technically we can just call reloadQueue instead of this but it'll save an unnecessary full list reset + // and slightly reduce lag if the user reshuffles + public func toggleShuffle() { + isShuffled.toggle() + + if(isShuffled) { + songQueue.shuffle() + } else { + reloadQueue(newContext: currentContext, shuffle: isShuffled, songs: songQueue) + } + } + + // Prepare the audio player with the selected song + private func loadAudio(fileName: String) { + + guard let path = Bundle.main.path(forResource: fileName, ofType: "mp3", inDirectory: "Music") else { + print("Could not find file: \(fileName).mp3") + return + } + + let url = URL(fileURLWithPath: path) + + do { + audioPlayer = try AVAudioPlayer(contentsOf: url) + audioPlayer?.delegate = self + audioPlayer?.prepareToPlay() + songLength = audioPlayer?.duration ?? 0.0 + currentTime = 0.0 + } catch { + print("Could not create audio player: \(error)") + } + } + + // MARK: AVAudioPlayerDelegate + // Automatically play the next song when the current one finishes + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if flag { + print("Song finished, playing next song") + skip() + } + } + + // MARK: Song Database Functions + // Insert a new song + // @TODO: Add field for uploading .mp3 files + // @TODO: figure out how to handle the population detection issue + public func addSong(title: String, artist: String, modelContext: ModelContext) { + let newSong = Song(title: title, songName: "filename", artist: artist, locations: ["location"], populationMin: 0, populationMax: 9999) + modelContext.insert(newSong) + try? modelContext.save() + } + + // Remove an existing song + private func removeSong(_ song: Song, modelContext: ModelContext) { + modelContext.delete(song) + try? modelContext.save() + } + + // Reset the queue upon entering a new location/context + public func reloadQueue(newContext: Context, shuffle: Bool, songs: [Song]) { + + songQueue.removeAll() + + // add only the new songs to the queue + songQueue = songs.filter { $0.locations.contains(newContext.rawValue) } + + // shuffle if user has the option toggled + if isShuffled { + songQueue.shuffle() + } + + // reset the current index to 0 + currentIndex = 0 + + // Set the current song to the first in queue but don't auto-play + if !songQueue.isEmpty { + currentSong = songQueue[currentIndex] + loadAudio(fileName: songQueue[currentIndex].songName) + print("Queue loaded with \(songQueue.count) songs") + } else { + print("No songs matched the current context") + } + } + + // Timer management for playback progress + private func startPlaybackTimer() { + stopPlaybackTimer() + playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + guard let self = self, let player = self.audioPlayer else { return } + self.currentTime = player.currentTime + if !player.isPlaying { + self.stopPlaybackTimer() + } + } + } + + private func stopPlaybackTimer() { + playbackTimer?.invalidate() + playbackTimer = nil + } +} diff --git a/LittleRoute/Data Handling/Song.swift b/LittleRoute/Data Handling/Song.swift index b05d015..98a5e9b 100644 --- a/LittleRoute/Data Handling/Song.swift +++ b/LittleRoute/Data Handling/Song.swift @@ -6,20 +6,35 @@ // import SwiftData +import AVFoundation @Model final class Song { var title: String - var location: String // context in which the song will play + var songName: String /* { + // This is the actual file name of the song, without the extension + return title.replacingOccurrences(of: ".mp3", with: "") + } */ + var artist: String? = nil // optional artist name + // @TODO: set possible locations to an enum + var locations: [String] // context in which the song will play + // @TODO: set population min/max to be dependent on location automatically var populationMin: Int = 0 // minimum population of an area where the song will play var populationMax: Int = 1000000000 // maximum population of an area where the song will play -- default overly large - init(title: String, location: String, populationMin: Int?, populationMax: Int?) { + init(title: String, songName: String, artist: String?, locations: [String], populationMin: Int?, populationMax: Int?) { self.title = title - self.location = location + self.songName = songName.replacingOccurrences(of: ".mp3", with: "") + self.artist = artist + self.locations = locations self.populationMin = populationMin ?? self.populationMin // if no value is provided, default to 0 self.populationMax = populationMax ?? self.populationMax // "" } + + // remove class from memory + deinit { + // only run this when the user deletes a song + } } diff --git a/LittleRoute/Primary Views/ContentView.swift b/LittleRoute/Primary Views/ContentView.swift index b59f10c..d8af772 100644 --- a/LittleRoute/Primary Views/ContentView.swift +++ b/LittleRoute/Primary Views/ContentView.swift @@ -1,3 +1,4 @@ +// Swift // // ContentView.swift // LittleRoute @@ -10,22 +11,45 @@ import SwiftData import MapKit import AVFoundation + struct ContentView: View { - + @ObservedObject private var audioManager = AudioPlayerManager.shared @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] - - private var audioPlayer: AVAudioPlayer? - @State private var paused: Bool = false - + @Query private var songs: [Song] // Query all songs from the database + @State private var showSongList = false + + let sampleSong: Song = Song(title: "Sample Song", songName: "RSEmart", artist: "Sample Artist", locations: ["all"], populationMin: 0, populationMax: 10000) let c_radius: CGFloat = 20.0 // corner radius for consistency - + var body: some View { - + ZStack { VStack { + // Top bar + HStack { + Button { + // Settings button + } label: { + Image(systemName: "gearshape.fill") + } + .padding() + + Spacer() + Text("LittleRoute") + .font(.largeTitle) + .padding() + Spacer() + Button { + // Show song list + showSongList.toggle() + } label: { + Image(systemName: "music.note.list") + } + .padding() + } + // Map! Map() { @@ -34,48 +58,109 @@ struct ContentView: View { .cornerRadius(c_radius) .padding() - // Location info - LazyVStack { - + // Song list + if showSongList { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(songs, id: \.songName) { song in + Button { + audioManager.currentSong = song + audioManager.reloadQueue(newContext: audioManager.currentContext, shuffle: audioManager.isShuffled, songs: songs) + } label: { + HStack { + VStack(alignment: .leading) { + Text(song.title) + .font(.headline) + .foregroundColor(.primary) + Text(song.artist ?? "Unknown Artist") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if audioManager.currentSong?.songName == song.songName { + Image(systemName: "speaker.wave.2.fill") + .foregroundColor(.accentColor) + } + } + .padding() + .background(Color.black.opacity(0.3)) + .cornerRadius(8) + } + } + } + .padding() + } + .frame(height: 200) + .background(Color.black.opacity(0.2)) + .cornerRadius(c_radius) + .padding(.horizontal) } - .background(.black) - .padding() - .cornerRadius(c_radius) // Music bar HStack { + + // Previous Button Button { - paused = !paused // @TODO -- make this play/pause the music - musicPlayPause() + audioManager.previous() } label: { - Image(systemName: paused ? "pause.fill" : "play.fill") + Image(systemName: "backward.fill") .imageScale(.large) } - ProgressView(value: /*@START_MENU_TOKEN@*/0.5/*@END_MENU_TOKEN@*/) // @TODO -- Show song's current progression + + // Play/Pause Button + Button { + + audioManager.musicPlayPause() + } label: { + Image(systemName: audioManager.isPaused ? "play.fill" : "pause.fill") + .imageScale(.large) + } + // Song progress bar + ProgressView(value: audioManager.progress) } .padding() + + // Skip Button + Button { + audioManager.skip() + } label: { + Image(systemName: "forward.fill") + .imageScale(.large) + } + + VStack { + Text(audioManager.currentSong?.title ?? "No song playing") + .font(.title) + .padding() + Text(audioManager.currentSong?.artist ?? "No artist") + + Button { + audioManager.toggleShuffle() + } label: { + Image(systemName: audioManager.isShuffled ? "shuffle.circle.fill" : "shuffle.circle") + } + } } // Set background color Color("Background").ignoresSafeArea(edges: .all) .zIndex(-1.0) } - } - - // Play the music if not paused, pause the music if paused. ezpz - private func musicPlayPause() { - if audioPlayer != nil && audioPlayer!.isPlaying { - audioPlayer!.pause() - paused = !paused + .onAppear { + // Load songs from Music folder if none exist + if songs.isEmpty { + let loadedSongs = audioManager.loadSongsFromBundle(modelContext: modelContext) + // Queue the loaded songs immediately + if !loadedSongs.isEmpty { + audioManager.reloadQueue(newContext: audioManager.currentContext, shuffle: audioManager.isShuffled, songs: loadedSongs) + } + } else { + // Populate the song queue with all songs from the database + audioManager.reloadQueue(newContext: audioManager.currentContext, shuffle: audioManager.isShuffled, songs: songs) + } } - else if audioPlayer != nil && !audioPlayer!.isPlaying { - audioPlayer!.play() - paused = !paused + .onChange(of: songs) { oldValue, newValue in + audioManager.reloadQueue(newContext: audioManager.currentContext, shuffle: audioManager.isShuffled, songs: newValue) } } } - -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) -}