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
57 changes: 57 additions & 0 deletions Shfl/Views/Components/ClassicPlayerLayout.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 24 additions & 0 deletions Shfl/Views/Components/PlaybackStateContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import SwiftUI

/// Generic view that eliminates duplicate switch statements on PlaybackState
/// by providing loading/active/empty ViewBuilder slots
struct PlaybackStateContent<Loading: View, Active: View, Empty: View>: 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)
}
}
}
11 changes: 11 additions & 0 deletions Shfl/Views/Components/PlayerActions.swift
Original file line number Diff line number Diff line change
@@ -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
}
26 changes: 26 additions & 0 deletions Shfl/Views/Components/PlayerControlsPanel.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
34 changes: 34 additions & 0 deletions Shfl/Views/Components/PlayerProgressState.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
43 changes: 43 additions & 0 deletions Shfl/Views/Components/PlayerTopBar.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
89 changes: 89 additions & 0 deletions Shfl/Views/Components/SongInfoDisplay.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
59 changes: 59 additions & 0 deletions Shfl/Views/Components/ThemeController.swift
Original file line number Diff line number Diff line change
@@ -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..<ShuffleTheme.allThemes.count)
}

func makeSwipeGesture() -> 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
}
}
}
}
Loading
Loading