diff --git a/HackerTube/Domain/ApiService.swift b/HackerTube/Domain/ApiService.swift index d18bdc8..17766d9 100644 --- a/HackerTube/Domain/ApiService.swift +++ b/HackerTube/Domain/ApiService.swift @@ -13,7 +13,7 @@ class ApiService { private let client: MediaCCCApiClient init() { - client = .init() + client = .init(urlSession: .mediaCCCApi) } // MARK: Conferences diff --git a/HackerTube/Extensions/URLSession+Extensions.swift b/HackerTube/Extensions/URLSession+Extensions.swift new file mode 100644 index 0000000..8913ee4 --- /dev/null +++ b/HackerTube/Extensions/URLSession+Extensions.swift @@ -0,0 +1,39 @@ +// +// URLSession+Extensions.swift +// HackerTube +// +// Created by Mathijs Bernson on 04/11/2026. +// + +import Foundation +import OSLog + +extension URLSession { + /// URLSession with a cache intended for talk thumbnails, conference logos, etcetera. + static let remoteImages: URLSession = { + let directory = URL.cachesDirectory.appending(path: "RemoteImages", directoryHint: .isDirectory) + let cache = URLCache( + memoryCapacity: 20_000_000, // 20 megabytes + diskCapacity: 100_000_000, // 100 megabytes + directory: directory + ) + let configuration = URLSessionConfiguration.default + configuration.urlCache = cache + return URLSession(configuration: configuration) + }() + + /// URLSession for making calls to the `media.ccc.de` API + static let mediaCCCApi: URLSession = { + // The API answers with `Cache-Control: max-age=0, must-revalidate` and an ETag, so every request is + // revalidated. The larger cache makes sure the 300 KB listing pages survive until the `304 Not Modified`. + let directory = URL.cachesDirectory.appending(path: "MediaCCCApi", directoryHint: .isDirectory) + let cache = URLCache( + memoryCapacity: 10_000_000, // 10 megabytes + diskCapacity: 50_000_000, // 50 megabytes + directory: directory + ) + let configuration = URLSessionConfiguration.default + configuration.urlCache = cache + return URLSession(configuration: configuration) + }() +} diff --git a/HackerTube/Features/Conferences/ConferenceThumbnail.swift b/HackerTube/Features/Conferences/ConferenceThumbnail.swift index ab8b3d8..b5534dc 100644 --- a/HackerTube/Features/Conferences/ConferenceThumbnail.swift +++ b/HackerTube/Features/Conferences/ConferenceThumbnail.swift @@ -43,6 +43,7 @@ struct ConferenceThumbnail: View { ProgressView() } } + .backport.asyncImageURLSession(.remoteImages) } } diff --git a/HackerTube/Features/Talk/TalkListItem.swift b/HackerTube/Features/Talk/TalkListItem.swift index 81e468a..31dde02 100644 --- a/HackerTube/Features/Talk/TalkListItem.swift +++ b/HackerTube/Features/Talk/TalkListItem.swift @@ -20,6 +20,7 @@ struct TalkListItem: View { } placeholder: { ProgressView() } + .backport.asyncImageURLSession(.remoteImages) .frame(width: width, height: width * (9 / 16)) } diff --git a/HackerTube/Features/Talk/TalkMetadataFactory.swift b/HackerTube/Features/Talk/TalkMetadataFactory.swift index 85c4e14..1079500 100644 --- a/HackerTube/Features/Talk/TalkMetadataFactory.swift +++ b/HackerTube/Features/Talk/TalkMetadataFactory.swift @@ -63,7 +63,7 @@ struct TalkMetadataFactory { } func fetchImageData(forURL url: URL) async throws -> Data? { - let (imageData, _) = try await URLSession.shared.data(from: url) + let (imageData, _) = try await URLSession.remoteImages.data(from: url) return imageData } } diff --git a/HackerTube/Features/Talk/TalkThumbnail.swift b/HackerTube/Features/Talk/TalkThumbnail.swift index 7142954..6da8c4d 100644 --- a/HackerTube/Features/Talk/TalkThumbnail.swift +++ b/HackerTube/Features/Talk/TalkThumbnail.swift @@ -32,6 +32,7 @@ struct TalkThumbnail: View { ProgressView() } } + .backport.asyncImageURLSession(.remoteImages) .frame(maxWidth: .infinity, maxHeight: .infinity) .aspectRatio(16 / 9, contentMode: .fit) .id(id) diff --git a/HackerTube/Features/Talk/TalkViewModel.swift b/HackerTube/Features/Talk/TalkViewModel.swift index 1efc038..753f6d9 100644 --- a/HackerTube/Features/Talk/TalkViewModel.swift +++ b/HackerTube/Features/Talk/TalkViewModel.swift @@ -26,7 +26,7 @@ class TalkViewModel { private let mediaAnalyzer: MediaAnalyzer init() { - client = .init() + client = .init(urlSession: .mediaCCCApi) mediaAnalyzer = .init() } diff --git a/HackerTube/SVGImageView.swift b/HackerTube/SVGImageView.swift index bedd558..1b681de 100644 --- a/HackerTube/SVGImageView.swift +++ b/HackerTube/SVGImageView.swift @@ -56,7 +56,7 @@ struct SVGImageView: View { didFail = false do { - let (data, _) = try await URLSession.shared.data(from: url) + let (data, _) = try await URLSession.remoteImages.data(from: url) try Task.checkCancellation() let rendered = try await rasterizeSVG(data: data, size: size, scale: displayScale) cache[key] = rendered diff --git a/HackerTube/ViewModifiers/Backport.swift b/HackerTube/ViewModifiers/Backport.swift new file mode 100644 index 0000000..bcf8918 --- /dev/null +++ b/HackerTube/ViewModifiers/Backport.swift @@ -0,0 +1,32 @@ +// +// Backport.swift +// HackerTube +// +// Created by Mathijs Bernson on 04/11/2026. +// + +import Foundation +import SwiftUI + +// Thanks to: https://davedelong.com/blog/2021/10/09/simplifying-backwards-compatibility-in-swift/ +struct Backport { + let content: Content + + init(_ content: Content) { + self.content = content + } +} + +extension View { + var backport: Backport { Backport(self) } +} + +extension Backport where Content: View { + @ViewBuilder func asyncImageURLSession(_ urlSession: URLSession) -> some View { + if #available(iOS 27, tvOS 27, visionOS 27, *) { + content.asyncImageURLSession(urlSession) + } else { + content + } + } +} diff --git a/TopShelf/ContentProvider.swift b/TopShelf/ContentProvider.swift index 45373dc..853b358 100644 --- a/TopShelf/ContentProvider.swift +++ b/TopShelf/ContentProvider.swift @@ -13,7 +13,7 @@ class ContentProvider: TVTopShelfContentProvider { override func loadTopShelfContent() async -> TVTopShelfContent? { do { - let api = MediaCCCApiClient() + let api = MediaCCCApiClient(urlSession: .mediaCCCApi) async let recentTalks = api.recentTalks() let currentYear = Calendar.current.component(.year, from: .now) async let popularTalks = api.popularTalks(in: currentYear)