Skip to content
Open
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
50 changes: 23 additions & 27 deletions CCCApi/Sources/CCCApi/MediaCCCApiClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
//

import Foundation
import os.log

public final class MediaCCCApiClient {
private let session: URLSession
private let baseURL = URL(string: "https://api.media.ccc.de/public")!
private let decoder = JSONDecoder()
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CCCApi")

public init(urlSession: URLSession = .shared) {
session = urlSession
Expand All @@ -20,35 +22,42 @@ public final class MediaCCCApiClient {
// MARK: Conferences

public func conferences() async throws -> [Conference] {
let (data, _) = try await session.data(from: baseURL.appendingPathComponent("conferences"))
let url = baseURL.appendingPathComponent("conferences")
logger.debug("GET \(url)")
let (data, _) = try await session.data(from: url)
let response = try decoder.decode(ConferencesResponse.self, from: data)
return response.conferences
}

public func conference(acronym: String) async throws -> Conference {
let (data, _) = try await session.data(
from: baseURL.appendingPathComponent("conferences").appendingPathComponent(acronym))
let url = baseURL.appendingPathComponent("conferences").appendingPathComponent(acronym)
logger.debug("GET \(url)")
let (data, _) = try await session.data(from: url)
return try decoder.decode(Conference.self, from: data)
}

// MARK: Talks

public func talk(id: String) async throws -> Talk {
let (data, _) = try await session.data(
from: baseURL.appendingPathComponent("events").appendingPathComponent(id))
let url = baseURL.appendingPathComponent("events").appendingPathComponent(id)
logger.debug("GET \(url)")
let (data, _) = try await session.data(from: url)
let response = try decoder.decode(Talk.self, from: data)
return response
}

public func talks() async throws -> [Talk] {
let (data, _) = try await session.data(from: baseURL.appendingPathComponent("events"))
let url = baseURL.appendingPathComponent("events")
logger.debug("GET \(url)")
let (data, _) = try await session.data(from: url)
let response = try decoder.decode(EventsResponse.self, from: data)
return response.events
}

public func recentTalks() async throws -> [Talk] {
let (data, _) = try await session.data(
from: baseURL.appendingPathComponent("events").appendingPathComponent("recent"))
let url = baseURL.appendingPathComponent("events").appendingPathComponent("recent")
logger.debug("GET \(url)")
let (data, _) = try await session.data(from: url)
let response = try decoder.decode(EventsResponse.self, from: data)
return response.events
}
Expand All @@ -57,6 +66,7 @@ public final class MediaCCCApiClient {
let url = baseURL.appendingPathComponent("events").appendingPathComponent("popular")
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
components.queryItems = [URLQueryItem(name: "year", value: String(year))]
logger.debug("GET \(components.url!)")
let (data, _) = try await session.data(from: components.url!)
let response = try decoder.decode(EventsResponse.self, from: data)
return response.events
Expand All @@ -66,6 +76,7 @@ public final class MediaCCCApiClient {
let url = baseURL.appendingPathComponent("events").appendingPathComponent("search")
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
components.queryItems = [URLQueryItem(name: "q", value: query)]
logger.debug("GET \(components.url!)")
let (data, _) = try await session.data(from: components.url!)
let response = try decoder.decode(EventsResponse.self, from: data)
return response.events
Expand All @@ -74,25 +85,10 @@ public final class MediaCCCApiClient {
// MARK: Recordings

public func recordings(for talk: Talk) async throws -> [Recording] {
let (data, _) = try await session.data(
from: baseURL.appendingPathComponent("events").appendingPathComponent(talk.guid))
let url = baseURL.appendingPathComponent("events").appendingPathComponent(talk.guid)
logger.debug("GET \(url)")
let (data, _) = try await session.data(from: url)
let response = try decoder.decode(TalkExtended.self, from: data)
guard let recordings = response.recordings else {
return []
}
return
recordings
// Remove formats Apple doesn't support
.filter { !$0.mimeType.contains("opus") }
.filter { !$0.mimeType.contains("webm") }
.filter { !$0.mimeType.starts(with: "application") }
// Put the HD versions first
.sorted(by: { lhs, rhs in
lhs.isHighQuality && !rhs.isHighQuality
})
// Put the audio versions last
.sorted(by: { lhs, rhs in
!lhs.isAudio && rhs.isAudio
})
return response.recordings ?? []
}
}
2 changes: 1 addition & 1 deletion CCCApi/Sources/CCCApi/Models/Recording.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation

/// A recording is a file that belongs to a talk (event).
/// These can be video or audio recordings of the talk in different formats and languages (live-translation), subtitle tracks as srt or slides as pdf.
public struct Recording: Decodable, Identifiable, Equatable, Sendable {
public struct Recording: Decodable, Identifiable, Hashable, Sendable {
/// approximate file size in megabytes
public let size: Int?
/// duration in seconds
Expand Down
87 changes: 87 additions & 0 deletions HackerTube/Features/Talk/RecordingChooser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// RecordingChooser.swift
// HackerTube
//
// Created by Mathijs Bernson on 13/01/2026.
//

import AVFoundation
import CCCApi
import Foundation

struct RecordingChooser {
let preferredLanguages: [Locale]

init(preferredLanguages: [Locale] = {
if #available(iOS 26, tvOS 26, macOS 26, *) {
return Locale.preferredLocales
} else {
return Locale.preferredLanguages.map(Locale.init(identifier:))
}
}()) {
self.preferredLanguages = preferredLanguages
}

/// Checks whether the app on the current OS can play back the given recording.
func canPlay(recording: Recording) -> Bool {
let mimeType = recording.mimeType

// Ignore any files that are attachments (talk slides) etc.
if mimeType.starts(with: "application") {
return false
}

// Subtitle file, this cannot be played back
if mimeType.starts(with: "text") {
return false
}

return AVURLAsset.isPlayableExtendedMIMEType(mimeType)
}

/// Selects the best recording to be played, based on the parameters and user preferences.
func choosePreferredRecording(
from recordings: [Recording],
prefersHighQuality: Bool,
prefersAudio: Bool
) -> Recording? {
return recordings
// Remove everything that's not playable
.filter(canPlay)
// Put the audio versions first, if desired
.sorted(by: { lhs, rhs in
if prefersAudio {
return lhs.isAudio && !rhs.isAudio
} else {
return !lhs.isAudio && rhs.isAudio
}
})
// Sort by language preference
.sorted { lhs, rhs in
let l = languageScore(for: lhs)
let r = languageScore(for: rhs)
if l.0 != r.0 { return l.0 > r.0 }
return l.1 < r.1
}
// Put the HD versions first, if desired
.sorted(by: { lhs, rhs in
if prefersHighQuality {
return lhs.isHighQuality && !rhs.isHighQuality
} else {
return !lhs.isHighQuality && rhs.isHighQuality
}
})
.first
}

private func languageScore(for recording: Recording) -> (Int, Int) {
let preferredCodes = preferredLanguages.compactMap { $0.language.languageCode }
// Normalize ISO 639-2 codes (e.g. "eng") to BCP-47 (e.g. "en") via Locale
let recordingCodes = recording.language
.split(separator: "-")
.compactMap { Locale(identifier: String($0)).language.languageCode }
let matchCount = recordingCodes.filter { preferredCodes.contains($0) }.count
let firstMatchIndex = preferredCodes.firstIndex { recordingCodes.contains($0) } ?? Int.max
return (matchCount, firstMatchIndex)
}
}
68 changes: 59 additions & 9 deletions HackerTube/Features/Talk/TalkView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,21 @@ private struct TVPlayerView: View {

private struct TalkMainView: View {
let talk: Talk
var viewModel: TalkViewModel
@Bindable var viewModel: TalkViewModel

var body: some View {
VStack(alignment: .leading, spacing: 20) {
Group {
#if os(tvOS) || os(visionOS)
TVPlayerView(talk: talk, recording: viewModel.preferredRecording)
TVPlayerView(talk: talk, recording: viewModel.selectedRecording)
#else
Group {
if let preferredRecording = viewModel.preferredRecording {
if let selectedRecording = viewModel.selectedRecording {
TalkPlayerView(
talk: talk, recording: preferredRecording,
automaticallyStartsPlayback: true)
talk: talk,
recording: selectedRecording,
automaticallyStartsPlayback: true
)
} else {
Rectangle()
.fill(.black)
Expand All @@ -117,9 +119,13 @@ private struct TalkMainView: View {
.padding(.horizontal)
}

CopyrightView(talk: talk, viewModel: viewModel)
.padding(.horizontal)
RecordingSelectionView(
recordings: viewModel.recordings,
selectedRecording: $viewModel.selectedRecording
)
.padding(.horizontal)
}
.padding(.bottom)
.animation(.default, value: viewModel.copyright)
#if os(tvOS)
.focusSection()
Expand All @@ -138,7 +144,7 @@ private struct TalkMainView: View {

private struct CopyrightView: View {
let talk: Talk
var viewModel: TalkViewModel
@Bindable var viewModel: TalkViewModel

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Expand Down Expand Up @@ -246,7 +252,7 @@ private struct TalkDescriptionSheetView: View {
private struct TalkMetaView: View {
let talk: Talk
@Binding var selectedRecording: Recording?
var viewModel: TalkViewModel
@Bindable var viewModel: TalkViewModel

var body: some View {
VStack(alignment: .leading, spacing: 20) {
Expand All @@ -269,6 +275,50 @@ private struct TalkMetaView: View {
if !talk.persons.isEmpty {
Label(talk.persons.joined(separator: ", "), systemImage: "person")
}

CopyrightView(talk: talk, viewModel: viewModel)
}
}
}

private struct RecordingSelectionView: View {
let recordings: [Recording]
@Binding var selectedRecording: Recording?

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Recording")
.font(.headline)

Picker(selection: $selectedRecording) {
ForEach(recordings) { recording in
Label(recording.description, systemImage: recording.systemImageName)
.tag(recording)
}
} label: {
Text("Select recording")
}
.pickerStyle(.menu)
}
}
}

extension Recording {
var description: String {
if let width, let height, width > 0 && height > 0 {
return "\(language) \(folder) (\(width)x\(height))"
} else {
return "\(language) \(folder)"
}
}

var systemImageName: String {
if mimeType.starts(with: "video") {
return "film"
} else if mimeType.starts(with: "audio") {
return "speaker.wave.3"
} else {
return "questionmark"
}
}
}
Expand Down
30 changes: 23 additions & 7 deletions HackerTube/Features/Talk/TalkViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,41 @@ enum CopyrightState: Equatable {
class TalkViewModel {
var currentTalk: Talk?
var recordings: [Recording] = []
var preferredRecording: Recording?
var selectedRecording: Recording?
var copyright: CopyrightState = .loading

private let client: MediaCCCApiClient
private let mediaAnalyzer: MediaAnalyzer
private let recordingChooser: RecordingChooser

init() {
client = .init()
mediaAnalyzer = .init()
recordingChooser = .init()
}

func loadRecordings(for talk: Talk) async throws {
let recordings = try await client.recordings(for: talk)
// Populate the recordings dropdown
recordings = try await client.recordings(for: talk)
// Remove everything that's not playable
.filter { recordingChooser.canPlay(recording: $0) }
// Put the HD versions first
.sorted(by: { lhs, rhs in
return lhs.isHighQuality && !rhs.isHighQuality
})
// Put the audio versions last
.sorted(by: { lhs, rhs in
return !lhs.isAudio && rhs.isAudio
})

// Pre-select the preferred recording
selectedRecording = recordingChooser.choosePreferredRecording(
from: recordings,
prefersHighQuality: true, // TODO: Hook up with a preference and/or respect low data mode
prefersAudio: false // TODO: Hook up with a preference 'prefer audio'
)
Comment on lines +50 to +54
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we have a Settings screen in the app, it should be possible to:


currentTalk = talk
self.recordings = recordings
let hdRecording = recordings.first(where: { $0.isHighQuality && $0.isVideo })
let sdRecording = recordings.first(where: { !$0.isHighQuality && $0.isVideo })
let audioRecording = recordings.first(where: { $0.isAudio })
preferredRecording = hdRecording ?? sdRecording ?? audioRecording

await loadCopyright(for: recordings)
}
Expand Down
Loading