diff --git a/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate b/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate index b82f64c..b6da33b 100644 Binary files a/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate and b/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/RubyEvents/AppDelegate.swift b/RubyEvents/AppDelegate.swift index da3323d..4dea945 100644 --- a/RubyEvents/AppDelegate.swift +++ b/RubyEvents/AppDelegate.swift @@ -19,7 +19,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { Hotwire.config.applicationUserAgentPrefix = "Hotwire Native iOS; app_version: \(versionNumber); unique_device_id: \(uniqueDeviceId);" Hotwire.registerBridgeComponents([ - ButtonComponent.self + ButtonComponent.self, + PlayerComponent.self ]) Hotwire.config.showDoneButtonOnModals = true diff --git a/RubyEvents/Info.plist b/RubyEvents/Info.plist index e0f24c0..c3058a4 100644 --- a/RubyEvents/Info.plist +++ b/RubyEvents/Info.plist @@ -4,6 +4,10 @@ UIUserInterfaceStyle Light + UIBackgroundModes + + audio + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/RubyEvents/bridge/PlayerComponent.swift b/RubyEvents/bridge/PlayerComponent.swift new file mode 100644 index 0000000..96c9926 --- /dev/null +++ b/RubyEvents/bridge/PlayerComponent.swift @@ -0,0 +1,88 @@ +// +// PlayerComponent.swift +// RubyEvents +// + +import UIKit +import SwiftUI +import HotwireNative + +final class PlayerComponent: BridgeComponent { + override class var name: String { "player" } + + private weak var presentedController: UIViewController? + + override func onReceive(message: Message) { + switch message.event { + case "play": + handlePlay(message: message) + default: + break + } + } + + private var viewController: UIViewController? { + delegate.destination as? UIViewController + } + + private func handlePlay(message: Message) { + guard let data: PlayData = message.data() else { return } + guard let url = URL(string: data.url) else { return } + guard let viewController else { return } + + let params = PlayerParams( + slug: data.slug, + url: url, + title: data.title ?? "", + subtitle: data.subtitle, + poster: data.poster.flatMap(URL.init(string:)), + startAt: max(data.progressSeconds ?? 0, data.startSeconds ?? 0) + ) + + let screen = TalkPlayerScreen( + params: params, + navigator: App.instance.navigators.first, + onProgress: { [weak self] seconds in + self?.reply(to: "play", with: PlayProgress(progressSeconds: seconds)) + }, + onDismiss: { [weak self] in + self?.presentedController?.dismiss(animated: true) + } + ) + + let hosting = UIHostingController(rootView: screen) + + hosting.modalPresentationStyle = .overFullScreen + hosting.view.backgroundColor = .clear + presentedController = hosting + + topPresentedController(from: viewController).present(hosting, animated: true) + } + + private func topPresentedController(from controller: UIViewController) -> UIViewController { + var top = controller + + while let presented = top.presentedViewController, !(presented is UIHostingController) { + top = presented + } + + return top + } +} + +private extension PlayerComponent { + struct PlayData: Decodable { + let slug: String? + let url: String + let title: String? + let subtitle: String? + let poster: String? + let startSeconds: Double? + let progressSeconds: Double? + let durationSeconds: Double? + } + + struct PlayProgress: Encodable { + let progressSeconds: Double + } +} diff --git a/RubyEvents/components/cards/TalkCard.swift b/RubyEvents/components/cards/TalkCard.swift index 1981bca..b295a9e 100644 --- a/RubyEvents/components/cards/TalkCard.swift +++ b/RubyEvents/components/cards/TalkCard.swift @@ -12,10 +12,14 @@ struct TalkCard: View { let talk: Talk var navigator: Navigator? + @State private var showPlayer = false + var body: some View { Button(action: { - if (talk.url != nil) { - navigator?.route(talk.url!) + if talk.opensNativeScreen { + showPlayer = true + } else if let url = talk.url { + navigator?.route(url) } }) { VStack(alignment: .leading, spacing: 8) { @@ -60,7 +64,7 @@ struct TalkCard: View { .fontWeight(.medium) .foregroundStyle(.black) - Text("\(talk.speakers[0].name) • \(talk.event_name)") + Text(talk.speakers.first.map { "\($0.name) • \(talk.event_name)" } ?? talk.event_name) .font(.caption2) .foregroundColor(.gray) .fontWeight(.regular) @@ -71,6 +75,16 @@ struct TalkCard: View { .frame(maxWidth: 200) .aspectRatio(16/9, contentMode: .fit) } + .fullScreenCover(isPresented: $showPlayer) { + if let params = talk.playerParams { + TalkPlayerScreen( + params: params, + navigator: navigator, + onProgress: { _ in }, + onDismiss: { showPlayer = false } + ) + } + } } } diff --git a/RubyEvents/controllers/PageViewController.swift b/RubyEvents/controllers/PageViewController.swift index 8f8cb2b..559ebe3 100644 --- a/RubyEvents/controllers/PageViewController.swift +++ b/RubyEvents/controllers/PageViewController.swift @@ -15,7 +15,9 @@ struct PageViewController: UIViewControllerRepresentable { func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) { + guard let firstPage = pages.first else { return } + pageViewController.setViewControllers( - [UIHostingController(rootView: pages[0])], direction: .forward, animated: true) + [UIHostingController(rootView: firstPage)], direction: .forward, animated: true) } } diff --git a/RubyEvents/controllers/TabBarController.swift b/RubyEvents/controllers/TabBarController.swift index 6b76bc3..7406754 100644 --- a/RubyEvents/controllers/TabBarController.swift +++ b/RubyEvents/controllers/TabBarController.swift @@ -56,9 +56,8 @@ class TabBarController: UITabBarController { } func navigatorFor(title: String) -> Navigator? { - let index = configuration?.items.firstIndex { $0.self.title == title } - - guard let index else { return nil } + guard let index = configuration?.items.firstIndex(where: { $0.title == title }), + navigators.indices.contains(index) else { return nil } return navigators[index] } diff --git a/RubyEvents/models/Talk.swift b/RubyEvents/models/Talk.swift index c82b854..176888c 100644 --- a/RubyEvents/models/Talk.swift +++ b/RubyEvents/models/Talk.swift @@ -16,6 +16,9 @@ struct Talk: Identifiable, Decodable { let url: URL? let thumbnail_url: URL? let slug: String + let video_provider: String? + let video_id: String? + let video_url: String? enum CodingKeys: String, CodingKey { case id @@ -26,6 +29,9 @@ struct Talk: Identifiable, Decodable { case url case thumbnail_url case slug + case video_provider + case video_id + case video_url } init(from decoder: Decoder) throws { @@ -50,9 +56,12 @@ struct Talk: Identifiable, Decodable { } slug = try container.decode(String.self, forKey: .slug) + video_provider = try container.decodeIfPresent(String.self, forKey: .video_provider) + video_id = try container.decodeIfPresent(String.self, forKey: .video_id) + video_url = try container.decodeIfPresent(String.self, forKey: .video_url) } - init(id: Int64, title: String, speakers: [Speaker], duration_in_seconds: Int32?, event_name: String, url: URL?, thumbnail_url: URL?, slug: String) { + init(id: Int64, title: String, speakers: [Speaker], duration_in_seconds: Int32?, event_name: String, url: URL?, thumbnail_url: URL?, slug: String, video_provider: String? = nil, video_id: String? = nil, video_url: String? = nil) { self.id = id self.title = title self.speakers = speakers @@ -61,6 +70,70 @@ struct Talk: Identifiable, Decodable { self.url = url self.thumbnail_url = thumbnail_url self.slug = slug + self.video_provider = video_provider + self.video_id = video_id + self.video_url = video_url + } + + var nativelyPlayable: Bool { + guard video_provider == "mp4", let value = video_url?.lowercased() else { return false } + return value.hasPrefix("https://") && (value.hasSuffix(".mp4") || value.hasSuffix(".m4v") || value.hasSuffix(".mov")) + } + + var isYouTube: Bool { + video_provider == "youtube" && !(video_id?.isEmpty ?? true) + } + + var statusLabel: String? { + switch video_provider { + case "scheduled": return "Scheduled" + case "not_recorded": return "Not recorded" + case "not_published": return "Not published yet" + default: return nil + } + } + + var opensNativeScreen: Bool { + nativelyPlayable || isYouTube || statusLabel != nil + } + + var playerParams: PlayerParams? { + if nativelyPlayable, let video_url, let mediaURL = URL(string: video_url) { + return PlayerParams( + slug: slug, + url: mediaURL, + title: title, + subtitle: event_name, + poster: thumbnail_url, + startAt: 0 + ) + } + + if isYouTube, let video_id { + return PlayerParams( + slug: slug, + url: nil, + title: title, + subtitle: event_name, + poster: thumbnail_url, + startAt: 0, + youtubeVideoID: video_id + ) + } + + if let statusLabel { + return PlayerParams( + slug: slug, + url: nil, + title: title, + subtitle: event_name, + poster: thumbnail_url, + startAt: 0, + statusLabel: statusLabel + ) + } + + return nil } func formatted_duration() -> String { diff --git a/RubyEvents/models/TalkDetail.swift b/RubyEvents/models/TalkDetail.swift new file mode 100644 index 0000000..cd9f650 --- /dev/null +++ b/RubyEvents/models/TalkDetail.swift @@ -0,0 +1,102 @@ +// +// TalkDetail.swift +// RubyEvents +// + +import Foundation + +struct TalkDetailResponse: Decodable { + let talk: TalkDetail +} + +struct TalkDetail: Decodable { + let id: Int64? + let slug: String + let title: String + let description: String? + let summary: String? + let formatted_date: String? + let kind: String? + let video_provider: String + let video_url: String? + let thumbnail_url: String? + let duration_in_seconds: Int32? + let event: TalkEvent? + let speakers: [TalkSpeaker] + let related_talks: [Talk]? + + var videoURL: URL? { + guard let video_url else { return nil } + return URL(string: video_url) + } + + var thumbnailURL: URL? { + guard let thumbnail_url else { return nil } + return URL(string: thumbnail_url) + } + + var seriesName: String? { + event?.series?.name ?? event?.name + } + + var speakerNames: String { + speakers.map(\.name).joined(separator: ", ") + } +} + +struct TalkEvent: Decodable { + let slug: String? + let name: String? + let series: TalkSeries? + let avatar_url: String? + let start_date: String? + let end_date: String? + let location: String? + + var avatarURL: URL? { + guard let avatar_url else { return nil } + return URL(string: avatar_url) + } + + var dateText: String? { + guard let start_date else { return nil } + + let parser = DateFormatter() + parser.locale = Locale(identifier: "en_US_POSIX") + parser.dateFormat = "yyyy-MM-dd" + + guard let startDate = parser.date(from: start_date) else { return nil } + + let full = DateFormatter() + full.dateFormat = "MMM d, yyyy" + + if let end_date, let endDate = parser.date(from: end_date), Calendar.current.startOfDay(for: endDate) != Calendar.current.startOfDay(for: startDate) { + let short = DateFormatter() + short.dateFormat = "MMM d" + + return "\(short.string(from: startDate)) – \(full.string(from: endDate))" + } + + return full.string(from: startDate) + } +} + +struct TalkSeries: Decodable { + let id: Int64? + let name: String? + let slug: String? +} + +struct TalkSpeaker: Decodable, Identifiable { + let id: Int64 + let name: String + let slug: String + let bio: String? + let avatar_url: String? + let github_handle: String? + + var avatarURL: URL? { + guard let avatar_url else { return nil } + return URL(string: avatar_url) + } +} diff --git a/RubyEvents/player/PlayerControlsView.swift b/RubyEvents/player/PlayerControlsView.swift new file mode 100644 index 0000000..a72af8a --- /dev/null +++ b/RubyEvents/player/PlayerControlsView.swift @@ -0,0 +1,298 @@ +// +// PlayerControlsView.swift +// RubyEvents +// + +import SwiftUI + +struct PlayerControlsView: View { + @ObservedObject var viewModel: PlayerViewModel + let title: String + let subtitle: String? + let speakerName: String? + let eventAvatarURL: URL? + let isFullscreen: Bool + var onToggleFullscreen: () -> Void + var onDismiss: () -> Void + + @State private var controlsVisible = true + @State private var hideTask: Task? + @State private var leftFlash = false + @State private var rightFlash = false + + var body: some View { + ZStack { + HStack(spacing: 0) { + skipZone(seconds: -10, systemImage: "gobackward.10", flashing: $leftFlash) + skipZone(seconds: 10, systemImage: "goforward.10", flashing: $rightFlash) + } + + if controlsVisible { + Color.black.opacity(0.35) + .allowsHitTesting(false) + .transition(.opacity) + } + + if controlsVisible { + VStack(spacing: 0) { + topBar + Spacer() + + if !isLoading { + centerTransport + } + + Spacer() + bottomInfoRow + } + .padding(isFullscreen ? 24 : 12) + .padding(.bottom, isFullscreen ? 18 : 12) + .transition(.opacity) + } + + if controlsVisible { + scrubber + .padding(.horizontal, 8) + .padding(.bottom, isFullscreen ? 24 : -6) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: controlsVisible) + .onAppear { scheduleAutoHide() } + .onChange(of: viewModel.isPlaying) { _ in scheduleAutoHide() } + .onChange(of: isFullscreen) { _ in + hideTask?.cancel() + controlsVisible = true + scheduleAutoHide() + } + } + + private var topBar: some View { + HStack(alignment: .top) { + if isFullscreen { + HStack(spacing: 10) { + if let eventAvatarURL { + AsyncImage(url: eventAvatarURL) { image in + image.resizable().scaledToFill() + } placeholder: { + RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.15)) + } + .frame(width: 34, height: 34) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(1) + + if let speakerName, !speakerName.isEmpty { + Text(speakerName) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + } else if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + } + } + } + } else { + Button(action: onDismiss) { + Image(systemName: "chevron.down") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 38, height: 38) + .background(.ultraThinMaterial, in: Circle()) + .environment(\.colorScheme, .dark) + } + .buttonStyle(.plain) + } + + Spacer() + + HStack(spacing: 12) { + RoutePickerView() + .frame(width: 28, height: 28) + + Button(action: onToggleFullscreen) { + Image(systemName: isFullscreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 38, height: 38) + .background(.ultraThinMaterial, in: Circle()) + .environment(\.colorScheme, .dark) + } + .buttonStyle(.plain) + } + } + } + + private var centerTransport: some View { + HStack(spacing: 40) { + transportButton(icon: "gobackward.10", iconSize: 26, diameter: 60) { + viewModel.skip(by: -10); scheduleAutoHide() + } + + transportButton(icon: viewModel.isPlaying ? "pause.fill" : "play.fill", iconSize: 34, diameter: 74) { + viewModel.togglePlay(); scheduleAutoHide() + } + + transportButton(icon: "goforward.10", iconSize: 26, diameter: 60) { + viewModel.skip(by: 10); scheduleAutoHide() + } + } + } + + private func transportButton(icon: String, iconSize: CGFloat, diameter: CGFloat, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: iconSize, weight: .regular)) + .foregroundStyle(.white) + .frame(width: diameter, height: diameter) + .background(.ultraThinMaterial, in: Circle()) + .environment(\.colorScheme, .dark) + } + .buttonStyle(.plain) + } + + private var isLoading: Bool { + viewModel.isBuffering || viewModel.isSeeking + } + + private var bottomInfoRow: some View { + HStack { + Text("\(timeString(viewModel.currentTime)) / \(timeString(viewModel.duration))") + .font(.footnote.monospacedDigit()) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(.ultraThinMaterial, in: Capsule()) + .environment(\.colorScheme, .dark) + + Spacer() + + Button(action: { viewModel.cycleRate(); scheduleAutoHide() }) { + Text(rateLabel(viewModel.rate)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(.ultraThinMaterial, in: Capsule()) + .environment(\.colorScheme, .dark) + } + .buttonStyle(.plain) + } + } + + private var scrubber: some View { + GeometryReader { geo in + let width = geo.size.width + let duration = max(viewModel.duration, 0.001) + let progress = min(max(viewModel.currentTime / duration, 0), 1) + let playheadX = width * progress + let thumb: CGFloat = viewModel.isScrubbing ? 15 : 12 + + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.28)) + .frame(height: 3) + + Capsule() + .fill(Color.white) + .frame(width: playheadX, height: 3) + + Circle() + .fill(Color.blue) + .frame(width: thumb, height: thumb) + .offset(x: playheadX - thumb / 2) + .animation(.easeOut(duration: 0.12), value: viewModel.isScrubbing) + } + .frame(width: width, height: thumb) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .contentShape(Rectangle()) + .highPriorityGesture( + DragGesture(minimumDistance: 4) + .onChanged { value in + hideTask?.cancel() + viewModel.isScrubbing = true + let ratio = min(max(value.location.x / width, 0), 1) + viewModel.currentTime = ratio * duration + } + .onEnded { value in + let ratio = min(max(value.location.x / width, 0), 1) + viewModel.seek(to: ratio * duration) + viewModel.isScrubbing = false + scheduleAutoHide() + } + ) + } + .frame(height: 34) + } + + private func skipZone(seconds: Double, systemImage: String, flashing: Binding) -> some View { + Color.clear + .contentShape(Rectangle()) + .overlay { + Image(systemName: systemImage) + .font(.system(size: 38)) + .foregroundStyle(.white) + .padding(22) + .background(Circle().fill(Color.black.opacity(0.35))) + .opacity(flashing.wrappedValue ? 1 : 0) + } + .onTapGesture(count: 2) { + viewModel.skip(by: seconds) + flash(flashing) + } + .onTapGesture { + toggleControls() + } + } + + private func flash(_ binding: Binding) { + withAnimation(.easeIn(duration: 0.1)) { binding.wrappedValue = true } + Task { + try? await Task.sleep(nanoseconds: 450_000_000) + withAnimation(.easeOut(duration: 0.2)) { binding.wrappedValue = false } + } + } + + private func toggleControls() { + controlsVisible.toggle() + if controlsVisible { scheduleAutoHide() } + } + + private func scheduleAutoHide() { + hideTask?.cancel() + guard viewModel.isPlaying else { return } + hideTask = Task { + try? await Task.sleep(nanoseconds: 3_500_000_000) + guard !Task.isCancelled else { return } + controlsVisible = false + } + } + + private func rateLabel(_ rate: Float) -> String { + if rate == rate.rounded() { + return "\(Int(rate))x" + } + return "\(rate)x" + } + + private func timeString(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "00:00" } + let total = Int(seconds) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + let secs = total % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) + } + return String(format: "%02d:%02d", minutes, secs) + } +} diff --git a/RubyEvents/player/PlayerLayerView.swift b/RubyEvents/player/PlayerLayerView.swift new file mode 100644 index 0000000..81bf234 --- /dev/null +++ b/RubyEvents/player/PlayerLayerView.swift @@ -0,0 +1,33 @@ +// +// PlayerLayerView.swift +// RubyEvents +// + +import AVKit +import SwiftUI + +struct PlayerLayerView: UIViewRepresentable { + let player: AVPlayer + var onLayerReady: ((AVPlayerLayer) -> Void)? + + func makeUIView(context: Context) -> PlayerContainerView { + let view = PlayerContainerView() + view.playerLayer.player = player + view.playerLayer.videoGravity = .resizeAspect + view.backgroundColor = .black + onLayerReady?(view.playerLayer) + return view + } + + func updateUIView(_ uiView: PlayerContainerView, context: Context) { + uiView.playerLayer.player = player + } +} + +final class PlayerContainerView: UIView { + override static var layerClass: AnyClass { AVPlayerLayer.self } + + var playerLayer: AVPlayerLayer { + layer as! AVPlayerLayer + } +} diff --git a/RubyEvents/player/PlayerViewModel.swift b/RubyEvents/player/PlayerViewModel.swift new file mode 100644 index 0000000..b368437 --- /dev/null +++ b/RubyEvents/player/PlayerViewModel.swift @@ -0,0 +1,389 @@ +// +// PlayerViewModel.swift +// RubyEvents +// + +import AVKit +import UIKit +import Combine +import Foundation +import MediaPlayer +import YouTubePlayerKit + +final class PlayerViewModel: NSObject, ObservableObject { + @Published var isPlaying = false + @Published var isBuffering = true + @Published var currentTime: Double = 0 + @Published var duration: Double = 0 + @Published var isScrubbing = false + @Published var rate: Float = 1.0 + + let player: AVPlayer + let rateOptions: [Float] = [1.0, 1.25, 1.5, 1.75, 2.0] + + var onProgress: ((Double) -> Void)? + + private var timeObserver: Any? + private var statusObservation: NSKeyValueObservation? + private var pipController: AVPictureInPictureController? + private var lastReportedProgress: Double = 0 + private var hasStarted = false + @Published private(set) var isSeeking = false + + private let nowPlayingTitle: String? + private var nowPlayingArtist: String? + private let nowPlayingArtworkURL: URL? + + let youtubePlayer: YouTubePlayer? + var isYouTube: Bool { youtubePlayer != nil } + + private var cancellables = Set() + + init(url: URL?, title: String?, subtitle: String?, poster: URL? = nil, startAt: Double = 0, youtubeVideoID: String? = nil) { + nowPlayingTitle = title + nowPlayingArtist = subtitle + nowPlayingArtworkURL = poster + + if let youtubeVideoID { + var configuration = YouTubePlayer.Configuration() + + configuration.autoPlay = true + configuration.showControls = false + configuration.showCaptions = false + configuration.showFullscreenButton = false + configuration.showRelatedVideos = false + configuration.useModestBranding = true + + youtubePlayer = YouTubePlayer(source: .video(id: youtubeVideoID), configuration: configuration) + } else { + youtubePlayer = nil + } + + if let url { + let item = AVPlayerItem(url: url) + item.externalMetadata = Self.metadata(title: title, subtitle: subtitle) + player = AVPlayer(playerItem: item) + } else { + player = AVPlayer() + } + + super.init() + + if let youtubePlayer { + isBuffering = true + observeYouTube(youtubePlayer) + } + + guard player.currentItem != nil else { + if youtubePlayer == nil { isBuffering = false } + return + } + + if startAt > 1 { + player.seek(to: CMTime(seconds: startAt, preferredTimescale: 600)) + } + + addTimeObserver() + + statusObservation = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, _ in + let status = player.timeControlStatus + + DispatchQueue.main.async { + self?.isBuffering = (status == .waitingToPlayAtSpecifiedRate) + self?.isPlaying = (status == .playing) + } + } + } + + func start() { + guard !hasStarted else { return } + guard isYouTube || player.currentItem != nil else { return } + + hasStarted = true + + configureAudioSession() + setupRemoteCommands() + loadArtwork() + + if isYouTube { + isPlaying = true + } else { + player.playImmediately(atRate: rate) + isPlaying = true + } + + updateNowPlayingInfo() + } + + func teardown() { + reportProgress(force: true) + + if let youtubePlayer { + Task { try? await youtubePlayer.pause() } + } + + player.pause() + removeTimeObserver() + statusObservation?.invalidate() + statusObservation = nil + pipController = nil + teardownRemoteCommands() + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + deinit { + statusObservation?.invalidate() + removeTimeObserver() + } + + func togglePlay() { + if isPlaying { + pause() + } else { + play() + } + } + + func play() { + configureAudioSession() + + if let youtubePlayer { + Task { try? await youtubePlayer.play() } + } else { + player.playImmediately(atRate: rate) + } + + isPlaying = true + updateNowPlayingInfo() + } + + func pause() { + if let youtubePlayer { + Task { try? await youtubePlayer.pause() } + } else { + player.pause() + } + + isPlaying = false + updateNowPlayingInfo() + } + + func skip(by seconds: Double) { + let upperBound = duration > 0 ? duration : currentTime + seconds + + seek(to: max(0, min(currentTime + seconds, upperBound))) + } + + func seek(to seconds: Double) { + currentTime = seconds + isSeeking = true + updateNowPlayingInfo() + + if let youtubePlayer { + Task { try? await youtubePlayer.seek(to: Measurement(value: seconds, unit: UnitDuration.seconds), allowSeekAhead: true) } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in self?.isSeeking = false } + return + } + + player.seek(to: CMTime(seconds: seconds, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in + DispatchQueue.main.async { self?.isSeeking = false } + } + } + + func updateNowPlaying(artist: String) { + nowPlayingArtist = artist + updateNowPlayingInfo() + } + + func cycleRate() { + let index = rateOptions.firstIndex(of: rate) ?? 0 + rate = rateOptions[(index + 1) % rateOptions.count] + + if let youtubePlayer { + Task { try? await youtubePlayer.set(playbackRate: Double(rate)) } + } else if player.timeControlStatus == .playing { + player.rate = rate + } + + updateNowPlayingInfo() + } + + private func observeYouTube(_ youtubePlayer: YouTubePlayer) { + youtubePlayer.playbackStatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self else { return } + self.isPlaying = (state == .playing) + self.isBuffering = (state == .buffering) + } + .store(in: &cancellables) + + youtubePlayer.currentTimePublisher() + .receive(on: DispatchQueue.main) + .sink { [weak self] measurement in + guard let self, !self.isScrubbing, !self.isSeeking else { return } + self.currentTime = measurement.converted(to: .seconds).value + self.reportProgress(force: false) + } + .store(in: &cancellables) + + youtubePlayer.durationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] measurement in + let seconds = measurement.converted(to: .seconds).value + if seconds.isFinite, seconds > 0 { self?.duration = seconds } + } + .store(in: &cancellables) + } + + func setupPictureInPicture(with layer: AVPlayerLayer) { + guard AVPictureInPictureController.isPictureInPictureSupported(), pipController == nil else { return } + + pipController = AVPictureInPictureController(playerLayer: layer) + pipController?.canStartPictureInPictureAutomaticallyFromInline = true + } + + private func addTimeObserver() { + let interval = CMTime(seconds: 0.5, preferredTimescale: 600) + + timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + guard let self else { return } + + let playing = self.player.timeControlStatus == .playing + + if self.isPlaying != playing { + self.isPlaying = playing + } + + if let itemDuration = self.player.currentItem?.duration.seconds, + itemDuration.isFinite, itemDuration > 0, self.duration != itemDuration { + self.duration = itemDuration + self.updateNowPlayingInfo() + } + + if !self.isScrubbing, !self.isSeeking, time.seconds.isFinite { + self.currentTime = time.seconds + } + + self.reportProgress(force: false) + } + } + + private func removeTimeObserver() { + if let timeObserver { + player.removeTimeObserver(timeObserver) + + self.timeObserver = nil + } + } + + private func configureAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + // Inline playback still works, we just won't get background audio + } + } + + private func setupRemoteCommands() { + let center = MPRemoteCommandCenter.shared() + + center.playCommand.addTarget { [weak self] _ in self?.play(); return .success } + center.pauseCommand.addTarget { [weak self] _ in self?.pause(); return .success } + center.togglePlayPauseCommand.addTarget { [weak self] _ in self?.togglePlay(); return .success } + + center.skipForwardCommand.preferredIntervals = [10] + center.skipForwardCommand.addTarget { [weak self] _ in self?.skip(by: 10); return .success } + + center.skipBackwardCommand.preferredIntervals = [10] + center.skipBackwardCommand.addTarget { [weak self] _ in self?.skip(by: -10); return .success } + + center.changePlaybackPositionCommand.addTarget { [weak self] event in + guard let self, let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } + + self.seek(to: event.positionTime) + + return .success + } + } + + private func teardownRemoteCommands() { + let center = MPRemoteCommandCenter.shared() + + [ + center.playCommand, + center.pauseCommand, + center.togglePlayPauseCommand, + center.skipForwardCommand, + center.skipBackwardCommand, + center.changePlaybackPositionCommand + ].forEach { $0.removeTarget(nil) } + } + + private func updateNowPlayingInfo() { + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + + info[MPMediaItemPropertyTitle] = nowPlayingTitle ?? "" + + if let nowPlayingArtist { info[MPMediaItemPropertyArtist] = nowPlayingArtist } + if duration > 0 { info[MPMediaItemPropertyPlaybackDuration] = duration } + + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime + info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? Double(rate) : 0.0 + + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + + private func loadArtwork() { + guard let url = nowPlayingArtworkURL else { return } + + URLSession.shared.dataTask(with: url) { data, _, _ in + guard let data, let image = UIImage(data: data) else { return } + + let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + + DispatchQueue.main.async { + var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + info[MPMediaItemPropertyArtwork] = artwork + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + }.resume() + } + + private func reportProgress(force: Bool) { + guard currentTime.isFinite else { return } + + if force || currentTime - lastReportedProgress >= 10 { + lastReportedProgress = currentTime + onProgress?(currentTime) + } + } + + private static func metadata(title: String?, subtitle: String?) -> [AVMetadataItem] { + var items: [AVMetadataItem] = [] + + if let title { + items.append(metadataItem(.commonIdentifierTitle, value: title)) + } + + if let subtitle, !subtitle.isEmpty { + items.append(metadataItem(.commonIdentifierArtist, value: subtitle)) + } + + return items + } + + private static func metadataItem(_ identifier: AVMetadataIdentifier, value: String) -> AVMetadataItem { + let item = AVMutableMetadataItem() + + item.identifier = identifier + item.value = value as NSString + item.extendedLanguageTag = "und" + + return item + } +} diff --git a/RubyEvents/player/RoutePickerView.swift b/RubyEvents/player/RoutePickerView.swift new file mode 100644 index 0000000..92c6f05 --- /dev/null +++ b/RubyEvents/player/RoutePickerView.swift @@ -0,0 +1,23 @@ +// +// RoutePickerView.swift +// RubyEvents +// + +import AVKit +import SwiftUI + +struct RoutePickerView: UIViewRepresentable { + var tintColor: UIColor = .white + + func makeUIView(context: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + view.tintColor = tintColor + view.activeTintColor = .systemBlue + view.prioritizesVideoDevices = true + return view + } + + func updateUIView(_ uiView: AVRoutePickerView, context: Context) { + uiView.tintColor = tintColor + } +} diff --git a/RubyEvents/views/TalkPlayerScreen.swift b/RubyEvents/views/TalkPlayerScreen.swift new file mode 100644 index 0000000..1ed84da --- /dev/null +++ b/RubyEvents/views/TalkPlayerScreen.swift @@ -0,0 +1,523 @@ +// +// TalkPlayerScreen.swift +// RubyEvents +// + +import AVKit +import HotwireNative +import SwiftUI +import UIKit +import YouTubePlayerKit + +struct PlayerParams { + let slug: String? + let url: URL? + let title: String + let subtitle: String? + let poster: URL? + let startAt: Double + + var statusLabel: String? = nil + var youtubeVideoID: String? = nil +} + +struct TalkPlayerScreen: View { + @StateObject private var viewModel: PlayerViewModel + @State private var detail: TalkDetail? + @State private var isFullscreen = false + @State private var showDescriptionSheet = false + @State private var descriptionSheetFraction: CGFloat = 0.7 + @State private var dragOffset: CGFloat = 0 + + @SwiftUI.Environment(\.verticalSizeClass) private var verticalSizeClass: UserInterfaceSizeClass? + + private var dragProgress: CGFloat { + min(max(dragOffset / 300, 0), 1) + } + + private let params: PlayerParams + private let navigator: Navigator? + private let onProgress: (Double) -> Void + private let onDismiss: () -> Void + + init(params: PlayerParams, navigator: Navigator?, onProgress: @escaping (Double) -> Void, onDismiss: @escaping () -> Void, previewDetail: TalkDetail? = nil) { + self.params = params + self.navigator = navigator + self.onProgress = onProgress + self.onDismiss = onDismiss + _detail = State(initialValue: previewDetail) + _viewModel = StateObject(wrappedValue: PlayerViewModel( + url: params.url, + title: params.title, + subtitle: params.subtitle, + poster: params.poster, + startAt: params.startAt, + youtubeVideoID: params.youtubeVideoID + )) + } + + var body: some View { + GeometryReader { geo in + ZStack { + Color.black.opacity(1 - dragProgress).ignoresSafeArea() + + VStack(spacing: 0) { + playerArea + .frame( + width: geo.size.width, + height: isFullscreen ? geo.size.height : geo.size.width * 9.0 / 16.0 + ) + .gesture(dismissDragGesture) + .zIndex(1) + + if !isFullscreen { + ScrollView { + detailContent + .padding(.top, 16) + } + .background(Color(.systemBackground)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .offset(y: dragOffset) + } + .onAppear { + let full = geo.size.height + geo.safeAreaInsets.top + geo.safeAreaInsets.bottom + let videoBottom = geo.safeAreaInsets.top + geo.size.width * 9.0 / 16.0 + + if full > 0 { + descriptionSheetFraction = max(0.4, min(0.95, 1 - videoBottom / full)) + } + } + } + .ignoresSafeArea(edges: isFullscreen ? .all : []) + .task { await loadDetail() } + .onChange(of: verticalSizeClass) { sizeClass in + isFullscreen = (sizeClass == .compact) + } + .onAppear { + viewModel.onProgress = onProgress + viewModel.start() + } + .onDisappear { viewModel.teardown() } + } + + private var dismissDragGesture: some Gesture { + DragGesture(minimumDistance: 10, coordinateSpace: .global) + .onChanged { value in + guard !isFullscreen else { return } + + let translation = value.translation + + if translation.height > 0, translation.height > abs(translation.width) { + dragOffset = translation.height + } + } + .onEnded { _ in + guard !isFullscreen else { return } + + if dragOffset > 120 { + onDismiss() + } else { + withAnimation(.spring()) { dragOffset = 0 } + } + } + } + + private func toggleFullscreen() { + let goingFullscreen = !isFullscreen + + isFullscreen = goingFullscreen + requestOrientation(goingFullscreen ? .landscapeRight : .portrait) + } + + private func requestOrientation(_ mask: UIInterfaceOrientationMask) { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first else { return } + + scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) + } + + private var playerArea: some View { + ZStack { + Color.black + + if params.url == nil && params.youtubeVideoID == nil { + statusPlaceholder + } else { + if let youtubePlayer = viewModel.youtubePlayer { + YouTubePlayerView(youtubePlayer) + .allowsHitTesting(false) + + VStack(spacing: 0) { + Rectangle() + .fill(Color.black) + .frame(height: 46) + + LinearGradient(colors: [.black, .clear], startPoint: .top, endPoint: .bottom) + .frame(height: 34) + + Spacer(minLength: 0) + } + .allowsHitTesting(false) + } else { + PlayerLayerView(player: viewModel.player) { layer in + viewModel.setupPictureInPicture(with: layer) + } + } + + if let poster = params.poster, viewModel.currentTime == 0, !viewModel.isPlaying { + AsyncImage(url: poster) { image in + image.resizable().scaledToFit() + } placeholder: { + Color.black + } + } + + PlayerControlsView( + viewModel: viewModel, + title: detail?.title ?? params.title, + subtitle: detail?.seriesName ?? params.subtitle, + speakerName: detail?.speakerNames, + eventAvatarURL: detail?.event?.avatarURL, + isFullscreen: isFullscreen, + onToggleFullscreen: toggleFullscreen, + onDismiss: onDismiss + ) + + if (viewModel.isBuffering || viewModel.isSeeking) && !viewModel.isScrubbing { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + .scaleEffect(1.4) + .allowsHitTesting(false) + } + } + } + } + + private var statusPlaceholder: some View { + ZStack { + if let poster = params.poster { + AsyncImage(url: poster) { image in + image.resizable().scaledToFit() + } placeholder: { + Color.black + } + } + + Color.black.opacity(0.55) + + VStack(spacing: 8) { + Image(systemName: "clock") + .font(.system(size: 34)) + .foregroundStyle(.white) + + Text(params.statusLabel ?? "") + .font(.headline) + .foregroundStyle(.white) + } + + VStack { + HStack { + Button(action: onDismiss) { + Image(systemName: "chevron.down") + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + .padding(10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Spacer() + } + + Spacer() + } + .padding(4) + } + } + + private var detailContent: some View { + VStack(alignment: .leading, spacing: 16) { + Text(detail?.title ?? params.title) + .font(.title2.bold()) + .foregroundStyle(.primary) + + HStack(spacing: 12) { + if let date = detail?.formatted_date { + Text(date) + .font(.subheadline) + .foregroundStyle(.secondary) + } + if let kind = detail?.kind, !kind.isEmpty { + badge(kind.replacingOccurrences(of: "_", with: " ").uppercased()) + } + } + + Divider() + + if let speaker = detail?.speakers.first { + speakerRow(speaker) + } + + if let descriptionText = [detail?.summary, detail?.description].compactMap({ $0 }).first(where: { !$0.isEmpty }) { + descriptionSection(descriptionText) + } + + if let event = detail?.event, event.name != nil { + eventCard(event) + } + + if let related = detail?.related_talks, !related.isEmpty { + Text("Recommended") + .font(.title3.bold()) + .padding(.top, 8) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(related) { talk in + TalkCard(talk: talk, navigator: navigator) + } + } + .padding(.horizontal, 16) + } + .padding(.horizontal, -16) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 40) + } + + private func badge(_ text: String) -> some View { + Text(text) + .font(.caption2.weight(.semibold)) + .tracking(0.5) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .overlay( + Capsule().stroke(Color(.separator), lineWidth: 1) + ) + } + + private func speakerRow(_ speaker: TalkSpeaker) -> some View { + Button(action: { openSpeaker(speaker) }) { + HStack(spacing: 10) { + AsyncImage(url: speaker.avatarURL) { image in + image.resizable().scaledToFill() + } placeholder: { + Circle().fill(Color(.secondarySystemBackground)) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 1) { + let extra = (detail?.speakers.count ?? 0) - 1 + + Text(extra > 0 ? "\(speaker.name) +\(extra)" : speaker.name) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + + if let handle = speaker.github_handle, !handle.isEmpty { + Text("@\(handle)") + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + } + } + .buttonStyle(.plain) + } + + private func eventCard(_ event: TalkEvent) -> some View { + Button(action: { openEvent(event) }) { + HStack(spacing: 12) { + AsyncImage(url: event.avatarURL) { image in + image.resizable().scaledToFill() + } placeholder: { + RoundedRectangle(cornerRadius: 8).fill(Color(.tertiarySystemBackground)) + } + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 2) { + Text("Recorded at") + .font(.caption) + .foregroundStyle(.secondary) + + Text(event.name ?? "") + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(2) + + let meta = [event.dateText, event.location].compactMap { $0 }.joined(separator: " · ") + + if !meta.isEmpty { + Text(meta) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .padding(12) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(.plain) + } + + private func openEvent(_ event: TalkEvent) { + guard let slug = event.slug else { return } + let url = Router.instance.root_url().appendingPathComponent("/events/\(slug)") + onDismiss() + navigator?.route(url) + } + + private func descriptionSection(_ text: String) -> some View { + Button(action: { showDescriptionSheet = true }) { + VStack(alignment: .leading, spacing: 8) { + Text(text) + .font(.body) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + .lineLimit(3) + + Text("VIEW MORE") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.blue) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .sheet(isPresented: $showDescriptionSheet) { + descriptionSheet(text) + } + } + + private func descriptionSheet(_ text: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text("Description") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(detail?.title ?? params.title) + .font(.title3.bold()) + .foregroundStyle(.primary) + + Text(text).font(.body).foregroundStyle(.primary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color(.systemBackground)) + .presentationDetents([.fraction(descriptionSheetFraction), .large]) + .presentationDragIndicator(.visible) + } + + private func openSpeaker(_ speaker: TalkSpeaker) { + let url = Router.instance.speaker_url(slug: speaker.slug) + onDismiss() + navigator?.route(url) + } + + private func loadDetail() async { + guard detail == nil else { return } + guard let slug = params.slug, !slug.isEmpty else { return } + let endpoint = Router.instance.root_url() + .appendingPathComponent("/talks/\(slug).json") + .absoluteString + + do { + let response: TalkDetailResponse = try await APIService.shared.fetchData(from: endpoint) + detail = response.talk + + let parts = [response.talk.speakerNames, response.talk.event?.name] + .compactMap { $0 } + .filter { !$0.isEmpty } + if !parts.isEmpty { + viewModel.updateNowPlaying(artist: parts.joined(separator: " - ")) + } + } catch { + // Keep the bridged fallback metadata already shown; detail is best-effort. + } + } +} + +#Preview { + let speaker = TalkSpeaker( + id: 1, + name: "Aaron Patterson", + slug: "aaron-patterson", + bio: nil, + avatar_url: "https://avatars.githubusercontent.com/u/3124?v=4", + github_handle: "tenderlove" + ) + + let event = TalkEvent( + slug: "rubyconf-2024", + name: "RubyConf 2024", + series: nil, + avatar_url: nil, + start_date: "2024-10-01", + end_date: "2024-10-03", + location: "Chicago, IL" + ) + + let detail = TalkDetail( + id: 1, + slug: "the-life-of-a-ruby-object", + title: "The Life of a Ruby Object", + description: "A deep dive into how Ruby objects are allocated, live, and are eventually collected — with plenty of live demos and a few surprises along the way.", + summary: "A deep dive into how Ruby objects are allocated, live, and are eventually collected.", + formatted_date: "October 1, 2024", + kind: "talk", + video_provider: "mp4", + video_url: "https://assets.lrug.org/videos/2020/february/elena-tanasoiu-you-dont-know-what-you-dont-know-lrug-feb-2020.mp4", + thumbnail_url: nil, + duration_in_seconds: 1800, + event: event, + speakers: [speaker], + related_talks: Talk.samples() + ) + + let params = PlayerParams( + slug: detail.slug, + url: URL(string: detail.video_url!)!, + title: detail.title, + subtitle: event.name, + poster: nil, + startAt: 0 + ) + + return TalkPlayerScreen( + params: params, + navigator: nil, + onProgress: { _ in }, + onDismiss: {}, + previewDetail: detail + ) +}