From f1ed8771bc790647a0309abac5a2181138975660 Mon Sep 17 00:00:00 2001 From: Joshua Hughes Date: Fri, 9 Jan 2026 17:19:53 -0500 Subject: [PATCH] perf: optimize first-launch startup and fix play button shader - Add batch addSongs() method with O(n) duplicate checking via Set - Parallelize authorization check with song loading - Prepare queue before dismissing loading screen for instant Play - Add dynamic loading messages for user feedback - Emit loading state immediately when Play tapped before queue ready - Fix play button shader clipping (add clipShape to prevent white square) Co-Authored-By: Claude Opus 4.5 --- Shfl/Domain/ShufflePlayer.swift | 19 +++++++++++++- Shfl/ViewModels/AppViewModel.swift | 29 +++++++++++++++------ Shfl/Views/Components/PlayPauseButton.swift | 3 ++- Shfl/Views/MainView.swift | 2 +- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/Shfl/Domain/ShufflePlayer.swift b/Shfl/Domain/ShufflePlayer.swift index feedc1d..5f35022 100644 --- a/Shfl/Domain/ShufflePlayer.swift +++ b/Shfl/Domain/ShufflePlayer.swift @@ -160,6 +160,19 @@ final class ShufflePlayer: ObservableObject { rebuildQueueIfPlaying() } + func addSongs(_ newSongs: [Song]) throws { + let existingIds = Set(songs.map(\.id)) + let uniqueNewSongs = newSongs.filter { !existingIds.contains($0.id) } + + let availableCapacity = Self.maxSongs - songs.count + guard uniqueNewSongs.count <= availableCapacity else { + throw ShufflePlayerError.capacityReached + } + + songs.append(contentsOf: uniqueNewSongs) + // Don't rebuild queue during initial load - not playing yet + } + func removeSong(id: String) { songs.removeAll { $0.id == id } rebuildQueueIfPlaying() @@ -203,9 +216,13 @@ final class ShufflePlayer: ObservableObject { lastObservedSongId = nil if !isQueuePrepared { + // Emit loading state for immediate UI feedback + if let firstSong = songs.first { + playbackState = .loading(firstSong) + } try await prepareQueue() } - + try await musicService.play() } diff --git a/Shfl/ViewModels/AppViewModel.swift b/Shfl/ViewModels/AppViewModel.swift index 83dd2cc..612f462 100644 --- a/Shfl/ViewModels/AppViewModel.swift +++ b/Shfl/ViewModels/AppViewModel.swift @@ -12,6 +12,7 @@ final class AppViewModel: ObservableObject { @Published var isAuthorized = false @Published var isLoading = true + @Published var loadingMessage = "Loading..." @Published var showingManage = false @Published var showingPicker = false @Published var showingPickerDirect = false @@ -40,21 +41,33 @@ final class AppViewModel: ObservableObject { } func onAppear() async { - isAuthorized = await musicService.isAuthorized + // Check authorization in parallel with song loading + loadingMessage = "Loading your music..." + async let authStatus = musicService.isAuthorized + // Load songs (synchronous SwiftData call, runs on MainActor) + var songs: [Song] = [] do { - let songs = try repository.loadSongs() - for song in songs { - try? player.addSong(song) - } + songs = try repository.loadSongs() } catch { print("Failed to load songs: \(error)") } - isLoading = false + // Batch-add songs (O(n) instead of O(n²)) + if !songs.isEmpty { + try? player.addSongs(songs) + } + + // Wait for auth check + isAuthorized = await authStatus - // Prepare queue in background for instant playback - Task { try? await player.prepareQueue() } + // Prepare queue before dismissing loading screen + if !player.songs.isEmpty { + loadingMessage = "Preparing playback..." + try? await player.prepareQueue() + } + + isLoading = false } func requestAuthorization() async { diff --git a/Shfl/Views/Components/PlayPauseButton.swift b/Shfl/Views/Components/PlayPauseButton.swift index bb5331e..b6fda0d 100644 --- a/Shfl/Views/Components/PlayPauseButton.swift +++ b/Shfl/Views/Components/PlayPauseButton.swift @@ -23,6 +23,7 @@ struct PlayPauseButton: View { ZStack { Circle() .fill(buttonBackgroundColor) + .frame(width: buttonSize, height: buttonSize) .colorEffect( ShaderLibrary.shfl_brushedMetal( .float2(buttonSize / 2, buttonSize / 2), @@ -30,7 +31,7 @@ struct PlayPauseButton: View { .float(theme.brushedMetalIntensity) ) ) - .frame(width: buttonSize, height: buttonSize) + .clipShape(Circle()) .shadow( color: .black.opacity(0.1), radius: isPressed ? ClickWheelFeedback.centerPressedShadowRadius : ClickWheelFeedback.centerNormalShadowRadius, diff --git a/Shfl/Views/MainView.swift b/Shfl/Views/MainView.swift index 5ea820f..ead17ba 100644 --- a/Shfl/Views/MainView.swift +++ b/Shfl/Views/MainView.swift @@ -77,7 +77,7 @@ struct MainView: View { ProgressView() .scaleEffect(1.5) - Text("Loading your music...") + Text(viewModel.loadingMessage) .font(.subheadline) .foregroundStyle(.secondary) }