Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
3 changes: 2 additions & 1 deletion RubyEvents/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions RubyEvents/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<dict>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
Expand Down
88 changes: 88 additions & 0 deletions RubyEvents/bridge/PlayerComponent.swift
Original file line number Diff line number Diff line change
@@ -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<TalkPlayerScreen>) {
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
}
}
20 changes: 17 additions & 3 deletions RubyEvents/components/cards/TalkCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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 }
)
}
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion RubyEvents/controllers/PageViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ struct PageViewController<Page: View>: 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)
}
}
5 changes: 2 additions & 3 deletions RubyEvents/controllers/TabBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Expand Down
75 changes: 74 additions & 1 deletion RubyEvents/models/Talk.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
102 changes: 102 additions & 0 deletions RubyEvents/models/TalkDetail.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading