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>
155 lines
6.4 KiB
Swift
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))
|
|
}
|
|
}
|