From 04a5c0926b7b9a803a5e4cd3be9112bb73f7fbac Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Thu, 11 Dec 2025 12:35:39 -0300 Subject: [PATCH 1/4] Version iPhone --- .../Modifiers/PodcastMiniPlayerModifier.swift | 79 +++++++++++++ .../Podcast/Views/Player/MiniPlayerView.swift | 105 ++++++++---------- .../Views/Player/PodcastPlayerView.swift | 62 ----------- .../Sources/Podcast/Views/Player/Ticker.swift | 2 +- .../Podcast/Views/PodcastFullScreenView.swift | 46 ++++++++ .../Sources/Podcast/Views/PodcastView.swift | 58 +++------- .../MacMagazine/MainApp/MacMagazineApp.swift | 3 + .../MacMagazine/MainApp/MainView.swift | 28 ++++- .../MainApp/Sizecalss/MainView+tabbar.swift | 7 +- 9 files changed, 218 insertions(+), 172 deletions(-) create mode 100644 MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift create mode 100644 MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastFullScreenView.swift 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..3872a1c7 --- /dev/null +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift @@ -0,0 +1,79 @@ +import MacMagazineLibrary +import SwiftUI +import UIComponentsLibrary + +public struct PodcastMiniPlayerModifier: ViewModifier { + @Environment(PodcastPlayerManager.self) private var manager + @Namespace private var animation + + @State private var showFullPlayer = false + + 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 + + let base = content + .fullScreenCover(isPresented: $showFullPlayer) { + PodcastFullScreenView( + manager: manager, + animation: animation, + onDismiss: { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + } + } + ) + } + .onChange(of: manager.currentPodcast?.id) { _, newID in + if newID == nil { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = false + } + } + } + + return Group { + if shouldShowMini { + base + .tabBarMinimizeBehavior(.onScrollDown) + .tabViewBottomAccessory { + if let current = manager.currentPodcast { + MiniPlayerView( + playerManager: manager, + currentPodcast: current + ) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + showFullPlayer = true + } + } + .matchedTransitionSource(id: "MINIPLAYER", in: animation) + .padding(.horizontal, 8) + } + } + } else { + base // sem accessory, sem espaço sobrando + } + } + } +} + +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/MiniPlayerView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift index 356ba997..bda2f793 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift @@ -2,89 +2,77 @@ 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 - @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, + onTap: @escaping () -> Void + ) { + self.playerManager = playerManager + self.currentPodcast = currentPodcast + self.onTap = onTap + } + + 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 - } - .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 - } - } + if vertical > 100 { + if playerManager.isPlaying { + playerManager.pause() } - ) + 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) + } + .contentShape(Rectangle()) + .onTapGesture { + onTap() + } - Spacer() - - HStack(spacing: 20) { - Button { - playerManager.skip(by: -15) - } label: { - Image(systemName: "gobackward.15") - .font(.system(size: 20)) - } + Spacer() - Button { - playerManager.togglePlayPause() - } label: { - Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 24)) - } - - Button { - playerManager.skip(by: 15) - } label: { - Image(systemName: "goforward.15") - .font(.system(size: 20)) - } - } + Button { + playerManager.togglePlayPause() + } label: { + Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 24)) } - .foregroundColor(.primary) - .padding(8) - } - .padding() - .background { - Capsule() - .fill(.bar) - .glassEffect(.clear) - .padding() } + .foregroundColor(.black) } @ViewBuilder @@ -96,5 +84,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 index da6d028a..cfce60f2 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift @@ -131,12 +131,6 @@ private extension PodcastPlayerView { CachedAsyncImage(image: artworkURL) .cornerRadius(24) - .overlay( - ShimmerHighlight( - cornerRadius: 24, - isActive: isPlaying - ) - ) .frame(maxWidth: maxSize, maxHeight: maxSize) .scaleEffect(isPlaying ? 1.05 : 0.8) .shadow( @@ -559,62 +553,6 @@ private extension PodcastPlayerView { } } -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 { 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/PodcastFullScreenView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastFullScreenView.swift new file mode 100644 index 00000000..ac443a1f --- /dev/null +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastFullScreenView.swift @@ -0,0 +1,46 @@ +// +// PodcastFullScreenView.swift +// PodcastLibrary +// +// Created by Renato Ferraz on 11/12/25. +// + +import MacMagazineLibrary +import SwiftUI +import UIComponentsLibrary + +public struct PodcastFullScreenView: View { + @Environment(\.theme) private var theme: ThemeColor + + let manager: PodcastPlayerManager + let animation: Namespace.ID + let onDismiss: () -> Void + + public init( + manager: PodcastPlayerManager, + animation: Namespace.ID, + onDismiss: @escaping () -> Void + ) { + self.manager = manager + self.animation = animation + self.onDismiss = onDismiss + } + + public var body: some View { + ZStack { + PodcastPlayerView(playerManager: manager) + .ignoresSafeArea() + .navigationTransition(.zoom(sourceID: "MINIPLAYER", in: animation)) + + VStack(spacing: 0) { + Capsule() + .fill(Color.secondary.opacity(0.6)) + .frame(width: 60, height: 4) + .padding(.top, 8) + .padding(.bottom, 8) + + Spacer() + } + } + } +} 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..1fd97229 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 = "" @@ -22,6 +24,14 @@ struct MainView: View { transaction.disablesAnimations = true } + .podcastMiniPlayer { + if viewModel.tab == .social { + return viewModel.social == .podcast + } else { + return true + } + } + .onChange(of: horizontalSizeClass) { old, new in navigationState.navigate( from: old, @@ -29,18 +39,26 @@ struct MainView: View { viewModel: viewModel ) } + .onAppear { viewModel.settingsViewModel.updateTabs(currentTab: $bindableViewModel.tab) } + + .onChange(of: viewModel.social) { _, newValue in + if viewModel.tab == .social, + newValue == .videos || newValue == .instagram { + if podcastManager.isPlaying { + podcastManager.pause() + } + } + } } + // MARK: - Layout root + @ViewBuilder var content: some View { - if shouldUseSidebar { - sideBarContentView - } else { - tabContentView - } + tabContentView } } diff --git a/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift b/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift index dc7503fa..21e591bb 100644 --- a/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift +++ b/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift @@ -9,7 +9,12 @@ extension MainView { 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)) } From f862b2a5ac3ab94af31d63e89317641f48c14d54 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Thu, 11 Dec 2025 14:47:43 -0300 Subject: [PATCH 2/4] Version for ipad, not finished --- .../Definitions/AppDefinitions.swift | 2 +- .../Features/Social/SocialView.swift | 4 +- .../MacMagazine/MainApp/MainView.swift | 16 ++ .../MainApp/Sizecalss/MainView+tabbar.swift | 173 ++++++++++++++++-- 4 files changed, 176 insertions(+), 19 deletions(-) diff --git a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/AppDefinitions.swift b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/AppDefinitions.swift index 6d0512dc..3cba97e3 100644 --- a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/AppDefinitions.swift +++ b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/AppDefinitions.swift @@ -2,11 +2,11 @@ import Foundation public enum AppTabs: String, CaseIterable, Codable, Equatable { + case search = "Busca" case live = "MM Live" case news = "Notícias" case social = "Mídias" case settings = "Ajustes" - case search = "Busca" public var icon: String { switch self { diff --git a/MacMagazine/MacMagazine/Features/Social/SocialView.swift b/MacMagazine/MacMagazine/Features/Social/SocialView.swift index f72780ea..3c00ba2b 100644 --- a/MacMagazine/MacMagazine/Features/Social/SocialView.swift +++ b/MacMagazine/MacMagazine/Features/Social/SocialView.swift @@ -38,9 +38,9 @@ struct SocialView: View { menuView } ToolbarItem(placement: .principal) { - if !shouldUseSidebar { +// if !shouldUseSidebar { optionsView - } +// } } } } diff --git a/MacMagazine/MacMagazine/MainApp/MainView.swift b/MacMagazine/MacMagazine/MainApp/MainView.swift index 1fd97229..085c62a7 100644 --- a/MacMagazine/MacMagazine/MainApp/MainView.swift +++ b/MacMagazine/MacMagazine/MainApp/MainView.swift @@ -14,6 +14,15 @@ struct MainView: View { @State var searchText: String = "" + var isSidebarActive: Bool { + UIDevice.current.userInterfaceIdiom == .pad && + horizontalSizeClass == .regular + } + + var isTabBarActive: Bool { + !isSidebarActive + } + var body: some View { @Bindable var bindableViewModel = viewModel @@ -59,6 +68,13 @@ struct MainView: View { @ViewBuilder var content: some View { tabContentView + .onAppear() { + if isSidebarActive { + print("sidebar") + } else { + print("tabbar") + } + } } } diff --git a/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift b/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift index 21e591bb..87750b3f 100644 --- a/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift +++ b/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift @@ -3,33 +3,142 @@ 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 - ) { - NavigationStack { - AnyView(contentView(for: tab)) + if UIDevice.current.userInterfaceIdiom == .pad { + + 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 + ) { + NavigationStack { + AnyView(contentView(for: tab)) + } + } + .defaultVisibility(.visible, for: .tabBar) + } + + TabSection("Notícias") { + ForEach(viewModel.settingsViewModel.news, id: \.self) { newsItem in + Tab( + newsItem.rawValue, + systemImage: newsItem.icon, + + value: AppTabs.news, + role: .none + ) { + NavigationStack { + contentView(for: newsItem) + } + } + .defaultVisibility(.visible, for: .sidebar) + // e opcionalmente esconder na TabBar: + .defaultVisibility(.hidden, for: .tabBar) + } + } + .defaultVisibility(.hidden, for: .tabBar) + + TabSection("Social") { + ForEach(viewModel.settingsViewModel.social, id: \.self) { socialItem in + Tab( + socialItem.rawValue, + systemImage: socialItem.icon, + value: AppTabs.social, + role: .none + ) { + NavigationStack { + contentView(for: socialItem) + } + } + .defaultVisibility(.visible, for: .sidebar) + .defaultVisibility(.hidden, for: .tabBar) + } + } + .defaultVisibility(.hidden, for: .tabBar) + + } + .tabViewStyle(.sidebarAdaptable) + + } else if UIDevice.current.userInterfaceIdiom == .phone { + + 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 + ) { + NavigationStack { + AnyView(contentView(for: tab)) + } } } } } } +} + + // MARK: - Helpers de lista / navegação (mantidos como você tinha) + +private extension MainView { + func show( + destination: any CaseIterable & Equatable, + title: String, + icon: String + ) -> some View { + Button(title, systemImage: icon) { + process(destination) + } + .listRowBackground( + RoundedRectangle(cornerRadius: 18) + .fill( + areEqual(navigationState.selectedItem, destination) ? Color.accentColor.opacity(0.2) : Color.clear + ) + ) + } +} + +private extension MainView { + func animateContentStackView(for item: any CaseIterable & Equatable) -> some View { + NavigationStack(path: Binding( + get: { navigationState.navigationPath }, + set: { navigationState.navigationPath = $0 } + )) { + contentView(for: navigationState.selectedItem) + } + } + + @ViewBuilder - func contentView(for tab: AppTabs) -> some View { - switch tab { - case .news: NewsView() - case .social: SocialView() - case .settings: SettingsView() - case .live: MMLiveWebView(colorSchema: viewModel.settingsViewModel.colorSchema) + func contentView(for item: any CaseIterable & Equatable) -> some View { + switch item { + case AppTabs.news: NewsView() + case AppTabs.social: SocialView() + case AppTabs.settings: SettingsView() + case AppTabs.live: MMLiveWebView(colorSchema: viewModel.settingsViewModel.colorSchema) + + case Social.videos: SocialView() + case Social.podcast: SocialView() + case Social.instagram: SocialView() + + case News.all: NewsView() + case News.news: NewsView() + case News.highlights: NewsView() + case News.appletv: NewsView() + case News.reviews: NewsView() + case News.rumors: NewsView() + case News.tutoriais: NewsView() + default: ContentUnavailableView( "Página em construção", @@ -39,3 +148,35 @@ extension MainView { } } } + +private extension MainView { + func process(_ destination: any CaseIterable & Equatable) { + withAnimation(.easeInOut(duration: 0.4)) { + switch destination { + case Social.videos: viewModel.social = .videos + case Social.podcast: viewModel.social = .podcast + case Social.instagram: viewModel.social = .instagram + + case News.all: viewModel.news = .all + case News.news: viewModel.news = .news + case News.highlights: viewModel.news = .highlights + case News.appletv: viewModel.news = .appletv + case News.reviews: viewModel.news = .reviews + case News.rumors: viewModel.news = .rumors + case News.tutoriais: viewModel.news = .tutoriais + + default: break + } + navigationState.navigate(to: destination) + } + } + + func id(for destination: any CaseIterable & Equatable) -> String { + switch destination { + case is Social: (destination as? Social)?.rawValue ?? UUID().uuidString + case is News: (destination as? News)?.rawValue ?? UUID().uuidString + case is AppTabs: (destination as? AppTabs)?.rawValue ?? UUID().uuidString + default: UUID().uuidString + } + } +} From a8eb6d81bd7298819334e8907ce52c44e38282be Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Thu, 11 Dec 2025 18:25:12 -0300 Subject: [PATCH 3/4] Change miniPlayer, remove fullPlayer, add new Medium player for ipad. --- .../Modifiers/PodcastMiniPlayerModifier.swift | 64 +- .../Views/Player/MediumPlayerView.swift | 131 ++++ .../Podcast/Views/Player/MiniPlayerView.swift | 39 +- .../Views/Player/PodcastPlayerView.swift | 585 ------------------ .../Podcast/Views/PodcastFullScreenView.swift | 46 -- .../Features/Social/SocialView.swift | 4 +- .../MacMagazine/MainApp/MainView.swift | 51 +- .../MainApp/Sizecalss/MainView+sidebar.swift | 16 +- .../MainApp/Sizecalss/MainView+tabbar.swift | 172 +---- 9 files changed, 238 insertions(+), 870 deletions(-) create mode 100644 MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MediumPlayerView.swift delete mode 100644 MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift delete mode 100644 MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastFullScreenView.swift diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift index 3872a1c7..f6d15ace 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Modifiers/PodcastMiniPlayerModifier.swift @@ -4,10 +4,9 @@ import UIComponentsLibrary public struct PodcastMiniPlayerModifier: ViewModifier { @Environment(PodcastPlayerManager.self) private var manager + @Environment(\.shouldUseSidebar) private var shouldUseSidebar @Namespace private var animation - @State private var showFullPlayer = false - private let isAllowedToShow: () -> Bool public init( @@ -21,46 +20,37 @@ public struct PodcastMiniPlayerModifier: ViewModifier { let canShowInContext = isAllowedToShow() let shouldShowMini = hasPodcast && canShowInContext - let base = content - .fullScreenCover(isPresented: $showFullPlayer) { - PodcastFullScreenView( - manager: manager, - animation: animation, - onDismiss: { - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - showFullPlayer = false - } - } - ) - } - .onChange(of: manager.currentPodcast?.id) { _, newID in - if newID == nil { - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - showFullPlayer = false - } - } - } - return Group { if shouldShowMini { - base - .tabBarMinimizeBehavior(.onScrollDown) - .tabViewBottomAccessory { - if let current = manager.currentPodcast { - MiniPlayerView( - playerManager: manager, - currentPodcast: current - ) { - withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { - showFullPlayer = true - } + 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) } - .matchedTransitionSource(id: "MINIPLAYER", in: animation) - .padding(.horizontal, 8) } - } + } } else { - base // sem accessory, sem espaço sobrando + content } } } 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 bda2f793..3467c54d 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/MiniPlayerView.swift @@ -9,20 +9,18 @@ public enum PodcastMiniPlayerLayout { public struct MiniPlayerView: View { @Environment(\.theme) private var theme + @Environment(\.shouldUseSidebar) private var shouldUseSidebar @Bindable var playerManager: PodcastPlayerManager let currentPodcast: PodcastDB - let onTap: () -> Void public init( playerManager: PodcastPlayerManager, - currentPodcast: PodcastDB, - onTap: @escaping () -> Void + currentPodcast: PodcastDB ) { self.playerManager = playerManager self.currentPodcast = currentPodcast - self.onTap = onTap } public var body: some View { @@ -57,20 +55,31 @@ private extension MiniPlayerView { Ticker(text: podcast.title, speed: 30) .frame(height: 30) .id(podcast.id) - } - .contentShape(Rectangle()) - .onTapGesture { - onTap() - } - Spacer() + HStack(spacing: 20) { + Button { + playerManager.skip(by: -15) + } label: { + Image(systemName: "gobackward.15") + .font(.system(size: 20)) + } - Button { - playerManager.togglePlayPause() - } label: { - Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 24)) + Button { + playerManager.togglePlayPause() + } label: { + Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 24)) + } + + Button { + playerManager.skip(by: 15) + } label: { + Image(systemName: "goforward.15") + .font(.system(size: 20)) + } + } } + .contentShape(Rectangle()) } .foregroundColor(.black) } 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 cfce60f2..00000000 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/PodcastPlayerView.swift +++ /dev/null @@ -1,585 +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) - .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) - } -} - -// 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/PodcastFullScreenView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastFullScreenView.swift deleted file mode 100644 index ac443a1f..00000000 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/PodcastFullScreenView.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// PodcastFullScreenView.swift -// PodcastLibrary -// -// Created by Renato Ferraz on 11/12/25. -// - -import MacMagazineLibrary -import SwiftUI -import UIComponentsLibrary - -public struct PodcastFullScreenView: View { - @Environment(\.theme) private var theme: ThemeColor - - let manager: PodcastPlayerManager - let animation: Namespace.ID - let onDismiss: () -> Void - - public init( - manager: PodcastPlayerManager, - animation: Namespace.ID, - onDismiss: @escaping () -> Void - ) { - self.manager = manager - self.animation = animation - self.onDismiss = onDismiss - } - - public var body: some View { - ZStack { - PodcastPlayerView(playerManager: manager) - .ignoresSafeArea() - .navigationTransition(.zoom(sourceID: "MINIPLAYER", in: animation)) - - VStack(spacing: 0) { - Capsule() - .fill(Color.secondary.opacity(0.6)) - .frame(width: 60, height: 4) - .padding(.top, 8) - .padding(.bottom, 8) - - Spacer() - } - } - } -} diff --git a/MacMagazine/MacMagazine/Features/Social/SocialView.swift b/MacMagazine/MacMagazine/Features/Social/SocialView.swift index 3c00ba2b..f72780ea 100644 --- a/MacMagazine/MacMagazine/Features/Social/SocialView.swift +++ b/MacMagazine/MacMagazine/Features/Social/SocialView.swift @@ -38,9 +38,9 @@ struct SocialView: View { menuView } ToolbarItem(placement: .principal) { -// if !shouldUseSidebar { + if !shouldUseSidebar { optionsView -// } + } } } } diff --git a/MacMagazine/MacMagazine/MainApp/MainView.swift b/MacMagazine/MacMagazine/MainApp/MainView.swift index 085c62a7..5abdf041 100644 --- a/MacMagazine/MacMagazine/MainApp/MainView.swift +++ b/MacMagazine/MacMagazine/MainApp/MainView.swift @@ -14,15 +14,6 @@ struct MainView: View { @State var searchText: String = "" - var isSidebarActive: Bool { - UIDevice.current.userInterfaceIdiom == .pad && - horizontalSizeClass == .regular - } - - var isTabBarActive: Bool { - !isSidebarActive - } - var body: some View { @Bindable var bindableViewModel = viewModel @@ -33,14 +24,6 @@ struct MainView: View { transaction.disablesAnimations = true } - .podcastMiniPlayer { - if viewModel.tab == .social { - return viewModel.social == .podcast - } else { - return true - } - } - .onChange(of: horizontalSizeClass) { old, new in navigationState.navigate( from: old, @@ -54,8 +37,16 @@ struct MainView: View { } .onChange(of: viewModel.social) { _, newValue in - if viewModel.tab == .social, + 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() } @@ -67,21 +58,29 @@ struct MainView: View { @ViewBuilder var content: some View { - tabContentView - .onAppear() { - if isSidebarActive { - print("sidebar") - } else { - print("tabbar") + 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 87750b3f..03c6b26b 100644 --- a/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift +++ b/MacMagazine/MacMagazine/MainApp/Sizecalss/MainView+tabbar.swift @@ -8,137 +8,29 @@ extension MainView { var tabContentView: some View { @Bindable var bindableViewModel = viewModel - if UIDevice.current.userInterfaceIdiom == .pad { - - 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 - ) { - NavigationStack { - AnyView(contentView(for: tab)) - } - } - .defaultVisibility(.visible, for: .tabBar) - } - - TabSection("Notícias") { - ForEach(viewModel.settingsViewModel.news, id: \.self) { newsItem in - Tab( - newsItem.rawValue, - systemImage: newsItem.icon, - - value: AppTabs.news, - role: .none - ) { - NavigationStack { - contentView(for: newsItem) - } - } - .defaultVisibility(.visible, for: .sidebar) - // e opcionalmente esconder na TabBar: - .defaultVisibility(.hidden, for: .tabBar) - } - } - .defaultVisibility(.hidden, for: .tabBar) - - TabSection("Social") { - ForEach(viewModel.settingsViewModel.social, id: \.self) { socialItem in - Tab( - socialItem.rawValue, - systemImage: socialItem.icon, - value: AppTabs.social, - role: .none - ) { - NavigationStack { - contentView(for: socialItem) - } - } - .defaultVisibility(.visible, for: .sidebar) - .defaultVisibility(.hidden, for: .tabBar) - } - } - .defaultVisibility(.hidden, for: .tabBar) - - } - .tabViewStyle(.sidebarAdaptable) - - } else if UIDevice.current.userInterfaceIdiom == .phone { - - 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 - ) { - NavigationStack { - AnyView(contentView(for: tab)) - } + 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 + ) { + NavigationStack { + AnyView(contentView(for: tab)) } } } } } -} - - // MARK: - Helpers de lista / navegação (mantidos como você tinha) - -private extension MainView { - func show( - destination: any CaseIterable & Equatable, - title: String, - icon: String - ) -> some View { - Button(title, systemImage: icon) { - process(destination) - } - .listRowBackground( - RoundedRectangle(cornerRadius: 18) - .fill( - areEqual(navigationState.selectedItem, destination) ? Color.accentColor.opacity(0.2) : Color.clear - ) - ) - } -} - -private extension MainView { - func animateContentStackView(for item: any CaseIterable & Equatable) -> some View { - NavigationStack(path: Binding( - get: { navigationState.navigationPath }, - set: { navigationState.navigationPath = $0 } - )) { - contentView(for: navigationState.selectedItem) - } - } - - @ViewBuilder - func contentView(for item: any CaseIterable & Equatable) -> some View { - switch item { - case AppTabs.news: NewsView() - case AppTabs.social: SocialView() - case AppTabs.settings: SettingsView() - case AppTabs.live: MMLiveWebView(colorSchema: viewModel.settingsViewModel.colorSchema) - - case Social.videos: SocialView() - case Social.podcast: SocialView() - case Social.instagram: SocialView() - - case News.all: NewsView() - case News.news: NewsView() - case News.highlights: NewsView() - case News.appletv: NewsView() - case News.reviews: NewsView() - case News.rumors: NewsView() - case News.tutoriais: NewsView() - + func contentView(for tab: AppTabs) -> some View { + switch tab { + case .news: NewsView() + case .social: SocialView() + case .settings: SettingsView() + case .live: MMLiveWebView(colorSchema: viewModel.settingsViewModel.colorSchema) default: ContentUnavailableView( "Página em construção", @@ -148,35 +40,3 @@ private extension MainView { } } } - -private extension MainView { - func process(_ destination: any CaseIterable & Equatable) { - withAnimation(.easeInOut(duration: 0.4)) { - switch destination { - case Social.videos: viewModel.social = .videos - case Social.podcast: viewModel.social = .podcast - case Social.instagram: viewModel.social = .instagram - - case News.all: viewModel.news = .all - case News.news: viewModel.news = .news - case News.highlights: viewModel.news = .highlights - case News.appletv: viewModel.news = .appletv - case News.reviews: viewModel.news = .reviews - case News.rumors: viewModel.news = .rumors - case News.tutoriais: viewModel.news = .tutoriais - - default: break - } - navigationState.navigate(to: destination) - } - } - - func id(for destination: any CaseIterable & Equatable) -> String { - switch destination { - case is Social: (destination as? Social)?.rawValue ?? UUID().uuidString - case is News: (destination as? News)?.rawValue ?? UUID().uuidString - case is AppTabs: (destination as? AppTabs)?.rawValue ?? UUID().uuidString - default: UUID().uuidString - } - } -} From 23ccc97e613f128feb0d753ecc3f220deb08e917 Mon Sep 17 00:00:00 2001 From: Renato Ferraz Date: Thu, 11 Dec 2025 18:27:35 -0300 Subject: [PATCH 4/4] Reorders app tabs for better user experience Moves the search tab to the end of the list of available app tabs. This change aims to improve discoverability by prioritizing the most frequently used tabs in a more intuitive order. --- .../Sources/SettingsLibrary/Definitions/AppDefinitions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/AppDefinitions.swift b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/AppDefinitions.swift index 3cba97e3..6d0512dc 100644 --- a/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/AppDefinitions.swift +++ b/MacMagazine/Features/SettingsLibrary/Sources/SettingsLibrary/Definitions/AppDefinitions.swift @@ -2,11 +2,11 @@ import Foundation public enum AppTabs: String, CaseIterable, Codable, Equatable { - case search = "Busca" case live = "MM Live" case news = "Notícias" case social = "Mídias" case settings = "Ajustes" + case search = "Busca" public var icon: String { switch self {