Kachel größer ziehen, Senderlogos vollständig, Sendung im Player
Drei Sachen aus dem Feldtest. Der Griff zum Größerziehen war wirkungslos, und zwar durch meinen Fehler: die Drehung lag auf dem ganzen Überzug statt auf dem Symbol. Bei zwei der vier Ecken kippte damit die gesamte Trefferfläche weg, und man griff ins Leere. Jetzt sitzt der Griff über `overlay(alignment:)`, nur der Pfeil dreht sich, und das Ziehen hat Vorrang vor dem Verschieben der Kachel — sonst gewinnt das Verschieben, das am selben Ort hängt. Senderlogos sind Schriftzüge und fast nie quadratisch. Formatfüllend in einem Quadrat fehlten links und rechts genau die Teile, an denen man den Sender erkennt. Sie bekommen jetzt eine breitere Fläche und werden eingepasst statt beschnitten — `FlixyArtwork` kann beides, und welches gilt, entscheidet der Aufrufer. Und im Player steht bei Live-TV, was gerade läuft. Der Name reist mit dem Einschalten mit, denn nachzuschlagen ist er nicht — ein Sender hat keine Metadaten in der Mediathek. Beim Aufdecken der Bedienelemente wird er nachgeholt, aber nur, wenn die alte Sendung wirklich abgelaufen ist: sonst stünde derselbe Name den ganzen Abend, und das wäre derselbe Fehler, der eben erst im Programm behoben wurde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
af45bebceb
commit
c348ab0755
+2677
-2675
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,18 @@ struct PlaybackRequest: Identifiable, Hashable {
|
||||
let ratingKey: String
|
||||
/// Für die Titelzeile, solange die Metadaten noch geladen werden.
|
||||
let title: String
|
||||
/// Die zweite Zeile, wenn sie schon feststeht.
|
||||
///
|
||||
/// Bei Live-TV die laufende Sendung: sie steht nirgends nachzuschlagen —
|
||||
/// ein Sender hat keine Metadaten in der Mediathek —, und ohne sie sieht
|
||||
/// man im Player nur den Sendernamen und weiß nicht, was läuft.
|
||||
var subtitle: String?
|
||||
/// Wann die laufende Sendung endet.
|
||||
///
|
||||
/// Damit im Player nicht den ganzen Abend derselbe Name steht: ein Sender
|
||||
/// läuft weiter, die Sendung wechselt. Ohne diese Zeit wüsste niemand,
|
||||
/// wann die Angabe falsch wird.
|
||||
var subtitleEndsAt: Date?
|
||||
/// Welche Fassung, wenn ein Titel in mehreren vorliegt.
|
||||
let mediaIndex: Int
|
||||
/// Ab wo — aus dem gemeldeten Fortschritt.
|
||||
@@ -67,12 +79,16 @@ final class PlaybackCoordinator {
|
||||
func playLive(
|
||||
channelID: String,
|
||||
title: String,
|
||||
programme: String?,
|
||||
programmeEndsAt: Date?,
|
||||
stream: URL,
|
||||
subscriptionKey: String?
|
||||
) {
|
||||
play(PlaybackRequest(
|
||||
ratingKey: channelID,
|
||||
title: title,
|
||||
subtitle: programme,
|
||||
subtitleEndsAt: programmeEndsAt,
|
||||
mediaIndex: 0,
|
||||
startTime: .zero,
|
||||
streamURL: stream,
|
||||
|
||||
@@ -59,12 +59,12 @@ struct ChannelSelectionSheet: View {
|
||||
} label: {
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
FlixyArtwork(
|
||||
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
|
||||
aspectRatio: 1,
|
||||
fallbackSymbol: "tv"
|
||||
url: session.server?.artworkURL(path: channel.thumb, width: 200, height: 120),
|
||||
aspectRatio: FlixyAspectRatio.channelLogo,
|
||||
fallbackSymbol: "tv",
|
||||
contentMode: .fit
|
||||
)
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
.frame(width: 48, height: 29)
|
||||
|
||||
if let number = channel.channelIdentifier {
|
||||
Text(number)
|
||||
|
||||
@@ -255,6 +255,8 @@ struct LiveTVView: View {
|
||||
playback.playLive(
|
||||
channelID: channel.id,
|
||||
title: channel.title,
|
||||
programme: running?.title,
|
||||
programmeEndsAt: running?.endsAt,
|
||||
stream: stream,
|
||||
subscriptionKey: result.subscriptionKey
|
||||
)
|
||||
@@ -277,13 +279,16 @@ private struct ChannelRow: View {
|
||||
var body: some View {
|
||||
Button(action: onTune) {
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
|
||||
// fast nie quadratisch, und formatfüllend fehlten links und
|
||||
// rechts genau die Teile, an denen man den Sender erkennt.
|
||||
FlixyArtwork(
|
||||
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
|
||||
aspectRatio: 1,
|
||||
fallbackSymbol: "tv"
|
||||
url: session.server?.artworkURL(path: channel.thumb, width: 200, height: 120),
|
||||
aspectRatio: FlixyAspectRatio.channelLogo,
|
||||
fallbackSymbol: "tv",
|
||||
contentMode: .fit
|
||||
)
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
.frame(width: 56, height: 34)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: FlixySpacing.snug) {
|
||||
|
||||
@@ -162,34 +162,49 @@ struct MiniPlayer: View {
|
||||
/// 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() }
|
||||
// Über `overlay(alignment:)` gesetzt, nicht über gedrehte Stapel:
|
||||
// eine Drehung auf dem Überzug kippt die gesamte Trefferfläche mit,
|
||||
// und der Griff lag dann dort, wo man nicht hinfasst. Genau daran war
|
||||
// das Ziehen vorher wirkungslos.
|
||||
Color.clear.overlay(alignment: handleAlignment(for: corner)) {
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.font(.caption.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.
|
||||
// Nur das Symbol dreht sich, damit der Pfeil in die Richtung
|
||||
// zeigt, in die es tatsächlich größer wird.
|
||||
.rotationEffect(.degrees(corner.isTop == corner.isLeading ? 0 : 90))
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(.rect)
|
||||
// Vorrang vor dem Verschieben der Kachel, das am selben Ort
|
||||
// hängt — sonst gewinnt das Ziehen und die Kachel wandert.
|
||||
.highPriorityGesture(resizeGesture(corner: corner))
|
||||
// Und ein Tippen darf hier nicht ins Vollbild führen.
|
||||
.onTapGesture { reveal() }
|
||||
.accessibilityLabel(Text("miniPlayer.resize"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Ecke der Kachel, die zur Bildschirmmitte zeigt.
|
||||
private func handleAlignment(for corner: MiniPlayerPlacement.Corner) -> Alignment {
|
||||
switch corner {
|
||||
case .topLeading: .bottomTrailing
|
||||
case .topTrailing: .bottomLeading
|
||||
case .bottomLeading: .topTrailing
|
||||
case .bottomTrailing: .topLeading
|
||||
}
|
||||
}
|
||||
|
||||
private func resizeGesture(corner: MiniPlayerPlacement.Corner) -> some Gesture {
|
||||
DragGesture()
|
||||
DragGesture(minimumDistance: 2)
|
||||
.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
|
||||
// Die **begrenzte** Breite merken, nicht die gezogene: eine
|
||||
// einmal zu weit gezogene Kachel bliebe sonst für immer zu
|
||||
// groß, auch auf einem kleineren Bildschirm.
|
||||
storedWidth = Double(lastWidth)
|
||||
widthChange = 0
|
||||
reveal()
|
||||
|
||||
@@ -91,6 +91,9 @@ final class PlayerModel {
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var reportTask: Task<Void, Never>?
|
||||
private var transcodeSession: String?
|
||||
/// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht
|
||||
/// mehr.
|
||||
private var programmeEndsAt: Date?
|
||||
/// Marken, die bereits automatisch übersprungen wurden. Ohne dieses
|
||||
/// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen
|
||||
/// lassen — und man käme nie zurück.
|
||||
@@ -234,6 +237,10 @@ final class PlayerModel {
|
||||
diagnostics.note("Live-Strom öffnen")
|
||||
isLive = true
|
||||
title = request.title
|
||||
// Was gerade läuft, kommt mit dem Wunsch — nachzuschlagen ist es
|
||||
// nicht, ein Sender steht in keiner Mediathek.
|
||||
subtitle = request.subtitle
|
||||
programmeEndsAt = request.subtitleEndsAt
|
||||
|
||||
// Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der
|
||||
// Tuner nach dem Schließen belegt — und beim nächsten Sender hat der
|
||||
@@ -355,6 +362,36 @@ final class PlayerModel {
|
||||
|
||||
// MARK: - Steuerung
|
||||
|
||||
/// Holt die laufende Sendung nach, falls die angezeigte vorbei ist.
|
||||
///
|
||||
/// Beim Einschalten reist der Name mit; ein Sender läuft aber weiter,
|
||||
/// während die Sendung wechselt. Nur dann abgefragt, wenn die alte
|
||||
/// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist
|
||||
/// nichts, was man bei jedem Antippen tun will.
|
||||
func refreshLiveProgrammeIfNeeded() async {
|
||||
guard isLive, let server else { return }
|
||||
if let programmeEndsAt, programmeEndsAt > .now { return }
|
||||
|
||||
guard let provider = await server.electronicProgramGuideIdentifier() else { return }
|
||||
let now = Date()
|
||||
let entries = (try? await server.guide(
|
||||
providerIdentifier: provider,
|
||||
from: now,
|
||||
to: now.addingTimeInterval(2 * 3_600)
|
||||
)) ?? []
|
||||
|
||||
guard let match = entries.first(where: {
|
||||
$0.channel?.id == request.ratingKey && $0.program.isRunning()
|
||||
}) else {
|
||||
// Lieber gar nichts als etwas Falsches.
|
||||
subtitle = nil
|
||||
return
|
||||
}
|
||||
|
||||
subtitle = match.program.title
|
||||
programmeEndsAt = match.program.endsAt
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
guard let engine else { return }
|
||||
if isPlaying { engine.pause() } else { engine.play() }
|
||||
|
||||
@@ -382,6 +382,11 @@ struct PlayerView: View {
|
||||
|
||||
private func toggleControls() {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { showsControls.toggle() }
|
||||
// Beim Aufdecken nachsehen, was inzwischen läuft — genau dann will
|
||||
// man es wissen.
|
||||
if showsControls, model.isLive {
|
||||
Task { await model.refreshLiveProgrammeIfNeeded() }
|
||||
}
|
||||
if showsControls { scheduleControlsHide() }
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,24 @@ public struct FlixyArtwork: View {
|
||||
private let url: URL?
|
||||
private let aspectRatio: CGFloat
|
||||
private let fallbackSymbol: String
|
||||
private let contentMode: ContentMode
|
||||
|
||||
public init(url: URL?, aspectRatio: CGFloat, fallbackSymbol: String = "film") {
|
||||
/// - Parameter contentMode: `.fill` füllt die Fläche und schneidet ab —
|
||||
/// richtig für Plakate und Szenenbilder, die ohnehin formatfüllend
|
||||
/// gedacht sind. `.fit` zeigt das ganze Bild. Das braucht alles, was
|
||||
/// kein festes Seitenverhältnis hat: **Sendersymbole** sind mal breit,
|
||||
/// mal schmal, und formatfüllend in einem Quadrat fehlten links und
|
||||
/// rechts genau die Teile, an denen man den Sender erkennt.
|
||||
public init(
|
||||
url: URL?,
|
||||
aspectRatio: CGFloat,
|
||||
fallbackSymbol: String = "film",
|
||||
contentMode: ContentMode = .fill
|
||||
) {
|
||||
self.url = url
|
||||
self.aspectRatio = aspectRatio
|
||||
self.fallbackSymbol = fallbackSymbol
|
||||
self.contentMode = contentMode
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
@@ -25,7 +38,10 @@ public struct FlixyArtwork: View {
|
||||
AsyncImage(url: url, transaction: Transaction(animation: .easeOut(duration: 0.2))) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image.resizable().scaledToFill()
|
||||
switch contentMode {
|
||||
case .fill: image.resizable().scaledToFill()
|
||||
case .fit: image.resizable().scaledToFit()
|
||||
}
|
||||
case .failure:
|
||||
// Kein Fehlertext: bei einer Reihe aus zwanzig Kacheln
|
||||
// wäre das mehr Lärm als Hilfe. Das Symbol sagt genug.
|
||||
|
||||
@@ -54,4 +54,7 @@ public enum FlixyAspectRatio {
|
||||
/// Der Aufmacher auf der Startseite — dasselbe wie `landscape`, weil Plex
|
||||
/// dafür `art` liefert.
|
||||
public static let hero: CGFloat = landscape
|
||||
/// Sendersymbole. Breiter als hoch, weil Senderlogos Schriftzüge sind —
|
||||
/// quadratisch beschnitten fehlt der halbe Name.
|
||||
public static let channelLogo: CGFloat = 5.0 / 3.0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user