diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift new file mode 100644 index 00000000..f6d15ace --- /dev/null +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift @@ -0,0 +1,69 @@ +import MacMagazineLibrary +import SwiftUI +import UIComponentsLibrary + +public struct PodcastMiniPlayerModifier: ViewModifier { + @Environment(PodcastPlayerManager.self) private var manager + @Environment(\.shouldUseSidebar) private var shouldUseSidebar + @Namespace private var animation + + private let isAllowedToShow: () -> Bool + + public init( + isAllowedToShow: @escaping () -> Bool + ) { + self.isAllowedToShow = isAllowedToShow + } + + public func body(content: Content) -> some View { + let hasPodcast = manager.currentPodcast != nil + let canShowInContext = isAllowedToShow() + let shouldShowMini = hasPodcast && canShowInContext + + return Group { + if shouldShowMini { + if shouldUseSidebar { + content + .safeAreaInset(edge: .bottom, spacing: 16) { + if let current = manager.currentPodcast { + MediumPlayerView( + playerManager: manager, + currentPodcast: current + ) + .frame(maxWidth: 550) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 20) + } + } + } else { + content + .tabBarMinimizeBehavior(.onScrollDown) + .tabViewBottomAccessory { + if let current = manager.currentPodcast { + MiniPlayerView( + playerManager: manager, + currentPodcast: current + ) + .matchedTransitionSource(id: "MINIPLAYER", in: animation) + .padding(.horizontal, 8) + } + } + } + } else { + content + } + } + } +} + +public extension View { + func podcastMiniPlayer( + isAllowedToShow: @escaping () -> Bool = { true } + ) -> some View { + self.modifier( + PodcastMiniPlayerModifier( + isAllowedToShow: isAllowedToShow + ) + ) + } +} diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MediumPlayerView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MediumPlayerView.swift new file mode 100644 index 00000000..cc825ff0 --- /dev/null +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MediumPlayerView.swift @@ -0,0 +1,131 @@ +import FeedLibrary +import MacMagazineLibrary +import SwiftUI +import UIComponentsLibrary + +struct MediumPlayerView: View { + @Environment(\.theme) private var theme + + @State private var offset: CGSize = .zero + @Bindable var playerManager: PodcastPlayerManager + + let currentPodcast: PodcastDB + + var body: some View { + mediumPlayerContent(podcast: currentPodcast) + .offset(y: offset.height) + .gesture( + DragGesture() + .onChanged { value in + let height = value.translation.height + offset = CGSize(width: 0, height: max(height, 0)) + } + .onEnded { _ in + let finalOffset = offset.height + + if finalOffset > 80 { + if playerManager.isPlaying { + playerManager.pause() + } + playerManager.currentPodcast = nil + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + offset = .zero + } + } + } + ) + } +} + +private extension MediumPlayerView { + func mediumPlayerContent(podcast: PodcastDB) -> some View { + Group { + HStack(spacing: 8) { + artwork(podcast.artworkURL) + + VStack(alignment: .leading, spacing: 0) { + Ticker(text: podcast.title, speed: 30) + .frame(height: 30) + .id(podcast.id) + + Text("MacMagazine no Ar") + .font(.system(size: 16)) + .lineLimit(1) + .fixedSize() + .foregroundStyle(.gray) + .offset(y: -4) + } + + Spacer() + + HStack(spacing: 24) { + Button { + playerManager.skip(by: -15) + } label: { + Image(systemName: "gobackward.15") + .font(.system(size: 24)) + } + + Button { + playerManager.togglePlayPause() + } label: { + Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 40)) + } + + Button { + playerManager.skip(by: 15) + } label: { + Image(systemName: "goforward.15") + .font(.system(size: 24)) + } + } + } + .foregroundColor(.primary) + .padding(.horizontal, 18) + .padding(.vertical, 12) + } + .glassEffect(.regular) + } + + @ViewBuilder + func artwork(_ artworkURL: String) -> some View { + if let url = URL(string: artworkURL) { + CachedAsyncImage(image: url) + .scaledToFill() + .clipShape(Circle()) + .frame(width: 60, height: 60) + } + } +} + +#Preview { + @Previewable @State var playerManager = PodcastPlayerManager() + + let mockPodcast = PodcastDB( + postId: "1", + title: "MacMagazine no Ar #123: Especial WWDC 2024", + subtitle: "Neste episódio especial, discutimos todas as novidades anunciadas na WWDC 2024", + 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: 50_000_000, + duration: "45:30", + podcastFrame: "", + favorite: false, + playable: true + ) + + ZStack { + LinearGradient(colors: [.blue, .purple], startPoint: .top, endPoint: .bottom).ignoresSafeArea() + } + .safeAreaInset(edge: .bottom, spacing: 16) { + MediumPlayerView( + playerManager: playerManager, + currentPodcast: mockPodcast + ) + .padding() + } + .environment(\.theme, ThemeColor()) +} diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift index 356ba997..3467c54d 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift @@ -2,56 +2,60 @@ import FeedLibrary import SwiftUI import UIComponentsLibrary -struct MiniPlayerView: View { +public enum PodcastMiniPlayerLayout { + case tabBar + case sidebar +} + +public struct MiniPlayerView: View { @Environment(\.theme) private var theme + @Environment(\.shouldUseSidebar) private var shouldUseSidebar - @State private var offset: CGSize = .zero @Bindable var playerManager: PodcastPlayerManager let currentPodcast: PodcastDB - let onTap: () -> Void - var body: some View { + public init( + playerManager: PodcastPlayerManager, + currentPodcast: PodcastDB + ) { + self.playerManager = playerManager + self.currentPodcast = currentPodcast + } + + public var body: some View { miniPlayerContent(podcast: currentPodcast) - .offset(y: offset.height) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .gesture(dragToDismiss) + } - .onTapGesture { - onTap() - } + private var dragToDismiss: some Gesture { + DragGesture(minimumDistance: 20) + .onEnded { value in + let vertical = value.translation.height - .gesture( - DragGesture() - .onChanged { value in - offset = value.translation + if vertical > 100 { + if playerManager.isPlaying { + playerManager.pause() } - .onEnded { value in - if value.translation.height > 80 { - if playerManager.isPlaying { - playerManager.pause() - } - playerManager.currentPodcast = nil - } else { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - offset = .zero - } - } - } - ) + playerManager.currentPodcast = nil + } + } } } private extension MiniPlayerView { func miniPlayerContent(podcast: PodcastDB) -> some View { - Group { - HStack { + HStack { + HStack(spacing: 8) { artwork(podcast.artworkURL) Ticker(text: podcast.title, speed: 30) .frame(height: 30) .id(podcast.id) - Spacer() - HStack(spacing: 20) { Button { playerManager.skip(by: -15) @@ -75,16 +79,9 @@ private extension MiniPlayerView { } } } - .foregroundColor(.primary) - .padding(8) - } - .padding() - .background { - Capsule() - .fill(.bar) - .glassEffect(.clear) - .padding() + .contentShape(Rectangle()) } + .foregroundColor(.black) } @ViewBuilder @@ -96,5 +93,4 @@ private extension MiniPlayerView { .frame(width: 30, height: 30) } } - } diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift deleted file mode 100644 index da6d028a..00000000 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift +++ /dev/null @@ -1,647 +0,0 @@ -import FeedLibrary -import MacMagazineLibrary -import MacMagazineUILibrary -import SwiftUI -import UIComponentsLibrary -import UtilityLibrary - -// 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 private var playerManager: PodcastPlayerManager - - 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() - } - } - } - - // MARK: - Layout principal - - @ViewBuilder - 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) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - Controles de volume / AirPlay - -private extension PodcastPlayerView { - - @ViewBuilder - 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(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(alignment: .center, spacing: 12) { - Text(title) - .font(.headline) - .lineLimit(3) - .foregroundColor(.primary) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.bottom, 8) - } - - @ViewBuilder - var playbackControls: some View { - 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 - 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: { elapsedTime }, - set: { newValue in - playerManager.seek(to: newValue) - } - ), - in: 0...safeDuration - ) - .sliderThumbVisibility(.hidden) - .tint(.primary) - - HStack { - Text(formatTime(elapsedTime)) - Spacer() - Text("-" + formatTime(remainingTime)) - } - .font(.caption) - .foregroundColor(.primary.opacity(0.6)) - } - .padding(.bottom) - } - - @ViewBuilder - var playPauseButton: some View { - Button { - playerManager.togglePlayPause() - } label: { - Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") - } - .font(.system(size: 52)) - } - - @ViewBuilder - func skipButton(systemName: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: systemName) - } - .font(.system(size: 28)) - } -} - -// MARK: - Speed button + popup - -private extension PodcastPlayerView { - - @ViewBuilder - var speedButton: some View { - 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: { - 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) - } - } - .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" } - - let hours = Int(time) / 3600 - let minutes = Int(time) / 60 % 60 - let seconds = Int(time) % 60 - - if hours > 0 { - return String(format: "%d:%02d:%02d", hours, minutes, seconds) - } else { - 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() - - let mockPodcast = PodcastDB( - postId: "1", - title: "MacMagazine no Ar #123: Especial WWDC 2024", - subtitle: "Neste episódio especial, discutimos todas as novidades anunciadas na WWDC 2024", - 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: 50_000_000, - duration: "45:30", - podcastFrame: "", - favorite: false, - playable: true - ) - - 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/Player/Ticker.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/Ticker.swift index 2e9938fd..1d8e685a 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/Ticker.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/Ticker.swift @@ -11,7 +11,7 @@ struct Ticker: View { TimelineView(.animation) { timeline in GeometryReader { geometry in Text(text) - .font(.headline) + .font(.system(size: 16)) .lineLimit(1) .fixedSize() .frame(height: geometry.size.height) diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastView.swift index be634143..34bbd2a9 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastView.swift @@ -7,6 +7,7 @@ import UIComponentsLibrary public struct PodcastView: View { @Environment(\.theme) private var theme: ThemeColor + @Environment(PodcastPlayerManager.self) private var podcastPlayerManager @Environment(\.modelContext) private var modelContext @Environment(SessionState.self) private var sessionState var viewModel: PodcastViewModel @@ -15,8 +16,6 @@ public struct PodcastView: View { @Binding var scrollPosition: ScrollPosition @State private var search: String = "" - @State private var playerManager = PodcastPlayerManager() - @State private var showFullPlayer = false @Query private var podcasts: [PodcastDB] @@ -33,37 +32,24 @@ public struct PodcastView: View { let predicate = #Predicate { $0.favorite == favorite } - _podcasts = Query(filter: favorite ? predicate : nil, - sort: \PodcastDB.pubDate, - order: .reverse, - animation: .smooth) + _podcasts = Query( + filter: favorite ? predicate : nil, + sort: \PodcastDB.pubDate, + order: .reverse, + animation: .smooth + ) } public var body: some View { - content.overlay { - miniPlayer - .animation(.spring(response: 0.4, - dampingFraction: 0.8), - value: playerManager.currentPodcast?.id) - .frame(maxWidth: 420) - } - - .task { - // Only fetch if not yet fetched this session - if viewModel.status == .idle && !sessionState.hasFetchedPodcasts { - try? await viewModel.getPodcasts() - sessionState.hasFetchedPodcasts = true + content + .task { + if viewModel.status == .idle && !sessionState.hasFetchedPodcasts { + try? await viewModel.getPodcasts() + sessionState.hasFetchedPodcasts = true + } } - } - - .sheet(isPresented: $showFullPlayer) { - PodcastPlayerView(playerManager: playerManager, - backgroundGradientStyle: .fourTone) - .presentationDragIndicator(.visible) - } } } - extension PodcastView { @ViewBuilder var content: some View { @@ -84,27 +70,11 @@ extension PodcastView { content: { ForEach(podcasts) { podcast in AdaptivePodcastCardView(podcast: podcast.toCardContent(using: modelContext)) { - playerManager.loadPodcast(podcast) + podcastPlayerManager.loadPodcast(podcast) } } }, retryAction: favorite ? nil : retryAction ) } - - @ViewBuilder - var miniPlayer: some View { - if let currentPodcast = playerManager.currentPodcast { - VStack { - Spacer() - MiniPlayerView( - playerManager: playerManager, - currentPodcast: currentPodcast - ) { - showFullPlayer = true - } - } - .transition(.move(edge: .bottom)) - } - } } diff --git a/MacMagazine/MacMagazine/MainApp/MacMagazineApp.swift b/MacMagazine/MacMagazine/MainApp/MacMagazineApp.swift index 33b4a5b3..e47fccff 100644 --- a/MacMagazine/MacMagazine/MainApp/MacMagazineApp.swift +++ b/MacMagazine/MacMagazine/MainApp/MacMagazineApp.swift @@ -1,3 +1,4 @@ +import PodcastLibrary import SettingsLibrary import StorageLibrary import SwiftData @@ -7,6 +8,7 @@ import UIKit @main struct MacMagazineApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @State private var podcastPlayerManager = PodcastPlayerManager() @State var viewModel = MainViewModel() var body: some Scene { @@ -15,6 +17,7 @@ struct MacMagazineApp: App { .modelContainer(viewModel.storage.sharedModelContainer) .environment(viewModel) .environment(viewModel.settingsViewModel) + .environment(podcastPlayerManager) .environment(viewModel.sessionState) .preferredColorScheme(viewModel.settingsViewModel.colorSchema) .task { diff --git a/MacMagazine/MacMagazine/MainApp/MainView.swift b/MacMagazine/MacMagazine/MainApp/MainView.swift index 64eba45d..5abdf041 100644 --- a/MacMagazine/MacMagazine/MainApp/MainView.swift +++ b/MacMagazine/MacMagazine/MainApp/MainView.swift @@ -1,4 +1,5 @@ import MacMagazineLibrary +import PodcastLibrary import SettingsLibrary import SwiftUI import UIComponentsLibrary @@ -9,6 +10,7 @@ struct MainView: View { @Environment(\.shouldUseSidebar) private var shouldUseSidebar @Environment(\.theme) private var theme: ThemeColor @Environment(MainViewModel.self) var viewModel + @Environment(PodcastPlayerManager.self) private var podcastManager @State var searchText: String = "" @@ -29,25 +31,56 @@ struct MainView: View { viewModel: viewModel ) } + .onAppear { viewModel.settingsViewModel.updateTabs(currentTab: $bindableViewModel.tab) } + + .onChange(of: viewModel.social) { _, newValue in + let isInSocialContext: Bool + if shouldUseSidebar { + isInSocialContext = navigationState.selectedItem is Social + } else { + isInSocialContext = (viewModel.tab == .social) + } + + if isInSocialContext, + newValue == .videos || newValue == .instagram { + + if podcastManager.isPlaying { + podcastManager.pause() + } + } + } } + // MARK: - Layout root + @ViewBuilder var content: some View { if shouldUseSidebar { sideBarContentView } else { tabContentView + .podcastMiniPlayer { + if viewModel.tab == .social { + return viewModel.social == .podcast + } else { + return true + } + } } } } #if DEBUG #Preview { + let viewModel = MainViewModel(inMemory: true) + let podcastManager = PodcastPlayerManager() + MainView() .environment(\.theme, ThemeColor()) - .environment(MainViewModel(inMemory: true)) + .environment(viewModel) + .environment(podcastManager) } #endif diff --git a/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+sidebar.swift b/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+sidebar.swift index ceb2c309..97047916 100644 --- a/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+sidebar.swift +++ b/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+sidebar.swift @@ -1,4 +1,5 @@ import MMLiveLibrary +import PodcastLibrary import SettingsLibrary import SwiftUI @@ -9,13 +10,22 @@ extension MainView { .searchable(text: $searchText, prompt: "Search items") } detail: { animateContentStackView(for: navigationState.selectedItem) + .podcastMiniPlayer { + if let social = navigationState.selectedItem as? Social { + return social == .podcast + } else { + return true + } + } } .navigationSplitViewStyle(.balanced) } var sidebar: some View { - List(selection: Binding(get: { id(for: navigationState.selectedItem) }, - set: { _ in })) { + List(selection: Binding( + get: { id(for: navigationState.selectedItem) }, + set: { _ in } + )) { ForEach(viewModel.settingsViewModel.tabs, id: \.self) { tab in switch tab { case .news: news(tab: tab).id(id(for: tab)) @@ -59,7 +69,7 @@ private extension MainView { Section { ForEach(viewModel.settingsViewModel.social, id: \.self) { option in show(destination: option, title: option.rawValue, icon: option.icon) - .padding(.leading) + .padding(.leading) } } header: { Text(tab.rawValue) diff --git a/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift b/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift index dc7503fa..03c6b26b 100644 --- a/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift +++ b/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift @@ -3,13 +3,19 @@ import SettingsLibrary import SwiftUI extension MainView { + @ViewBuilder var tabContentView: some View { @Bindable var bindableViewModel = viewModel TabView(selection: $bindableViewModel.tab) { ForEach(viewModel.settingsViewModel.tabs, id: \.self) { tab in - Tab(tab.rawValue, systemImage: tab.icon, value: tab, role: tab == .search ? .search : .none) { + Tab( + tab.rawValue, + systemImage: tab.icon, + value: tab, + role: tab == .search ? .search : .none + ) { NavigationStack { AnyView(contentView(for: tab)) }