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
@@ -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
)
)
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -75,16 +79,9 @@ private extension MiniPlayerView {
}
}
}
.foregroundColor(.primary)
.padding(8)
}
.padding()
.background {
Capsule()
.fill(.bar)
.glassEffect(.clear)
.padding()
.contentShape(Rectangle())
}
.foregroundColor(.black)
}

@ViewBuilder
Expand All @@ -96,5 +93,4 @@ private extension MiniPlayerView {
.frame(width: 30, height: 30)
}
}

}
Loading