Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 52 additions & 0 deletions Shfl/Domain/AppSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation
import SwiftUI

/// Centralized app settings using @Observable for automatic SwiftUI updates.
/// Replaces NotificationCenter-based communication for settings changes.
@Observable
@MainActor
final class AppSettings {
var shuffleAlgorithm: ShuffleAlgorithm {
didSet {
guard shuffleAlgorithm != oldValue else { return }
UserDefaults.standard.set(shuffleAlgorithm.rawValue, forKey: "shuffleAlgorithm")
}
}

var librarySortOption: SortOption {
didSet {
guard librarySortOption != oldValue else { return }
UserDefaults.standard.set(librarySortOption.rawValue, forKey: "librarySortOption")
}
}

init() {
let algorithmRaw = UserDefaults.standard.string(forKey: "shuffleAlgorithm") ?? ShuffleAlgorithm.noRepeat.rawValue
self.shuffleAlgorithm = ShuffleAlgorithm(rawValue: algorithmRaw) ?? .noRepeat

let sortRaw = UserDefaults.standard.string(forKey: "librarySortOption") ?? SortOption.mostPlayed.rawValue
self.librarySortOption = SortOption(rawValue: sortRaw) ?? .mostPlayed
}
}

// MARK: - Environment Keys

private struct AppSettingsKey: EnvironmentKey {
static let defaultValue: AppSettings? = nil
}

private struct ShufflePlayerKey: EnvironmentKey {
static let defaultValue: ShufflePlayer? = nil
}

extension EnvironmentValues {
var appSettings: AppSettings? {
get { self[AppSettingsKey.self] }
set { self[AppSettingsKey.self] = newValue }
}

var shufflePlayer: ShufflePlayer? {
get { self[ShufflePlayerKey.self] }
set { self[ShufflePlayerKey.self] = newValue }
}
}
50 changes: 15 additions & 35 deletions Shfl/Domain/ShufflePlayer.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Combine
import Foundation

enum ShufflePlayerError: Error, Equatable {
Expand All @@ -7,24 +6,25 @@ enum ShufflePlayerError: Error, Equatable {
case playbackFailed(String)
}

@Observable
@MainActor
final class ShufflePlayer: ObservableObject {
final class ShufflePlayer {
static let maxSongs = 120

private let musicService: MusicService
@Published private(set) var songs: [Song] = []
private var stateTask: Task<Void, Never>?
@ObservationIgnored private let musicService: MusicService
private(set) var songs: [Song] = []
@ObservationIgnored private var stateTask: Task<Void, Never>?

@Published private(set) var playbackState: PlaybackState = .empty
private(set) var playbackState: PlaybackState = .empty

/// Debug: The last shuffled queue order (for verifying shuffle algorithms)
@Published private(set) var lastShuffledQueue: [Song] = []
private(set) var lastShuffledQueue: [Song] = []
/// Debug: The algorithm used for the last shuffle
@Published private(set) var lastUsedAlgorithm: ShuffleAlgorithm = .noRepeat
private(set) var lastUsedAlgorithm: ShuffleAlgorithm = .noRepeat

private var playedSongIds: Set<String> = []
private var lastObservedSongId: String?
private var preparedSongIds: Set<String> = []
@ObservationIgnored private var playedSongIds: Set<String> = []
@ObservationIgnored private var lastObservedSongId: String?
@ObservationIgnored private var preparedSongIds: Set<String> = []

private var isQueuePrepared: Bool {
Set(songs.map(\.id)) == preparedSongIds
Expand All @@ -38,32 +38,15 @@ final class ShufflePlayer: ObservableObject {
/// Exposed for testing only
var playedSongIdsForTesting: Set<String> { playedSongIds }

private var algorithmObserver: NSObjectProtocol?

init(musicService: MusicService) {
self.musicService = musicService
observePlaybackState()
observeAlgorithmChanges()
}

private func observeAlgorithmChanges() {
algorithmObserver = NotificationCenter.default.addObserver(
forName: .shuffleAlgorithmChanged,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
await self?.reshuffleWithNewAlgorithm()
}
}
}

private func reshuffleWithNewAlgorithm() async {
/// Called when shuffle algorithm changes. Views should call this via onChange(of: appSettings.shuffleAlgorithm).
func reshuffleWithNewAlgorithm(_ algorithm: ShuffleAlgorithm) async {
guard !songs.isEmpty, playbackState.isActive else { return }

let algorithmRaw = UserDefaults.standard.string(forKey: "shuffleAlgorithm") ?? ShuffleAlgorithm.noRepeat.rawValue
let algorithm = ShuffleAlgorithm(rawValue: algorithmRaw) ?? .noRepeat

print("🎲 Algorithm changed to \(algorithm.displayName), reshuffling...")

// Get currently playing song
Expand Down Expand Up @@ -101,15 +84,12 @@ final class ShufflePlayer: ObservableObject {

deinit {
stateTask?.cancel()
if let observer = algorithmObserver {
NotificationCenter.default.removeObserver(observer)
}
}

private func observePlaybackState() {
stateTask = Task { [weak self] in
stateTask = Task { @MainActor [weak self] in
guard let self else { return }
for await state in musicService.playbackStateStream {
for await state in self.musicService.playbackStateStream {
self.handlePlaybackStateChange(state)
}
}
Expand Down
17 changes: 10 additions & 7 deletions Shfl/Services/ArtworkLoader.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import Combine
import MusicKit
import SwiftUI

/// Lazy artwork loader with rate limiting to avoid overwhelming MusicKit
@Observable
@MainActor
final class ArtworkLoader: ObservableObject {
final class ArtworkLoader {
static let shared = ArtworkLoader()

private var cache: [String: Artwork] = [:]
private var pending: Set<String> = []
private var loadQueue: [String] = []
private var isProcessing = false
@ObservationIgnored private var cache: [String: Artwork] = [:]
@ObservationIgnored private var pending: Set<String> = []
@ObservationIgnored private var loadQueue: [String] = []
@ObservationIgnored private var isProcessing = false

/// Triggers view updates when artwork is loaded
private(set) var lastUpdateTimestamp = Date()

private init() {}

Expand Down Expand Up @@ -62,7 +65,7 @@ final class ArtworkLoader: ObservableObject {
pending.remove(song.id.rawValue)
}
// Trigger UI update
objectWillChange.send()
lastUpdateTimestamp = Date()
} catch {
// Remove from pending on error so they can retry
for id in songIds {
Expand Down
12 changes: 6 additions & 6 deletions Shfl/Utilities/AlbumArtColorExtractor.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import Combine
import MusicKit
import SwiftUI
import UIKit

/// Extracts colors from album artwork using MusicKit's catalog data
@Observable
@MainActor
final class AlbumArtColorExtractor: ObservableObject {
@Published private(set) var extractedColor: Color?
final class AlbumArtColorExtractor {
private(set) var extractedColor: Color?

private var currentSongId: String?
private var currentTask: Task<Void, Never>?
private var colorCache: [String: Color] = [:]
@ObservationIgnored private var currentSongId: String?
@ObservationIgnored private var currentTask: Task<Void, Never>?
@ObservationIgnored private var colorCache: [String: Color] = [:]

/// Updates the extracted color for the given song by fetching from Apple Music catalog
func updateColor(for songId: String) {
Expand Down
46 changes: 30 additions & 16 deletions Shfl/Utilities/VolumeController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import UIKit
/// - The view hierarchy lookup fails for other reasons
enum VolumeController {
private static let volumeStep: Float = 0.0625 // 1/16, matches iOS default
private static var isInitialized = false

private static var volumeView: MPVolumeView = {
let view = MPVolumeView(frame: .zero)
Expand All @@ -24,32 +25,45 @@ enum VolumeController {
volumeView.subviews.compactMap { $0 as? UISlider }.first
}

private static func ensureVolumeViewInHierarchy() {
guard volumeView.superview == nil,
let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.windows.first else { return }
/// Call this early in app lifecycle (e.g., from a view's onAppear)
/// to ensure the volume view is ready before user interaction.
/// Safe to call multiple times - will only initialize once when a window is available.
static func initialize() {
guard !isInitialized else { return }

guard let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.windows.first else {
// Window not available yet - this is expected if called too early.
// Volume control will attempt to initialize lazily when first used.
return
}

isInitialized = true
window.addSubview(volumeView)
// Force layout so subviews are populated
volumeView.layoutIfNeeded()
}

static func increaseVolume() {
ensureVolumeViewInHierarchy()
guard let slider = volumeSlider else {
assertionFailure("VolumeController: Could not find volume slider in MPVolumeView hierarchy")
return
}
let newValue = min(slider.value + volumeStep, 1.0)
slider.value = newValue
slider.sendActions(for: .touchUpInside)
adjustVolume(by: volumeStep)
}

static func decreaseVolume() {
ensureVolumeViewInHierarchy()
adjustVolume(by: -volumeStep)
}

private static func adjustVolume(by delta: Float) {
// Attempt lazy initialization if not done yet
if !isInitialized {
initialize()
}

guard let slider = volumeSlider else {
assertionFailure("VolumeController: Could not find volume slider in MPVolumeView hierarchy")
// Slider may be unavailable if window isn't ready or MPVolumeView structure changed
return
}
let newValue = max(slider.value - volumeStep, 0.0)
let newValue = max(0.0, min(slider.value + delta, 1.0))
slider.value = newValue
slider.sendActions(for: .touchUpInside)
}
Expand Down
63 changes: 43 additions & 20 deletions Shfl/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import Combine
import SwiftData
import SwiftUI

@Observable
@MainActor
final class AppViewModel: ObservableObject {
final class AppViewModel {
let player: ShufflePlayer
let musicService: MusicService
private let repository: SongRepository
private let scrobbleTracker: ScrobbleTracker
private var cancellables = Set<AnyCancellable>()

@Published var isAuthorized = false
@Published var isLoading = true
@Published var loadingMessage = "Loading..."
@Published var showingManage = false
@Published var showingPicker = false
@Published var showingPickerDirect = false
@Published var showingSettings = false
@Published var authorizationError: String?
@ObservationIgnored let musicService: MusicService
@ObservationIgnored private let repository: SongRepository
@ObservationIgnored private let scrobbleTracker: ScrobbleTracker

var isAuthorized = false
var isLoading = true
var loadingMessage = "Loading..."
var showingManage = false
var showingPicker = false
var showingPickerDirect = false
var showingSettings = false
var authorizationError: String?

init(musicService: MusicService, modelContext: ModelContext) {
self.musicService = musicService
Expand All @@ -32,14 +31,38 @@ final class AppViewModel: ObservableObject {
let scrobbleManager = ScrobbleManager(transports: [lastFMTransport])
self.scrobbleTracker = ScrobbleTracker(scrobbleManager: scrobbleManager, musicService: musicService)

// Forward playback state to scrobble tracker
player.$playbackState
.sink { [weak self] state in
self?.scrobbleTracker.onPlaybackStateChanged(state)
// Start observing playback state for scrobbling
startObservingPlaybackState()
}

@ObservationIgnored private var scrobbleObservationTask: Task<Void, Never>?

deinit {
scrobbleObservationTask?.cancel()
}

private func startObservingPlaybackState() {
// Track last state to avoid duplicate notifications
var lastState: PlaybackState?

scrobbleObservationTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self else { return }

// Check if state changed
let currentState = self.player.playbackState
if currentState != lastState {
lastState = currentState
self.scrobbleTracker.onPlaybackStateChanged(currentState)
}

// Poll at reasonable interval - scrobbling doesn't need instant updates
try? await Task.sleep(for: .milliseconds(250))
}
.store(in: &cancellables)
}
}


func onAppear() async {
// Check authorization in parallel with song loading
loadingMessage = "Loading your music..."
Expand Down
Loading
Loading