Flixy heißt ab hier Halo
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,137 @@
|
||||
import AVKit
|
||||
import UIKit
|
||||
import HaloPlayback
|
||||
|
||||
/// Baut aus unseren Spurlisten die Menüs für Apples Transportleiste.
|
||||
///
|
||||
/// Die reine Abbildung steht in `TrackMenu` und ist dort geprüft; hier geht es
|
||||
/// nur noch um UIKit.
|
||||
@MainActor
|
||||
enum TVTrackMenuBuilder {
|
||||
/// - Parameter appearance: Der Untertitelstil. Er steht **in** diesem
|
||||
/// Menü und nicht daneben: vorher gab es zwei Knöpfe mit derselben
|
||||
/// Sprechblase — einen für die Spur, einen für den Stil —, und welcher
|
||||
/// was tat, sah man erst nach dem Drücken.
|
||||
static func items(
|
||||
for aufnahme: TVPlayerSnapshot,
|
||||
appearance: SubtitleAppearance,
|
||||
onAudio: @escaping (Int?) -> Void,
|
||||
onSubtitle: @escaping (Int?) -> Void
|
||||
) -> [UIMenuElement] {
|
||||
var menüs: [UIMenuElement] = []
|
||||
|
||||
// **Unser Tonmenü nur, wenn Apples eigenes es nicht kann.**
|
||||
//
|
||||
// Wird die Datei unverändert gespielt und stimmt die Spurzahl, liegen
|
||||
// alle Spuren im Stream — dann zeigt Apple sie selbst, und zwei
|
||||
// Knöpfe nebeneinander wären dasselbe zweimal. Beim Umrechnen liegt
|
||||
// im Stream dagegen genau eine, und nur unser Menü führt weiter.
|
||||
if aufnahme.audio.count > 1, aufnahme.audio.contains(where: \.rebuilds) {
|
||||
menüs.append(UIMenu(
|
||||
title: String(localized: "player.audioTracks"),
|
||||
image: UIImage(systemName: "waveform"),
|
||||
options: [.singleSelection],
|
||||
children: einträge(aufnahme.audio, gesperrt: aufnahme.isRebuilding, aktion: onAudio)
|
||||
))
|
||||
}
|
||||
|
||||
// **Untertitel immer über uns**, auch wenn Apple es könnte: hier hängt
|
||||
// der Stil mit drin, und der ist unserer — `AVTextStyleRule`s aus
|
||||
// `SubtitleAppearance`, nicht die Systemeinstellung, die Apples Menü
|
||||
// verstellt. Apples eigener Knopf ist deshalb abgeschaltet; siehe
|
||||
// `TVPlayerCoordinator`.
|
||||
menüs.append(UIMenu(
|
||||
title: String(localized: "player.subtitleTracks"),
|
||||
image: UIImage(systemName: "captions.bubble"),
|
||||
children: [
|
||||
// Inline: die Spuren stehen ohne Umweg da, der Stil darunter
|
||||
// als eigene Untermenüs. Tiefer geht es nicht — die
|
||||
// Transportleiste zeigt genau eine Ebene.
|
||||
UIMenu(options: [.displayInline, .singleSelection],
|
||||
children: einträge(aufnahme.subtitles,
|
||||
gesperrt: aufnahme.isRebuilding,
|
||||
aktion: onSubtitle)),
|
||||
größenMenü(appearance),
|
||||
darstellungsMenü(appearance),
|
||||
]
|
||||
))
|
||||
|
||||
return menüs
|
||||
}
|
||||
|
||||
// MARK: - Die Spuren
|
||||
|
||||
private static func einträge(
|
||||
_ liste: [TrackMenu.Entry],
|
||||
gesperrt: Bool,
|
||||
aktion: @escaping (Int?) -> Void
|
||||
) -> [UIMenuElement] {
|
||||
liste.map { eintrag in
|
||||
UIAction(
|
||||
title: eintrag.title,
|
||||
// **Vorher ansagen, nicht hinterher erklären.** Steht hier
|
||||
// nichts, ist die Sekundenpause danach eine Panne; steht es
|
||||
// hier, ist sie eine angekündigte Folge. Das ist der billigste
|
||||
// Teil dieser ganzen Sache und der wirksamste.
|
||||
subtitle: eintrag.rebuilds ? String(localized: "player.rebuilds") : nil,
|
||||
// **Keine zweite Bestellung, solange die erste läuft.** Sonst
|
||||
// räumt `restart()` eine Engine ab, die eine andere gerade baut.
|
||||
attributes: gesperrt ? [.disabled] : [],
|
||||
state: eintrag.isSelected ? .on : .off
|
||||
) { auslöser in
|
||||
// **Sofort quittieren.** Das Menü schliesst sich beim Drücken,
|
||||
// und darin ist kein Platz für einen Kreisel. Der Haken
|
||||
// wandert, bevor sich am Bild etwas tut.
|
||||
auslöser.state = .on
|
||||
aktion(eintrag.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Der Stil
|
||||
|
||||
private static func größenMenü(_ appearance: SubtitleAppearance) -> UIMenu {
|
||||
UIMenu(
|
||||
title: String(localized: "subtitleStyle.sizeHeader"),
|
||||
image: UIImage(systemName: "textformat.size"),
|
||||
options: [.singleSelection],
|
||||
children: SubtitleStyle.Size.allCases.map { größe in
|
||||
UIAction(
|
||||
title: größe.label,
|
||||
state: appearance.size == größe ? .on : .off
|
||||
) { auslöser in
|
||||
auslöser.state = .on
|
||||
appearance.size = größe
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private static func darstellungsMenü(_ appearance: SubtitleAppearance) -> UIMenu {
|
||||
UIMenu(
|
||||
title: String(localized: "subtitleStyle.paletteHeader"),
|
||||
image: UIImage(systemName: "paintpalette"),
|
||||
options: [.singleSelection],
|
||||
children: SubtitleStyle.Palette.allCases.map { palette in
|
||||
UIAction(
|
||||
title: palette.label,
|
||||
state: appearance.palette == palette ? .on : .off
|
||||
) { auslöser in
|
||||
auslöser.state = .on
|
||||
appearance.palette = palette
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// „Intro überspringen" unten rechts, solange eine Marke läuft.
|
||||
///
|
||||
/// Die tvOS-Entsprechung zu dem Knopf, den der iOS-Player selbst zeichnet.
|
||||
static func contextualActions(
|
||||
for aufnahme: TVPlayerSnapshot,
|
||||
onSkip: @escaping () -> Void
|
||||
) -> [UIAction] {
|
||||
guard let beschriftung = aufnahme.markerLabel else { return [] }
|
||||
return [UIAction(title: beschriftung) { _ in onSkip() }]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user