Skip to content

Music Playback - #16

Merged
deannemphos merged 18 commits into
mainfrom
MusicPlayback
Mar 24, 2026
Merged

deannemphos merged 18 commits into
mainfrom
MusicPlayback

Conversation

@deannemphos

@deannemphos deannemphos commented Mar 12, 2026 •

Copy link
Copy Markdown
Owner

Draft as of 3/11/2026

Requirements:

  • Users should be able to pause and resume the current song
  • Users should be able to skip to the next song
  • Users should be able to rewind the song
  • Timestamp and song progress bar
  • Song queue should preload the next song for seamless transitions

…tion so the contentview can reference it. Was driving me crazy
…dioPlayer to be mutable. Initial playback & song info display works in my hacky tests.
…ill handle music across views & for less clutter.
@deannemphos

Copy link
Copy Markdown
Owner Author

Progress bar works, going to leave the timestamp out for now until I figure out the UI redesign.

@deannemphos
deannemphos marked this pull request as ready for review March 24, 2026 23:20
Copilot AI review requested due to automatic review settings March 24, 2026 23:20
@deannemphos
deannemphos merged commit 2fafab0 into main Mar 24, 2026
3 of 5 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements core in-app music playback with a shared audio manager, exposing play/pause, previous/next, shuffle, queue loading from the app bundle, and a basic UI for selecting songs.

Changes:

  • Added AudioPlayerManager singleton to load songs from a bundled Music/ folder and manage playback/queueing/progress.
  • Updated ContentView with playback controls, a toggleable song list, and queue reload hooks.
  • Expanded Song model to support songName, artist, and multiple locations; added Music folder as a project resource.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
LittleRoute/Primary Views/ContentView.swift Wires the new audio manager into the UI (song list + playback controls) and triggers initial queue setup.
LittleRoute/Data Handling/Song.swift Updates the Song SwiftData model to include filename/artist and multiple locations.
LittleRoute/Data Handling/AudioPlayerManager.swift Adds the playback/queue manager (AVAudioPlayer + bundle loading + progress timer).
LittleRoute.xcodeproj/project.pbxproj Adds LittleRoute/Music folder to app resources so mp3s ship in the bundle.
.vscode/settings.json Adds SweetPad workspace configuration for VS Code builds.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +116 to +132
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()
}

Copilot AI Mar 24, 2026

Copy link

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 uses AI. Check for mistakes.
Comment on lines +164 to +170
// 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
}

Copilot AI Mar 24, 2026

Copy link

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 uses AI. Check for mistakes.
Comment on lines +157 to +160
if(isShuffled) {
songQueue.shuffle()
} else {
reloadQueue(newContext: currentContext, shuffle: isShuffled, songs: songQueue)

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turning shuffle off does not restore the original (unshuffled) order: you call reloadQueue(..., songs: songQueue) using the already shuffled queue as the source. To make shuffle reversible, maintain an unshuffled source list (e.g., allSongsForContext) and build songQueue from that when isShuffled == false.

Suggested change
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 uses AI. Check for mistakes.
Comment on lines +218 to +219
// shuffle if user has the option toggled
if isShuffled {

Copilot AI Mar 24, 2026

Copy link

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.

Suggested change
// shuffle if user has the option toggled
if isShuffled {
// shuffle if requested by caller
if shuffle {

Copilot uses AI. Check for mistakes.
Comment on lines +187 to +192
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
if flag {
print("Song finished, playing next song")
skip()
}
}

Copilot AI Mar 24, 2026

Copy link

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 uses AI. Check for mistakes.
Comment on lines +14 to +17
var songName: String /* {
// This is the actual file name of the song, without the extension
return title.replacingOccurrences(of: ".mp3", with: "")
} */

Copilot AI Mar 24, 2026

Copy link

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 uses AI. Check for mistakes.
Comment on lines +34 to +37
// remove class from memory
deinit {
// only run this when the user deletes a song
}

Copilot AI Mar 24, 2026

Copy link

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 uses AI. Check for mistakes.
if showSongList {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(songs, id: \.songName) { song in

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using songName as the ForEach identifier will break UI diffing (and can crash) if there are duplicates (e.g., two records with the same filename base). Prefer a stable unique identifier from SwiftData (e.g., song.persistentModelID) rather than a human/file-derived string.

Suggested change
ForEach(songs, id: \.songName) { song in
ForEach(songs, id: \.persistentModelID) { song in

Copilot uses AI. Check for mistakes.
else if audioPlayer != nil && !audioPlayer!.isPlaying {
audioPlayer!.play()
paused = !paused
.onChange(of: songs) { oldValue, newValue in

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reloading the entire queue on every songs change (including the initial insert from loadSongsFromBundle) will reset currentIndex to 0 and call loadAudio, which can interrupt/replace an in-progress playback session. Consider gating this (e.g., only reload when the content meaningfully changes and the user is not playing, or preserve the currently playing song/index when rebuilding the queue).

Suggested change
.onChange(of: songs) { oldValue, newValue in
.onChange(of: songs) { oldValue, newValue in
// Avoid interrupting an in-progress playback session by reloading the entire queue
guard !audioManager.isPlaying else {
return
}

Copilot uses AI. Check for mistakes.
@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)

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sample Song is not used within ContentView (and locations: [\"all\"] also won’t match your context filter which uses Context.rawValue like \"All\"). Remove this constant if it’s leftover, or update it and wire it into previews/tests where it’s actually exercised.

Suggested change
let sampleSong: Song = Song(title: "Sample Song", songName: "RSEmart", artist: "Sample Artist", locations: ["all"], populationMin: 0, populationMax: 10000)

Copilot uses AI. Check for mistakes.
deannemphos added a commit that referenced this pull request May 14, 2026
Music playback feature. Basic playback and queueing up next song for seamless playback when skipping. Added working play/pause, skip/rewind, and shuffle buttons. Progress bar now working.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants