From 8fceed121d1acc18caa155a4d5939b19987ea8a5 Mon Sep 17 00:00:00 2001 From: Joshua Hughes Date: Sat, 31 Jan 2026 16:45:43 -0500 Subject: [PATCH] refactor: extract PlayerView into smaller reusable components Extract PlayerView (597 lines) into focused, composable components: New components: - PlayerActions: Callback container for player actions - PlayerProgressState: Timer-based progress tracking (@Observable) - ThemeController: Theme switching + swipe gesture (@Observable) - PlaybackStateContent: Generic switch eliminator for PlaybackState - SongInfoDisplay: Song title/artist/progress display - PlayerTopBar: Add/Settings buttons - PlayerControlsPanel: ClickWheel in ShuffleBody wrapper - ClassicPlayerLayout: Composes all components into current layout Benefits: - PlayerView main struct reduced to ~143 lines (orchestration only) - Eliminates 4 duplicate switch statements on PlaybackState - Separates timer/gesture/lifecycle code from view code - Makes layout experimentation straightforward (swap ClassicPlayerLayout) - Each component is independently testable and reusable Co-Authored-By: Claude Opus 4.5 --- .../Components/ClassicPlayerLayout.swift | 57 +++ .../Components/PlaybackStateContent.swift | 24 + Shfl/Views/Components/PlayerActions.swift | 11 + .../Components/PlayerControlsPanel.swift | 26 ++ .../Components/PlayerProgressState.swift | 34 ++ Shfl/Views/Components/PlayerTopBar.swift | 43 ++ Shfl/Views/Components/SongInfoDisplay.swift | 89 ++++ Shfl/Views/Components/ThemeController.swift | 59 +++ Shfl/Views/PlayerView.swift | 411 +++--------------- 9 files changed, 392 insertions(+), 362 deletions(-) create mode 100644 Shfl/Views/Components/ClassicPlayerLayout.swift create mode 100644 Shfl/Views/Components/PlaybackStateContent.swift create mode 100644 Shfl/Views/Components/PlayerActions.swift create mode 100644 Shfl/Views/Components/PlayerControlsPanel.swift create mode 100644 Shfl/Views/Components/PlayerProgressState.swift create mode 100644 Shfl/Views/Components/PlayerTopBar.swift create mode 100644 Shfl/Views/Components/SongInfoDisplay.swift create mode 100644 Shfl/Views/Components/ThemeController.swift diff --git a/Shfl/Views/Components/ClassicPlayerLayout.swift b/Shfl/Views/Components/ClassicPlayerLayout.swift new file mode 100644 index 0000000..1d39941 --- /dev/null +++ b/Shfl/Views/Components/ClassicPlayerLayout.swift @@ -0,0 +1,57 @@ +import SwiftUI + +/// The classic iPod Shuffle player layout, composing PlayerTopBar, SongInfoDisplay, and PlayerControlsPanel +struct ClassicPlayerLayout: View { + let playbackState: PlaybackState + let isControlsDisabled: Bool + let currentTime: TimeInterval + let duration: TimeInterval + let highlightOffset: CGPoint + let actions: PlayerActions + let showError: Bool + let errorMessage: String + let safeAreaInsets: EdgeInsets + let onDismissError: () -> Void + + var body: some View { + VStack(spacing: 0) { + if showError { + ErrorBanner(message: errorMessage, onDismiss: onDismissError) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + PlayerTopBar( + onAddTapped: actions.onAdd, + onSettingsTapped: actions.onSettings, + topPadding: showError ? 16 : safeAreaInsets.top + 16 + ) + + Spacer() + + // Song info panel - brushed metal + ShuffleBodyView(highlightOffset: highlightOffset, height: 120) { + SongInfoDisplay( + playbackState: playbackState, + currentTime: currentTime, + duration: duration + ) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .padding(.horizontal, 20) + } + .padding(.horizontal, 20) + .padding(.bottom, 12) + + // Controls panel + PlayerControlsPanel( + isPlaying: playbackState.isPlaying, + isDisabled: isControlsDisabled, + highlightOffset: highlightOffset, + actions: actions + ) + .padding(.horizontal, 20) + .padding(.bottom, safeAreaInsets.bottom + 12) + } + .animation(.easeInOut(duration: 0.2), value: showError) + } +} diff --git a/Shfl/Views/Components/PlaybackStateContent.swift b/Shfl/Views/Components/PlaybackStateContent.swift new file mode 100644 index 0000000..b58ac04 --- /dev/null +++ b/Shfl/Views/Components/PlaybackStateContent.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// Generic view that eliminates duplicate switch statements on PlaybackState +/// by providing loading/active/empty ViewBuilder slots +struct PlaybackStateContent: View { + let playbackState: PlaybackState + @ViewBuilder let loading: (Song) -> Loading + @ViewBuilder let active: (Song) -> Active + @ViewBuilder let empty: () -> Empty + + var body: some View { + switch playbackState { + case .loading(let song): + loading(song) + .transition(.opacity) + case .playing(let song), .paused(let song): + active(song) + .transition(.opacity) + case .empty, .stopped, .error: + empty() + .transition(.opacity) + } + } +} diff --git a/Shfl/Views/Components/PlayerActions.swift b/Shfl/Views/Components/PlayerActions.swift new file mode 100644 index 0000000..2be567a --- /dev/null +++ b/Shfl/Views/Components/PlayerActions.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Callback container for player actions, enabling decoupled view composition +struct PlayerActions { + let onPlayPause: () -> Void + let onSkipForward: () -> Void + let onSkipBack: () -> Void + let onManage: () -> Void + let onAdd: () -> Void + let onSettings: () -> Void +} diff --git a/Shfl/Views/Components/PlayerControlsPanel.swift b/Shfl/Views/Components/PlayerControlsPanel.swift new file mode 100644 index 0000000..b194fd9 --- /dev/null +++ b/Shfl/Views/Components/PlayerControlsPanel.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// Click wheel controls wrapped in a brushed metal panel +struct PlayerControlsPanel: View { + let isPlaying: Bool + let isDisabled: Bool + let highlightOffset: CGPoint + let actions: PlayerActions + + var body: some View { + ShuffleBodyView(highlightOffset: highlightOffset) { + ClickWheelView( + isPlaying: isPlaying, + onPlayPause: actions.onPlayPause, + onSkipForward: actions.onSkipForward, + onSkipBack: actions.onSkipBack, + onVolumeUp: { VolumeController.increaseVolume() }, + onVolumeDown: { VolumeController.decreaseVolume() }, + highlightOffset: highlightOffset, + scale: 0.6 + ) + .disabled(isDisabled) + .opacity(isDisabled ? 0.6 : 1.0) + } + } +} diff --git a/Shfl/Views/Components/PlayerProgressState.swift b/Shfl/Views/Components/PlayerProgressState.swift new file mode 100644 index 0000000..c549600 --- /dev/null +++ b/Shfl/Views/Components/PlayerProgressState.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Timer-based progress tracking for playback position +@Observable @MainActor +final class PlayerProgressState { + private(set) var currentTime: TimeInterval = 0 + private(set) var duration: TimeInterval = 0 + + private let musicService: MusicService + private var timer: Timer? + + init(musicService: MusicService) { + self.musicService = musicService + } + + func startUpdating() { + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + self.currentTime = self.musicService.currentPlaybackTime + self.duration = self.musicService.currentSongDuration + } + } + } + + func stopUpdating() { + timer?.invalidate() + timer = nil + } + + func refreshDuration() { + duration = musicService.currentSongDuration + } +} diff --git a/Shfl/Views/Components/PlayerTopBar.swift b/Shfl/Views/Components/PlayerTopBar.swift new file mode 100644 index 0000000..a905813 --- /dev/null +++ b/Shfl/Views/Components/PlayerTopBar.swift @@ -0,0 +1,43 @@ +import SwiftUI + +/// Top bar with Add and Settings buttons for the player view +struct PlayerTopBar: View { + @Environment(\.shuffleTheme) private var theme + + let onAddTapped: () -> Void + let onSettingsTapped: () -> Void + let topPadding: CGFloat + + var body: some View { + HStack { + Button(action: onAddTapped) { + Image(systemName: "music.note.list") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) + .background(theme.bodyGradientTop) + .clipShape(Circle()) + .overlay( + Circle() + .strokeBorder(.white.opacity(0.3), lineWidth: 1) + ) + } + Spacer() + Button(action: onSettingsTapped) { + Image(systemName: "gearshape") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 40, height: 40) + .background(theme.bodyGradientTop) + .clipShape(Circle()) + .overlay( + Circle() + .strokeBorder(.white.opacity(0.3), lineWidth: 1) + ) + } + } + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + .padding(.horizontal, 20) + .padding(.top, topPadding) + } +} diff --git a/Shfl/Views/Components/SongInfoDisplay.swift b/Shfl/Views/Components/SongInfoDisplay.swift new file mode 100644 index 0000000..3628226 --- /dev/null +++ b/Shfl/Views/Components/SongInfoDisplay.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// Displays song title, artist, and optional progress bar based on playback state +struct SongInfoDisplay: View { + @Environment(\.shuffleTheme) private var theme + + let playbackState: PlaybackState + let currentTime: TimeInterval + let duration: TimeInterval + let showProgressBar: Bool + + init( + playbackState: PlaybackState, + currentTime: TimeInterval = 0, + duration: TimeInterval = 0, + showProgressBar: Bool = FeatureFlags.showProgressBar + ) { + self.playbackState = playbackState + self.currentTime = currentTime + self.duration = duration + self.showProgressBar = showProgressBar + } + + var body: some View { + PlaybackStateContent( + playbackState: playbackState, + loading: { song in + loadingContent(song: song) + }, + active: { song in + activeContent(song: song) + }, + empty: { + emptyContent + } + ) + } + + @ViewBuilder + private func loadingContent(song: Song) -> some View { + VStack(spacing: 8) { + ProgressView() + .scaleEffect(0.8) + .tint(theme.textColor) + Text(song.title) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(theme.textColor) + .lineLimit(1) + Text(song.artist) + .font(.system(size: 14)) + .foregroundStyle(theme.secondaryTextColor) + .lineLimit(1) + } + } + + @ViewBuilder + private func activeContent(song: Song) -> some View { + VStack(spacing: 4) { + Text(song.title) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(theme.textColor) + .lineLimit(1) + Text(song.artist) + .font(.system(size: 14)) + .foregroundStyle(theme.secondaryTextColor) + .lineLimit(1) + + if showProgressBar { + PlaybackProgressBar( + currentTime: currentTime, + duration: duration + ) + .padding(.top, 8) + } + } + } + + @ViewBuilder + private var emptyContent: some View { + VStack(spacing: 8) { + Text("No songs yet") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(theme.textColor) + Text("Add some music to get started") + .font(.system(size: 14)) + .foregroundStyle(theme.secondaryTextColor) + } + } +} diff --git a/Shfl/Views/Components/ThemeController.swift b/Shfl/Views/Components/ThemeController.swift new file mode 100644 index 0000000..7dd2de7 --- /dev/null +++ b/Shfl/Views/Components/ThemeController.swift @@ -0,0 +1,59 @@ +import SwiftUI + +/// Manages theme switching with swipe gesture support +@Observable @MainActor +final class ThemeController { + private(set) var currentThemeIndex: Int + private(set) var dragOffset: CGFloat = 0 + + private let swipeThreshold: CGFloat = 100 + + var currentTheme: ShuffleTheme { + ShuffleTheme.allThemes[currentThemeIndex] + } + + init(startingIndex: Int? = nil) { + self.currentThemeIndex = startingIndex ?? Int.random(in: 0.. some Gesture { + DragGesture(minimumDistance: 30) + .onChanged { [weak self] value in + guard let self else { return } + let translation = value.translation.width + // Add rubber-band resistance at edges + if (self.currentThemeIndex == 0 && translation > 0) || + (self.currentThemeIndex == ShuffleTheme.allThemes.count - 1 && translation < 0) { + self.dragOffset = translation * 0.3 // Resistance + } else { + self.dragOffset = translation + } + } + .onEnded { [weak self] value in + guard let self else { return } + let translation = value.translation.width + let velocity = value.predictedEndTranslation.width + + withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { + if translation < -self.swipeThreshold || velocity < -500 { + // Swipe left - next theme + if self.currentThemeIndex < ShuffleTheme.allThemes.count - 1 { + self.currentThemeIndex += 1 + HapticFeedback.light.trigger() + } else { + HapticFeedback.light.trigger() // Boundary bump + } + } else if translation > self.swipeThreshold || velocity > 500 { + // Swipe right - previous theme + if self.currentThemeIndex > 0 { + self.currentThemeIndex -= 1 + HapticFeedback.light.trigger() + } else { + HapticFeedback.light.trigger() // Boundary bump + } + } + self.dragOffset = 0 + } + } + } +} diff --git a/Shfl/Views/PlayerView.swift b/Shfl/Views/PlayerView.swift index 5e99c9d..044711c 100644 --- a/Shfl/Views/PlayerView.swift +++ b/Shfl/Views/PlayerView.swift @@ -8,31 +8,12 @@ struct PlayerView: View { let onSettingsTapped: () -> Void @Environment(\.motionManager) private var motionManager + @State private var themeController = ThemeController() + @State private var progressState: PlayerProgressState? @State private var colorExtractor = AlbumArtColorExtractor() @State private var highlightOffset: CGPoint = .zero @State private var showError = false @State private var errorMessage = "" - @State private var currentTime: TimeInterval = 0 - @State private var duration: TimeInterval = 0 - @State private var progressTimer: Timer? - @State private var currentThemeIndex: Int = Int.random(in: 0.. some View { - HStack { - Button(action: onAddTapped) { - Image(systemName: "music.note.list") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .background(currentTheme.bodyGradientTop) - .clipShape(Circle()) - .overlay( - Circle() - .strokeBorder(.white.opacity(0.3), lineWidth: 1) - ) + .simultaneousGesture(themeController.makeSwipeGesture()) + .environment(\.shuffleTheme, themeController.currentTheme) + .onAppear { + if progressState == nil { + progressState = PlayerProgressState(musicService: musicService) } - Spacer() - Button(action: onSettingsTapped) { - Image(systemName: "gearshape") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .background(currentTheme.bodyGradientTop) - .clipShape(Circle()) - .overlay( - Circle() - .strokeBorder(.white.opacity(0.3), lineWidth: 1) - ) + progressState?.startUpdating() + motionManager?.start() + if let song = player.playbackState.currentSong { + colorExtractor.updateColor(for: song.id) } } - .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) - .padding(.horizontal, 20) - .padding(.top, showError ? 16 : geometry.safeAreaInsets.top + 16) - } - - @ViewBuilder - private var nowPlayingSection: some View { - switch player.playbackState { - case .loading(let song): - VStack(spacing: 12) { - ProgressView() - .scaleEffect(1.2) - .tint(currentTheme.textColor) - NowPlayingInfo(title: song.title, artist: song.artist) - .opacity(0.7) - } - .transition(.opacity) - case .playing(let song), .paused(let song): - NowPlayingInfo(title: song.title, artist: song.artist) - .transition(.opacity) - default: - emptyStateView + .onDisappear { + progressState?.stopUpdating() + motionManager?.stop() } - } - - @ViewBuilder - private var songInfoContent: some View { - switch player.playbackState { - case .loading(let song): - VStack(spacing: 8) { - ProgressView() - .scaleEffect(0.8) - .tint(currentTheme.textColor) - Text(song.title) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(currentTheme.textColor) - .lineLimit(1) - Text(song.artist) - .font(.system(size: 14)) - .foregroundStyle(currentTheme.secondaryTextColor) - .lineLimit(1) - } - case .playing(let song), .paused(let song): - VStack(spacing: 4) { - Text(song.title) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(currentTheme.textColor) - .lineLimit(1) - Text(song.artist) - .font(.system(size: 14)) - .foregroundStyle(currentTheme.secondaryTextColor) - .lineLimit(1) - - if FeatureFlags.showProgressBar { - PlaybackProgressBar( - currentTime: currentTime, - duration: duration - ) - .padding(.top, 8) - } - } - default: - VStack(spacing: 8) { - Text("No songs yet") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(currentTheme.textColor) - Text("Add some music to get started") - .font(.system(size: 14)) - .foregroundStyle(currentTheme.secondaryTextColor) - } + .onChange(of: player.playbackState) { _, newState in + handlePlaybackStateChange(newState) } - } - - @ViewBuilder - private var songInfoPanel: some View { - switch player.playbackState { - case .loading(let song): - FrostedPanel { - VStack(spacing: 8) { - ProgressView() - .scaleEffect(0.8) - .tint(.white) - Text(song.title) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(1) - Text(song.artist) - .font(.system(size: 14)) - .foregroundStyle(.white.opacity(0.7)) - .lineLimit(1) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - .transition(.opacity) - case .playing(let song), .paused(let song): - FrostedPanel { - VStack(spacing: 4) { - Text(song.title) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(1) - Text(song.artist) - .font(.system(size: 14)) - .foregroundStyle(.white.opacity(0.7)) - .lineLimit(1) - - if FeatureFlags.showProgressBar { - PlaybackProgressBar( - currentTime: currentTime, - duration: duration - ) - .padding(.top, 8) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .padding(.horizontal, 8) - } - .transition(.opacity) - default: - FrostedPanel { - VStack(spacing: 8) { - Text("No songs yet") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - Text("Add some music to get started") - .font(.system(size: 14)) - .foregroundStyle(.white.opacity(0.7)) - Button(action: onAddTapped) { - Text("Add Songs") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.white) - .padding(.horizontal, 20) - .padding(.vertical, 8) - .background(.white.opacity(0.2)) - .clipShape(Capsule()) - } - .padding(.top, 4) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - } - } - } - - @ViewBuilder - private var overlayNowPlayingSection: some View { - switch player.playbackState { - case .loading(let song): - VStack(spacing: 12) { - ProgressView() - .scaleEffect(1.2) - .tint(.white) - OverlaySongInfo(title: song.title, artist: song.artist) - .opacity(0.7) - } - .transition(.opacity) - case .playing(let song), .paused(let song): - OverlaySongInfo(title: song.title, artist: song.artist) - .transition(.opacity) - default: - overlayEmptyStateView + .onChange(of: motionManager?.pitch) { _, _ in + updateHighlightOffset() } - } - - private var overlayEmptyStateView: some View { - VStack(spacing: 8) { - Text("No songs yet") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - - Text("Add some music to get started") - .font(.system(size: 14)) - .foregroundStyle(.white.opacity(0.7)) + .onChange(of: motionManager?.roll) { _, _ in + updateHighlightOffset() } - .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) - } - - private var themeSwipeGesture: some Gesture { - DragGesture(minimumDistance: 30) - .onChanged { value in - let translation = value.translation.width - // Add rubber-band resistance at edges - if (currentThemeIndex == 0 && translation > 0) || - (currentThemeIndex == ShuffleTheme.allThemes.count - 1 && translation < 0) { - dragOffset = translation * 0.3 // Resistance - } else { - dragOffset = translation - } - } - .onEnded { value in - let translation = value.translation.width - let velocity = value.predictedEndTranslation.width - - withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { - if translation < -swipeThreshold || velocity < -500 { - // Swipe left - next theme - if currentThemeIndex < ShuffleTheme.allThemes.count - 1 { - currentThemeIndex += 1 - HapticFeedback.light.trigger() - } else { - HapticFeedback.light.trigger() // Boundary bump - } - } else if translation > swipeThreshold || velocity > 500 { - // Swipe right - previous theme - if currentThemeIndex > 0 { - currentThemeIndex -= 1 - HapticFeedback.light.trigger() - } else { - HapticFeedback.light.trigger() // Boundary bump - } - } - dragOffset = 0 - } - } } - private var emptyStateView: some View { - VStack(spacing: 8) { - Text("No songs yet") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(currentTheme.textColor) + // MARK: - Actions - Text("Add some music to get started") - .font(.system(size: 14)) - .foregroundStyle(currentTheme.secondaryTextColor) - } + private func makeActions() -> PlayerActions { + PlayerActions( + onPlayPause: handlePlayPause, + onSkipForward: handleSkipForward, + onSkipBack: handleSkipBack, + onManage: onManageTapped, + onAdd: onAddTapped, + onSettings: onSettingsTapped + ) } - // MARK: - Actions - private func handlePlayPause() { Task { try? await player.togglePlayback() @@ -418,10 +123,8 @@ struct PlayerView: View { } } - // Update duration when song changes - duration = musicService.currentSongDuration + progressState?.refreshDuration() - // Extract color from album artwork if let song = newState.currentSong { colorExtractor.updateColor(for: song.id) } else { @@ -429,20 +132,6 @@ struct PlayerView: View { } } - // MARK: - Progress Timer - - private func startProgressTimer() { - progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - currentTime = musicService.currentPlaybackTime - duration = musicService.currentSongDuration - } - } - - private func stopProgressTimer() { - progressTimer?.invalidate() - progressTimer = nil - } - // MARK: - Motion private func updateHighlightOffset() { @@ -450,12 +139,14 @@ struct PlayerView: View { highlightOffset = MotionManager.highlightOffset( pitch: manager.pitch, roll: manager.roll, - sensitivity: currentTheme.motionSensitivity, + sensitivity: themeController.currentTheme.motionSensitivity, maxOffset: 220 ) } } +// MARK: - Previews + private final class PreviewMockMusicService: MusicService, @unchecked Sendable { let initialState: PlaybackState @@ -508,7 +199,6 @@ private let previewSong = Song( #Preview("Playing") { ZStack { - // Local asset for preview (network images don't load in previews) GeometryReader { geometry in Image("SampleAlbumArt") .resizable() @@ -520,7 +210,6 @@ private let previewSong = Song( } VStack(spacing: 0) { - // Top bar HStack { Button(action: {}) { Image(systemName: "music.note.list") @@ -554,7 +243,6 @@ private let previewSong = Song( Spacer() - // Song info panel - brushed metal ShuffleBodyView(highlightOffset: .zero, height: 120) { VStack(spacing: 4) { Text(previewSong.title) @@ -576,7 +264,6 @@ private let previewSong = Song( .padding(.horizontal, 20) .padding(.bottom, 12) - // Controls panel - brushed metal ShuffleBodyView(highlightOffset: .zero) { ClickWheelView( isPlaying: true,