Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ec5506c
added sweetpad build config for vscode bc xcode keeps lagging out on me
deannemphos Apr 1, 2025
f6e40a3
commented out preview since it doesn't work with SweetPad regardless
deannemphos Apr 1, 2025
bb9a424
Added Music folder to "Copy Bundle Resources" in the Build Phases sec…
deannemphos Apr 5, 2025
234c223
added params for file and artist names, changed locations to an array…
deannemphos Apr 5, 2025
b238629
added loadAudio function for prepping audio, song array, and fixed au…
deannemphos Apr 5, 2025
ce3d7d9
Added rewind and skip icons.
deannemphos Apr 14, 2025
21f7554
Added (basic) functions for adding and removing songs from the databa…
deannemphos Apr 14, 2025
20a1a87
add TODO comment I forgot to stage lol
deannemphos Apr 14, 2025
a2f733e
added boolean to track if shuffle is on. renamed paused to isPaused f…
deannemphos Apr 15, 2025
1d958e1
Added enums for different location contexts
deannemphos Apr 15, 2025
060ab94
Added shuffle feature and reload for the song queue
deannemphos Apr 15, 2025
1ac2522
Added deinit for Song class
deannemphos May 7, 2025
73c68db
Fixed skip() and previous() song functions
deannemphos May 7, 2025
120f6ac
Created outline for separate manager for the AVAudioPlayer and added …
deannemphos May 7, 2025
f908c48
Moved all functions and actions to separate AudioPlayerManager that w…
deannemphos Jun 1, 2025
6c2d206
Made music playback functions public
deannemphos Jun 6, 2025
cc4c210
Playback fix
deannemphos Mar 12, 2026
294d78d
wip timeline and timestamp, playback update
deannemphos Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"sweetpad.build.xcodeWorkspacePath": "LittleRoute.xcodeproj/project.xcworkspace"
}
7 changes: 7 additions & 0 deletions LittleRoute.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +28,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
28465EAF2DA10E9600D0508D /* Music */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Music; path = LittleRoute/Music; sourceTree = "<group>"; };
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; };
Expand Down Expand Up @@ -75,6 +80,7 @@
28D3DCA62D00D87700CA0FC2 = {
isa = PBXGroup;
children = (
28465EAF2DA10E9600D0508D /* Music */,
28D3DCB12D00D87700CA0FC2 /* LittleRoute */,
28D3DCC42D00D87900CA0FC2 /* LittleRouteTests */,
28D3DCCE2D00D87900CA0FC2 /* LittleRouteUITests */,
Expand Down Expand Up @@ -212,6 +218,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
28465EB02DA10E9600D0508D /* Music in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
252 changes: 252 additions & 0 deletions LittleRoute/Data Handling/AudioPlayerManager.swift
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

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.

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., SwiftData for ModelContext/FetchDescriptor) and remove the internal import.

Suggested change
import _SwiftData_SwiftUI
import SwiftData

Copilot uses AI. Check for mistakes.

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

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.

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

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.
}
}

// 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
}
Comment on lines +164 to +170

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.

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

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.

// 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 {
Comment on lines +218 to +219

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.
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
}
}
21 changes: 18 additions & 3 deletions LittleRoute/Data Handling/Song.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,35 @@
//

import SwiftData
import AVFoundation

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.

AVFoundation is imported but not used anywhere in this file. Remove the import to avoid unnecessary dependencies and compiler warnings.

Suggested change
import AVFoundation

Copilot uses AI. Check for mistakes.

@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: "")
} */
Comment on lines +14 to +17

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.
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
}
Comment on lines +34 to +37

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.
}


Loading
Loading