Skip to content
Merged
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
8 changes: 8 additions & 0 deletions HackerTube.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,21 @@
);
target = D591393E2895851300369402 /* TopShelf */;
};
D5E0A1B22EC7F00100DEE71A /* Exceptions for "HackerTube" folder in "TopShelf" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Domain/DeepLink.swift,
);
target = D591393E2895851300369402 /* TopShelf */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
D5AFD6D92C9BFCF700BF89F5 /* HackerTube */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
D5AFD6F62C9BFCF700BF89F5 /* Exceptions for "HackerTube" folder in "HackerTube" target */,
D5E0A1B22EC7F00100DEE71A /* Exceptions for "HackerTube" folder in "TopShelf" target */,
);
path = HackerTube;
sourceTree = "<group>";
Expand Down
70 changes: 23 additions & 47 deletions HackerTube/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,73 +5,49 @@
// Created by Mathijs Bernson on 29/07/2022.
//

import MediaCCCApi
import SwiftUI

struct ContentView: View {
@State private var talk: TalkToPlay?
@State var error: Error?
@Environment(ApiService.self) var api
@Environment(AppCoordinator.self) private var coordinator

var body: some View {
TabView {
Tab("Recent", systemImage: "clock") {
BrowseView(query: .recent)
@Bindable var coordinator = coordinator

TabView(selection: $coordinator.selectedTab) {
Tab("Recent", systemImage: "clock", value: AppTab.recent) {
BrowseView(query: .recent, path: coordinator.path(for: .recent))
}

Tab("Popular", systemImage: "popcorn") {
BrowseView(query: .popular)
Tab("Popular", systemImage: "popcorn", value: AppTab.popular) {
BrowseView(query: .popular, path: coordinator.path(for: .popular))
}

Tab("Conferences", systemImage: "star") {
ConferencesView()
Tab("Conferences", systemImage: "star", value: AppTab.conferences) {
ConferencesView(path: coordinator.path(for: .conferences))
}

Tab("Search", systemImage: "magnifyingglass", role: .search) {
SearchView()
Tab("Search", systemImage: "magnifyingglass", value: AppTab.search, role: .search) {
SearchView(path: coordinator.path(for: .search))
}
}
.alert("Failed to load data from the media.ccc.de API", error: $error)
.fullScreenCover(item: $talk) { talk in
NavigationStack {
TalkView(talk: talk.talk, selectedRecording: talk.recordingToPlay)
#if os(tvOS) || os(visionOS)
.fullScreenCover(isPresented: $coordinator.isPlayerPresented) {
TalkPlayerView()
.ignoresSafeArea()
}
}
#endif
.onOpenURL { url in
let factory = URLParser()
guard let route = factory.parseURL(url) else { return }
Task {
await openRoute(route: route)
}
}
}

func openRoute(route: URLRoute) async {
do {
switch route {
case .openTalk(let id):
let talk = try await api.talk(id: id)
self.talk = TalkToPlay(talk: talk, recordingToPlay: nil)
case .playTalk(let id):
let talk = try await api.talk(id: id)
let recordings = try await api.recordings(for: talk)
let recording =
recordings.first(where: { $0.isHighQuality })
?? recordings.first(where: { $0.isVideo })
self.talk = TalkToPlay(talk: talk, recordingToPlay: recording)
if let link = DeepLink(url: url) {
coordinator.open(link)
}
} catch {
self.error = error
}
}
}

private struct TalkToPlay: Identifiable {
let id: Int = 1
let talk: Talk
let recordingToPlay: Recording?
}

#Preview {
let playback = PlaybackController()
ContentView()
.environment(ApiService())
.environment(playback)
.environment(AppCoordinator(playback: playback))
}
56 changes: 56 additions & 0 deletions HackerTube/Domain/DeepLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// DeepLink.swift
// HackerTube
//
// Created by Mathijs Bernson on 02/11/2026.
//

import Foundation

/// A location inside the app that can be reached from outside of it, such as from the tvOS Top Shelf.
///
/// Deep links are represented as `ccctube://` URLs. This type is shared with the Top Shelf extension,
/// so the URLs it produces and the URLs the app accepts are always defined in one place.
enum DeepLink: Equatable, Hashable, Sendable {
enum TalkAction: Equatable, Hashable, Sendable {
/// Show the talk's detail screen.
case open
/// Show the talk's detail screen and immediately start playback.
case play
}

case talk(id: String, action: TalkAction)

static let scheme = "ccctube"

init?(url: URL) {
guard url.scheme == Self.scheme else { return nil }
guard url.host == "talk" else { return nil }

let components = url.pathComponents.filter { $0 != "/" }
guard let id = components.first, !id.isEmpty else { return nil }

if components.count >= 2, components.last == "play" {
self = .talk(id: id, action: .play)
} else {
self = .talk(id: id, action: .open)
}
}

var url: URL {
var components = URLComponents()
components.scheme = Self.scheme
switch self {
case .talk(let id, let action):
components.host = "talk"
switch action {
case .open:
components.path = "/\(id)"
case .play:
components.path = "/\(id)/play"
}
}
// The components above are always valid, so this can't fail.
return components.url!
}
}
7 changes: 5 additions & 2 deletions HackerTube/Features/Browse/BrowseView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ enum EventsQuery: Equatable {

struct BrowseView: View {
let query: EventsQuery
@Binding var path: [Destination]
@State var year: Int = Calendar.current.component(.year, from: .now)
@State var talks: [Talk] = []
@State var isLoading = true
@State var error: Error?
@Environment(ApiService.self) var api

var body: some View {
NavigationStack {
NavigationStack(path: $path) {
ScrollView {
#if os(tvOS)
if query == .popular {
Expand Down Expand Up @@ -80,6 +81,7 @@ struct BrowseView: View {
await refresh()
}
.alert("Failed to load data from the media.ccc.de API", error: $error)
.appDestinations()
}
}

Expand Down Expand Up @@ -113,5 +115,6 @@ private struct YearPicker: View {
}

#Preview {
BrowseView(query: .popular, talks: [.example])
BrowseView(query: .popular, path: .constant([]), talks: [.example])
.environment(ApiService())
}
8 changes: 2 additions & 6 deletions HackerTube/Features/Conferences/ConferencesGrid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ private struct ConferencesGridRegular: View {
var body: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(conferences) { conference in
NavigationLink {
ConferenceView(conference: conference)
} label: {
NavigationLink(value: Destination.conference(conference)) {
ConferenceCell(conference: conference)
#if os(visionOS)
.padding()
Expand Down Expand Up @@ -61,9 +59,7 @@ private struct ConferencesGridRegular: View {
var body: some View {
LazyVGrid(columns: columns, spacing: 64) {
ForEach(conferences) { conference in
NavigationLink {
ConferenceView(conference: conference)
} label: {
NavigationLink(value: Destination.conference(conference)) {
ConferenceThumbnail(conference: conference)
.background(.primary)
.hoverEffect(.highlight)
Expand Down
7 changes: 5 additions & 2 deletions HackerTube/Features/Conferences/ConferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import MediaCCCApi
import SwiftUI

struct ConferencesView: View {
@Binding var path: [Destination]
@State var conferences: [Conference] = []
@State var filterQuery = ""
@State var isLoading = true
@State var error: Error?
@Environment(ApiService.self) var api

var body: some View {
NavigationStack {
NavigationStack(path: $path) {
ScrollView {
let filterQuery = filterQuery.lowercased()
let conferences: [Conference] = filterQuery.isEmpty
Expand Down Expand Up @@ -44,6 +45,7 @@ struct ConferencesView: View {
await refresh()
}
.alert("Failed to load data from the media.ccc.de API", error: $error)
.appDestinations()
}
}

Expand All @@ -67,5 +69,6 @@ struct ConferencesView: View {
}

#Preview {
ConferencesView()
ConferencesView(path: .constant([]))
.environment(ApiService())
}
11 changes: 6 additions & 5 deletions HackerTube/Features/Search/SearchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import MediaCCCApi
import SwiftUI

struct SearchView: View {
@Binding var path: [Destination]
@State var searchContext = SearchContext()
@State var results: [Talk] = []
@State var query = ""
Expand All @@ -18,14 +19,12 @@ struct SearchView: View {
@Environment(ApiService.self) var api

var body: some View {
NavigationStack {
NavigationStack(path: $path) {
Group {
#if os(tvOS)
List {
ForEach(results) { talk in
NavigationLink {
TalkView(talk: talk)
} label: {
NavigationLink(value: Destination.talk(talk)) {
TalkListItem(talk: talk)
}

Expand Down Expand Up @@ -74,6 +73,7 @@ struct SearchView: View {
.onAppear(perform: runSearch)
.onSubmit(of: .search, runSearch)
.alert("Failed to load data from the media.ccc.de API", error: $error)
.appDestinations()
}
}

Expand Down Expand Up @@ -103,5 +103,6 @@ struct SearchView: View {
}

#Preview {
SearchView()
SearchView(path: .constant([]))
.environment(ApiService())
}
44 changes: 44 additions & 0 deletions HackerTube/Features/Talk/DeepLinkedTalkView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// DeepLinkedTalkView.swift
// HackerTube
//
// Created by Mathijs Bernson on 02/11/2026.
//

import MediaCCCApi
import SwiftUI

/// Shows a talk that is only known by its identifier, such as the target of a deep link.
struct DeepLinkedTalkView: View {
let id: String
let autoplay: Bool

@State private var talk: Talk?
@State private var error: Error?
@State private var didFail = false
@Environment(ApiService.self) private var api

var body: some View {
Group {
if let talk {
TalkView(talk: talk, autoplay: autoplay)
} else if didFail {
ContentUnavailableView("Talk not available", systemImage: "exclamationmark.triangle")
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task(id: id) {
guard talk?.id != id else { return }
do {
talk = try await api.talk(id: id)
} catch is CancellationError {
} catch {
self.error = error
didFail = true
}
}
.alert("Failed to load data from the media.ccc.de API", error: $error)
}
}
Loading