Files
Flaro/Halo/Session/NowPlaying.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 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>
2026-10-03 11:12:32 +02:00

155 lines
6.4 KiB
Swift

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))
}
}