Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -30,7 +30,9 @@ struct ChaptersView: View {
backgroundGradientColors: backgroundGradientColors,
usesGradient: false
)
chapters
.ignoresSafeArea()

content
}
.trackScreen(
AnalyticsConstants.Screen.podcastChapters.name,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,17 @@ struct FullPlayerView: View {
Spacer()
actions
}
.padding(.top, 20)
.padding(.top, 5)

artworkView
podcastTitle(podcast.title)

Spacer()

progressSlider
.padding(.bottom, 20)
playbackControls
.padding(.bottom, 20)
volumeSlider
}
.padding(.horizontal)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand All @@ -660,4 +666,6 @@ private extension FullPlayerView {
playerManager.duration = 2_730
playerManager.currentTime = 450
}
.environmentObject(analytics)

}