From d8bde2d7d5d6ecf06c2daf82ba81a71bf259d31e Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Thu, 9 Jul 2026 01:13:25 +0200 Subject: [PATCH] Native Talk Player View --- .../UserInterfaceState.xcuserstate | Bin 15413 -> 162916 bytes RubyEvents/AppDelegate.swift | 3 +- RubyEvents/Info.plist | 4 + RubyEvents/bridge/PlayerComponent.swift | 88 +++ RubyEvents/components/cards/TalkCard.swift | 20 +- .../controllers/PageViewController.swift | 4 +- RubyEvents/controllers/TabBarController.swift | 5 +- RubyEvents/models/Talk.swift | 75 ++- RubyEvents/models/TalkDetail.swift | 102 ++++ RubyEvents/player/PlayerControlsView.swift | 298 ++++++++++ RubyEvents/player/PlayerLayerView.swift | 33 ++ RubyEvents/player/PlayerViewModel.swift | 389 +++++++++++++ RubyEvents/player/RoutePickerView.swift | 23 + RubyEvents/views/TalkPlayerScreen.swift | 523 ++++++++++++++++++ 14 files changed, 1558 insertions(+), 9 deletions(-) create mode 100644 RubyEvents/bridge/PlayerComponent.swift create mode 100644 RubyEvents/models/TalkDetail.swift create mode 100644 RubyEvents/player/PlayerControlsView.swift create mode 100644 RubyEvents/player/PlayerLayerView.swift create mode 100644 RubyEvents/player/PlayerViewModel.swift create mode 100644 RubyEvents/player/RoutePickerView.swift create mode 100644 RubyEvents/views/TalkPlayerScreen.swift diff --git a/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate b/RubyEvents.xcodeproj/project.xcworkspace/xcuserdata/marcoroth.xcuserdatad/UserInterfaceState.xcuserstate index b82f64c13680c65ed1ca02b68a54e49859bce5e7..b6da33bc0a47b752925e9a9c5e0a60de5bbbc3ff 100644 GIT binary patch literal 162916 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∈=kRMj4fQlf_ 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�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 JJ$Y=IUiql`kKWz<4LDRe+vmI^r+ktqmN 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 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 + ) +}