diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index bc2e8498f..c69033a7d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -6,13 +6,45 @@ // import Foundation +import Models import Services import SwiftUI +import Views + +struct DynamicStack: 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( @@ -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() } + } } } } diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioSession.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioSession.swift index 9185fef59..00784a29b 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioSession.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioSession.swift @@ -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) } }