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>
138 lines
5.7 KiB
Swift
138 lines
5.7 KiB
Swift
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() }]
|
|
}
|
|
}
|