diff --git a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift index 244dc25..4f181ba 100644 --- a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift +++ b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift @@ -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 ?? [] } } diff --git a/CCCApi/Sources/CCCApi/Models/Recording.swift b/CCCApi/Sources/CCCApi/Models/Recording.swift index a196efa..3eb8748 100644 --- a/CCCApi/Sources/CCCApi/Models/Recording.swift +++ b/CCCApi/Sources/CCCApi/Models/Recording.swift @@ -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 diff --git a/HackerTube/Features/Talk/RecordingChooser.swift b/HackerTube/Features/Talk/RecordingChooser.swift new file mode 100644 index 0000000..8d52088 --- /dev/null +++ b/HackerTube/Features/Talk/RecordingChooser.swift @@ -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) + 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 + } +} diff --git a/HackerTube/Features/Talk/TalkView.swift b/HackerTube/Features/Talk/TalkView.swift index 75df53a..28f628d 100644 --- a/HackerTube/Features/Talk/TalkView.swift +++ b/HackerTube/Features/Talk/TalkView.swift @@ -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) @@ -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() @@ -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) { @@ -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) { @@ -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" } } } diff --git a/HackerTube/Features/Talk/TalkViewModel.swift b/HackerTube/Features/Talk/TalkViewModel.swift index e2fff71..6ec9251 100644 --- a/HackerTube/Features/Talk/TalkViewModel.swift +++ b/HackerTube/Features/Talk/TalkViewModel.swift @@ -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' + ) + 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) } diff --git a/HackerTube/Localizable.xcstrings b/HackerTube/Localizable.xcstrings index 15922d1..e2eee33 100644 --- a/HackerTube/Localizable.xcstrings +++ b/HackerTube/Localizable.xcstrings @@ -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" : { @@ -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, @@ -304,5 +338,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file