diff --git a/AudioPlayer/AudioPlayer.xcodeproj/xcshareddata/xcschemes/AudioPlayer.xcscheme b/AudioPlayer/AudioPlayer.xcodeproj/xcshareddata/xcschemes/AudioPlayer.xcscheme
index a60a8f9..7517c81 100644
--- a/AudioPlayer/AudioPlayer.xcodeproj/xcshareddata/xcschemes/AudioPlayer.xcscheme
+++ b/AudioPlayer/AudioPlayer.xcodeproj/xcshareddata/xcschemes/AudioPlayer.xcscheme
@@ -51,6 +51,13 @@
ReferencedContainer = "container:AudioPlayer.xcodeproj">
+
+
+
+
0 {
+ return "Loop: Single (\(times)x)"
+ }
+ return "Loop: Single (∞)"
+ case .all(let times):
+ if let times = times, times > 0 {
+ return "Loop: All (\(times)x)"
+ }
+ return "Loop: All (∞)"
+ }
+ }
+
+ var isLoopActive: Bool {
+ if case .off = loopMode {
+ return false
+ }
+ return true
+ }
+
+ var currentLoopTimes: Int? {
+ switch loopMode {
+ case .off:
+ return nil
+ case .single(let times):
+ return times
+ case .all(let times):
+ return times
+ }
+ }
init(audioPlayerService: AudioPlayerService) {
self.audioPlayerService = audioPlayerService
@@ -220,6 +306,19 @@ extension AudioPlayerControls {
isMuted.toggle()
audioPlayerService.toggleMute()
}
+
+ func cycleLoopMode() {
+ audioPlayerService.cycleLoopMode()
+ loopMode = audioPlayerService.loopMode
+ // Update loopTimes to match the current mode's times
+ loopTimes = Double(currentLoopTimes ?? 0)
+ }
+
+ func updateLoopTimes(_ times: Double) {
+ let timesValue = times == 0 ? nil : Int(times)
+ audioPlayerService.setLoopTimes(timesValue)
+ loopMode = audioPlayerService.loopMode
+ }
func playPause() {
if audioPlayerService.state == .playing {
diff --git a/AudioPlayer/AudioPlayer/Dependencies/AudioPlayerService.swift b/AudioPlayer/AudioPlayer/Dependencies/AudioPlayerService.swift
index 3c56949..f6455ff 100644
--- a/AudioPlayer/AudioPlayer/Dependencies/AudioPlayerService.swift
+++ b/AudioPlayer/AudioPlayer/Dependencies/AudioPlayerService.swift
@@ -39,6 +39,11 @@ final class AudioPlayerService {
var state: AudioPlayerState {
player.state
}
+
+ var loopMode: AudioPlayerLoopMode {
+ get { player.loopMode }
+ set { player.loopMode = newValue }
+ }
var statusChangedNotifier = Notifier()
var metadataReceivedNotifier = Notifier<[String: String]>()
@@ -114,6 +119,32 @@ final class AudioPlayerService {
func seek(at time: Double) {
player.seek(to: time)
}
+
+ func setLoopMode(_ mode: AudioPlayerLoopMode) {
+ player.loopMode = mode
+ }
+
+ func cycleLoopMode() {
+ switch player.loopMode {
+ case .off:
+ player.loopMode = .single(times: nil)
+ case .single:
+ player.loopMode = .all(times: nil)
+ case .all:
+ player.loopMode = .off
+ }
+ }
+
+ func setLoopTimes(_ times: Int?) {
+ switch player.loopMode {
+ case .off:
+ break
+ case .single:
+ player.loopMode = .single(times: times)
+ case .all:
+ player.loopMode = .all(times: times)
+ }
+ }
private func recreatePlayer() {
player = audioPlayerProvider()
diff --git a/AudioStreaming/Core/Helpers/Logger.swift b/AudioStreaming/Core/Helpers/Logger.swift
index b871872..c469e15 100644
--- a/AudioStreaming/Core/Helpers/Logger.swift
+++ b/AudioStreaming/Core/Helpers/Logger.swift
@@ -4,14 +4,14 @@
//
import Foundation
-import os
+import OSLog
private let loggingSubsystem = "audio.streaming.log"
-enum Logger {
- private static let audioRendering = OSLog(subsystem: loggingSubsystem, category: "audio.rendering")
- private static let networking = OSLog(subsystem: loggingSubsystem, category: "audio.networking")
- private static let generic = OSLog(subsystem: loggingSubsystem, category: "audio.streaming.generic")
+extension Logger {
+ public static let audioRendering = Logger(subsystem: loggingSubsystem, category: "audio.rendering")
+ public static let networking = Logger(subsystem: loggingSubsystem, category: "audio.networking")
+ public static let generic = Logger(subsystem: loggingSubsystem, category: "audio.streaming.generic")
/// Defines is the the logger displays any logs
static var isEnabled = true
@@ -21,7 +21,7 @@ enum Logger {
case networking
case generic
- func toOSLog() -> OSLog {
+ func toOSLog() -> Logger {
switch self {
case .audioRendering: return Logger.audioRendering
case .networking: return Logger.networking
@@ -30,24 +30,31 @@ enum Logger {
}
}
- static func error(_ message: StaticString, category: Category, args: CVarArg...) {
+ static func error(_ message: String, category: Category, args: CVarArg...) {
process(message, category: category, type: .error, args: args)
}
- static func error(_ message: StaticString, category: Category) {
+ static func error(_ message: String, category: Category) {
error(message, category: category, args: [])
}
- static func debug(_ message: StaticString, category: Category, args: CVarArg...) {
+ static func debug(_ message: String, category: Category, args: CVarArg...) {
process(message, category: category, type: .debug, args: args)
}
- static func debug(_ message: StaticString, category: Category) {
+ static func debug(_ message: String, category: Category) {
debug(message, category: category, args: [])
}
- private static func process(_ message: StaticString, category: Category, type: OSLogType, args: CVarArg...) {
+ private static func process(_ message: String, category: Category, type: OSLogType, args: CVarArg...) {
guard isEnabled else { return }
- os_log(message, log: category.toOSLog(), type: type, args)
+ switch type {
+ case .debug:
+ category.toOSLog().debug("\(message)")
+ case .error:
+ category.toOSLog().error("\(message)")
+ default:
+ category.toOSLog().info("\(message)")
+ }
}
}
diff --git a/AudioStreaming/OggVorbis/VorbisFileDecoder.swift b/AudioStreaming/OggVorbis/VorbisFileDecoder.swift
index 90ff3d8..14988f4 100644
--- a/AudioStreaming/OggVorbis/VorbisFileDecoder.swift
+++ b/AudioStreaming/OggVorbis/VorbisFileDecoder.swift
@@ -1,6 +1,7 @@
import Foundation
import AudioCodecs
import AVFoundation
+import OSLog
/// A simple decoder for Ogg Vorbis files using libvorbisfile
final class VorbisFileDecoder {
diff --git a/AudioStreaming/Streaming/Audio Source/Mp4/Mp4Restructure.swift b/AudioStreaming/Streaming/Audio Source/Mp4/Mp4Restructure.swift
index 9eed1e1..c41395c 100644
--- a/AudioStreaming/Streaming/Audio Source/Mp4/Mp4Restructure.swift
+++ b/AudioStreaming/Streaming/Audio Source/Mp4/Mp4Restructure.swift
@@ -4,6 +4,7 @@
//
import Foundation
+import OSLog
struct MP4Atom: Equatable, CustomDebugStringConvertible {
let type: Int
diff --git a/AudioStreaming/Streaming/Audio Source/Mp4/RemoteMp4Restructure.swift b/AudioStreaming/Streaming/Audio Source/Mp4/RemoteMp4Restructure.swift
index b51ed0e..25f1de8 100644
--- a/AudioStreaming/Streaming/Audio Source/Mp4/RemoteMp4Restructure.swift
+++ b/AudioStreaming/Streaming/Audio Source/Mp4/RemoteMp4Restructure.swift
@@ -4,6 +4,7 @@
//
import Foundation
+import OSLog
final class RemoteMp4Restructure {
struct RestructuredData {
diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift
index 97bfd3e..73556a9 100644
--- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift
+++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift
@@ -5,6 +5,7 @@
import AVFoundation
import CoreAudio
+import OSLog
open class AudioPlayer {
public weak var delegate: AudioPlayerDelegate?
@@ -124,6 +125,13 @@ open class AudioPlayer {
/// The current configuration of the player.
public let configuration: AudioPlayerConfiguration
+ /// The loop mode for the audio player
+ /// Defaults to `.off`.
+ /// - Use `.single(times:)` to loop the current track
+ /// - Use `.all(times:)` to loop the entire queue
+ /// - Pass `nil` for times to loop infinitely, or a positive integer to loop that many times
+ public var loopMode: AudioPlayerLoopMode = .off
+
/// A Boolean value that indicates whether the audio engine is running.
/// `true` if the engine is running, otherwise, `false`
public var isEngineRunning: Bool { audioEngine.isRunning }
@@ -167,6 +175,13 @@ open class AudioPlayer {
private let entryProvider: AudioEntryProviding
var entriesQueue: PlayerQueueEntries
+
+ /// Stores the original queue entries for loop all functionality
+ private var originalQueueForLoop: [LoopEntryInfo] = []
+ /// Tracks played entries for loop functionality
+ private var playedEntries: [LoopEntryInfo] = []
+ /// Tracks the current loop iteration (how many times we've looped so far)
+ private var currentLoopIteration: Int = 0
public init(configuration: AudioPlayerConfiguration = .default) {
self.configuration = configuration.normalizeValues()
@@ -259,6 +274,9 @@ open class AudioPlayer {
checkRenderWaitingAndNotifyIfNeeded()
serializationQueue.sync {
clearQueue()
+ // Reset loop tracking when starting new playback
+ playedEntries.removeAll()
+ currentLoopIteration = 0
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
playerContext.setInternalState(to: .pendingNext)
do {
@@ -432,7 +450,7 @@ open class AudioPlayer {
do {
try startEngine()
} catch {
- Logger.debug("resuming audio engine failed: %@", category: .generic, args: error.localizedDescription)
+ Logger.debug("resuming audio engine failed: \(error.localizedDescription)", category: .generic)
}
if let playingEntry = playerContext.audioReadingEntry {
if playingEntry.seekRequest.requested {
@@ -535,7 +553,7 @@ open class AudioPlayer {
audioEngine.prepare()
try audioEngine.start()
} catch {
- Logger.error("⚠️ error setting up audio engine: %@", category: .generic, args: error.localizedDescription)
+ Logger.error("⚠️ error setting up audio engine: \(error.localizedDescription)", category: .generic)
}
}
@@ -578,7 +596,14 @@ open class AudioPlayer {
guard let self = self else { return }
self.serializationQueue.sync {
let nextEntry = self.entriesQueue.dequeue(type: .buffering)
- self.processFinishPlaying(entry: entry, with: nextEntry)
+
+ if nextEntry == nil {
+ if let entry = entry {
+ self.handleLoopingIfNeeded(for: entry)
+ }
+ } else {
+ self.processFinishPlaying(entry: entry, with: nextEntry)
+ }
}
self.sourceQueue.async {
self.processSource()
@@ -763,7 +788,7 @@ open class AudioPlayer {
private func setCurrentReading(entry: AudioEntry?, startPlaying: Bool, shouldClearQueue: Bool) {
guard let entry = entry else { return }
- Logger.debug("Setting current reading entry to: %@", category: .generic, args: entry.debugDescription)
+ Logger.debug("Setting current reading entry to: \(entry.debugDescription)", category: .generic)
if startPlaying {
rendererContext.fillSilenceAudioBuffer()
}
@@ -794,7 +819,9 @@ open class AudioPlayer {
private func processFinishPlaying(entry: AudioEntry?, with nextEntry: AudioEntry?) {
let playingEntry = playerContext.entriesLock.withLock { playerContext.audioPlayingEntry }
- guard entry == playingEntry else { return }
+ guard entry == playingEntry else {
+ return
+ }
let isPlayingSameItemProbablySeek = playerContext.audioPlayingEntry === nextEntry
@@ -807,6 +834,20 @@ open class AudioPlayer {
nextEntry.seekRequest.requested = false
}
}
+
+ // Track entries as they transition (NOT for .all mode - handled separately to preserve order)
+ if let entry = entry, !isPlayingSameItemProbablySeek, let url = URL(string: entry.id.id) {
+ if case .all = loopMode {
+ // Skip tracking for .all mode - it's handled in handleLoopingIfNeeded to preserve order
+ } else {
+ let alreadyTracked = playedEntries.contains(where: { $0.url == url })
+ if !alreadyTracked {
+ let entryInfo = LoopEntryInfo(url: url, headers: [:])
+ playedEntries.append(entryInfo)
+ }
+ }
+ }
+
playerContext.entriesLock.lock()
playerContext.audioPlayingEntry = nextEntry
let playingQueueEntryId = playerContext.audioPlayingEntry?.id ?? AudioEntryId(id: "")
@@ -830,6 +871,15 @@ open class AudioPlayer {
}
if !isPlayingSameItemProbablySeek {
playerContext.setInternalState(to: .waitingForData)
+
+ // For .all mode, track the STARTING entry to preserve queue order
+ if case .all = loopMode, let url = URL(string: nextEntry.id.id) {
+ let alreadyTracked = playedEntries.contains(where: { $0.url == url })
+ if !alreadyTracked {
+ let entryInfo = LoopEntryInfo(url: url, headers: [:])
+ playedEntries.append(entryInfo)
+ }
+ }
asyncOnMain { [weak self] in
guard let self = self else { return }
@@ -837,6 +887,19 @@ open class AudioPlayer {
}
}
} else {
+ // Track the LAST entry (NOT for .all mode - handled in handleLoopingIfNeeded to preserve order)
+ if let entry = entry, !isPlayingSameItemProbablySeek, let url = URL(string: entry.id.id) {
+ if case .all = loopMode {
+ // Skip tracking for .all mode - it's handled in handleLoopingIfNeeded to preserve order
+ } else {
+ let alreadyTracked = playedEntries.contains(where: { $0.url == url })
+ if !alreadyTracked {
+ let entryInfo = LoopEntryInfo(url: url, headers: [:])
+ playedEntries.append(entryInfo)
+ }
+ }
+ }
+
playerContext.entriesLock.lock()
playerContext.audioPlayingEntry = nil
playerContext.entriesLock.unlock()
@@ -871,6 +934,8 @@ open class AudioPlayer {
private func clearQueue() {
let pendingItems = entriesQueue.pendingEntriesId()
entriesQueue.removeAll()
+ playedEntries.removeAll()
+ currentLoopIteration = 0
if !pendingItems.isEmpty {
asyncOnMain { [weak self] in
guard let self = self else { return }
@@ -892,7 +957,72 @@ open class AudioPlayer {
guard let self = self else { return }
self.delegate?.audioPlayerUnexpectedError(player: self, error: error)
}
- Logger.error("Error: %@", category: .generic, args: error.localizedDescription)
+ Logger.error("Error: \(error.localizedDescription)", category: .generic)
+ }
+
+ /// Handles looping based on the current loop mode
+ /// - Parameter entry: The audio entry that just finished playing
+ private func handleLoopingIfNeeded(for entry: AudioEntry) {
+ // For .all mode, track the last entry before checking if we should loop
+ // This ensures the complete queue is captured for re-queueing
+ if case .all = loopMode, let url = URL(string: entry.id.id) {
+ let alreadyTracked = playedEntries.contains(where: { $0.url == url })
+ if !alreadyTracked {
+ let entryInfo = LoopEntryInfo(url: url, headers: [:])
+ playedEntries.append(entryInfo)
+ }
+ }
+
+ switch loopMode {
+ case .off:
+ processFinishPlaying(entry: entry, with: nil)
+
+ case .single(let times):
+ // Check if we've reached the loop count limit
+ if let maxLoops = times, maxLoops > 0, currentLoopIteration >= maxLoops {
+ processFinishPlaying(entry: entry, with: nil)
+ return
+ }
+
+ guard let url = URL(string: entry.id.id) else {
+ processFinishPlaying(entry: entry, with: nil)
+ return
+ }
+
+ // Increment loop iteration
+ currentLoopIteration += 1
+
+ // Re-create the entry and enqueue for playback
+ let newEntry = entryProvider.provideAudioEntry(url: url, headers: [:])
+ newEntry.delegate = self
+ entriesQueue.enqueue(item: newEntry, type: .upcoming)
+
+ case .all(let times):
+ // Check if we're at the end of all entries (nothing in queue)
+ if entriesQueue.count(for: .upcoming) == 0 && entriesQueue.count(for: .buffering) == 0 {
+ // Check if we've reached the loop count limit
+ if let maxLoops = times, maxLoops > 0, currentLoopIteration >= maxLoops {
+ processFinishPlaying(entry: entry, with: nil)
+ return
+ }
+
+ // Increment loop iteration
+ currentLoopIteration += 1
+
+ // Make a copy of playedEntries to re-queue
+ let entriesToRequeue = playedEntries
+
+ // Clear played entries BEFORE re-queueing to prevent duplicate tracking
+ playedEntries.removeAll()
+
+ // Re-create all played entries and enqueue as upcoming
+ for entryInfo in entriesToRequeue {
+ let newEntry = entryProvider.provideAudioEntry(url: entryInfo.url, headers: entryInfo.headers)
+ newEntry.delegate = self
+ entriesQueue.enqueue(item: newEntry, type: .upcoming)
+ }
+ }
+ }
}
}
diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift
index 90069fc..949a823 100644
--- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift
+++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerConfiguration.swift
@@ -4,6 +4,7 @@
//
import Foundation
+import OSLog
public struct AudioPlayerConfiguration: Equatable {
/// All pending items will be flushed when seeking a track if this is set to `true`
@@ -44,7 +45,7 @@ public struct AudioPlayerConfiguration: Equatable {
secondsRequiredToStartPlaying: Double = 1,
gracePeriodAfterSeekInSeconds: Double = 0.5,
secondsRequiredToStartPlayingAfterBufferUnderrun: Int = 1,
- enableLogs: Bool = false)
+ enableLogs: Bool = true)
{
self.flushQueueOnSeek = flushQueueOnSeek
self.bufferSizeInSeconds = bufferSizeInSeconds
@@ -52,6 +53,7 @@ public struct AudioPlayerConfiguration: Equatable {
self.gracePeriodAfterSeekInSeconds = gracePeriodAfterSeekInSeconds
self.secondsRequiredToStartPlayingAfterBufferUnderrun = secondsRequiredToStartPlayingAfterBufferUnderrun
self.enableLogs = enableLogs
+ Logger.isEnabled = enableLogs
}
/// Normalize values on any zero values passed
diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayerLoopMode.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerLoopMode.swift
new file mode 100644
index 0000000..4922069
--- /dev/null
+++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerLoopMode.swift
@@ -0,0 +1,25 @@
+//
+// Created by Dimitrios Chatzieleftheriou on 22/11/2025.
+// Copyright © 2025 Decimal. All rights reserved.
+//
+
+import Foundation
+
+/// Defines the loop/repeat mode for the audio player
+public enum AudioPlayerLoopMode: Equatable {
+ /// No looping - plays through the queue once
+ case off
+ /// Loop the current track
+ /// - Parameter times: Number of times to loop (nil = infinite)
+ case single(times: Int?)
+ /// Loop the entire queue
+ /// - Parameter times: Number of times to loop (nil = infinite)
+ case all(times: Int?)
+}
+
+/// Stores information needed to recreate an audio entry for looping
+internal struct LoopEntryInfo: Equatable {
+ let url: URL
+ let headers: [String: String]
+}
+
diff --git a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift
index 82b6cff..e67fa6a 100644
--- a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift
+++ b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioFileStreamProcessor.swift
@@ -7,6 +7,7 @@
import AVFoundation
import CoreAudio
+import OSLog
enum AudioConvertStatus: Int32 {
case done = 100
diff --git a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioPlayerRenderProcessor.swift b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioPlayerRenderProcessor.swift
index 812d9bc..08e2a27 100644
--- a/AudioStreaming/Streaming/AudioPlayer/Processors/AudioPlayerRenderProcessor.swift
+++ b/AudioStreaming/Streaming/AudioPlayer/Processors/AudioPlayerRenderProcessor.swift
@@ -6,6 +6,7 @@
//
import AVFoundation
+import OSLog
final class AudioPlayerRenderProcessor: NSObject {
/// The AVAudioEngine's `AVAudioEngineManualRenderingBlock` render block from manual rendering
diff --git a/AudioStreaming/Streaming/AudioPlayer/Processors/OggVorbisStreamProcessor.swift b/AudioStreaming/Streaming/AudioPlayer/Processors/OggVorbisStreamProcessor.swift
index cb01857..884f467 100644
--- a/AudioStreaming/Streaming/AudioPlayer/Processors/OggVorbisStreamProcessor.swift
+++ b/AudioStreaming/Streaming/AudioPlayer/Processors/OggVorbisStreamProcessor.swift
@@ -8,6 +8,7 @@
import Foundation
import AVFoundation
import CoreAudio
+import OSLog
/// A processor for Ogg Vorbis audio streams using libvorbisfile
final class OggVorbisStreamProcessor {
diff --git a/README.md b/README.md
index 8423257..99181a8 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,27 @@ player.queue(urls: [
])
```
+### Loop mode
+```swift
+let player = AudioPlayer()
+player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
+
+// no looping (default)
+player.loopMode = .off
+
+// loop the current track infinitely
+player.loopMode = .single(times: nil)
+
+// loop the current track 3 times
+player.loopMode = .single(times: 3)
+
+// loop the entire queue infinitely
+player.loopMode = .all(times: nil)
+
+// loop the entire queue 2 times
+player.loopMode = .all(times: 2)
+```
+
### Adjusting playback properties
```swift
let player = AudioPlayer()