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
18 changes: 1 addition & 17 deletions CCCApi/Sources/CCCApi/MediaCCCApiClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,22 +77,6 @@ public final class MediaCCCApiClient {
let (data, _) = try await session.data(
from: baseURL.appendingPathComponent("events").appendingPathComponent(talk.guid))
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
56 changes: 56 additions & 0 deletions HackerTube/Features/Talk/RecordingChooser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// RecordingChooser.swift
// HackerTube
//
// Created by Mathijs Bernson on 13/01/2026.
//

import CCCApi
import Foundation

struct RecordingChooser {
func canPlay(recording: Recording) -> Bool {
// AVPlayer cannot play Webm video
if recording.mimeType.contains("webm") {
return false
}
// Ignore any files that are attachments (talk slides) etc.
if recording.mimeType.starts(with: "application") {
return false
}
// Subtitle file, this cannot be played back
if recording.mimeType.starts(with: "text") {
return false
}

return true
}

func choosePreferredRecording(
from recordings: [Recording],
prefersHighQuality: Bool,
prefersAudio: Bool
) -> Recording? {
// TODO: Sort according to the user's preferred language(s)
Copy link
Owner Author

Choose a reason for hiding this comment

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

The initially selected recording should be picked based on the user's language

return recordings
// Remove everything that's not playable
.filter { canPlay(recording: $0) }
// 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
}
})
// 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
}
}
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
36 changes: 35 additions & 1 deletion HackerTube/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,23 @@
}
}
},
"Recording" : {
"comment" : "A label displayed above a picker for selecting a recording, meaning the file to be played back.",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aufnahme"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Opname"
}
}
}
},
"Search" : {
"comment" : "Heading for the search view and tab.",
"localizations" : {
Expand Down Expand Up @@ -285,6 +302,23 @@
}
}
},
"Select recording" : {
"comment" : "A label for a picker that lets a user select a recording, meaning the file to be played back.",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aufnahme auswählen"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kies opname"
}
}
}
},
"Year" : {
"comment" : "A label that indicates the year to filter talks by.",
"isCommentAutoGenerated" : true,
Expand All @@ -304,5 +338,5 @@
}
}
},
"version" : "1.0"
"version" : "1.1"
}