diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 08ca871..ab08abe 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -118,6 +118,22 @@ } } }, + "action.done": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fertig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + } + } + }, "action.markUnwatched": { "extractionState": "stale", "localizations": { @@ -2114,6 +2130,54 @@ } } }, + "liveTV.selection": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sender auswählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose channels" + } + } + } + }, + "liveTV.selection.all": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + } + } + }, + "liveTV.selection.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ohne Auswahl werden alle Sender gezeigt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "With nothing selected, all channels are shown." + } + } + } + }, "liveTV.timeRange %@ %@": { "comment": "Platzhalter: Anfangs- und Endzeit", "localizations": { @@ -2181,6 +2245,7 @@ } }, "miniPlayer.expand": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -2196,6 +2261,22 @@ } } }, + "miniPlayer.resize": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Größe ändern" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Resize" + } + } + } + }, "placeholder.description": { "comment": "Platzhalter-Bildschirme", "localizations": { @@ -2247,6 +2328,7 @@ } }, "player.close": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { diff --git a/Flixy/Views/LiveTV/ChannelSelectionSheet.swift b/Flixy/Views/LiveTV/ChannelSelectionSheet.swift new file mode 100644 index 0000000..30643b0 --- /dev/null +++ b/Flixy/Views/LiveTV/ChannelSelectionSheet.swift @@ -0,0 +1,121 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Welche Sender überhaupt angezeigt werden. +/// +/// Der Empfänger liefert alles, was die Senderliste hergibt — bei einer +/// Swisscom-Liste sind das mehr als zwanzig, und die meisten sieht man nie an. +/// Jeden davon zu zeichnen und sein Programm zu holen kostet Zeit und macht +/// die Ansicht unübersichtlich. +struct ChannelSelectionSheet: View { + let channels: [PlexChannel] + @Binding var selection: Set + + @Environment(FlixySession.self) private var session + @Environment(\.flixyAccent) private var accent + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + ForEach(channels) { channel in + row(for: channel) + } + } footer: { + Text("liveTV.selection.hint") + } + } + .navigationTitle(Text("liveTV.selection")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + // „Alle" statt „keine": eine leere Auswahl bedeutet + // ohnehin alle, und ein Knopf, der scheinbar nichts tut, + // wäre verwirrend. + Button("liveTV.selection.all") { selection.removeAll() } + .disabled(selection.isEmpty) + } + ToolbarItem(placement: .confirmationAction) { + Button("action.done") { dismiss() } + } + } + } + } + + private func row(for channel: PlexChannel) -> some View { + Button { + // Beim ersten Antippen aus „alle" eine echte Auswahl machen: + // sonst hakt man einen Sender an und alle anderen bleiben + // trotzdem sichtbar. + if selection.isEmpty { + selection = Set(channels.map(\.id)).subtracting([channel.id]) + } else if selection.contains(channel.id) { + selection.remove(channel.id) + } else { + selection.insert(channel.id) + } + } label: { + HStack(spacing: FlixySpacing.regular) { + FlixyArtwork( + url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120), + aspectRatio: 1, + fallbackSymbol: "tv" + ) + .frame(width: 32, height: 32) + .clipShape(.rect(cornerRadius: 4)) + + if let number = channel.channelIdentifier { + Text(number) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Color.flixyTextMuted) + } + + Text(channel.title) + .font(.flixyBody) + .foregroundStyle(Color.flixyTextPrimary) + + Spacer() + + Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isShown(channel) ? accent : Color.flixyTextMuted) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } + + private func isShown(_ channel: PlexChannel) -> Bool { + ChannelSelection.shows(channel, selection: selection) + } +} + +/// Die Regel, nach der ausgewählt wird. +/// +/// Eigener Typ, weil sie an zwei Stellen gilt — in der Auswahl und in der +/// Anzeige — und beide dasselbe meinen müssen. Eine **leere** Auswahl bedeutet +/// „alle": so sieht man beim ersten Öffnen sein Programm, ohne vorher etwas +/// angehakt zu haben. +enum ChannelSelection { + static func shows(_ channel: PlexChannel, selection: Set) -> Bool { + selection.isEmpty || selection.contains(channel.id) + } + + static func apply(_ selection: Set, to channels: [PlexChannel]) -> [PlexChannel] { + guard !selection.isEmpty else { return channels } + let kept = channels.filter { selection.contains($0.id) } + // Wählt jemand Sender ab, die es nicht mehr gibt, bliebe sonst eine + // leere Liste stehen, ohne dass er es ändern könnte. + return kept.isEmpty ? channels : kept + } + + /// Die Auswahl als Text, wie sie in den Einstellungen liegt. + static func decode(_ stored: String) -> Set { + Set(stored.split(separator: "\n").map(String.init)) + } + + static func encode(_ selection: Set) -> String { + selection.sorted().joined(separator: "\n") + } +} diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index 2bead33..441e358 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -28,6 +28,15 @@ struct LiveTVView: View { /// die Liste zum schnellen Umschalten. Die Wahl bleibt erhalten, weil /// niemand sie zweimal treffen will. @AppStorage("liveTV.showsGuide") private var showsGuide = true + /// Welche Sender gezeigt werden. Leer heißt alle. + @AppStorage("liveTV.selection") private var storedSelection = "" + @State private var showsSelection = false + + /// Die Sender nach der Auswahl — überall dieselbe Liste, damit Raster und + /// Liste nicht auseinanderlaufen. + private var shownChannels: [PlexChannel] { + ChannelSelection.apply(ChannelSelection.decode(storedSelection), to: channels) + } var body: some View { Group { @@ -72,7 +81,7 @@ struct LiveTVView: View { case .available: if showsGuide { ProgramGuide( - channels: channels, + channels: shownChannels, programs: programs, tuning: isTuning, artwork: { channel in @@ -90,6 +99,14 @@ struct LiveTVView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { if case .available = availability, !channels.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + Button { + showsSelection = true + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + .accessibilityLabel(Text("liveTV.selection")) + } ToolbarItem(placement: .topBarTrailing) { Picker("liveTV.mode", selection: $showsGuide) { Image(systemName: "calendar.day.timeline.left").tag(true) @@ -102,6 +119,15 @@ struct LiveTVView: View { } .task { await load() } .refreshable { await load() } + .sheet(isPresented: $showsSelection) { + ChannelSelectionSheet( + channels: channels, + selection: Binding( + get: { ChannelSelection.decode(storedSelection) }, + set: { storedSelection = ChannelSelection.encode($0) } + ) + ) + } // Ein Blatt statt eines Dialogs: die Antwort des Servers ist // mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort // wieder nur, dass es nicht ging. @@ -157,7 +183,7 @@ struct LiveTVView: View { private var channelList: some View { ScrollView { LazyVStack(spacing: 0) { - ForEach(channels) { channel in + ForEach(shownChannels) { channel in ChannelRow( channel: channel, programs: programs[channel.id] ?? [], diff --git a/Flixy/Views/Player/MiniPlayer.swift b/Flixy/Views/Player/MiniPlayer.swift index 3e2a414..649a47c 100644 --- a/Flixy/Views/Player/MiniPlayer.swift +++ b/Flixy/Views/Player/MiniPlayer.swift @@ -30,23 +30,43 @@ struct MiniPlayer: View { MiniPlayerPlacement.Corner(rawValue: storedCorner) ?? .bottomTrailing } - /// Auf dem iPad darf sie größer sein — dort ist genug Fläche, und bei + /// Die selbst eingestellte Breite. Null heißt „noch nie angefasst". + @AppStorage("miniPlayer.width") private var storedWidth: Double = 0 + /// Änderung während des Ziehens am Griff, noch nicht übernommen. + @State private var widthChange: CGFloat = 0 + /// Die zuletzt tatsächlich gezeigte Breite — schon begrenzt. + /// + /// Ohne sie würde beim Loslassen der ungebremste Wert gespeichert, und + /// eine einmal zu weit gezogene Kachel bliebe für immer zu groß. + @State private var lastWidth: CGFloat = 0 + + /// Auf dem iPad größer voreingestellt — dort ist genug Fläche, und bei /// 180 Punkten sähe man auf einem 13-Zoll-Schirm nichts. - private var tileWidth: CGFloat { sizeClass == .regular ? 280 : 180 } - private var tileSize: CGSize { - CGSize(width: tileWidth, height: (tileWidth / FlixyAspectRatio.landscape).rounded()) + private var defaultWidth: CGFloat { sizeClass == .regular ? 280 : 180 } + + private func tileSize(in bounds: CGSize) -> CGSize { + let chosen = storedWidth > 0 ? CGFloat(storedWidth) : defaultWidth + // Nach oben durch den Bildschirm begrenzt: eine Kachel, die breiter + // ist als das Fenster, ließe sich nicht mehr wegziehen. + let upper = max(160, min(bounds.width * 0.75, 560)) + let width = min(max(chosen + widthChange, 140), upper) + return CGSize(width: width, height: (width / FlixyAspectRatio.landscape).rounded()) } var body: some View { GeometryReader { geometry in + let tileSize = tileSize(in: geometry.size) let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize) let resting = placement.center(for: corner) let position = placement.clamped( center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height) ) - tile + tile(corner: corner) .frame(width: tileSize.width, height: tileSize.height) + .onChange(of: tileSize.width, initial: true) { _, width in + lastWidth = width + } .position(position) .gesture(dragGesture(in: placement, from: resting)) // Nur während des Ziehens ohne Animation, sonst würde die @@ -56,7 +76,7 @@ struct MiniPlayer: View { .transition(.scale(scale: 0.8).combined(with: .opacity)) } - private var tile: some View { + private func tile(corner: MiniPlayerPlacement.Corner) -> some View { ZStack { Color.black @@ -68,6 +88,7 @@ struct MiniPlayer: View { if showsControls { controls.transition(.opacity) + resizeHandle(corner: corner).transition(.opacity) } // Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung — @@ -135,6 +156,46 @@ struct MiniPlayer: View { } } + /// Der Griff zum Größerziehen. + /// + /// Er sitzt in der Ecke, die zur Bildschirmmitte zeigt — dort ist er + /// erreichbar, egal in welcher Ecke die Kachel klebt. Am äußeren Rand + /// läge er halb außerhalb des Daumens. + private func resizeHandle(corner: MiniPlayerPlacement.Corner) -> some View { + VStack { + if corner.isTop { Spacer() } + HStack { + if corner.isLeading { Spacer() } + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(FlixySpacing.snug) + .contentShape(.rect) + .gesture(resizeGesture(corner: corner)) + .accessibilityLabel(Text("miniPlayer.resize")) + if !corner.isLeading { Spacer() } + } + if !corner.isTop { Spacer() } + } + // Gespiegelt, damit das Symbol in die Richtung zeigt, in die es + // tatsächlich größer wird. + .rotationEffect(.degrees(corner.isTop == corner.isLeading ? 0 : 90)) + } + + private func resizeGesture(corner: MiniPlayerPlacement.Corner) -> some Gesture { + DragGesture() + .onChanged { value in + hideTask?.cancel() + // Klebt die Kachel rechts, wächst sie beim Ziehen nach links. + widthChange = corner.isLeading ? value.translation.width : -value.translation.width + } + .onEnded { _ in + storedWidth = Double(lastWidth) + widthChange = 0 + reveal() + } + } + private var progress: Double? { guard let duration = model.duration, duration.seconds > 0 else { return nil } return model.currentTime.seconds / duration.seconds diff --git a/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift b/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift index f72ac77..9b3b844 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift @@ -12,8 +12,8 @@ public struct MiniPlayerPlacement: Sendable, Equatable { public enum Corner: String, Sendable, CaseIterable, Codable { case topLeading, topTrailing, bottomLeading, bottomTrailing - var isTop: Bool { self == .topLeading || self == .topTrailing } - var isLeading: Bool { self == .topLeading || self == .bottomLeading } + public var isTop: Bool { self == .topLeading || self == .topTrailing } + public var isLeading: Bool { self == .topLeading || self == .bottomLeading } } /// Der sichtbare Bereich, in dem die Kachel sich bewegen darf. diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 1599e67..1c3db9e 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -247,11 +247,24 @@ extension PlexServer { /// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender /// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden /// braucht niemand beim Umschalten. + /// + /// Zurück kommt, was den Zeitraum **berührt** — nicht, was vollständig + /// hineinpasst. Das ist der Unterschied, an dem das Programm vorher falsch + /// war: gefiltert wurde auf `endsAt <` Fensterende und `beginsAt >` + /// Fensteranfang, und damit fiel ausgerechnet die laufende Sendung heraus. + /// Ein Zweistünder, der um halb vier begann, war um Viertel nach fünf + /// unsichtbar, und angezeigt wurde stattdessen die nächste, die durch den + /// Filter passte — sichtbar falsch, und anders als Plex' eigene Anzeige. public func guide( providerIdentifier: String, from start: Date, to end: Date ) async throws(PlexError) -> [PlexGuideEntry] { + // Nur der **Anfang** wird eingegrenzt, und mit Vorlauf. Eine Grenze + // auf das Ende schließt jede Sendung aus, die über das Fenster + // hinausläuft — also gerade die längsten. + let lead = start.addingTimeInterval(-Self.longestProgramme) + let container = try await client.send( PlexRequest( path: "/\(providerIdentifier)/grid", @@ -259,8 +272,8 @@ extension PlexServer { // Ohne Typfilter. `type=1` steht bei Plex für Filme, und // im Programm laufen auch Folgen, Sport und Nachrichten — // der Filter hat das Raster leer zurückgegeben. - URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))), - URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))), + URLQueryItem(name: "beginsAt>", value: String(Int(lead.timeIntervalSince1970))), + URLQueryItem(name: "beginsAt<", value: String(Int(end.timeIntervalSince1970))), ], timeout: .seconds(30) ), @@ -277,6 +290,12 @@ extension PlexServer { } } + /// Wie weit vor dem Fenster gesucht wird. + /// + /// Sechs Stunden decken alles ab, was im Fernsehen am Stück läuft — lange + /// Spielfilme, Sportübertragungen, Wahlabende. + static var longestProgramme: TimeInterval { 6 * 3_600 } + /// Senderliste und Programm in einem Zug. /// /// Der Weg über die Senderliste des Anbieters ist der erste Versuch; wo @@ -309,7 +328,14 @@ extension PlexServer { to: now.addingTimeInterval(TimeInterval(hours) * 3_600) )) ?? [] - let programs = Dictionary(grouping: entries.compactMap { entry in + // Der Vorlauf der Abfrage holt auch Abgelaufenes mit — das gehört + // nicht ins Programm. **Nur** die Sendungen werden so gefiltert, nicht + // die Sender: ein Sender ohne frische Programmdaten soll trotzdem in + // der Liste stehen, sonst verschwindet er, statt „kein Programm" zu + // sagen. + let fresh = entries.filter { $0.program.endsAt > now } + + let programs = Dictionary(grouping: fresh.compactMap { entry in entry.program.channelID.map { (key: $0, program: entry.program) } }, by: \.key).mapValues { pairs in pairs.map(\.program).sorted { $0.startsAt < $1.startsAt } diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid-overlap.json b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid-overlap.json new file mode 100644 index 0000000..c358393 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid-overlap.json @@ -0,0 +1,43 @@ +{ + "MediaContainer": { + "size": 2, + "Metadata": [ + { + "ratingKey": "long", + "title": "Der lange Film", + "Media": [ + { + "channelIdentifier": "ch-1", + "channelTitle": "SRF 1 HD", + "channelVcn": "002", + "beginsAt": 1789205400, + "endsAt": 1789212600, + "Part": [ + { + "key": "/livetv/channels/2" + } + ] + } + ] + }, + { + "ratingKey": "after", + "title": "Was danach kommt", + "Media": [ + { + "channelIdentifier": "ch-1", + "channelTitle": "SRF 1 HD", + "channelVcn": "002", + "beginsAt": 1789212600, + "endsAt": 1789216200, + "Part": [ + { + "key": "/livetv/channels/2" + } + ] + } + ] + } + ] + } +} \ No newline at end of file diff --git a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid.json b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid.json index e173ba5..730c786 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid.json +++ b/Packages/FlixyKit/Tests/PlexKitTests/Fixtures/livetv-grid.json @@ -17,8 +17,8 @@ "channelTitle": "006 ZDFGERH (ZDF HD)", "channelVcn": "006", "onAir": true, - "beginsAt": 1757682000, - "endsAt": 1757685600 + "beginsAt": 4102444800, + "endsAt": 4102445700 } ] }, @@ -33,8 +33,8 @@ "channelIdentifier": "6228f7f2364e02fa5ec8e51c-5fc705f9c8d56d002e06f849", "channelTitle": "006 ZDFGERH (ZDF HD)", "channelVcn": "006", - "beginsAt": 1757685600, - "endsAt": 1757687400 + "beginsAt": 4102445700, + "endsAt": 4102446000 } ] }, @@ -48,8 +48,8 @@ "channelID": 12, "channelIdentifier": "6228f7f2364e02fa5ec8e51c-12", "channelVcn": "002", - "beginsAt": 1757682000, - "endsAt": 1757685600 + "beginsAt": 4102444800, + "endsAt": 4102448400 } ] } diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index 35951c0..119cb00 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -9,9 +9,32 @@ import Testing /// keine Vermutung: die Weboberfläche von Plex zeichnet ihren Guide daraus, /// und jeder Eintrag trägt den Sender mit sich. Diese Tests halten fest, dass /// die Sender dort auch dann herauskommen, wenn sonst nichts antwortet. +/// Hält fest, womit tatsächlich gefragt wurde. +/// +/// Ein Actor, weil der Transport aus einem anderen Zusammenhang schreibt und +/// Swift 6 das sonst zu Recht verweigert. +private actor QueryRecorder { + private(set) var items: [URLQueryItem] = [] + func note(_ new: [URLQueryItem]) { items = new } +} + @Suite("Senderliste aus dem Programmraster") struct LiveTVGuideTests { + private func server(transport: FakeTransport) -> PlexServer { + PlexServer( + resource: PlexResource( + name: "Test", + clientIdentifier: "srv", + accessToken: "token", + connections: [PlexConnection(uri: "http://server:32400", local: true)] + ), + connection: PlexConnection(uri: "http://server:32400", local: true), + identity: .test, + transport: transport + )! + } + private func server(routes: [String: String]) -> PlexServer { PlexServer( resource: PlexResource( @@ -26,6 +49,9 @@ struct LiveTVGuideTests { )! } + // Die Zeiten in `livetv-grid.json` liegen bewusst weit in der Zukunft: + // das Programm wird auf Frische gefiltert, und ein Abzug mit den echten + // Zeitstempeln von damals wäre ab dem Tag nach der Aufnahme leer gewesen. private func fixture(_ name: String) throws -> String { let url = try #require(Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json")) return try String(contentsOf: url, encoding: .utf8) @@ -78,6 +104,39 @@ struct LiveTVGuideTests { #expect(guide.programs.count == 2) } + @Test("Die laufende Sendung fällt nicht aus dem Fenster") + func keepsTheRunningProgramme() async throws { + // Der Fehler, der das Programm sichtbar falsch machte: gefiltert wurde + // auf „endet vor Fensterende" und „beginnt nach Fensteranfang". Ein + // Zweistünder ab 15:30 war damit um 17:18 unsichtbar, und angezeigt + // wurde die nächste Sendung, die durch den Filter passte. + let body = try fixture("livetv-grid-overlap") + let recorder = QueryRecorder() + let server = server(transport: FakeTransport { request in + await recorder.note( + URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems ?? [] + ) + return (Data(body.utf8), FakeTransport.response(for: request, status: 200)) + }) + + // 17:18 — mitten im langen Film. + let now = Date(timeIntervalSince1970: 1_789_211_880) + let entries = try await server.guide( + providerIdentifier: "tv.plex.providers.epg.cloud:2", + from: now, + to: now.addingTimeInterval(6 * 3_600) + ) + + #expect(entries.map(\.program.title).contains("Der lange Film")) + #expect(entries.first?.program.isRunning(at: now) == true) + + // Und der Grund dafür steht in der Abfrage: nur der Anfang wird + // eingegrenzt, mit Vorlauf — keine Grenze auf das Ende. + let seen = await recorder.items + #expect(seen.contains { $0.name == "beginsAt<" }) + #expect(!seen.contains { $0.name == "endsAt<" }) + } + @Test("Ein Sender ohne Namen taucht nicht als Strich auf") func skipsNamelessChannels() async throws { let json = """