diff --git a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/Extensions/Color.swift b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/Extensions/Color.swift new file mode 100644 index 00000000..62fbc3cb --- /dev/null +++ b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/Extensions/Color.swift @@ -0,0 +1,41 @@ +import SwiftUI + +public extension Color { + + func idealTextColor() -> Color { + guard let components = UIColor(self).cgColor.components else { + return .black + } + + let red = Double(components[0]) + let green = Double(components[1]) + let blue = Double(components[2]) + + let brightness = + (red * 299) + + (green * 587) + + (blue * 114) + + return brightness < 0.6 ? .white : .black + } +} + +public extension UIColor { + var perceivedBrightness: CGFloat { + var redComponent: CGFloat = 0 + var greenComponent: CGFloat = 0 + var blueComponent: CGFloat = 0 + var alphaComponent: CGFloat = 0 + + guard getRed(&redComponent, green: &greenComponent, blue: &blueComponent, alpha: &alphaComponent) else { + return 0.5 + } + + let brightness = + (redComponent * 299) + + (greenComponent * 587) + + (blueComponent * 114) + + return brightness / 1000 + } +} diff --git a/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/Extensions/UIImage.swift b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/Extensions/UIImage.swift new file mode 100644 index 00000000..e7b03042 --- /dev/null +++ b/MacMagazine/Features/MacMagazineLibrary/Sources/MacMagazineLibrary/Extensions/UIImage.swift @@ -0,0 +1,165 @@ +import UIKit + +public extension UIImage { + + func averageColor() -> UIColor? { + guard let cgImage = cgImage else { return nil } + + let targetSize = CGSize(width: 1, height: 1) + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue + + guard let context = CGContext( + data: nil, + width: Int(targetSize.width), + height: Int(targetSize.height), + bitsPerComponent: 8, + bytesPerRow: Int(targetSize.width) * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: bitmapInfo + ) else { + return nil + } + + context.interpolationQuality = .medium + context.draw(cgImage, in: CGRect(origin: .zero, size: targetSize)) + + guard let pixelBuffer = context.data else { return nil } + + let pixelPointer = pixelBuffer.bindMemory(to: UInt8.self, capacity: 4) + + let redComponent = CGFloat(pixelPointer[0]) / 255.0 + let greenComponent = CGFloat(pixelPointer[1]) / 255.0 + let blueComponent = CGFloat(pixelPointer[2]) / 255.0 + let alphaComponent = CGFloat(pixelPointer[3]) / 255.0 + + return UIColor( + red: redComponent, + green: greenComponent, + blue: blueComponent, + alpha: alphaComponent + ) + } + + func twoToneGradientColors() -> (UIColor, UIColor)? { + guard let averageColor = averageColor() else { return nil } + + var hueValue: CGFloat = 0 + var saturationValue: CGFloat = 0 + var brightnessValue: CGFloat = 0 + var alphaValue: CGFloat = 0 + + guard averageColor.getHue( + &hueValue, + saturation: &saturationValue, + brightness: &brightnessValue, + alpha: &alphaValue + ) else { + return (averageColor, averageColor) + } + + let lighterTopColor = UIColor( + hue: hueValue, + saturation: min(max(saturationValue * 0.9, 0.2), 1.0), + brightness: min(brightnessValue * 1.2, 1.0), + alpha: 1.0 + ) + + let darkerBottomColor = UIColor( + hue: hueValue, + saturation: min(max(saturationValue * 1.1, 0.25), 1.0), + brightness: max(brightnessValue * 0.5, 0.1), + alpha: 1.0 + ) + + return (lighterTopColor, darkerBottomColor) + } + + func threeToneGradientColors() -> [UIColor]? { + guard let averageColor = averageColor() else { return nil } + + var hueValue: CGFloat = 0 + var saturationValue: CGFloat = 0 + var brightnessValue: CGFloat = 0 + var alphaValue: CGFloat = 0 + + guard averageColor.getHue( + &hueValue, + saturation: &saturationValue, + brightness: &brightnessValue, + alpha: &alphaValue + ) else { + return [averageColor, averageColor, averageColor] + } + + let topColor = UIColor( + hue: hueValue, + saturation: min(max(saturationValue * 0.85, 0.18), 1.0), + brightness: min(brightnessValue * 1.25, 1.0), + alpha: 1.0 + ) + + let middleColor = UIColor( + hue: hueValue, + saturation: min(max(saturationValue * 1.0, 0.22), 1.0), + brightness: min(max(brightnessValue * 0.95, 0.15), 1.0), + alpha: 1.0 + ) + + let bottomColor = UIColor( + hue: hueValue, + saturation: min(max(saturationValue * 1.15, 0.28), 1.0), + brightness: max(brightnessValue * 0.45, 0.08), + alpha: 1.0 + ) + + return [topColor, middleColor, bottomColor] + } + + func fourToneGradientColors() -> [UIColor]? { + guard let averageColor = averageColor() else { return nil } + + var hueValue: CGFloat = 0 + var saturationValue: CGFloat = 0 + var brightnessValue: CGFloat = 0 + var alphaValue: CGFloat = 0 + + guard averageColor.getHue( + &hueValue, + saturation: &saturationValue, + brightness: &brightnessValue, + alpha: &alphaValue + ) else { + return [averageColor, averageColor, averageColor, averageColor] + } + + let topColor = UIColor( + hue: hueValue, + saturation: min(max(saturationValue * 0.8, 0.18), 1.0), + brightness: min(brightnessValue * 1.25, 1.0), + alpha: 1.0 + ) + + let upperMiddleColor = UIColor( + hue: hueValue, + saturation: min(max(saturationValue * 0.95, 0.20), 1.0), + brightness: min(max(brightnessValue * 1.05, 0.18), 1.0), + alpha: 1.0 + ) + + let lowerMiddleColor = UIColor( + hue: hueValue, + saturation: min(max(saturationValue * 1.1, 0.24), 1.0), + brightness: max(brightnessValue * 0.75, 0.12), + alpha: 1.0 + ) + + let bottomColor = UIColor( + hue: hueValue, + saturation: min(max(saturationValue * 1.2, 0.30), 1.0), + brightness: max(brightnessValue * 0.45, 0.08), + alpha: 1.0 + ) + + return [topColor, upperMiddleColor, lowerMiddleColor, bottomColor] + } +} diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Manager/PodcastPlayerManager.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Manager/PodcastPlayerManager.swift index e679e731..e9b7bfe8 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Manager/PodcastPlayerManager.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Manager/PodcastPlayerManager.swift @@ -3,16 +3,17 @@ import Combine import FeedLibrary import Foundation import MediaPlayer +import Observation import UIKit @MainActor @Observable -class PodcastPlayerManager { - var currentPodcast: PodcastDB? - var isPlaying: Bool = false - var currentTime: TimeInterval = 0 - var duration: TimeInterval = 0 - var playbackRate: Float = 1.0 +public class PodcastPlayerManager { + public var currentPodcast: PodcastDB? + public var isPlaying: Bool = false + public var currentTime: TimeInterval = 0 + public var duration: TimeInterval = 0 + public var playbackRate: Float = 1.0 private var player: AVPlayer? private var timeObserver: Any? @@ -20,8 +21,7 @@ class PodcastPlayerManager { private var isAudioSessionSetup = false private var isRemoteControlsSetup = false - init() { - } + public init() {} private func setupAudioSession() { guard !isAudioSessionSetup else { return } @@ -156,13 +156,13 @@ class PodcastPlayerManager { updateNowPlayingInfo() } - func pause() { + public func pause() { player?.pause() isPlaying = false updateNowPlayingInfo() } - func togglePlayPause() { + public func togglePlayPause() { if isPlaying { pause() } else { diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift index d624f1af..da6d028a 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift @@ -1,83 +1,270 @@ import FeedLibrary import MacMagazineLibrary +import MacMagazineUILibrary import SwiftUI import UIComponentsLibrary +import UtilityLibrary -struct PodcastPlayerView: View { +// MARK: - Estilo de degradê de fundo + +public enum PodcastBackgroundGradientStyle { + case twoTone + case threeTone + case fourTone +} + +// MARK: - PodcastPlayerView + +public struct PodcastPlayerView: View { @Environment(\.theme) private var theme - @Bindable var playerManager: PodcastPlayerManager + @Bindable private var playerManager: PodcastPlayerManager - var body: some View { - VStack(spacing: 0) { - if let podcast = playerManager.currentPodcast { - fullPlayerContent(podcast: podcast) + private let backgroundGradientStyle: PodcastBackgroundGradientStyle + + @State private var isShowingSpeedDialog = false + @State private var isUsingAdvancedSpeedControl = false + @State private var backgroundGradientColors: [Color] = [ + .black, + .black + ] + + @State private var isDarkBackground = false + @State private var hasAppeared = false + + // MARK: - Init + + public init( + playerManager: PodcastPlayerManager, + backgroundGradientStyle: PodcastBackgroundGradientStyle = .fourTone + ) { + self.playerManager = playerManager + self.backgroundGradientStyle = backgroundGradientStyle + } + + // MARK: - Body + + public var body: some View { + GeometryReader { proxy in + let size = proxy.size + let isPad = UIDevice.current.userInterfaceIdiom == .pad + let isPadPortrait = size.height >= size.width + + let horizontalPadding: CGFloat = { + guard isPad else { return 20 } + return isPadPortrait ? 80 : 300 + }() + + let artworkSize: CGFloat = isPad ? 640 : 320 + + ZStack { + LinearGradient( + gradient: Gradient(colors: backgroundGradientColors), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + if let podcast = playerManager.currentPodcast { + fullPlayerContent( + podcast: podcast, + artworkSize: artworkSize, + horizontalPadding: horizontalPadding + ) + } + } + } + .frame(width: size.width, height: size.height) + .preferredColorScheme(isDarkBackground ? .dark : .light) + .onAppear { + updateBackgroundGradient() + hasAppeared = true + } + .onChange(of: playerManager.currentPodcast?.artworkURL) { _, _ in + updateBackgroundGradient() } } - .background(.background) } + // MARK: - Layout principal + @ViewBuilder - private func fullPlayerContent(podcast: PodcastDB) -> some View { - VStack { - artworkView(artworkURL: URL(string: podcast.artworkURL)) - podcastTitle(podcast.title) - progressSlider - playbackControls + private func fullPlayerContent( + podcast: PodcastDB, + artworkSize: CGFloat, + horizontalPadding: CGFloat + ) -> some View { + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + Spacer() + + artworkView( + artworkURL: URL(string: podcast.artworkURL), + maxSize: artworkSize + ) + + Spacer() + + podcastTitle(podcast.title) + progressSlider + playbackControls + } + .padding(.horizontal, horizontalPadding) + .padding(.bottom, 30) } - .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } +// MARK: - Controles de volume / AirPlay + private extension PodcastPlayerView { + @ViewBuilder - func artworkView(artworkURL: URL?) -> some View { + func artworkView(artworkURL: URL?, maxSize: CGFloat) -> some View { if let artworkURL { + let haloColor: Color = isDarkBackground + ? .white.opacity(0.55) + : .black.opacity(0.55) + + let isPlaying = playerManager.isPlaying + CachedAsyncImage(image: artworkURL) - .cornerRadius(16) + .cornerRadius(24) + .overlay( + ShimmerHighlight( + cornerRadius: 24, + isActive: isPlaying + ) + ) + .frame(maxWidth: maxSize, maxHeight: maxSize) + .scaleEffect(isPlaying ? 1.05 : 0.8) + .shadow( + color: Color.black.opacity(isPlaying ? 0.3 : 0.0), + radius: 28, + x: 0, + y: 16 + ) + .background( + RoundedRectangle(cornerRadius: 28) + .fill(haloColor) + .blur(radius: 36) + .opacity(isPlaying ? 1.0 : 0.0) + .scaleEffect(1.18) + ) .padding(.bottom, 20) + .animation( + hasAppeared ? .easeInOut(duration: 0.35) : nil, + value: isPlaying + ) + } + } + + @ViewBuilder + func artworkHaloBackground( + maxSize: CGFloat, + cornerRadius: CGFloat, + accentColor: Color + ) -> some View { + if playerManager.isPlaying { + RoundedRectangle(cornerRadius: cornerRadius) + .fill( + RadialGradient( + colors: [ + accentColor.opacity(0.35), + accentColor.opacity(0.0) + ], + center: .center, + startRadius: 0, + endRadius: maxSize + ) + ) + .scaleEffect(1.35) + .blur(radius: 32) + .animation(.easeInOut(duration: 0.6), value: playerManager.isPlaying) + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.clear) } } @ViewBuilder func podcastTitle(_ title: String) -> some View { - HStack { + HStack(alignment: .center, spacing: 12) { Text(title) .font(.headline) - .lineLimit(4) + .lineLimit(3) .foregroundColor(.primary) - Spacer() + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity) + .padding(.bottom, 8) } @ViewBuilder var playbackControls: some View { - HStack(spacing: 40) { - speedButton - skipButton(systemName: "gobackward.15", action: { playerManager.skip(by: -15) }) - playPauseButton - skipButton(systemName: "goforward.15", action: { playerManager.skip(by: 15) }) + ZStack { + HStack { + speedButton + Spacer() + } + + HStack(spacing: 40) { + skipButton( + systemName: "gobackward.15", + action: { playerManager.skip(by: -15) } + ) + + playPauseButton + + skipButton( + systemName: "goforward.15", + action: { playerManager.skip(by: 15) } + ) + } } .tint(.primary) + .padding(.top, 24) } + // MARK: - Progress slider (com tempo restante correto) + @ViewBuilder - var progressSlider: some View { + private var progressSlider: some View { + let rawDuration = playerManager.duration + let safeDuration: Double = { + guard rawDuration.isFinite, + !rawDuration.isNaN, + rawDuration > 0 else { + return 0.1 + } + return rawDuration + }() + + let elapsedTime: TimeInterval = { + let currentTime = playerManager.currentTime + guard currentTime.isFinite, !currentTime.isNaN else { return 0 } + return min(max(0, currentTime), safeDuration) + }() + + let remainingTime: TimeInterval = max(0, safeDuration - elapsedTime) + VStack(spacing: 0) { Slider( value: Binding( - get: { playerManager.currentTime }, - set: { playerManager.seek(to: $0) } + get: { elapsedTime }, + set: { newValue in + playerManager.seek(to: newValue) + } ), - in: 0...max(playerManager.duration, 0.1) + in: 0...safeDuration ) .sliderThumbVisibility(.hidden) .tint(.primary) HStack { - Text(formatTime(playerManager.currentTime)) + Text(formatTime(elapsedTime)) Spacer() - Text(formatTime(playerManager.currentTime - playerManager.duration)) + Text("-" + formatTime(remainingTime)) } .font(.caption) .foregroundColor(.primary.opacity(0.6)) @@ -92,47 +279,182 @@ private extension PodcastPlayerView { } label: { Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") } - .font(.system(size: 46)) + .font(.system(size: 52)) } @ViewBuilder func skipButton(systemName: String, action: @escaping () -> Void) -> some View { - Button { - action() - } label: { + Button(action: action) { Image(systemName: systemName) } .font(.system(size: 28)) } } +// MARK: - Speed button + popup + private extension PodcastPlayerView { + @ViewBuilder var speedButton: some View { - Menu { - ForEach([0.75, 1.0, 1.25, 1.75, 2.0], id: \.self) { speed in + let currentSpeed = Double(playerManager.playbackRate) + let isBoosted = abs(currentSpeed - 1.0) > 0.001 + + let currentLabel = formattedSpeedForButton(currentSpeed) + + let widestLabel: String = { + let candidateSpeeds: [Double] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0] + let labels = candidateSpeeds.map { formattedSpeedForButton($0) } + return labels.max(by: { $0.count < $1.count }) ?? currentLabel + }() + + Button { + isUsingAdvancedSpeedControl = false + isShowingSpeedDialog = true + } label: { + ZStack { + Text(widestLabel) + .opacity(0) + + Text(currentLabel) + .opacity(0.6) + } + .font(.system(size: 15)) + .padding(.horizontal, 4) + .shadow( + color: isBoosted ? Color.primary.opacity(0.6) : .clear, + radius: isBoosted ? 2 : 0, + x: 0, + y: 0 + ) + } + .transaction { transaction in + transaction.animation = nil + } + .popover( + isPresented: $isShowingSpeedDialog, + attachmentAnchor: .rect(.bounds) + ) { + VStack(alignment: .leading, spacing: 12) { + Text("Velocidade de reprodução") + .font(.system(size: 17, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 18) + + Divider() + + speedPopoverContent + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 4) + .padding(.bottom, 2) + .presentationCompactAdaptation(.popover) + + Text( + isUsingAdvancedSpeedControl + ? "Ajuste a velocidade de reprodução" + : "Deslize para ver mais velocidades" + ) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 4) + .padding(.bottom, 10) // 🔽 bem menor que 18 + } + .padding(.horizontal, 20) + .dynamicTypeSize(.medium) + } + } + + var speedOptions: [Double] { + [0.75, 1.0, 1.25, 1.5, 2.0] + } + + @ViewBuilder + var speedPopoverContent: some View { + if isUsingAdvancedSpeedControl { + advancedSpeedControl + } else { + simpleSpeedChips + .highPriorityGesture( + DragGesture(minimumDistance: 8) + .onChanged { _ in + isUsingAdvancedSpeedControl = true + } + ) + } + } + + @ViewBuilder + var simpleSpeedChips: some View { + HStack(spacing: 10) { + ForEach(speedOptions, id: \.self) { speed in Button { playerManager.setPlaybackRate(Float(speed)) + hapticTick() } label: { - HStack { - Text("\(speed, specifier: "%.2g")×") + let isSelected = abs(speed - Double(playerManager.playbackRate)) < 0.001 + + ZStack { + Circle() + .fill( + isSelected + ? Color.accentColor + : Color.primary.opacity(0.05) + ) + .frame(width: 36, height: 36) + + Text(formattedSpeedForButton(speed)) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor( + isSelected + ? .white + : Color.primary.opacity(0.9) + ) } } + .buttonStyle(.plain) } - } label: { - Text("\(playerManager.playbackRate, specifier: "%.2g")x") - - } - .font(.system(size: 18)) - .transaction { transaction in - transaction.animation = nil } + .frame(width: 200) + .padding(.vertical, 8) + } + + @ViewBuilder + var advancedSpeedControl: some View { + SpeedWheelPicker( + value: Binding( + get: { Double(playerManager.playbackRate) }, + set: { newValue in + playerManager.setPlaybackRate(Float(newValue)) + hapticTick() + } + ), + minValue: 0.5, + maxValue: 3.0, + step: 0.1, + width: 220, + leftIcon: { + Image(systemName: "tortoise.fill") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + }, + rightIcon: { + Image(systemName: "hare.fill") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + ) + .padding(.vertical, 4) } } +// MARK: - Helpers (tempo, velocidade, haptic, degradê) + private extension PodcastPlayerView { + func formatTime(_ time: TimeInterval) -> String { - guard time.isFinite && !time.isNaN else { return "0:00" } + guard time.isFinite, !time.isNaN else { return "0:00" } + let hours = Int(time) / 3600 let minutes = Int(time) / 60 % 60 let seconds = Int(time) % 60 @@ -143,8 +465,158 @@ private extension PodcastPlayerView { return String(format: "%d:%02d", minutes, seconds) } } + + func formattedSpeedForButton(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.locale = .current + formatter.minimumFractionDigits = value == 1.0 ? 0 : 1 + formatter.maximumFractionDigits = 1 + + let base = formatter.string(from: NSNumber(value: value)) ?? String(value) + return base + "x" + } + + func hapticTick() { + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + } + + func updateBackgroundGradient() { + guard let artworkURLString = playerManager.currentPodcast?.artworkURL, + let artworkURL = URL(string: artworkURLString) else { + backgroundGradientColors = [.black, .black] + isDarkBackground = false + return + } + + let selectedStyle = backgroundGradientStyle + + Task(priority: .background) { + let result = await processArtworkColors( + from: artworkURL, + style: selectedStyle + ) + + await MainActor.run { + backgroundGradientColors = result.colors + isDarkBackground = result.isDark + } + } + } + + func processArtworkColors( + from artworkURL: URL, + style: PodcastBackgroundGradientStyle + ) async -> (colors: [Color], isDark: Bool) { + let request = URLRequest( + url: artworkURL, + cachePolicy: .returnCacheDataElseLoad + ) + + guard + let (imageData, _) = try? await URLSession.shared.data(for: request), + let artworkImage = UIImage(data: imageData) + else { + return ([.black, .black], false) + } + + var uiGradientColors: [UIColor] = [UIColor.black, UIColor.black] + + switch style { + case .fourTone: + if let fourToneColors = artworkImage.fourToneGradientColors() { + uiGradientColors = fourToneColors + } else if let threeToneColors = artworkImage.threeToneGradientColors() { + uiGradientColors = threeToneColors + } else if let twoToneTuple = artworkImage.twoToneGradientColors() { + uiGradientColors = [twoToneTuple.0, twoToneTuple.1] + } + + case .threeTone: + if let threeToneColors = artworkImage.threeToneGradientColors() { + uiGradientColors = threeToneColors + } else if let twoToneTuple = artworkImage.twoToneGradientColors() { + uiGradientColors = [twoToneTuple.0, twoToneTuple.1] + } + + case .twoTone: + if let twoToneTuple = artworkImage.twoToneGradientColors() { + uiGradientColors = [twoToneTuple.0, twoToneTuple.1] + } + } + + let swiftUIColors = uiGradientColors.map { Color(uiColor: $0) } + + let brightnessValues = uiGradientColors.map { $0.perceivedBrightness } + let totalBrightness = brightnessValues.reduce(0, +) + let averageBrightness = brightnessValues.isEmpty + ? CGFloat(0.5) + : totalBrightness / CGFloat(brightnessValues.count) + + let isDark = averageBrightness < 0.6 + + return (swiftUIColors, isDark) + } +} + +struct ShimmerHighlight: View { + let cornerRadius: CGFloat + let isActive: Bool + + @State private var phase: CGFloat = -1.0 + + var body: some View { + GeometryReader { geo in + let width = geo.size.width + + RoundedRectangle(cornerRadius: cornerRadius) + .fill( + LinearGradient( + colors: [ + Color.white.opacity(0.0), + Color.white.opacity(0.7), + Color.white.opacity(0.0) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .scaleEffect(x: 0.45, y: 1.0, anchor: .center) + .rotationEffect(.degrees(22)) + .offset(x: phase * width) + .mask( + RoundedRectangle(cornerRadius: cornerRadius) + ) + .opacity(isActive ? 1 : 0) + .onAppear { + guard isActive else { return } + startAnimation(width: width) + } + .onChange(of: isActive) { _, newValue in + if newValue { + startAnimation(width: width) + } else { + withAnimation(.linear(duration: 0.2)) { + phase = -1.0 + } + } + } + } + } + + private func startAnimation(width: CGFloat) { + phase = -1.0 + withAnimation( + .linear(duration: 1.8) + .repeatForever(autoreverses: false) + ) { + phase = 1.4 + } + } } +// MARK: - Preview + #Preview { @Previewable @State var playerManager = PodcastPlayerManager() @@ -155,18 +627,21 @@ private extension PodcastPlayerView { pubDate: Date(), artworkURL: "https://macmagazine.com.br/wp-content/uploads/2025/11/28-podcast-1260x709.jpg", podcastURL: "https://traffic.libsyn.com/secure/macmagazine/MacMagazine_no_Ar_001.mp3", - podcastSize: 50000000, + podcastSize: 50_000_000, duration: "45:30", podcastFrame: "", favorite: false, playable: true ) - PodcastPlayerView(playerManager: playerManager) - .environment(\.theme, ThemeColor()) - .onAppear { - playerManager.currentPodcast = mockPodcast - playerManager.duration = 2730 // 45:30 in seconds - playerManager.currentTime = 450 // 7:30 in seconds - } + PodcastPlayerView( + playerManager: playerManager, + backgroundGradientStyle: .fourTone + ) + .environment(\.theme, ThemeColor()) + .onAppear { + playerManager.currentPodcast = mockPodcast + playerManager.duration = 2_730 + playerManager.currentTime = 450 + } } diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastView.swift index 14fa71a7..7929a87b 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastView.swift @@ -53,7 +53,8 @@ public struct PodcastView: View { } .sheet(isPresented: $showFullPlayer) { - PodcastPlayerView(playerManager: playerManager) + PodcastPlayerView(playerManager: playerManager, + backgroundGradientStyle: .fourTone) .presentationDragIndicator(.visible) } } diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/ScrollStylePicker.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/ScrollStylePicker.swift new file mode 100644 index 00000000..3d52131a --- /dev/null +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/ScrollStylePicker.swift @@ -0,0 +1,202 @@ +import SwiftUI + +struct SpeedWheelPicker: View { + @Binding var value: Double + + private let minValue: Double + private let maxValue: Double + private let step: Double + private let width: CGFloat + + private let leftIcon: LeftIcon + private let rightIcon: RightIcon + + private let tickSpacing: CGFloat = 12 + private let majorModulo: Int = 5 + private let tickHeightMajor: CGFloat = 18 + private let tickHeightMinor: CGFloat = 10 + + @State private var isLoaded = false + + init( + value: Binding, + minValue: Double, + maxValue: Double, + step: Double, + width: CGFloat, + @ViewBuilder leftIcon: () -> LeftIcon, + @ViewBuilder rightIcon: () -> RightIcon + ) { + _value = value + self.minValue = minValue + self.maxValue = maxValue + self.step = step + self.width = width + self.leftIcon = leftIcon() + self.rightIcon = rightIcon() + } + + var body: some View { + HStack(spacing: 6) { + Button { stepDown() } label: { leftIcon } + .buttonStyle(.plain) + + GeometryReader { geo in + let size = geo.size + let horizontalPadding = size.width / 2 + + ScrollView(.horizontal) { + HStack(spacing: tickSpacing) { + let totalSteps = Int(round((maxValue - minValue) / step)) + + ForEach(0...totalSteps, id: \.self) { idx in + let val = minValue + Double(idx) * step + let major = idx % majorModulo == 0 + + Divider() + .background( + major + ? Color.primary + : Color.secondary.opacity(0.5) + ) + .frame( + width: 0, + height: major ? tickHeightMajor : tickHeightMinor + ) + .overlay(alignment: .bottom) { + if major { + Text(formattedLabel(val)) + .font(.system(size: 11)) + .fixedSize() + .offset(y: 24) + } + } + } + } + .offset(y: -18) + .frame(height: tickHeightMajor + 18) + .scrollTargetLayout() + } + .scrollIndicators(.hidden) + .scrollTargetBehavior(.viewAligned) + .scrollPosition(id: scrollBinding) + .safeAreaPadding(.horizontal, horizontalPadding) + .overlay(alignment: .center) { + Rectangle() + .fill(Color.black) + .offset(y: -12) + .frame(width: 1, height: 30) + } + .onAppear { + DispatchQueue.main.async { + isLoaded = true + } + } + } + .frame(height: 40) + + Button { stepUp() } label: { rightIcon } + .buttonStyle(.plain) + } + .frame(width: width, height: 40) + .dynamicTypeSize(.medium) + } + + // MARK: - SCROLL BINDING + + private var scrollBinding: Binding { + Binding( + get: { + guard isLoaded else { return nil } + return index(for: value) + }, + set: { newIndex in + guard let newIndex else { return } + let total = Int(round((maxValue - minValue) / step)) + let clamped = min(max(newIndex, 0), total) + let new = minValue + Double(clamped) * step + if abs(new - value) > 0.0001 { + value = new + } + } + ) + } + + // MARK: - HELPERS + + private func index(for value: Double) -> Int { + let clamped = min(max(value, minValue), maxValue) + let raw = (clamped - minValue) / step + return Int(round(raw)) + } + + private func formattedLabel(_ val: Double) -> String { + let numberFormatter = NumberFormatter() + numberFormatter.locale = .current + numberFormatter.minimumFractionDigits = 1 + numberFormatter.maximumFractionDigits = 1 + return (numberFormatter.string(from: NSNumber(value: val)) ?? "\(val)") + "x" + } + + private func roundedToOneDecimal(_ value: Double) -> Double { + let scale = 10.0 + return (value * scale).rounded() / scale + } + + private func stepDown() { + let decremented = value - step + let clamped = max(minValue, decremented) + let new = roundedToOneDecimal(clamped) + + if abs(new - value) > 0.0001 { + value = new + haptic() + } + } + + private func stepUp() { + let incremented = value + step + let clamped = min(maxValue, incremented) + let new = roundedToOneDecimal(clamped) + + if abs(new - value) > 0.0001 { + value = new + haptic() + } + } + + private func haptic() { + let haptic = UIImpactFeedbackGenerator(style: .light) + haptic.impactOccurred() + } +} + +#Preview { + struct Wrapper: View { + @State var value: Double = 1.0 + + var body: some View { + VStack(spacing: 16) { + SpeedWheelPicker( + value: $value, + minValue: 0.5, + maxValue: 3.0, + step: 0.1, + width: 280, + leftIcon: { + Image(systemName: "tortoise.fill") + .font(.system(size: 14)) + .foregroundStyle(.primary) + }, + rightIcon: { + Image(systemName: "hare.fill") + .font(.system(size: 14)) + .foregroundStyle(.primary) + } + ) + } + .padding() + } + } + return Wrapper() +}