mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Make a full screen audio player that can be swapped with the miniplayer
This commit is contained in:
parent
4ce7352013
commit
b5c0915e22
2 changed files with 257 additions and 34 deletions
|
|
@ -6,13 +6,45 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct DynamicStack<Content: View>: View {
|
||||
var isExpanded: Bool = false
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
isExpanded ? AnyView(vStack) : AnyView(hStack)
|
||||
}
|
||||
}
|
||||
|
||||
private extension DynamicStack {
|
||||
var hStack: some View {
|
||||
HStack(
|
||||
alignment: .top,
|
||||
spacing: 0,
|
||||
content: content
|
||||
).background(.yellow)
|
||||
}
|
||||
|
||||
var vStack: some View {
|
||||
VStack(
|
||||
alignment: .leading,
|
||||
spacing: 0,
|
||||
content: content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct MiniPlayer: View {
|
||||
@EnvironmentObject var audioSession: AudioSession
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
|
||||
@State var expanded = false
|
||||
var safeArea = UIApplication.shared.windows.first?.safeAreaInsets
|
||||
|
||||
private let presentingView: AnyView
|
||||
|
||||
init<PresentingView>(
|
||||
|
|
@ -21,6 +53,10 @@ public struct MiniPlayer: View {
|
|||
self.presentingView = AnyView(presentingView)
|
||||
}
|
||||
|
||||
var isPresented: Bool {
|
||||
audioSession.item != nil && audioSession.state != .stopped
|
||||
}
|
||||
|
||||
var playPauseButtonItem: some View {
|
||||
if let item = audioSession.item, audioSession.isLoadingItem(item: item) {
|
||||
return AnyView(ProgressView())
|
||||
|
|
@ -56,36 +92,207 @@ public struct MiniPlayer: View {
|
|||
)
|
||||
}
|
||||
|
||||
var playerView: some View {
|
||||
if let item = audioSession.item {
|
||||
return AnyView(playerContent(item)
|
||||
.padding()
|
||||
.animation(.spring(), value: true)
|
||||
.tint(.appGrayTextContrast))
|
||||
} else {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func playerContent(_ item: LinkedItem) -> some View {
|
||||
VStack {
|
||||
if expanded {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
HStack {
|
||||
if expanded {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Group {
|
||||
if let imageURL = item.imageURL {
|
||||
let dim = expanded ? 2 * (UIScreen.main.bounds.width / 3) : 64
|
||||
AsyncLoadingImage(url: imageURL) { imageStatus in
|
||||
if case let AsyncImageStatus.loaded(image) = imageStatus {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: dim, height: dim)
|
||||
.cornerRadius(6)
|
||||
} else if case AsyncImageStatus.loading = imageStatus {
|
||||
Color.appButtonBackground
|
||||
.frame(width: dim, height: dim)
|
||||
.cornerRadius(6)
|
||||
} else {
|
||||
EmptyView().frame(width: dim, height: dim, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !expanded {
|
||||
Text(item.unwrappedTitle)
|
||||
.font(expanded ? .appTitle : .appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: false)
|
||||
.frame(maxWidth: .infinity, alignment: expanded ? .center : .leading)
|
||||
|
||||
playPauseButtonItem
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
stopButton
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
|
||||
if expanded {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
if expanded {
|
||||
Text(item.unwrappedTitle)
|
||||
.lineLimit(1)
|
||||
.font(expanded ? .appTitle : .appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: expanded ? .center : .leading)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
if let author = item.author {
|
||||
Text(author)
|
||||
.lineLimit(1)
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
if item.author != nil, item.siteName != nil {
|
||||
Text(" • ")
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
if let site = item.siteName {
|
||||
Text(site)
|
||||
.lineLimit(1)
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(alignment: .leading)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Slider(value: $audioSession.timeElapsed,
|
||||
in: audioSession.timeElapsed ... audioSession.duration,
|
||||
onEditingChanged: { _ in
|
||||
// isEditing = editing
|
||||
})
|
||||
.accentColor(.appCtaYellow)
|
||||
.introspectSlider { slider in
|
||||
// Make the thumb a little smaller than the default and give it the CTA color
|
||||
let tintColor = UIColor(Color.appCtaYellow)
|
||||
|
||||
let image = UIImage(systemName: "circle.fill", withConfiguration: UIImage.SymbolConfiguration(scale: .small))?
|
||||
.withTintColor(tintColor)
|
||||
.withRenderingMode(.alwaysOriginal)
|
||||
|
||||
slider.setThumbImage(image, for: .normal)
|
||||
slider.minimumTrackTintColor = tintColor
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(audioSession.timeElapsedString ?? "0:00")
|
||||
.font(.appCaptionTwo)
|
||||
.foregroundColor(.appGrayText)
|
||||
Spacer()
|
||||
Text(audioSession.durationString ?? "0:00")
|
||||
.font(.appCaptionTwo)
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button(
|
||||
action: {},
|
||||
label: {
|
||||
Image(systemName: "gobackward.30")
|
||||
.font(.appTitleTwo)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
action: {},
|
||||
label: {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.system(size: 64.0, weight: .thin))
|
||||
}
|
||||
)
|
||||
.frame(width: 64, height: 64)
|
||||
.padding(32)
|
||||
|
||||
Button(
|
||||
action: {},
|
||||
label: {
|
||||
Image(systemName: "goforward.30")
|
||||
.font(.appTitleTwo)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func miniview(_ item: LinkedItem) -> some View {
|
||||
HStack {
|
||||
Text(item.unwrappedTitle)
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
playPauseButtonItem
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
stopButton
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
GeometryReader { _ in
|
||||
ZStack(alignment: .center) {
|
||||
presentingView
|
||||
VStack {
|
||||
Spacer()
|
||||
if let item = audioSession.item, self.audioSession.state != .stopped {
|
||||
HStack {
|
||||
Text(item.unwrappedTitle)
|
||||
.font(.appCallout)
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if isPresented {
|
||||
VStack {
|
||||
if expanded {
|
||||
Capsule()
|
||||
.fill(.gray)
|
||||
.frame(width: 60, height: 4)
|
||||
// .padding(.top, safeArea?.top ?? 0)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
playPauseButtonItem
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
stopButton
|
||||
.frame(width: 28, height: 28)
|
||||
playerView
|
||||
}
|
||||
.padding()
|
||||
.frame(width: geometry.size.width, height: 88) // this should be 108 once we add GrabberVisible at the bottom
|
||||
.animation(.spring(), value: true)
|
||||
.tint(.appGrayTextContrast)
|
||||
.frame(maxHeight: expanded ? .infinity : 88)
|
||||
.background(
|
||||
Color.systemBackground
|
||||
.shadow(color: .gray.opacity(0.33), radius: 8, x: 0, y: 4)
|
||||
.shadow(color: expanded ? .clear : .gray.opacity(0.33), radius: 8, x: 0, y: 4)
|
||||
.mask(Rectangle().padding(.top, -20))
|
||||
)
|
||||
.onTapGesture {
|
||||
withAnimation(.easeIn(duration: 0.08)) { expanded.toggle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ public class AudioSession: ObservableObject {
|
|||
@Published public var state: AudioSessionState = .stopped
|
||||
@Published public var item: LinkedItem?
|
||||
|
||||
@Published public var timeElapsed: TimeInterval = 0
|
||||
@Published public var duration: TimeInterval = 0
|
||||
@Published public var timeElapsedString: String?
|
||||
@Published public var durationString: String?
|
||||
|
||||
let appEnvironment: AppEnvironment
|
||||
let networker: Networker
|
||||
|
||||
|
|
@ -50,6 +55,8 @@ public class AudioSession: ObservableObject {
|
|||
player = nil
|
||||
item = nil
|
||||
state = .stopped
|
||||
timeElapsed = 0
|
||||
duration = 1
|
||||
}
|
||||
|
||||
public func isLoadingItem(item: LinkedItem) -> Bool {
|
||||
|
|
@ -65,21 +72,21 @@ public class AudioSession: ObservableObject {
|
|||
|
||||
let pageId = item!.unwrappedID
|
||||
|
||||
Task {
|
||||
do {
|
||||
_ = try await downloadAudioFile(pageId: pageId)
|
||||
DispatchQueue.main.async {
|
||||
self.startDownloadedAudioFile(pageId: pageId)
|
||||
}
|
||||
} catch {
|
||||
// TODO: maybe we need a failed state?
|
||||
DispatchQueue.main.async {
|
||||
self.state = .stopped
|
||||
}
|
||||
print("FAILED TO DOWNLOAD AUDIO URL")
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
// Task {
|
||||
// do {
|
||||
// _ = try await downloadAudioFile(pageId: pageId)
|
||||
// DispatchQueue.main.async {
|
||||
// self.startDownloadedAudioFile(pageId: pageId)
|
||||
// }
|
||||
// } catch {
|
||||
// // TODO: maybe we need a failed state?
|
||||
//// DispatchQueue.main.async {
|
||||
//// self.state = .stopped
|
||||
//// }
|
||||
// print("FAILED TO DOWNLOAD AUDIO URL")
|
||||
// print(error.localizedDescription)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
private func startDownloadedAudioFile(pageId: String) {
|
||||
|
|
@ -154,6 +161,15 @@ public class AudioSession: ObservableObject {
|
|||
@objc func update(_: Timer) {
|
||||
if let player = player, player.isPlaying {
|
||||
print("play time in ms: ", Int(player.currentTime * 1000))
|
||||
timeElapsed = player.currentTime
|
||||
duration = player.duration
|
||||
|
||||
let componentFormatter = DateComponentsFormatter()
|
||||
componentFormatter.unitsStyle = .positional
|
||||
componentFormatter.zeroFormattingBehavior = .dropAll
|
||||
|
||||
timeElapsedString = componentFormatter.string(from: timeElapsed)
|
||||
durationString = componentFormatter.string(from: duration)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue