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,154 @@
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
|
||||
/// Meldet die laufende Wiedergabe beim System an — und nimmt von dort Befehle
|
||||
/// entgegen.
|
||||
///
|
||||
/// Ohne diese Anmeldung sind die Medientasten der Tastatur wirkungslos, und
|
||||
/// zwar lautlos: macOS schickt sie an das Programm, das sich als „läuft
|
||||
/// gerade" gemeldet hat. Meldet sich keines, gewinnt irgendein anderes —
|
||||
/// Musik, ein Browser-Tab, was zuletzt etwas abgespielt hat. Man drückt auf
|
||||
/// Pause und pausiert etwas, das man gar nicht sieht.
|
||||
///
|
||||
/// Dieselbe Anmeldung trägt die Sperrbildschirm-Bedienung, das
|
||||
/// Kontrollzentrum, die Knöpfe an Kopfhörern und die Auskunft in der
|
||||
/// Menüleiste. Es ist **eine** Sache, nicht vier.
|
||||
///
|
||||
/// Eine Stelle für die ganze App, kein Wert je Player: das System kennt
|
||||
/// ohnehin nur einen laufenden Titel. Läge die Anmeldung beim Modell, würde
|
||||
/// ein zweiter Player dem ersten die Tasten wegnehmen und beim Beenden die
|
||||
/// des anderen abräumen.
|
||||
@MainActor
|
||||
final class NowPlaying {
|
||||
static let shared = NowPlaying()
|
||||
|
||||
/// Wofür wir uns angemeldet haben — zum Wiederabmelden.
|
||||
///
|
||||
/// `addTarget` gibt eine Marke zurück, und nur mit ihr lässt sich der
|
||||
/// eigene Eintrag wieder lösen. Ohne diese Liste sammelten sich bei jedem
|
||||
/// neuen Film weitere Empfänger an, und ein Tastendruck löste die
|
||||
/// Wiedergabe fünfmal um.
|
||||
private var registered: [(command: MPRemoteCommand, target: Any)] = []
|
||||
fileprivate(set) weak var model: PlayerModel?
|
||||
|
||||
/// Wann zuletzt gemeldet wurde und mit welchem Tempo.
|
||||
///
|
||||
/// Die Stelle kommt viermal je Sekunde herein; sie jedes Mal zu melden
|
||||
/// wäre umsonst, weil das System zwischen zwei Meldungen selbst
|
||||
/// weiterzählt — es kennt ja das Tempo. Gemeldet wird deshalb nur, wenn
|
||||
/// sich das Tempo ändert oder seit der letzten Meldung Zeit vergangen ist.
|
||||
private var lastReport: (at: Date, rate: Double)?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - An- und abmelden
|
||||
|
||||
func takeOver(_ model: PlayerModel) {
|
||||
release()
|
||||
self.model = model
|
||||
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
|
||||
add(center.togglePlayPauseCommand) { $0.togglePlayPause() }
|
||||
add(center.playCommand) { if !$0.isPlaying { $0.togglePlayPause() } }
|
||||
add(center.pauseCommand) { if $0.isPlaying || $0.isBuffering { $0.togglePlayPause() } }
|
||||
|
||||
// Die Spulweiten aus den Einstellungen, damit die Knöpfe im
|
||||
// Kontrollzentrum dasselbe tun wie die im Player. Zwei Zahlen, die
|
||||
// auseinanderlaufen, sind schlimmer als eine, die nicht gefällt.
|
||||
center.skipBackwardCommand.preferredIntervals = [NSNumber(value: model.rewindSeconds)]
|
||||
center.skipForwardCommand.preferredIntervals = [NSNumber(value: model.forwardSeconds)]
|
||||
add(center.skipBackwardCommand) { $0.skip(by: .seconds(-$0.rewindSeconds)) }
|
||||
add(center.skipForwardCommand) { $0.skip(by: .seconds($0.forwardSeconds)) }
|
||||
|
||||
// Das Ziehen am Balken im Kontrollzentrum. Die Stelle wird vor dem
|
||||
// Sprung auf den Hauptakteur ausgelesen: das Ereignis selbst darf
|
||||
// nicht mitreisen.
|
||||
let position = center.changePlaybackPositionCommand
|
||||
let target = position.addTarget { event in
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||
let seconds = event.positionTime
|
||||
Task { @MainActor in
|
||||
guard let model = NowPlaying.shared.model else { return }
|
||||
model.seek(to: .seconds(seconds))
|
||||
NowPlaying.shared.update(from: model)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
registered.append((position, target))
|
||||
|
||||
update(from: model)
|
||||
}
|
||||
|
||||
/// Meldet ab — aber nur, wenn dieser Player noch der angemeldete ist.
|
||||
///
|
||||
/// Ohne die Prüfung räumte ein Player, der spät zu Ende geht, dem längst
|
||||
/// gestarteten nächsten die Anmeldung weg: die Tasten wären tot, obwohl
|
||||
/// etwas läuft.
|
||||
func release(for model: PlayerModel? = nil) {
|
||||
if let model, self.model !== model { return }
|
||||
|
||||
for (command, target) in registered { command.removeTarget(target) }
|
||||
registered = []
|
||||
self.model = nil
|
||||
lastReport = nil
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
||||
}
|
||||
|
||||
// MARK: - Melden
|
||||
|
||||
func update(from model: PlayerModel) {
|
||||
guard self.model === model else { return }
|
||||
|
||||
let rate = model.isPlaying ? 1.0 : 0.0
|
||||
if let last = lastReport, last.rate == rate, Date.now.timeIntervalSince(last.at) < 2 {
|
||||
return
|
||||
}
|
||||
lastReport = (Date.now, rate)
|
||||
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: model.title,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: rate,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: model.currentTime.seconds,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: model.isLive,
|
||||
]
|
||||
if let subtitle = model.subtitle {
|
||||
info[MPMediaItemPropertyArtist] = subtitle
|
||||
}
|
||||
// Bei einem laufenden Sender keine Länge: es gibt kein Ende, und ein
|
||||
// Balken, der auf eines zuläuft, wäre eine Behauptung.
|
||||
if let duration = model.duration, !model.isLive {
|
||||
info[MPMediaItemPropertyPlaybackDuration] = duration.seconds
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
MPNowPlayingInfoCenter.default().playbackState = model.isPlaying ? .playing : .paused
|
||||
}
|
||||
|
||||
// MARK: - Kleinkram
|
||||
|
||||
/// Meldet sich für einen Befehl an und merkt sich die Marke dazu.
|
||||
///
|
||||
/// Die Handlung läuft über einen Sprung auf den Hauptakteur statt über
|
||||
/// `assumeIsolated`: dass diese Rückrufe dort ankommen, ist nirgends
|
||||
/// zugesagt, und dieselbe Annahme hat die App schon einmal
|
||||
/// zum Absturz gebracht. Der Umweg kostet einen Durchlauf — unbemerkbar
|
||||
/// neben dem Tastendruck, der ihn ausgelöst hat.
|
||||
private func add(
|
||||
_ command: MPRemoteCommand,
|
||||
handle: @escaping @Sendable @MainActor (PlayerModel) -> Void
|
||||
) {
|
||||
command.isEnabled = true
|
||||
let target = command.addTarget { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let model = self?.model else { return }
|
||||
handle(model)
|
||||
self?.update(from: model)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
registered.append((command, target))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user