Repository navigation
Music Playback #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Music Playback #16
Changes from all commits
ec5506c
f6e40a3
bb9a424
234c223
b238629
ce3d7d9
21f7554
20a1a87
a2f733e
1d958e1
060ab94
1ac2522
73c68db
120f6ac
f908c48
6c2d206
cc4c210
294d78d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| { | ||
| "sweetpad.build.xcodeWorkspacePath": "LittleRoute.xcodeproj/project.xcworkspace" | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<Song>(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() | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+116
to
+132
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||
|
Comment on lines
+157
to
+160
|
||||||||||||||||||||||||
| if(isShuffled) { | |
| songQueue.shuffle() | |
| } else { | |
| reloadQueue(newContext: currentContext, shuffle: isShuffled, songs: songQueue) | |
| if isShuffled { | |
| // Shuffle the existing queue in place | |
| songQueue.shuffle() | |
| } else { | |
| // Rebuild the queue from the original source for the current context, | |
| // instead of using the already shuffled queue as the base ordering. | |
| reloadQueue(newContext: currentContext, shuffle: false, songs: []) |
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If loadAudio fails (file missing), skip()/previous() will still update currentSong/currentIndex and then audioPlayer?.play() can continue playing the previous audio (or leave the player in an inconsistent state). Make loadAudio return a success flag (or throw) and only update currentSong/start playback when loading succeeds; alternatively, set audioPlayer = nil/stop playback when the guard fails.
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AVAudioPlayerDelegate callbacks are not guaranteed to occur on the main thread. skip() mutates multiple @Published properties and may create/prepare an AVAudioPlayer; doing this off-main can cause UI update warnings/crashes. Dispatch the state mutations to the main queue (or annotate the class/functions with @MainActor and ensure calls cross actors correctly).
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The shuffle parameter is ignored (reloadQueue uses isShuffled instead). This can produce unexpected behavior for callers that pass shuffle: expecting it to be honored. Either remove the parameter entirely (and rely on isShuffled), or use the parameter (if shuffle { ... }) consistently.
| // shuffle if user has the option toggled | |
| if isShuffled { | |
| // shuffle if requested by caller | |
| if shuffle { |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -6,20 +6,35 @@ | |||
| // | ||||
|
|
||||
| import SwiftData | ||||
| import AVFoundation | ||||
|
||||
| import AVFoundation |
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two issues here: (1) the commented-out computed property embedded in the songName declaration is confusing and reads like partial code; move this into a normal comment or implement a separate computed property if needed. (2) The deinit comment is inaccurate: deinit runs when the object is deallocated, not specifically/only when a user deletes a song, and SwiftData deletion does not guarantee immediate deallocation. Either remove deinit or update the commentary to reflect actual behavior.
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two issues here: (1) the commented-out computed property embedded in the songName declaration is confusing and reads like partial code; move this into a normal comment or implement a separate computed property if needed. (2) The deinit comment is inaccurate: deinit runs when the object is deallocated, not specifically/only when a user deletes a song, and SwiftData deletion does not guarantee immediate deallocation. Either remove deinit or update the commentary to reflect actual behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid importing underscored/internal modules (
_SwiftData_SwiftUI), as they are not public API and can break across toolchain updates. Prefer importing the public modules you actually use here (e.g.,SwiftDataforModelContext/FetchDescriptor) and remove the internal import.