Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
10 KiB
Swift
249 lines
10 KiB
Swift
import SwiftUI
|
|
import HaloDesign
|
|
|
|
/// Die schwebende Videokachel.
|
|
///
|
|
/// Damit läuft weiter, was man gerade sieht, während man nach dem Nächsten
|
|
/// stöbert — der Grund, warum es sie gibt. Plex' eigene Handy-Apps reißen die
|
|
/// Wiedergabe beim Zurückgehen ab, und genau in dem Moment will man
|
|
/// weiterschauen und nebenher nachsehen, was sonst noch läuft.
|
|
///
|
|
/// Bewusst schwebend statt als Leiste am unteren Rand: eine Leiste nimmt jeder
|
|
/// Liste Platz weg und müsste in jedem Bildschirm berücksichtigt werden. Eine
|
|
/// Kachel schwebt darüber, und wem sie im Weg ist, der zieht sie weg.
|
|
struct MiniPlayer: View {
|
|
let model: PlayerModel
|
|
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
|
|
/// Seite und Höhe bleiben über Starts hinweg erhalten. Wer die Kachel
|
|
/// einmal nach links unten gezogen hat, will sie dort wiederfinden.
|
|
@AppStorage("miniPlayer.side") private var storedSide = MiniPlayerPlacement.Side.trailing.rawValue
|
|
/// Als Anteil, nicht in Punkten: beim Drehen bleibt sie so an derselben
|
|
/// Stelle, statt unten herauszufallen.
|
|
@AppStorage("miniPlayer.height") private var storedFraction = 0.85
|
|
|
|
/// Verschiebung während des Ziehens, noch nicht eingerastet.
|
|
@State private var drag: CGSize = .zero
|
|
|
|
private var side: MiniPlayerPlacement.Side {
|
|
MiniPlayerPlacement.Side(rawValue: storedSide) ?? .trailing
|
|
}
|
|
|
|
/// Die selbst eingestellte Breite. Null heißt „noch nie angefasst".
|
|
@AppStorage("miniPlayer.width") private var storedWidth: Double = 0
|
|
/// Vergrößerung während des Zusammen- oder Auseinanderziehens.
|
|
@State private var zoom: CGFloat = 1
|
|
/// 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 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 * zoom, 140), upper)
|
|
return CGSize(width: width, height: (width / HaloAspectRatio.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(side: side, verticalFraction: storedFraction)
|
|
let position = placement.clamped(
|
|
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
|
|
)
|
|
|
|
tile
|
|
.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))
|
|
// Gleichzeitig, damit das Zusammenziehen nicht erst nach dem
|
|
// Loslassen greift — beides passiert mit denselben Fingern.
|
|
.simultaneousGesture(zoomGesture(in: geometry.size))
|
|
// Nur während des Ziehens ohne Animation, sonst würde die
|
|
// Kachel dem Finger hinterherfedern statt an ihm zu kleben.
|
|
.animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position)
|
|
}
|
|
.transition(.scale(scale: 0.8).combined(with: .opacity))
|
|
}
|
|
|
|
private var tile: some View {
|
|
ZStack {
|
|
Color.black
|
|
|
|
VideoSurface(
|
|
engine: model.engine,
|
|
surface: model.surface,
|
|
isActive: playback.presentation == .mini
|
|
)
|
|
|
|
if model.isBuffering {
|
|
ProgressView().tint(.white)
|
|
}
|
|
|
|
corners
|
|
|
|
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
|
|
// es gibt kein Ende, auf das er zulaufen könnte.
|
|
if !model.isLive, let fraction = progress {
|
|
VStack {
|
|
Spacer()
|
|
WatchProgressBar(fraction: fraction)
|
|
}
|
|
}
|
|
}
|
|
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: HaloRadius.card)
|
|
.strokeBorder(Color.haloSeparator, lineWidth: 0.5)
|
|
}
|
|
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
|
|
.contentShape(.rect)
|
|
// Antippen vergrößert, sofort. Vorher brauchte es zwei Berührungen:
|
|
// die erste holte die Bedienelemente hervor, die zweite sollte
|
|
// öffnen — traf dabei aber den Abspielknopf, der genau in der Mitte
|
|
// lag. Vergrößern war damit überhaupt nicht möglich.
|
|
.onTapGesture { playback.expand() }
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(Text(model.title))
|
|
}
|
|
|
|
/// Die beiden Knöpfe in den oberen Ecken.
|
|
///
|
|
/// Dauerhaft sichtbar statt auf Antippen hin. Die Fläche in der Mitte
|
|
/// gehört dem Vergrößern — sie ist das, was man an einer solchen Kachel
|
|
/// am häufigsten tut, und sie darf nicht mit einem Knopf belegt sein.
|
|
/// Unten bleibt frei für den Fortschrittsbalken.
|
|
private var corners: some View {
|
|
VStack {
|
|
HStack {
|
|
tileButton(
|
|
model.isPlaying ? "pause.fill" : "play.fill",
|
|
label: model.isPlaying ? "player.pause" : "player.play"
|
|
) {
|
|
model.togglePlayPause()
|
|
}
|
|
Spacer()
|
|
tileButton("xmark", label: "miniPlayer.close") {
|
|
playback.close()
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
HStack {
|
|
Spacer()
|
|
// Ein eigener Knopf, obwohl auch das Antippen der Fläche
|
|
// vergrößert. Beim Ziehen und Aufziehen liegen auf derselben
|
|
// Fläche schon zwei Gesten, und was daneben geht, sieht aus
|
|
// wie „reagiert nicht". Ein Knopf geht nicht daneben.
|
|
tileButton(
|
|
"arrow.up.left.and.arrow.down.right",
|
|
label: "miniPlayer.expand"
|
|
) {
|
|
playback.expand()
|
|
}
|
|
}
|
|
}
|
|
.padding(HaloSpacing.tight)
|
|
// Über dem Fortschrittsbalken, nicht darauf.
|
|
.padding(.bottom, HaloSpacing.tight)
|
|
}
|
|
|
|
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
|
|
///
|
|
/// Die dunkle Scheibe darunter ist nicht Zierde: ein weißes Symbol auf
|
|
/// einer hellen Szene verschwindet, und die Kachel zeigt Filmbilder, deren
|
|
/// Helligkeit sich jede Sekunde ändert.
|
|
private func tileButton(
|
|
_ symbol: String,
|
|
label: LocalizedStringKey,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: symbol)
|
|
.font(.caption.weight(.bold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 26, height: 26)
|
|
.background(.black.opacity(0.55), in: .circle)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(Text(label))
|
|
}
|
|
|
|
/// Größer und kleiner durch Zusammen- und Auseinanderziehen.
|
|
///
|
|
/// Kein Griff mehr: der saß zwangsläufig irgendwo auf dem Bild, war klein
|
|
/// und stritt sich mit dem Verschieben um dieselbe Geste.
|
|
private func zoomGesture(in bounds: CGSize) -> some Gesture {
|
|
MagnifyGesture()
|
|
.onChanged { value in
|
|
zoom = value.magnification
|
|
}
|
|
.onEnded { _ in
|
|
// Die **begrenzte** Breite merken, nicht die gezogene: eine
|
|
// einmal zu weit aufgezogene Kachel bliebe sonst für immer zu
|
|
// groß, auch auf einem kleineren Bildschirm.
|
|
storedWidth = Double(lastWidth)
|
|
zoom = 1
|
|
}
|
|
}
|
|
|
|
private var progress: Double? {
|
|
guard let duration = model.duration, duration.seconds > 0 else { return nil }
|
|
return model.currentTime.seconds / duration.seconds
|
|
}
|
|
|
|
private func dragGesture(
|
|
in placement: MiniPlayerPlacement,
|
|
from resting: CGPoint
|
|
) -> some Gesture {
|
|
DragGesture()
|
|
.onChanged { value in
|
|
drag = value.translation
|
|
}
|
|
.onEnded { value in
|
|
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
|
|
// dem Finger: sonst springt eine Kachel, die man am Rand
|
|
// angefasst hat, auf die falsche Seite.
|
|
let center = placement.clamped(center: CGPoint(
|
|
x: resting.x + value.predictedEndTranslation.width,
|
|
y: resting.y + value.predictedEndTranslation.height
|
|
))
|
|
// Seitlich einrasten, in der Höhe **nicht** — dort bleibt sie,
|
|
// wohin man sie gezogen hat.
|
|
storedSide = placement.nearestSide(toCenter: center).rawValue
|
|
storedFraction = placement.verticalFraction(forCenter: center)
|
|
drag = .zero
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// Legt die schwebende Kachel über diesen Bildschirm.
|
|
///
|
|
/// Bewusst hier und nicht ganz oben in der Wurzel: auf dem iPhone soll die
|
|
/// Seitenleiste die Kachel verdecken, wenn sie aufgeht, und nicht
|
|
/// umgekehrt. Deshalb liegt sie **innerhalb** des Inhalts.
|
|
func miniPlayer(_ model: PlayerModel?, isVisible: Bool) -> some View {
|
|
overlay {
|
|
if let model, isVisible {
|
|
MiniPlayer(model: model)
|
|
}
|
|
}
|
|
}
|
|
}
|