diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/ChaptersView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/ChaptersView.swift index aa7fad46..8237da2b 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/ChaptersView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/ChaptersView.swift @@ -8,8 +8,8 @@ struct ChaptersView: View { @Bindable private var playerManager: PodcastPlayerManager @Binding private var isShowingChapterDialog: Bool - @State var backgroundGradientColors: [Color] = [.black, .black] - @State var isDarkBackground: Bool = false + @State private var backgroundGradientColors: [Color] = [.black, .black] + @State private var isDarkBackground = false private let backgroundGradientStyle: PodcastBackgroundGradientStyle @@ -30,7 +30,9 @@ struct ChaptersView: View { backgroundGradientColors: backgroundGradientColors, usesGradient: false ) - chapters + .ignoresSafeArea() + + content } .trackScreen( AnalyticsConstants.Screen.podcastChapters.name, @@ -47,50 +49,147 @@ struct ChaptersView: View { } } +// MARK: - UI + private extension ChaptersView { - var chapters: some View { - List(playerManager.chapters, id: \.id) { chapter in - Button(action: { - playerManager.seek(to: chapter.start.seconds) - isShowingChapterDialog.toggle() - }, label: { - HStack(spacing: 20) { - PodcastImageView( - artworkData: chapter.artworkData, - location: .chapter, - fallback: { Rectangle().fill(.clear) }) - .frame(width: 45, height: 45) - .cornerRadius(8) - - VStack(alignment: .leading, spacing: 10) { - Text(chapter.title).bold().font(.body) - - HStack(spacing: 10) { - HStack(spacing: 4) { - Image(systemName: "clock").font(.system(size: 14)) - Text(chapter.startString) - } - HStack(spacing: 4) { - Image(systemName: "microphone").font(.system(size: 14)) - Text(chapter.durationString) - } - Spacer() + var content: some View { + ScrollView { + LazyVStack(spacing: 10) { + ForEach(playerManager.chapters, id: \.id) { chapter in + chapterRow(chapter) + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 24) + } + .scrollIndicators(.hidden) + } + + func chapterRow(_ chapter: PodcastChapter) -> some View { + return Button { + playerManager.seek(to: chapter.start.seconds) + isShowingChapterDialog.toggle() + } label: { + HStack(spacing: 12) { + PodcastImageView( + artworkData: chapter.artworkData, + location: .chapter, + fallback: { Rectangle().fill(.clear) } + ) + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 8) { + Text(chapter.title) + .font(isActive(for: chapter) ? .body.weight(.bold) : .body.weight(.semibold)) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + + HStack(spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "clock") + .font(.system(size: 14)) + Text(chapter.startString) + } + + Spacer() + + HStack(spacing: 6) { + Text(chapter.durationString) + Image(systemName: "microphone") + .font(.system(size: 14)) } - .font(.callout) + } + .font(.callout) + .foregroundStyle(.secondary) + + if isActive(for: chapter) { + TimelineView(.animation) { _ in + progressBar(for: chapter) + } + .transition(.opacity) } } - }) - .listRowBackground(chapter.backgroundColor(at: playerManager.currentTime, using: backgroundGradientColors)) - .accessibilityLabel("\(chapter.title), começando em \(chapter.startString) com duração de \(chapter.durationString).") + + Spacer(minLength: 0) + } + .padding(.vertical, 12) + .padding(.horizontal, 12) + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } - .scrollContentBackground(.hidden) + .buttonStyle(.plain) + .accessibilityLabel("\(chapter.title), começando em \(chapter.startString) com duração de \(chapter.durationString).") + .background { + cardBackground(for: chapter) + } + .scaleEffect(isActive(for: chapter) ? 1.035 : 1.0) + .animation(.spring(response: 0.35, dampingFraction: 0.85), value: isActive(for: chapter)) } } +// MARK: - Card background (glass + highlight + “band” que acompanha o tempo) + private extension ChaptersView { - func updateBackgroundGradient( - data: Data? = nil - ) { + func cardBackground(for chapter: PodcastChapter) -> some View { + return RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder( + .white.opacity( + isActive(for: chapter) ? 0.35 : (isDarkBackground ? 0.10 : 0.18) + ), + lineWidth: isActive(for: chapter) ? 1.2 : 0.5 + ) + } + } + + func chapterTintColor(for chapter: PodcastChapter) -> Color { + chapter.backgroundColor(at: playerManager.currentTime, using: backgroundGradientColors) + } +} + +// MARK: - Progress bar + +private extension ChaptersView { + func progressBar(for chapter: PodcastChapter) -> some View { + let progress = Double(progress(for: chapter)) + + return Slider( + value: .constant(progress), + in: 0...1 + ) + .tint(.white.opacity(isDarkBackground ? 0.75 : 0.65)) + .sliderThumbVisibility(.hidden) + .frame(height: 3) + .padding(.top, 2) + .accessibilityHidden(true) + .allowsHitTesting(false) + } +} + +// MARK: - Active/progress helpers + +private extension ChaptersView { + @MainActor + func isActive(for chapter: PodcastChapter) -> Bool { + playerManager.currentChapter == chapter + } + + func progress(for chapter: PodcastChapter) -> CGFloat { + let time = playerManager.currentTime + let start = chapter.start.seconds + let end = chapter.end.seconds + let denom = max(0.001, end - start) + return CGFloat((time - start) / denom) + } +} + +// MARK: - Background + +private extension ChaptersView { + func updateBackgroundGradient(data: Data? = nil) { BackgroundViewModel.backgroundGradient( data: data, artworkURL: Constants.coverURL, diff --git a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/FullPlayerView.swift b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/FullPlayerView.swift index df8d1ed0..487a32f1 100644 --- a/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/FullPlayerView.swift +++ b/MacMagazine/Features/PodcastLibrary/Sources/Podcast/Views/Player/FullPlayerView.swift @@ -146,7 +146,7 @@ struct FullPlayerView: View { Spacer() actions } - .padding(.top, 20) + .padding(.top, 5) artworkView podcastTitle(podcast.title) @@ -154,7 +154,9 @@ struct FullPlayerView: View { Spacer() progressSlider + .padding(.bottom, 20) playbackControls + .padding(.bottom, 20) volumeSlider } .padding(.horizontal) @@ -416,6 +418,8 @@ private extension FullPlayerView { )) }, label: { Image(systemName: "music.note.list") + .opacity(0.6) + .font(.system(size: 15)) }) .font(.system(size: 24)) .accessibilityLabel("Lista de capítulos") @@ -637,6 +641,8 @@ private extension FullPlayerView { #Preview { @Previewable @State var playerManager = PodcastPlayerManager() + let analytics = AnalyticsManager() + let mockPodcast = PodcastDB( postId: "1", title: "MacMagazine no Ar #123: Especial WWDC 2024", @@ -660,4 +666,6 @@ private extension FullPlayerView { playerManager.duration = 2_730 playerManager.currentTime = 450 } + .environmentObject(analytics) + }