From dc2e9c7a1ec7d109f23fc7eeb54f0da5421b576e Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Mon, 2 Nov 2026 15:53:12 +0100 Subject: [PATCH 1/2] Refactor navigation and playback --- HackerTube.xcodeproj/project.pbxproj | 8 + HackerTube/ContentView.swift | 70 +++---- HackerTube/Domain/DeepLink.swift | 56 ++++++ HackerTube/Features/Browse/BrowseView.swift | 7 +- .../Conferences/ConferencesGrid.swift | 8 +- .../Conferences/ConferencesView.swift | 7 +- HackerTube/Features/Search/SearchView.swift | 11 +- .../Features/Talk/DeepLinkedTalkView.swift | 44 +++++ HackerTube/Features/Talk/TalkPlayerView.swift | 58 ++---- .../Features/Talk/TalkPlayerViewModel.swift | 112 ----------- .../Features/Talk/TalkVideoPlayerView.swift | 38 +++- HackerTube/Features/Talk/TalkView.swift | 32 ++- HackerTube/Features/Talk/TalksGrid.swift | 8 +- .../VideoPlayer/VideoPlayerView.swift | 41 ++-- HackerTube/HackerTubeApp.swift | 18 +- HackerTube/Localizable.xcstrings | 34 +++- HackerTube/Navigation/AppCoordinator.swift | 101 ++++++++++ HackerTube/Navigation/AppTab.swift | 14 ++ HackerTube/Navigation/Destination.swift | 75 ++++++++ HackerTube/Playback/PlaybackController.swift | 182 ++++++++++++++++++ .../PlayerViewControllerDelegate.swift | 50 +++++ HackerTube/URLParser.swift | 26 --- HackerTube/URLRoute.swift | 13 -- HackerTubeTests/AppCoordinatorTests.swift | 102 ++++++++++ HackerTubeTests/DeepLinkTests.swift | 48 +++++ HackerTubeTests/URLParserTests.swift | 36 ---- TopShelf/TopShelfContentFactory.swift | 6 +- 27 files changed, 861 insertions(+), 344 deletions(-) create mode 100644 HackerTube/Domain/DeepLink.swift create mode 100644 HackerTube/Features/Talk/DeepLinkedTalkView.swift delete mode 100644 HackerTube/Features/Talk/TalkPlayerViewModel.swift create mode 100644 HackerTube/Navigation/AppCoordinator.swift create mode 100644 HackerTube/Navigation/AppTab.swift create mode 100644 HackerTube/Navigation/Destination.swift create mode 100644 HackerTube/Playback/PlaybackController.swift create mode 100644 HackerTube/Playback/PlayerViewControllerDelegate.swift delete mode 100644 HackerTube/URLParser.swift delete mode 100644 HackerTube/URLRoute.swift create mode 100644 HackerTubeTests/AppCoordinatorTests.swift create mode 100644 HackerTubeTests/DeepLinkTests.swift delete mode 100644 HackerTubeTests/URLParserTests.swift diff --git a/HackerTube.xcodeproj/project.pbxproj b/HackerTube.xcodeproj/project.pbxproj index 6df56bc..be89f5b 100644 --- a/HackerTube.xcodeproj/project.pbxproj +++ b/HackerTube.xcodeproj/project.pbxproj @@ -71,6 +71,13 @@ ); 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 */ @@ -78,6 +85,7 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( D5AFD6F62C9BFCF700BF89F5 /* Exceptions for "HackerTube" folder in "HackerTube" target */, + D5E0A1B22EC7F00100DEE71A /* Exceptions for "HackerTube" folder in "TopShelf" target */, ); path = HackerTube; sourceTree = ""; diff --git a/HackerTube/ContentView.swift b/HackerTube/ContentView.swift index ee5dfe6..72bb5a8 100644 --- a/HackerTube/ContentView.swift +++ b/HackerTube/ContentView.swift @@ -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) + .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)) } diff --git a/HackerTube/Domain/DeepLink.swift b/HackerTube/Domain/DeepLink.swift new file mode 100644 index 0000000..4d8d402 --- /dev/null +++ b/HackerTube/Domain/DeepLink.swift @@ -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! + } +} diff --git a/HackerTube/Features/Browse/BrowseView.swift b/HackerTube/Features/Browse/BrowseView.swift index 31eac6a..7f58afe 100644 --- a/HackerTube/Features/Browse/BrowseView.swift +++ b/HackerTube/Features/Browse/BrowseView.swift @@ -24,6 +24,7 @@ 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 @@ -31,7 +32,7 @@ struct BrowseView: View { @Environment(ApiService.self) var api var body: some View { - NavigationStack { + NavigationStack(path: $path) { ScrollView { #if os(tvOS) if query == .popular { @@ -80,6 +81,7 @@ struct BrowseView: View { await refresh() } .alert("Failed to load data from the media.ccc.de API", error: $error) + .appDestinations() } } @@ -113,5 +115,6 @@ private struct YearPicker: View { } #Preview { - BrowseView(query: .popular, talks: [.example]) + BrowseView(query: .popular, path: .constant([]), talks: [.example]) + .environment(ApiService()) } diff --git a/HackerTube/Features/Conferences/ConferencesGrid.swift b/HackerTube/Features/Conferences/ConferencesGrid.swift index c703d03..f883527 100644 --- a/HackerTube/Features/Conferences/ConferencesGrid.swift +++ b/HackerTube/Features/Conferences/ConferencesGrid.swift @@ -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() @@ -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) diff --git a/HackerTube/Features/Conferences/ConferencesView.swift b/HackerTube/Features/Conferences/ConferencesView.swift index 0fa6f27..bfa498b 100644 --- a/HackerTube/Features/Conferences/ConferencesView.swift +++ b/HackerTube/Features/Conferences/ConferencesView.swift @@ -9,6 +9,7 @@ import MediaCCCApi import SwiftUI struct ConferencesView: View { + @Binding var path: [Destination] @State var conferences: [Conference] = [] @State var filterQuery = "" @State var isLoading = true @@ -16,7 +17,7 @@ struct ConferencesView: View { @Environment(ApiService.self) var api var body: some View { - NavigationStack { + NavigationStack(path: $path) { ScrollView { let filterQuery = filterQuery.lowercased() let conferences: [Conference] = filterQuery.isEmpty @@ -44,6 +45,7 @@ struct ConferencesView: View { await refresh() } .alert("Failed to load data from the media.ccc.de API", error: $error) + .appDestinations() } } @@ -67,5 +69,6 @@ struct ConferencesView: View { } #Preview { - ConferencesView() + ConferencesView(path: .constant([])) + .environment(ApiService()) } diff --git a/HackerTube/Features/Search/SearchView.swift b/HackerTube/Features/Search/SearchView.swift index a4361e7..4b3fed0 100644 --- a/HackerTube/Features/Search/SearchView.swift +++ b/HackerTube/Features/Search/SearchView.swift @@ -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 = "" @@ -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) } @@ -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() } } @@ -103,5 +103,6 @@ struct SearchView: View { } #Preview { - SearchView() + SearchView(path: .constant([])) + .environment(ApiService()) } diff --git a/HackerTube/Features/Talk/DeepLinkedTalkView.swift b/HackerTube/Features/Talk/DeepLinkedTalkView.swift new file mode 100644 index 0000000..9fe7a66 --- /dev/null +++ b/HackerTube/Features/Talk/DeepLinkedTalkView.swift @@ -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) + } +} diff --git a/HackerTube/Features/Talk/TalkPlayerView.swift b/HackerTube/Features/Talk/TalkPlayerView.swift index 72532fd..3c03640 100644 --- a/HackerTube/Features/Talk/TalkPlayerView.swift +++ b/HackerTube/Features/Talk/TalkPlayerView.swift @@ -8,60 +8,28 @@ import MediaCCCApi import SwiftUI +/// Shows the app-wide shared player. What it plays is controlled by the ``PlaybackController``. struct TalkPlayerView: View { - let talk: Talk - let recording: Recording - let automaticallyStartsPlayback: Bool + /// The talk this view is shown for, or `nil` when it shows whatever is playing (the tvOS full-screen player). + var talk: Talk? = nil - @State private var isLoading = false - @State private var viewModel = TalkPlayerViewModel() + @Environment(PlaybackController.self) private var playback var body: some View { - VideoPlayerView(player: viewModel.player) + VideoPlayerView(playback: playback) .accessibilityIdentifier("Video") - .task(id: recording) { - guard recording != viewModel.currentRecording else { return } - - isLoading = true - await viewModel.prepareForPlayback(recording: recording, talk: talk) - if automaticallyStartsPlayback { - viewModel.play() - } else { - await viewModel.preroll() - } - isLoading = false - } .onDisappear { - viewModel.pause() - UIApplication.shared.isIdleTimerDisabled = false - } - .onChange(of: viewModel.isPlaying) { _, isPlaying in - // Keep the display awake while playback is in progress. - UIApplication.shared.isIdleTimerDisabled = isPlaying - } - #if os(iOS) - .overlay { - if isLoading { - VideoProgressIndicator() - } + // Keep playing when the video continues in Picture in Picture, or when the shared player has + // already moved on to another talk (that is why this view is being removed). + let showsCurrentTalk = talk == nil || playback.currentTalk?.id == talk?.id + if showsCurrentTalk && !playback.isPictureInPictureActive { + playback.pause() } - #endif + } } } -#if os(iOS) - private struct VideoProgressIndicator: View { - var body: some View { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.large) - .padding(10) - .background(in: Circle()) - .backgroundStyle(.regularMaterial) - } - } -#endif - #Preview { - TalkPlayerView(talk: .example, recording: .example, automaticallyStartsPlayback: false) + TalkPlayerView() + .environment(PlaybackController()) } diff --git a/HackerTube/Features/Talk/TalkPlayerViewModel.swift b/HackerTube/Features/Talk/TalkPlayerViewModel.swift deleted file mode 100644 index 4fc8bc9..0000000 --- a/HackerTube/Features/Talk/TalkPlayerViewModel.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// TalkPlayerViewModel.swift -// HackerTube -// -// Created by Mathijs Bernson on 29/12/2023. -// - -import AVKit -import MediaCCCApi -import Foundation -import os.log - -@Observable -@MainActor -class TalkPlayerViewModel { - var player = AVPlayer() - - var currentRecording: Recording? - - private(set) var isPlaying = false - - @ObservationIgnored - private let factory = TalkMetadataFactory() - - @ObservationIgnored - private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TalkPlayerViewModel") - - @ObservationIgnored - private var timeControlObserver: NSKeyValueObservation? - - init() { - timeControlObserver = player.observe(\.timeControlStatus, options: [.initial, .new]) { - [weak self] player, _ in - let playing = player.timeControlStatus == .playing - Task { @MainActor [weak self] in - self?.isPlaying = playing - } - } - } - - func prepareForPlayback(recording: Recording, talk: Talk) async { - player.defaultRate = UserDefaults.standard.float(forKey: UserDefaultsKey.playbackRate.rawValue) - - var metadata = factory.createMetadataItems(for: recording, talk: talk) - - // Fetch the poster artwork up front so the complete metadata set, - // including artwork, is attached in a single assignment before the - // item is enqueued for playback. - if let imageURL = talk.posterURL ?? talk.thumbURL, - let posterImageData = try? await factory.fetchImageData(forURL: imageURL), - let posterImageMetadata = factory.createArtworkMetadataItem(imageData: posterImageData) - { - metadata.append(posterImageMetadata) - } - - let playerItem = AVPlayerItem(url: recording.recordingURL) - playerItem.externalMetadata = metadata - - player.replaceCurrentItem(with: playerItem) - self.currentRecording = recording - logger.info( - "Preparing playback of recording: \(recording.recordingURL.absoluteString, privacy: .public)" - ) - } - - func preroll() async { - // Wait for the player to become ready before prerolling. - let ready = await waitUntilReadyToPlay() - guard ready else { - logger.warning("Player failed to become ready for preroll") - return - } - if await player.preroll(atRate: 1.0) { - logger.debug("Preroll success") - } else { - logger.warning("Preroll failed") - } - } - - private func waitUntilReadyToPlay() async -> Bool { - var observation: NSKeyValueObservation? - defer { observation?.invalidate() } - - return await withCheckedContinuation { continuation in - var didResume = false - let resume: (Bool) -> Void = { ready in - guard !didResume else { return } - didResume = true - continuation.resume(returning: ready) - } - - observation = player.observe(\.status, options: [.initial, .new]) { player, _ in - switch player.status { - case .readyToPlay: - resume(true) - case .failed: - resume(false) - default: - break - } - } - } - } - - func play() { - player.play() - } - - func pause() { - player.pause() - } -} diff --git a/HackerTube/Features/Talk/TalkVideoPlayerView.swift b/HackerTube/Features/Talk/TalkVideoPlayerView.swift index ab3503d..a936b5e 100644 --- a/HackerTube/Features/Talk/TalkVideoPlayerView.swift +++ b/HackerTube/Features/Talk/TalkVideoPlayerView.swift @@ -8,9 +8,16 @@ import SwiftUI import MediaCCCApi +/// The inline video player at the top of a talk's detail screen. +/// +/// Only the talk that is currently loaded in the shared player shows the video. Other talks show their +/// thumbnail with a play button, so that opening a talk never takes over playback by itself. struct TalkVideoPlayerView: View { let talk: Talk let preferredRecording: Recording? + @Environment(PlaybackController.self) private var playback + @Environment(AppCoordinator.self) private var coordinator + var aspectRatio: CGFloat { if let recording = preferredRecording, let width = recording.width, let height = recording.height { let size = CGSize(width: width, height: height) @@ -22,21 +29,36 @@ struct TalkVideoPlayerView: View { var body: some View { Group { - if let preferredRecording { - TalkPlayerView( - talk: talk, - recording: preferredRecording, - automaticallyStartsPlayback: true - ) + if playback.currentTalk?.id == talk.id { + TalkPlayerView(talk: talk) } else { - Rectangle() - .fill(.black) + Button { + if let preferredRecording { + coordinator.play(talk: talk, recording: preferredRecording) + } + } label: { + TalkThumbnail(talk: talk) + .overlay { + Image(systemName: "play.circle.fill") + .font(.system(size: 64)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black.opacity(0.5)) + } + } + .buttonStyle(.plain) + .disabled(preferredRecording == nil) + .accessibilityLabel("Play") } } + // Letterbox the thumbnail or video, but don't let the color extend into the safe area under the navigation bar. + .background(.black, ignoresSafeAreaEdges: []) .aspectRatio(aspectRatio, contentMode: .fit) } } #Preview { + let playback = PlaybackController() TalkVideoPlayerView(talk: .example, preferredRecording: .example) + .environment(playback) + .environment(AppCoordinator(playback: playback)) } diff --git a/HackerTube/Features/Talk/TalkView.swift b/HackerTube/Features/Talk/TalkView.swift index 0e62361..f44ff7c 100644 --- a/HackerTube/Features/Talk/TalkView.swift +++ b/HackerTube/Features/Talk/TalkView.swift @@ -10,14 +10,17 @@ import SwiftUI struct TalkView: View { let talk: Talk - @State var selectedRecording: Recording? + /// Whether playback of the preferred recording starts as soon as it is known. + var autoplay = false + @State private var selectedRecording: Recording? @State private var viewModel = TalkViewModel() @State private var error: Error? + @Environment(AppCoordinator.self) private var coordinator var body: some View { Group { #if os(tvOS) - TalkViewTV(talk: talk, selectedRecording: $selectedRecording, viewModel: viewModel) + TalkViewTV(talk: talk, viewModel: viewModel) #else TalkViewDefault(talk: talk, selectedRecording: $selectedRecording, viewModel: viewModel) #endif @@ -26,6 +29,9 @@ struct TalkView: View { guard talk != viewModel.currentTalk else { return } do { try await viewModel.loadRecordings(for: talk) + if autoplay, let recording = viewModel.preferredRecording { + coordinator.play(talk: talk, recording: recording) + } } catch is CancellationError { } catch { self.error = error @@ -85,8 +91,9 @@ private struct TalkViewDefault: View { @available(visionOS, unavailable) private struct TalkViewTV: View { let talk: Talk - @Binding var selectedRecording: Recording? var viewModel: TalkViewModel + @Environment(AppCoordinator.self) private var coordinator + @FocusState private var isWatchButtonFocused: Bool var body: some View { ScrollView { @@ -110,12 +117,15 @@ private struct TalkViewTV: View { HStack(alignment: .top) { VStack { Button { - selectedRecording = viewModel.preferredRecording + if let recording = viewModel.preferredRecording { + coordinator.play(talk: talk, recording: recording) + } } label: { Label("Watch", systemImage: "play.fill") .padding(.horizontal, 64) } .disabled(viewModel.preferredRecording == nil) + .focused($isWatchButtonFocused) } VStack(alignment: .leading) { @@ -150,15 +160,23 @@ private struct TalkViewTV: View { } } } - .fullScreenCover(item: $selectedRecording) { recording in - TalkPlayerView(talk: talk, recording: recording, automaticallyStartsPlayback: true) - .ignoresSafeArea() + .onChange(of: viewModel.preferredRecording != nil) { _, canWatch in + // Move focus into the content once the Watch button is enabled. Without this, focus can remain on the + // tab bar, e.g. when the talk was opened from a deep link, and Menu would then exit the app. + guard canWatch else { return } + Task { @MainActor in + isWatchButtonFocused = true + } } } } #Preview { + let playback = PlaybackController() NavigationStack { TalkView(talk: .example) } + .environment(ApiService()) + .environment(playback) + .environment(AppCoordinator(playback: playback)) } diff --git a/HackerTube/Features/Talk/TalksGrid.swift b/HackerTube/Features/Talk/TalksGrid.swift index 785314d..d7b0212 100644 --- a/HackerTube/Features/Talk/TalksGrid.swift +++ b/HackerTube/Features/Talk/TalksGrid.swift @@ -29,9 +29,7 @@ private struct TalksGridRegular: View { var body: some View { LazyVGrid(columns: columns, spacing: 16) { ForEach(talks) { talk in - NavigationLink { - TalkView(talk: talk) - } label: { + NavigationLink(value: Destination.talk(talk)) { TalkCell(talk: talk) #if os(visionOS) .padding() @@ -61,9 +59,7 @@ private struct TalksGridRegular: View { var body: some View { LazyVGrid(columns: columns, spacing: 64) { ForEach(talks) { talk in - NavigationLink { - TalkView(talk: talk) - } label: { + NavigationLink(value: Destination.talk(talk)) { TalkThumbnail(talk: talk) Text(talk.title) diff --git a/HackerTube/Features/VideoPlayer/VideoPlayerView.swift b/HackerTube/Features/VideoPlayer/VideoPlayerView.swift index 4296162..648cf50 100644 --- a/HackerTube/Features/VideoPlayer/VideoPlayerView.swift +++ b/HackerTube/Features/VideoPlayer/VideoPlayerView.swift @@ -7,21 +7,24 @@ import AVKit import SwiftUI -import os.log struct VideoPlayerView: UIViewControllerRepresentable { - let player: AVPlayer? + let playback: PlaybackController func makeUIViewController(context: Context) -> AVPlayerViewController { let playerViewController = AVPlayerViewController() - playerViewController.delegate = context.coordinator + // The delegate is owned by the playback controller, so it outlives this view while in Picture in Picture. + playerViewController.delegate = playback.playerViewControllerDelegate playerViewController.modalPresentationStyle = .fullScreen - playerViewController.allowsPictureInPicturePlayback = true #if os(tvOS) + // Picture in Picture is deliberately disabled on tvOS: talks are mostly slides, which are unreadable in + // a small window on a TV, and watching on a TV is a single-task activity. + playerViewController.allowsPictureInPicturePlayback = false playerViewController.appliesPreferredDisplayCriteriaAutomatically = true playerViewController.transportBarIncludesTitleView = true #else + playerViewController.allowsPictureInPicturePlayback = true playerViewController.canStartPictureInPictureAutomaticallyFromInline = true #endif @@ -35,27 +38,26 @@ struct VideoPlayerView: UIViewControllerRepresentable { } func updateUIViewController(_ playerViewController: AVPlayerViewController, context: Context) { - playerViewController.player = player + if playerViewController.player !== playback.player { + playerViewController.player = playback.player + } } func makeCoordinator() -> VideoPlayerCoordinator { - VideoPlayerCoordinator() - } - - static func dismantleUIViewController( - _ playerViewController: AVPlayerViewController, coordinator: VideoPlayerCoordinator - ) { - playerViewController.player?.cancelPendingPrerolls() - playerViewController.player?.pause() + VideoPlayerCoordinator(playback: playback) } } -class VideoPlayerCoordinator: NSObject, AVPlayerViewControllerDelegate { - let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, category: "VideoPlayerCoordinator") +@MainActor +class VideoPlayerCoordinator: NSObject { + let playback: PlaybackController private var selectedSpeedObserver: NSKeyValueObservation? + init(playback: PlaybackController) { + self.playback = playback + } + func observeSelectedSpeed(of playerViewController: AVPlayerViewController) { selectedSpeedObserver = playerViewController.observe(\.selectedSpeed, options: [.new]) { _, change in @@ -64,11 +66,4 @@ class VideoPlayerCoordinator: NSObject, AVPlayerViewControllerDelegate { } } } - - func playerViewController( - _ playerViewController: AVPlayerViewController, - failedToStartPictureInPictureWithError error: Error - ) { - logger.error("Failed to start picture in picture: \(error)") - } } diff --git a/HackerTube/HackerTubeApp.swift b/HackerTube/HackerTubeApp.swift index 776716d..5a7252c 100644 --- a/HackerTube/HackerTubeApp.swift +++ b/HackerTube/HackerTubeApp.swift @@ -12,10 +12,26 @@ import MediaCCCApi struct HackerTubeApp: App { @UIApplicationDelegateAdaptor var appDelegate: HackerTubeAppDelegate + @State private var api = ApiService() + @State private var playback: PlaybackController + @State private var coordinator: AppCoordinator + + init() { + let playback = PlaybackController() + let coordinator = AppCoordinator(playback: playback) + playback.onRestoreUserInterface = { [weak coordinator] talk, recording in + await coordinator?.restore(talk: talk, recording: recording) + } + _playback = State(initialValue: playback) + _coordinator = State(initialValue: coordinator) + } + var body: some Scene { WindowGroup { ContentView() - .environment(ApiService()) + .environment(api) + .environment(playback) + .environment(coordinator) } } } diff --git a/HackerTube/Localizable.xcstrings b/HackerTube/Localizable.xcstrings index cd881e4..5bc8d8b 100644 --- a/HackerTube/Localizable.xcstrings +++ b/HackerTube/Localizable.xcstrings @@ -265,6 +265,22 @@ } } }, + "Play" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Abspielen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Afspelen" + } + } + } + }, "Popular" : { "comment" : "Heading for the popular talks view and tab.", "localizations" : { @@ -351,6 +367,22 @@ } } }, + "Talk not available" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Vortrag nicht verfügbar" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Talk niet beschikbaar" + } + } + } + }, "Watch" : { "comment" : "Button to start playback of a talk video.", "localizations" : { @@ -387,5 +419,5 @@ } } }, - "version" : "1.1" + "version" : "1.3" } \ No newline at end of file diff --git a/HackerTube/Navigation/AppCoordinator.swift b/HackerTube/Navigation/AppCoordinator.swift new file mode 100644 index 0000000..b8e0a8b --- /dev/null +++ b/HackerTube/Navigation/AppCoordinator.swift @@ -0,0 +1,101 @@ +// +// AppCoordinator.swift +// HackerTube +// +// Created by Mathijs Bernson on 02/11/2026. +// + +import MediaCCCApi +import SwiftUI + +/// Owns the app's navigation state: the selected tab, a navigation path per tab, and the presentation of the +/// full-screen player. +/// +/// The coordinator never owns an `AVPlayer`; playback itself is delegated to the ``PlaybackController``. +@MainActor +@Observable +final class AppCoordinator { + var selectedTab: AppTab = .recent + var paths: [AppTab: [Destination]] = [:] + + /// Whether the full-screen player is presented. Only used on tvOS; other platforms play inline. + var isPlayerPresented = false + + @ObservationIgnored + let playback: PlaybackController + + init(playback: PlaybackController) { + self.playback = playback + } + + /// The navigation path of the currently selected tab. + var currentPath: [Destination] { + get { paths[selectedTab, default: []] } + set { paths[selectedTab] = newValue } + } + + func path(for tab: AppTab) -> Binding<[Destination]> { + Binding( + get: { self.paths[tab, default: []] }, + set: { self.paths[tab] = $0 } + ) + } + + func push(_ destination: Destination) { + currentPath.append(destination) + } + + // MARK: Playback + + func play(talk: Talk, recording: Recording) { + #if os(tvOS) + // Keep a talk that is already playing in Picture in Picture there. A different talk closes the PiP + // window (see `PlaybackController.load`) and plays full screen. + let isAlreadyInPictureInPicture = + playback.isPictureInPictureActive && playback.currentRecording == recording + if !isAlreadyInPictureInPicture { + isPlayerPresented = true + } + #endif + Task { + await playback.load(talk: talk, recording: recording, autoplay: true) + } + } + + /// Returns from Picture in Picture to the talk that is playing. + func restore(talk: Talk, recording: Recording) async { + if currentPath.last?.talkID != talk.id { + push(.talk(talk)) + } + #if os(tvOS) + isPlayerPresented = true + #endif + } + + // MARK: Deep links + + /// Shows the target of a deep link on top of the current tab, whatever state the app is in. + func open(_ link: DeepLink) { + // A presented player would cover the destination. + isPlayerPresented = false + + switch link { + case .talk(let id, let action): + let autoplay = action == .play + if currentPath.last?.talkID == id { + // The talk is already on screen. Only start playback if that was requested. + if autoplay, let recording = playback.currentRecording, let talk = playback.currentTalk, + talk.id == id + { + play(talk: talk, recording: recording) + } else if autoplay { + // Replace the top destination so its view loads the talk and starts playback. + currentPath.removeLast() + push(.deepLinkedTalk(id: id, autoplay: true)) + } + } else { + push(.deepLinkedTalk(id: id, autoplay: autoplay)) + } + } + } +} diff --git a/HackerTube/Navigation/AppTab.swift b/HackerTube/Navigation/AppTab.swift new file mode 100644 index 0000000..27ace1e --- /dev/null +++ b/HackerTube/Navigation/AppTab.swift @@ -0,0 +1,14 @@ +// +// AppTab.swift +// HackerTube +// +// Created by Mathijs Bernson on 02/11/2026. +// + +/// The top-level tabs of the app. Each tab has its own navigation stack. +enum AppTab: Hashable, CaseIterable, Sendable { + case recent + case popular + case conferences + case search +} diff --git a/HackerTube/Navigation/Destination.swift b/HackerTube/Navigation/Destination.swift new file mode 100644 index 0000000..34d8354 --- /dev/null +++ b/HackerTube/Navigation/Destination.swift @@ -0,0 +1,75 @@ +// +// Destination.swift +// HackerTube +// +// Created by Mathijs Bernson on 02/11/2026. +// + +import MediaCCCApi +import SwiftUI + +/// A screen that can be pushed onto a tab's navigation stack. +enum Destination: Hashable { + /// A talk that has already been loaded, e.g. from a grid. + case talk(Talk) + /// An overview page for a particular conference (like Congress for a certain year). + case conference(Conference) + /// A talk that is only known by its identifier, e.g. from a deep link. It is loaded by the destination view. + case deepLinkedTalk(id: String, autoplay: Bool) + + /// The identifier of the talk shown by this destination, if any. + var talkID: String? { + switch self { + case .talk(let talk): talk.id + case .deepLinkedTalk(let id, _): id + case .conference: nil + } + } + + // `Talk` and `Conference` are not `Hashable`, so we have to implement the conformance manually. + // The identity is based on the case and the model's `id`. + + static func == (lhs: Destination, rhs: Destination) -> Bool { + switch (lhs, rhs) { + case (.talk(let lhs), .talk(let rhs)): + lhs.id == rhs.id + case (.conference(let lhs), .conference(let rhs)): + lhs.id == rhs.id + case (.deepLinkedTalk(let lhsID, let lhsAutoplay), .deepLinkedTalk(let rhsID, let rhsAutoplay)): + lhsID == rhsID && lhsAutoplay == rhsAutoplay + default: + false + } + } + + func hash(into hasher: inout Hasher) { + switch self { + case .talk(let talk): + hasher.combine(0) + hasher.combine(talk.id) + case .conference(let conference): + hasher.combine(1) + hasher.combine(conference.id) + case .deepLinkedTalk(let id, let autoplay): + hasher.combine(2) + hasher.combine(id) + hasher.combine(autoplay) + } + } +} + +extension View { + /// Registers the views for every ``Destination`` on the enclosing navigation stack. + func appDestinations() -> some View { + navigationDestination(for: Destination.self) { destination in + switch destination { + case .talk(let talk): + TalkView(talk: talk) + case .conference(let conference): + ConferenceView(conference: conference) + case .deepLinkedTalk(let id, let autoplay): + DeepLinkedTalkView(id: id, autoplay: autoplay) + } + } + } +} diff --git a/HackerTube/Playback/PlaybackController.swift b/HackerTube/Playback/PlaybackController.swift new file mode 100644 index 0000000..a03dc46 --- /dev/null +++ b/HackerTube/Playback/PlaybackController.swift @@ -0,0 +1,182 @@ +// +// PlaybackController.swift +// HackerTube +// +// Created by Mathijs Bernson on 29/12/2023. +// + +import AVKit +import MediaCCCApi +import Foundation +import os.log + +/// Controls video playback app-wide. +/// +/// There is only ever one `AVPlayer` at a time, so starting a new talk replaces whatever was playing before, +/// including a talk that is playing in Picture in Picture. +@Observable +@MainActor +final class PlaybackController { + let player = AVPlayer() + + private(set) var currentTalk: Talk? + private(set) var currentRecording: Recording? + + private(set) var isPlaying = false { + didSet { + // Keep the display awake while playback is in progress, regardless of which view is on screen. + UIApplication.shared.isIdleTimerDisabled = isPlaying + } + } + + private(set) var isPictureInPictureActive = false + + /// The delegate shared by every `AVPlayerViewController` that shows ``player``. + @ObservationIgnored + let playerViewControllerDelegate = PlayerViewControllerDelegate() + + /// The player view controller that is currently shown in Picture in Picture, if any. + @ObservationIgnored + private weak var pictureInPictureViewController: AVPlayerViewController? + + /// Called when the user asks to return from Picture in Picture to the full player UI. + @ObservationIgnored + var onRestoreUserInterface: (@MainActor (Talk, Recording) async -> Void)? + + @ObservationIgnored + private let factory = TalkMetadataFactory() + + @ObservationIgnored + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PlaybackController") + + @ObservationIgnored + private var timeControlObserver: NSKeyValueObservation? + + init() { + playerViewControllerDelegate.playback = self + timeControlObserver = player.observe(\.timeControlStatus, options: [.initial, .new]) { + [weak self] player, _ in + let playing = player.timeControlStatus == .playing + Task { @MainActor [weak self] in + self?.isPlaying = playing + } + } + } + + /// Loads a recording into the shared player, replacing the current item. + /// + /// Does nothing if the recording is already loaded, apart from starting playback when requested. + func load(talk: Talk, recording: Recording, autoplay: Bool) async { + if recording != currentRecording { + // A different talk replaces the one in Picture in Picture. Close the PiP window, so the new talk only + // plays in the view that started it instead of in both places at once. + stopPictureInPicture() + await prepareForPlayback(recording: recording, talk: talk) + if !autoplay { + await preroll() + } + } + if autoplay { + play() + } + } + + func prepareForPlayback(recording: Recording, talk: Talk) async { + // Claim the slot before suspending, so a concurrent load for the same recording is skipped. + currentTalk = talk + currentRecording = recording + + player.defaultRate = UserDefaults.standard.float(forKey: UserDefaultsKey.playbackRate.rawValue) + + var metadata = factory.createMetadataItems(for: recording, talk: talk) + + // Fetch the poster artwork up front so the complete metadata set, + // including artwork, is attached in a single assignment before the + // item is enqueued for playback. + if let imageURL = talk.posterURL ?? talk.thumbURL, + let posterImageData = try? await factory.fetchImageData(forURL: imageURL), + let posterImageMetadata = factory.createArtworkMetadataItem(imageData: posterImageData) + { + metadata.append(posterImageMetadata) + } + + // Another talk may have been loaded while the artwork was being fetched. + guard currentRecording == recording else { return } + + let playerItem = AVPlayerItem(url: recording.recordingURL) + playerItem.externalMetadata = metadata + + player.replaceCurrentItem(with: playerItem) + logger.info( + "Preparing playback of recording: \(recording.recordingURL.absoluteString, privacy: .public)" + ) + } + + func preroll() async { + // Wait for the player to become ready before prerolling. + let ready = await waitUntilReadyToPlay() + guard ready else { + logger.warning("Player failed to become ready for preroll") + return + } + if await player.preroll(atRate: 1.0) { + logger.debug("Preroll success") + } else { + logger.warning("Preroll failed") + } + } + + private func waitUntilReadyToPlay() async -> Bool { + var observation: NSKeyValueObservation? + defer { observation?.invalidate() } + + return await withCheckedContinuation { continuation in + observation = player.observe(\.status, options: [.initial, .new]) { player, _ in + if player.status == .readyToPlay { + continuation.resume(returning: true) + } else if player.status == .failed { + continuation.resume(returning: false) + } + } + } + } + + func play() { + player.play() + } + + func pause() { + player.pause() + } + + // MARK: Picture in Picture + + func pictureInPictureWillStart(in playerViewController: AVPlayerViewController) { + pictureInPictureViewController = playerViewController + isPictureInPictureActive = true + } + + func pictureInPictureDidStop(in playerViewController: AVPlayerViewController) { + if pictureInPictureViewController === playerViewController { + pictureInPictureViewController = nil + } + isPictureInPictureActive = pictureInPictureViewController != nil + } + + /// Closes the Picture in Picture window, if there is one. + /// + /// `AVPlayerViewController` has no API to stop Picture in Picture, so this detaches the shared player from the + /// view controller that is in Picture in Picture, which makes the system close its window. + func stopPictureInPicture() { + guard let playerViewController = pictureInPictureViewController else { return } + logger.info("Stopping picture in picture to play a different talk") + playerViewController.player = nil + pictureInPictureViewController = nil + isPictureInPictureActive = false + } + + func restoreUserInterface() async { + guard let currentTalk, let currentRecording else { return } + await onRestoreUserInterface?(currentTalk, currentRecording) + } +} diff --git a/HackerTube/Playback/PlayerViewControllerDelegate.swift b/HackerTube/Playback/PlayerViewControllerDelegate.swift new file mode 100644 index 0000000..3bc2eb1 --- /dev/null +++ b/HackerTube/Playback/PlayerViewControllerDelegate.swift @@ -0,0 +1,50 @@ +// +// PlayerViewControllerDelegate.swift +// HackerTube +// +// Created by Mathijs Bernson on 02/11/2026. +// + +import AVKit +import os.log + +/// The delegate of every `AVPlayerViewController` in the app. +/// +/// It is owned by the ``PlaybackController`` instead of by an individual view, so Picture in Picture callbacks keep +/// arriving after the view that started Picture in Picture has been removed from the screen. +@MainActor +final class PlayerViewControllerDelegate: NSObject, AVPlayerViewControllerDelegate { + weak var playback: PlaybackController? + + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, category: "PlayerViewControllerDelegate") + + func playerViewController( + _ playerViewController: AVPlayerViewController, + failedToStartPictureInPictureWithError error: Error + ) { + logger.error("Failed to start picture in picture: \(error)") + } + + func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + playback?.pictureInPictureWillStart(in: playerViewController) + } + + func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { + playback?.pictureInPictureDidStop(in: playerViewController) + } + + func playerViewController( + _ playerViewController: AVPlayerViewController, + restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void + ) { + guard let playback else { + completionHandler(false) + return + } + Task { + await playback.restoreUserInterface() + completionHandler(true) + } + } +} diff --git a/HackerTube/URLParser.swift b/HackerTube/URLParser.swift deleted file mode 100644 index 0e6f918..0000000 --- a/HackerTube/URLParser.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// URLParser.swift -// HackerTube -// -// Created by Mathijs Bernson on 30/07/2022. -// - -import Foundation - -struct URLParser { - func parseURL(_ url: URL) -> URLRoute? { - guard url.scheme == "ccctube" else { return nil } - guard url.host == "talk" else { return nil } - - let components = url.pathComponents.filter { $0 != "/" } - guard components.count >= 1, - let id = components.first - else { return nil } - - if components.last == "play" { - return .playTalk(id: id) - } else { - return .openTalk(id: id) - } - } -} diff --git a/HackerTube/URLRoute.swift b/HackerTube/URLRoute.swift deleted file mode 100644 index ccbc48a..0000000 --- a/HackerTube/URLRoute.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// URLRoute.swift -// HackerTube -// -// Created by Mathijs Bernson on 30/07/2022. -// - -import Foundation - -enum URLRoute: Equatable { - case playTalk(id: String) - case openTalk(id: String) -} diff --git a/HackerTubeTests/AppCoordinatorTests.swift b/HackerTubeTests/AppCoordinatorTests.swift new file mode 100644 index 0000000..bf5f2a9 --- /dev/null +++ b/HackerTubeTests/AppCoordinatorTests.swift @@ -0,0 +1,102 @@ +// +// AppCoordinatorTests.swift +// HackerTubeTests +// +// Created by Mathijs Bernson on 02/11/2026. +// + +import MediaCCCApi +import Testing + +@testable import HackerTube + +@MainActor +@Suite struct AppCoordinatorTests { + let coordinator = AppCoordinator(playback: PlaybackController()) + let otherID = "44ab627f-ed5d-522b-b84b-15a3ed761895" + + @Test func `Deep link is pushed onto the selected tab only`() { + coordinator.selectedTab = .popular + coordinator.open(.talk(id: otherID, action: .open)) + + #expect(coordinator.paths[.popular] == [.deepLinkedTalk(id: otherID, autoplay: false)]) + for tab in AppTab.allCases where tab != .popular { + #expect(coordinator.paths[tab, default: []].isEmpty) + } + } + + @Test func `Deep link is pushed on the search tab`() { + coordinator.selectedTab = .search + coordinator.open(.talk(id: otherID, action: .open)) + + #expect(coordinator.paths[.search] == [.deepLinkedTalk(id: otherID, autoplay: false)]) + } + + @Test func `Deep link is pushed on top of an existing stack`() { + coordinator.selectedTab = .conferences + coordinator.push(.conference(.example)) + coordinator.push(.talk(.example)) + + coordinator.open(.talk(id: otherID, action: .open)) + + #expect( + coordinator.currentPath == [ + .conference(.example), + .talk(.example), + .deepLinkedTalk(id: otherID, autoplay: false), + ]) + } + + @Test func `Deep link to the talk on top is not pushed again`() { + coordinator.push(.talk(.example)) + + coordinator.open(.talk(id: Talk.example.id, action: .open)) + + #expect(coordinator.currentPath == [.talk(.example)]) + } + + @Test func `Repeated deep link is not pushed again`() { + coordinator.open(.talk(id: otherID, action: .open)) + coordinator.open(.talk(id: otherID, action: .open)) + + #expect(coordinator.currentPath == [.deepLinkedTalk(id: otherID, autoplay: false)]) + } + + @Test func `Play deep link pushes with autoplay`() { + coordinator.open(.talk(id: otherID, action: .play)) + + #expect(coordinator.currentPath == [.deepLinkedTalk(id: otherID, autoplay: true)]) + } + + @Test func `Play deep link to the talk on top starts playback there`() { + coordinator.push(.talk(.example)) + + coordinator.open(.talk(id: Talk.example.id, action: .play)) + + #expect(coordinator.currentPath == [.deepLinkedTalk(id: Talk.example.id, autoplay: true)]) + } + + @Test func `Deep link dismisses the player`() { + coordinator.isPlayerPresented = true + + coordinator.open(.talk(id: otherID, action: .open)) + + #expect(coordinator.isPlayerPresented == false) + } + + @Test func `Restore pushes the playing talk`() async { + coordinator.push(.conference(.example)) + + await coordinator.restore(talk: .example, recording: .example) + + #expect(coordinator.currentPath == [.conference(.example), .talk(.example)]) + } + + @Test func `Restore does not push the talk when it is already on top`() async { + coordinator.push(.talk(.example)) + + await coordinator.restore(talk: .example, recording: .example) + + #expect(coordinator.currentPath == [.talk(.example)]) + } +} diff --git a/HackerTubeTests/DeepLinkTests.swift b/HackerTubeTests/DeepLinkTests.swift new file mode 100644 index 0000000..532bd76 --- /dev/null +++ b/HackerTubeTests/DeepLinkTests.swift @@ -0,0 +1,48 @@ +// +// DeepLinkTests.swift +// HackerTubeTests +// +// Created by Mathijs Bernson on 30/07/2022. +// + +import Foundation +import Testing + +@testable import HackerTube + +@Suite struct DeepLinkTests { + let id = "44ab627f-ed5d-522b-b84b-15a3ed761895" + + @Test func `Parse open URL`() throws { + let url = try #require(URL(string: "ccctube://talk/\(id)")) + #expect(DeepLink(url: url) == .talk(id: id, action: .open)) + } + + @Test func `Parse play URL`() throws { + let url = try #require(URL(string: "ccctube://talk/\(id)/play")) + #expect(DeepLink(url: url) == .talk(id: id, action: .play)) + } + + @Test(arguments: [ + "ccctube://", + "ccctube://talk", + "https://google.com/", + "ccctube://foo", + "mailto:info@example.com", + ]) + func `Invalid URLs`(urlString: String) throws { + let url = try #require(URL(string: urlString)) + #expect(DeepLink(url: url) == nil) + } + + @Test(arguments: [DeepLink.TalkAction.open, .play]) + func `URL round trip`(action: DeepLink.TalkAction) { + let link = DeepLink.talk(id: id, action: action) + #expect(DeepLink(url: link.url) == link) + } + + @Test func `URLs match the format used by the Top Shelf`() { + #expect(DeepLink.talk(id: id, action: .open).url.absoluteString == "ccctube://talk/\(id)") + #expect(DeepLink.talk(id: id, action: .play).url.absoluteString == "ccctube://talk/\(id)/play") + } +} diff --git a/HackerTubeTests/URLParserTests.swift b/HackerTubeTests/URLParserTests.swift deleted file mode 100644 index 1d516da..0000000 --- a/HackerTubeTests/URLParserTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// URLParserTests.swift -// HackerTubeTests -// -// Created by Mathijs Bernson on 30/07/2022. -// - -import Foundation -import Testing - -@testable import HackerTube - -struct URLParserTests { - let parser = URLParser() - - @Test func `Parse open URL`() { - let url = URL(string: "ccctube://talk/44ab627f-ed5d-522b-b84b-15a3ed761895")! - #expect(parser.parseURL(url) == .openTalk(id: "44ab627f-ed5d-522b-b84b-15a3ed761895")) - } - - @Test func `Parse play URL`() { - let url = URL(string: "ccctube://talk/44ab627f-ed5d-522b-b84b-15a3ed761895/play")! - #expect(parser.parseURL(url) == .playTalk(id: "44ab627f-ed5d-522b-b84b-15a3ed761895")) - } - - @Test(arguments: [ - "ccctube://", - "https://google.com/", - "ccctube://foo", - "mailto:info@example.com", - ]) - func `Invalid URLs`(urlString: String) throws { - let url = try #require(URL(string: urlString)) - #expect(parser.parseURL(url) == nil) - } -} diff --git a/TopShelf/TopShelfContentFactory.swift b/TopShelf/TopShelfContentFactory.swift index fb9f5d9..97fca7f 100644 --- a/TopShelf/TopShelfContentFactory.swift +++ b/TopShelf/TopShelfContentFactory.swift @@ -31,10 +31,8 @@ struct TopShelfContentFactory { item.imageShape = .hdtv item.title = talk.title item.setImageURL(talk.posterURL, for: .screenScale1x) - item.displayAction = URL(string: "ccctube://talk/\(talk.id)") - .map(TVTopShelfAction.init) - item.playAction = URL(string: "ccctube://talk/\(talk.id)/play") - .map(TVTopShelfAction.init) + item.displayAction = TVTopShelfAction(url: DeepLink.talk(id: talk.id, action: .open).url) + item.playAction = TVTopShelfAction(url: DeepLink.talk(id: talk.id, action: .play).url) return item } } From 221492f20b1e55943c909ef49acc6dd72541c72e Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Mon, 2 Nov 2026 18:38:01 +0100 Subject: [PATCH 2/2] Fix playback on visionOS --- HackerTube/ContentView.swift | 2 +- HackerTube/Features/Talk/TalkPlayerView.swift | 2 +- HackerTube/Features/Talk/TalkView.swift | 16 +++++++--------- .../Features/VideoPlayer/VideoPlayerView.swift | 4 ++++ HackerTube/Navigation/AppCoordinator.swift | 6 +++--- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/HackerTube/ContentView.swift b/HackerTube/ContentView.swift index 72bb5a8..1f76835 100644 --- a/HackerTube/ContentView.swift +++ b/HackerTube/ContentView.swift @@ -30,7 +30,7 @@ struct ContentView: View { SearchView(path: coordinator.path(for: .search)) } } - #if os(tvOS) + #if os(tvOS) || os(visionOS) .fullScreenCover(isPresented: $coordinator.isPlayerPresented) { TalkPlayerView() .ignoresSafeArea() diff --git a/HackerTube/Features/Talk/TalkPlayerView.swift b/HackerTube/Features/Talk/TalkPlayerView.swift index 3c03640..2553bbd 100644 --- a/HackerTube/Features/Talk/TalkPlayerView.swift +++ b/HackerTube/Features/Talk/TalkPlayerView.swift @@ -10,7 +10,7 @@ import SwiftUI /// Shows the app-wide shared player. What it plays is controlled by the ``PlaybackController``. struct TalkPlayerView: View { - /// The talk this view is shown for, or `nil` when it shows whatever is playing (the tvOS full-screen player). + /// The talk this view is shown for, or `nil` when it shows whatever is playing (the full-screen player on tvOS and visionOS). var talk: Talk? = nil @Environment(PlaybackController.self) private var playback diff --git a/HackerTube/Features/Talk/TalkView.swift b/HackerTube/Features/Talk/TalkView.swift index f44ff7c..abbaab7 100644 --- a/HackerTube/Features/Talk/TalkView.swift +++ b/HackerTube/Features/Talk/TalkView.swift @@ -19,7 +19,7 @@ struct TalkView: View { var body: some View { Group { - #if os(tvOS) + #if os(tvOS) || os(visionOS) TalkViewTV(talk: talk, viewModel: viewModel) #else TalkViewDefault(talk: talk, selectedRecording: $selectedRecording, viewModel: viewModel) @@ -43,8 +43,7 @@ struct TalkView: View { } } -/// Talk View for use on all platforms by default. -@available(tvOS, unavailable) +/// Talk View with an inline player, used on iOS and macOS. private struct TalkViewDefault: View { let talk: Talk @Binding var selectedRecording: Recording? @@ -79,16 +78,12 @@ private struct TalkViewDefault: View { TalkMetadataDetailPane(talk: talk, selectedRecording: $selectedRecording, viewModel: viewModel) } } - .safeAreaPadding(.all) + .safeAreaPadding() } } } -/// Talk View that is specific to tvOS. -@available(iOS, unavailable) -@available(macOS, unavailable) -@available(watchOS, unavailable) -@available(visionOS, unavailable) +/// Talk View that is specific to tvOS and visionOS. private struct TalkViewTV: View { let talk: Talk var viewModel: TalkViewModel @@ -159,6 +154,9 @@ private struct TalkViewTV: View { .frame(maxWidth: .infinity, alignment: .leading) } } + #if os(visionOS) + .safeAreaPadding() + #endif } .onChange(of: viewModel.preferredRecording != nil) { _, canWatch in // Move focus into the content once the Watch button is enabled. Without this, focus can remain on the diff --git a/HackerTube/Features/VideoPlayer/VideoPlayerView.swift b/HackerTube/Features/VideoPlayer/VideoPlayerView.swift index 648cf50..f3ca90b 100644 --- a/HackerTube/Features/VideoPlayer/VideoPlayerView.swift +++ b/HackerTube/Features/VideoPlayer/VideoPlayerView.swift @@ -23,6 +23,10 @@ struct VideoPlayerView: UIViewControllerRepresentable { playerViewController.allowsPictureInPicturePlayback = false playerViewController.appliesPreferredDisplayCriteriaAutomatically = true playerViewController.transportBarIncludesTitleView = true + #elseif os(visionOS) + // visionOS plays full screen, like tvOS, and the coordinator's presentation logic assumes no + // Picture in Picture there. + playerViewController.allowsPictureInPicturePlayback = false #else playerViewController.allowsPictureInPicturePlayback = true playerViewController.canStartPictureInPictureAutomaticallyFromInline = true diff --git a/HackerTube/Navigation/AppCoordinator.swift b/HackerTube/Navigation/AppCoordinator.swift index b8e0a8b..b818dde 100644 --- a/HackerTube/Navigation/AppCoordinator.swift +++ b/HackerTube/Navigation/AppCoordinator.swift @@ -18,7 +18,7 @@ final class AppCoordinator { var selectedTab: AppTab = .recent var paths: [AppTab: [Destination]] = [:] - /// Whether the full-screen player is presented. Only used on tvOS; other platforms play inline. + /// Whether the full-screen player is presented. Only used on tvOS and visionOS; other platforms play inline. var isPlayerPresented = false @ObservationIgnored @@ -48,7 +48,7 @@ final class AppCoordinator { // MARK: Playback func play(talk: Talk, recording: Recording) { - #if os(tvOS) + #if os(tvOS) || os(visionOS) // Keep a talk that is already playing in Picture in Picture there. A different talk closes the PiP // window (see `PlaybackController.load`) and plays full screen. let isAlreadyInPictureInPicture = @@ -67,7 +67,7 @@ final class AppCoordinator { if currentPath.last?.talkID != talk.id { push(.talk(talk)) } - #if os(tvOS) + #if os(tvOS) || os(visionOS) isPlayerPresented = true #endif }