Ohne Server stand man vor der Tür, mit dem Film in der Tasche
Fiel der Server aus, endete der Start bei einem Fehlerbildschirm mit zwei Knöpfen: „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug nichts — und dahinter lagen die Dateien, die man genau für diesen Fall heruntergeladen hatte. Jetzt geht die App trotzdem auf: Token und Konto liegen auf dem Gerät, die Mediathekenliste auch, und die Startseite kommt neuerdings aus demselben Speicher. Oben die Downloads, darunter der Stand vom letzten Mal, ausgegraut, mit einem Satz, der sagt warum. Die Detailseite nimmt den Titel vom Gerät, wenn der Server schweigt, und der Abspielknopf bleibt an, weil die Datei ja da ist. Und was man offline geschaut hat, zählt: Fortschritt, den der Server nicht annimmt, wird zurückgelegt und nachgereicht, sobald er wieder antwortet. Dafür sagt `reportProgress` jetzt, ob die Meldung ankam. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
77593105de
commit
8f0568cb6c
@@ -36,6 +36,9 @@ struct FlaroApp: App {
|
|||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
@State private var homeLayout = HomeLayoutSettings()
|
@State private var homeLayout = HomeLayoutSettings()
|
||||||
@State private var diagnostics = PlaybackDiagnostics()
|
@State private var diagnostics = PlaybackDiagnostics()
|
||||||
|
/// Fortschritt, der den Server nicht erreicht hat — siehe
|
||||||
|
/// `Fortschrittspost`. Hier, weil er die Wiedergabe überleben muss.
|
||||||
|
@State private var post = Fortschrittspost.shared
|
||||||
@State private var downloads: DownloadCoordinator
|
@State private var downloads: DownloadCoordinator
|
||||||
|
|
||||||
private let modelContainer: ModelContainer
|
private let modelContainer: ModelContainer
|
||||||
@@ -92,6 +95,7 @@ struct FlaroApp: App {
|
|||||||
.environment(downloads)
|
.environment(downloads)
|
||||||
.environment(homeLayout)
|
.environment(homeLayout)
|
||||||
.environment(diagnostics)
|
.environment(diagnostics)
|
||||||
|
.environment(post)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
|
||||||
//
|
//
|
||||||
@@ -117,6 +121,8 @@ struct FlaroApp: App {
|
|||||||
backend: session.backend,
|
backend: session.backend,
|
||||||
coordinator: downloads
|
coordinator: downloads
|
||||||
).refill()
|
).refill()
|
||||||
|
// Was im Flugzeug liegengeblieben ist, geht jetzt raus.
|
||||||
|
await post.zustellen(über: session.backend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modelContainer(modelContainer)
|
.modelContainer(modelContainer)
|
||||||
|
|||||||
@@ -2146,6 +2146,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"home.offline" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "No server reachable. What you see is from last time — only what is on this device can be played."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"home.downloaded" : {
|
"home.downloaded" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
|
|||||||
@@ -283,6 +283,9 @@ final class FlaroSession {
|
|||||||
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
|
||||||
// verloren.
|
// verloren.
|
||||||
Task { await LibraryCache.shared.clearAll() }
|
Task { await LibraryCache.shared.clearAll() }
|
||||||
|
// Dasselbe gilt für den Fortschritt, der noch auf den Server
|
||||||
|
// wartet: er gehört dem Konto, das gerade gegangen ist.
|
||||||
|
Fortschrittspost.shared.leeren()
|
||||||
phase = .signedOut
|
phase = .signedOut
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,14 +475,45 @@ final class FlaroSession {
|
|||||||
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
||||||
// Server das, und es ist kein Grund, jemanden abzumelden.
|
// Server das, und es ist kein Grund, jemanden abzumelden.
|
||||||
//
|
//
|
||||||
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
|
// **Und auch kein Grund, vor der Tür stehen zu bleiben.** Vorher
|
||||||
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
|
// endete es hier bei einem Fehlerbildschirm mit zwei Knöpfen:
|
||||||
|
// „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug
|
||||||
|
// nichts, und das Heruntergeladene lag unerreichbar dahinter.
|
||||||
|
//
|
||||||
|
// Der Token liegt im Schlüsselbund, das Konto in den
|
||||||
|
// Voreinstellungen — beides reicht, um die App zu betreten.
|
||||||
|
// Jede einzelne Anfrage scheitert dann weiterhin, und jeder
|
||||||
|
// Bildschirm sagt das für sich.
|
||||||
|
await adoptFlaro(client: FlaroClient(
|
||||||
|
baseURL: baseURL, identity: flaroIdentity, token: token
|
||||||
|
), account: gemerktesFlaroKonto(baseURL: baseURL))
|
||||||
|
if case .failed = phase {
|
||||||
|
// Nicht einmal eine Mediathekenliste gemerkt — dann ist die
|
||||||
|
// App leer, und der Fehlerbildschirm ist die ehrlichere
|
||||||
|
// Antwort.
|
||||||
phase = .failed(message: ErrorText.describe(error))
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
activeKind = .flaro
|
activeKind = .flaro
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das Konto aus den Voreinstellungen, ohne den Server zu fragen.
|
||||||
|
///
|
||||||
|
/// Alles darin wurde beim letzten erfolgreichen Anmelden geschrieben.
|
||||||
|
/// Es ist vielleicht veraltet — ein umbenannter Server, ein geänderter
|
||||||
|
/// Anzeigename —, aber es ist richtiger als gar keins.
|
||||||
|
private func gemerktesFlaroKonto(baseURL: URL) -> FlaroAccount {
|
||||||
|
FlaroAccount(
|
||||||
|
baseURL: baseURL,
|
||||||
|
serverName: defaults.string(forKey: Key.flaroServerName)
|
||||||
|
?? baseURL.host() ?? "Flaro",
|
||||||
|
userID: defaults.string(forKey: Key.flaroUserID) ?? "",
|
||||||
|
userName: defaults.string(forKey: Key.flaroUserName) ?? "",
|
||||||
|
isAdministrator: defaults.bool(forKey: Key.flaroIsAdministrator)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func rememberFlaro(account: FlaroAccount, token: String?) {
|
private func rememberFlaro(account: FlaroAccount, token: String?) {
|
||||||
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
||||||
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
||||||
|
|||||||
@@ -43,7 +43,37 @@ enum Heruntergeladenes {
|
|||||||
parentIndex: eintrag.seasonIndex,
|
parentIndex: eintrag.seasonIndex,
|
||||||
parentTitle: eintrag.seriesTitle,
|
parentTitle: eintrag.seriesTitle,
|
||||||
seriesTitle: eintrag.seriesTitle,
|
seriesTitle: eintrag.seriesTitle,
|
||||||
seriesID: eintrag.seriesKey
|
seriesID: eintrag.seriesKey,
|
||||||
|
versions: [fassung(eintrag)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Datei auf dem Gerät als Fassung.
|
||||||
|
///
|
||||||
|
/// **Ohne sie bleibt der Abspielknopf grau.** Die Detailseite schaltet
|
||||||
|
/// ihn an `currentMedia?.files.first` fest, und das ist richtig: ein
|
||||||
|
/// Titel ohne Datei lässt sich nicht abspielen. Offline *gibt* es die
|
||||||
|
/// Datei aber — sie liegt nur woanders. Was der Server beim Herunterladen
|
||||||
|
/// über sie gesagt hat, steht in `offlineMediaData`.
|
||||||
|
private static func fassung(_ eintrag: DownloadItem) -> MediaVersion {
|
||||||
|
let angaben = eintrag.offlineMediaData.flatMap {
|
||||||
|
try? JSONDecoder().decode(OfflineMedia.self, from: $0)
|
||||||
|
}
|
||||||
|
return MediaVersion(
|
||||||
|
id: eintrag.ratingKey,
|
||||||
|
container: angaben?.container,
|
||||||
|
videoCodec: angaben?.videoCodec,
|
||||||
|
audioCodec: angaben?.audioCodec,
|
||||||
|
videoResolution: angaben?.videoResolution,
|
||||||
|
bitrate: angaben?.bitrate,
|
||||||
|
duration: eintrag.durationMilliseconds,
|
||||||
|
files: [MediaFile(
|
||||||
|
id: eintrag.ratingKey,
|
||||||
|
itemID: eintrag.ratingKey,
|
||||||
|
container: angaben?.container,
|
||||||
|
size: eintrag.totalBytes > 0 ? eintrag.totalBytes : nil,
|
||||||
|
duration: eintrag.durationMilliseconds
|
||||||
|
)]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ import Observation
|
|||||||
final class HomeFeed {
|
final class HomeFeed {
|
||||||
private(set) var state: LoadState<[MediaRowGroup]> = .loading
|
private(set) var state: LoadState<[MediaRowGroup]> = .loading
|
||||||
|
|
||||||
|
/// Ob das Gezeigte vom letzten Mal stammt, weil der Server nicht
|
||||||
|
/// antwortet.
|
||||||
|
///
|
||||||
|
/// Die Startseite ist dann **nicht** falsch, nur alt — und das muss
|
||||||
|
/// dranstehen. Ein Stand von gestern, der sich als heute ausgibt, ist
|
||||||
|
/// schlimmer als gar keiner.
|
||||||
|
private(set) var ausDemSpeicher = false
|
||||||
|
|
||||||
/// Holt die Reihen vom Server.
|
/// Holt die Reihen vom Server.
|
||||||
///
|
///
|
||||||
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
|
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
|
||||||
@@ -30,6 +38,24 @@ final class HomeFeed {
|
|||||||
state = await LoadState.load(keepingPrevious: state) {
|
state = await LoadState.load(keepingPrevious: state) {
|
||||||
try await backend.homeRows()
|
try await backend.homeRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .loaded(let reihen):
|
||||||
|
ausDemSpeicher = false
|
||||||
|
await LibraryCache.shared.storeHomeRows(reihen, server: backend.identity)
|
||||||
|
case .failed:
|
||||||
|
// **Der Abzug erst hier, nicht vorweg.** Ihn sofort zu zeigen
|
||||||
|
// hiesse, bei jedem Start eine alte Startseite aufzublitzen, die
|
||||||
|
// eine Sekunde später ersetzt wird. Er ist ein Rettungsanker,
|
||||||
|
// kein Beschleuniger.
|
||||||
|
if let gemerkt = await LibraryCache.shared.homeRows(server: backend.identity),
|
||||||
|
!gemerkt.isEmpty {
|
||||||
|
state = .loaded(gemerkt)
|
||||||
|
ausDemSpeicher = true
|
||||||
|
}
|
||||||
|
case .loading:
|
||||||
|
break
|
||||||
|
}
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
|
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
|
||||||
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
|
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
|
||||||
@@ -50,9 +76,11 @@ final class HomeFeed {
|
|||||||
/// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
|
/// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
|
||||||
/// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede
|
/// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede
|
||||||
/// andere, nur mit einer anderen Quelle.
|
/// andere, nur mit einer anderen Quelle.
|
||||||
|
/// **Auch ohne geladene Reihen**: scheitert der Server und liegt nichts
|
||||||
|
/// im Speicher, bleibt immer noch, was auf dem Gerät liegt.
|
||||||
func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] {
|
func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] {
|
||||||
guard case .loaded(let alle) = state else { return [] }
|
var reihen: [MediaRowGroup] = []
|
||||||
var reihen = prepared(alle, layout: layout)
|
if case .loaded(let alle) = state { reihen = prepared(alle, layout: layout) }
|
||||||
if !downloaded.isEmpty {
|
if !downloaded.isEmpty {
|
||||||
reihen.insert(
|
reihen.insert(
|
||||||
MediaRowGroup(
|
MediaRowGroup(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Detailseite eines Titels.
|
/// Die Detailseite eines Titels.
|
||||||
@@ -11,6 +12,7 @@ struct MediaDetailView: View {
|
|||||||
let route: MediaRoute
|
let route: MediaRoute
|
||||||
|
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@State private var state: LoadState<MediaItem> = .loading
|
@State private var state: LoadState<MediaItem> = .loading
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -50,10 +52,33 @@ struct MediaDetailView: View {
|
|||||||
.task(id: route.ratingKey) { await load() }
|
.task(id: route.ratingKey) { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holt den Titel — vom Server, und sonst vom Gerät.
|
||||||
|
///
|
||||||
|
/// **Sonst führte ein Download ins Leere.** Ausserhalb von
|
||||||
|
/// „Fortsetzen" öffnet ein Tipp auf eine Kachel diese Seite; ohne
|
||||||
|
/// Server endete sie bei einem Fehlerbild, und die Datei, die daneben
|
||||||
|
/// auf der Platte lag, war von hier aus nicht mehr zu erreichen.
|
||||||
|
///
|
||||||
|
/// Der Server bleibt massgeblich: nur wenn er nicht antwortet, tritt
|
||||||
|
/// das Wenige ein, was ein Download über sich selbst weiss.
|
||||||
private func load() async {
|
private func load() async {
|
||||||
guard let server = session.backend else { return }
|
guard let server = session.backend else {
|
||||||
|
state = vomGeraet.map { .loaded($0) }
|
||||||
|
?? .failed(message: String(localized: "error.serverUnreachable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
state = await LoadState.load {
|
state = await LoadState.load {
|
||||||
try await server.item(id: route.ratingKey)
|
try await server.item(id: route.ratingKey)
|
||||||
}
|
}
|
||||||
|
if case .failed(let message) = state {
|
||||||
|
state = vomGeraet.map { .loaded($0) } ?? .failed(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was über diesen Titel auf dem Gerät liegt.
|
||||||
|
private var vomGeraet: MediaItem? {
|
||||||
|
downloads.items
|
||||||
|
.first { $0.isComplete && $0.ratingKey == route.ratingKey }
|
||||||
|
.map { Heruntergeladenes.titel($0, in: downloads) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct HomeView: View {
|
|||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(Fortschrittspost.self) private var post
|
||||||
@State private var feed = HomeFeed()
|
@State private var feed = HomeFeed()
|
||||||
|
|
||||||
/// Was auf dem Gerät liegt, als Reihe ganz oben.
|
/// Was auf dem Gerät liegt, als Reihe ganz oben.
|
||||||
@@ -32,7 +33,7 @@ struct HomeView: View {
|
|||||||
case .loading:
|
case .loading:
|
||||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
case .failed(let message):
|
case .failed(let message) where heruntergeladen.isEmpty:
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||||
} description: {
|
} description: {
|
||||||
@@ -41,7 +42,11 @@ struct HomeView: View {
|
|||||||
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
case .loaded:
|
// **Mit Downloads ist „kein Server" kein Fehlerbildschirm.**
|
||||||
|
// Was auf dem Gerät liegt, lässt sich auch ohne ihn ansehen —
|
||||||
|
// genau dafür wurde es heruntergeladen. Ein Fehlerbild davor zu
|
||||||
|
// stellen hiesse, dem Nutzer etwas wegzunehmen, das er hat.
|
||||||
|
case .failed, .loaded:
|
||||||
let hubs = feed.rows(for: layout, downloaded: heruntergeladen)
|
let hubs = feed.rows(for: layout, downloaded: heruntergeladen)
|
||||||
if hubs.isEmpty {
|
if hubs.isEmpty {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
@@ -79,6 +84,10 @@ struct HomeView: View {
|
|||||||
// würde.
|
// würde.
|
||||||
await DownloadService(backend: session.backend, coordinator: downloads)
|
await DownloadService(backend: session.backend, coordinator: downloads)
|
||||||
.plakateNachholen()
|
.plakateNachholen()
|
||||||
|
// **Der Server hat geantwortet — dann nimmt er auch Post an.**
|
||||||
|
// Der Start der App ist dafür der falsche Zeitpunkt: da ist oft
|
||||||
|
// noch gar keine Verbindung.
|
||||||
|
if !feed.ausDemSpeicher { await post.zustellen(über: session.backend) }
|
||||||
}
|
}
|
||||||
.refreshable { await feed.load(from: session.backend) }
|
.refreshable { await feed.load(from: session.backend) }
|
||||||
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
||||||
@@ -89,13 +98,22 @@ struct HomeView: View {
|
|||||||
private func content(hubs: [MediaRowGroup]) -> some View {
|
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
|
if ohneServer {
|
||||||
|
offlinehinweis
|
||||||
|
} else {
|
||||||
let featured = feed.heroItems(for: layout)
|
let featured = feed.heroItems(for: layout)
|
||||||
if !featured.isEmpty {
|
if !featured.isEmpty {
|
||||||
HeroCarousel(items: featured)
|
HeroCarousel(items: featured)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ForEach(hubs) { hub in
|
ForEach(hubs) { hub in
|
||||||
MediaRow(row: hub)
|
MediaRow(row: hub)
|
||||||
|
// **Ausgegraut, aber nicht gesperrt.** Was hier steht,
|
||||||
|
// stammt vom letzten Mal; antippen darf man es
|
||||||
|
// trotzdem — dann kommt ein Satz, der sagt warum, und
|
||||||
|
// das ist ehrlicher als ein Knopf, der nichts tut.
|
||||||
|
.opacity(ohneServer && hub.id != HomeFeed.downloadsRow ? 0.4 : 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, FlaroSpacing.section)
|
.padding(.bottom, FlaroSpacing.section)
|
||||||
@@ -103,4 +121,36 @@ struct HomeView: View {
|
|||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.flaroRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ob gerade kein Server antwortet.
|
||||||
|
///
|
||||||
|
/// Zwei Wege führen hierher: gar nichts geladen, oder ein Abzug vom
|
||||||
|
/// letzten Mal. Für den Nutzer ist es dieselbe Lage.
|
||||||
|
private var ohneServer: Bool {
|
||||||
|
if case .failed = feed.state { return true }
|
||||||
|
return feed.ausDemSpeicher
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statt des Aufmachers, wenn der Server fehlt.
|
||||||
|
///
|
||||||
|
/// **An seiner Stelle, nicht darüber.** Ein grosses Bild vom letzten Mal
|
||||||
|
/// über einer Startseite, die nicht stimmt, wäre die falsche Betonung —
|
||||||
|
/// und die Downloads sollen oben stehen.
|
||||||
|
private var offlinehinweis: some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: FlaroSpacing.regular) {
|
||||||
|
Image(systemName: "wifi.slash")
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
Text("home.offline")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.padding(FlaroSpacing.regular)
|
||||||
|
.background(Color.flaroElevated, in: .rect(cornerRadius: 12))
|
||||||
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct RootView: View {
|
|||||||
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
@Environment(SubtitleAppearance.self) private var subtitleAppearance
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(Fortschrittspost.self) private var post
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
|
|
||||||
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
|
||||||
@@ -229,6 +230,7 @@ struct RootView: View {
|
|||||||
backend: session.backend,
|
backend: session.backend,
|
||||||
liveTV: session.liveTV,
|
liveTV: session.liveTV,
|
||||||
downloads: downloads,
|
downloads: downloads,
|
||||||
|
post: post,
|
||||||
diagnostics: diagnostics,
|
diagnostics: diagnostics,
|
||||||
policy: settings.policy(for: network),
|
policy: settings.policy(for: network),
|
||||||
settings: settings,
|
settings: settings,
|
||||||
|
|||||||
@@ -231,6 +231,9 @@ final class PlayerModel {
|
|||||||
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
|
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
|
||||||
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
|
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
|
||||||
private let downloads: DownloadCoordinator?
|
private let downloads: DownloadCoordinator?
|
||||||
|
/// Siehe `Fortschrittspost`: was der Server nicht annimmt, wird
|
||||||
|
/// zurückgelegt und nachgereicht.
|
||||||
|
private let post: Fortschrittspost?
|
||||||
private let diagnostics: PlaybackDiagnostics
|
private let diagnostics: PlaybackDiagnostics
|
||||||
private let policy: PlaybackPolicy
|
private let policy: PlaybackPolicy
|
||||||
private let settings: PlaybackSettings
|
private let settings: PlaybackSettings
|
||||||
@@ -481,6 +484,8 @@ final class PlayerModel {
|
|||||||
backend: (any MediaBackend)?,
|
backend: (any MediaBackend)?,
|
||||||
liveTV: (any LiveTVBackend)?,
|
liveTV: (any LiveTVBackend)?,
|
||||||
downloads: DownloadCoordinator? = nil,
|
downloads: DownloadCoordinator? = nil,
|
||||||
|
/// Wohin ein Fortschritt kommt, den der Server nicht annimmt.
|
||||||
|
post: Fortschrittspost? = nil,
|
||||||
diagnostics: PlaybackDiagnostics,
|
diagnostics: PlaybackDiagnostics,
|
||||||
policy: PlaybackPolicy,
|
policy: PlaybackPolicy,
|
||||||
settings: PlaybackSettings,
|
settings: PlaybackSettings,
|
||||||
@@ -494,6 +499,7 @@ final class PlayerModel {
|
|||||||
self.backend = backend
|
self.backend = backend
|
||||||
self.liveTV = liveTV
|
self.liveTV = liveTV
|
||||||
self.downloads = downloads
|
self.downloads = downloads
|
||||||
|
self.post = post
|
||||||
self.diagnostics = diagnostics
|
self.diagnostics = diagnostics
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -1781,14 +1787,19 @@ final class PlayerModel {
|
|||||||
let stelle = currentTime > .zero ? currentTime : request.startTime
|
let stelle = currentTime > .zero ? currentTime : request.startTime
|
||||||
guard stelle > .zero || state == .started else { return }
|
guard stelle > .zero || state == .started else { return }
|
||||||
|
|
||||||
await backend.reportProgress(PlaybackReport(
|
let bericht = PlaybackReport(
|
||||||
itemID: item.id,
|
itemID: item.id,
|
||||||
sessionID: playbackSession,
|
sessionID: playbackSession,
|
||||||
versionID: playbackVersionID,
|
versionID: playbackVersionID,
|
||||||
state: state,
|
state: state,
|
||||||
time: stelle,
|
time: stelle,
|
||||||
duration: total
|
duration: total
|
||||||
))
|
)
|
||||||
|
// **Was nicht ankommt, wird zurückgelegt.** Ohne das war eine Folge,
|
||||||
|
// die man offline zu Ende gesehen hat, für den Server nie gelaufen.
|
||||||
|
if await backend.reportProgress(bericht) == false {
|
||||||
|
post?.zuruecklegen(bericht, server: backend.identity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Marken und nächste Folge
|
// MARK: - Marken und nächste Folge
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
method: .delete, path: "/api/v1/stream/\(sessionID)"))
|
method: .delete, path: "/api/v1/stream/\(sessionID)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reportProgress(_ report: PlaybackReport) async {
|
public func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||||
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||||
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||||
// wäre die schlechtere Hälfte des Tauschs.
|
// wäre die schlechtere Hälfte des Tauschs.
|
||||||
@@ -458,10 +458,15 @@ public actor FlaroBackend: MediaBackend {
|
|||||||
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
|
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
|
||||||
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
||||||
// hiesse, den ersten Bericht wegzuwerfen.
|
// hiesse, den ersten Bericht wegzuwerfen.
|
||||||
_ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
do {
|
||||||
|
try await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
||||||
Wire.ProgressRequest(
|
Wire.ProgressRequest(
|
||||||
positionSeconds: report.time.seconds,
|
positionSeconds: report.time.seconds,
|
||||||
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Zustand melden
|
// MARK: - Zustand melden
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ extension JellyfinBackend {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reportProgress(_ report: PlaybackReport) async {
|
public func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||||
var fields: [String: Any] = [
|
var fields: [String: Any] = [
|
||||||
"ItemId": report.itemID,
|
"ItemId": report.itemID,
|
||||||
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
|
||||||
@@ -126,13 +126,18 @@ extension JellyfinBackend {
|
|||||||
default: "/Sessions/Playing/Progress"
|
default: "/Sessions/Playing/Progress"
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
|
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return false }
|
||||||
_ = try? await client.send(JellyfinRequest(
|
do {
|
||||||
|
try await client.send(JellyfinRequest(
|
||||||
method: .post,
|
method: .post,
|
||||||
path: path,
|
path: path,
|
||||||
body: body,
|
body: body,
|
||||||
timeout: .seconds(10)
|
timeout: .seconds(10)
|
||||||
))
|
))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hilfen
|
// MARK: - Hilfen
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// Fortschritt, der den Server nicht erreicht hat.
|
||||||
|
///
|
||||||
|
/// **Was ohne sie verlorenging.** Eine Folge im Flugzeug zur Hälfte
|
||||||
|
/// anzusehen, war für den Server ein Vorgang, der nie stattgefunden hat:
|
||||||
|
/// jede Meldung lief in die tote Leitung, und beim nächsten Verbinden stand
|
||||||
|
/// der Titel wieder bei null — auf dem Fernseher, im Web, überall. Genau
|
||||||
|
/// dieser Fall ist der Grund, aus dem man etwas herunterlädt.
|
||||||
|
///
|
||||||
|
/// **Eine Meldung je Titel, die letzte gewinnt.** Wer eine Stunde schaut,
|
||||||
|
/// erzeugt an die hundert Meldungen; nachzureichen ist davon genau eine
|
||||||
|
/// sinnvoll — die, an der er aufgehört hat. Alles andere wäre ein Protokoll
|
||||||
|
/// der Vergangenheit, das der Server erst wieder zusammenrechnen müsste.
|
||||||
|
///
|
||||||
|
/// Auf der Platte, nicht im Speicher: wer die App im Flugmodus beendet, hat
|
||||||
|
/// sonst umsonst geschaut.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class Fortschrittspost {
|
||||||
|
/// Der Briefkasten der App.
|
||||||
|
///
|
||||||
|
/// Einer, wie bei `LibraryCache`: zwei Instanzen auf dieselbe Datei
|
||||||
|
/// würden einander überschreiben. Der Bauweg mit eigener Ablage bleibt
|
||||||
|
/// für Prüfungen offen.
|
||||||
|
public static let shared = Fortschrittspost()
|
||||||
|
|
||||||
|
/// Ein zurückgelegter Stand.
|
||||||
|
public struct Eintrag: Codable, Sendable, Equatable {
|
||||||
|
/// Zu welchem Server er gehört.
|
||||||
|
///
|
||||||
|
/// **Sonst ginge er an den falschen.** Wer sich abmeldet und an
|
||||||
|
/// einem anderen Server anmeldet, bekäme sonst fremde Kennungen
|
||||||
|
/// mit dem eigenen Sehfortschritt gemeldet.
|
||||||
|
let server: String
|
||||||
|
let titelID: String
|
||||||
|
let sekunden: Double
|
||||||
|
let laufzeitSekunden: Double?
|
||||||
|
let zeitpunkt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private let ablage: URL
|
||||||
|
private let logger = Logger(subsystem: "app.flaro", category: "Fortschrittspost")
|
||||||
|
private var offen: [String: Eintrag]
|
||||||
|
|
||||||
|
/// Wie viele Stände noch auf einen Server warten.
|
||||||
|
public var wartend: Int { offen.count }
|
||||||
|
|
||||||
|
public init(ablage: URL? = nil) {
|
||||||
|
self.ablage = ablage ?? FileManager.default
|
||||||
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appending(path: "flaro-fortschritt.json")
|
||||||
|
self.offen = Self.lesen(from: self.ablage) ?? [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt einen Stand zurück, weil er nicht ankam.
|
||||||
|
public func zuruecklegen(_ report: PlaybackReport, server: String) {
|
||||||
|
offen[report.itemID] = Eintrag(
|
||||||
|
server: server,
|
||||||
|
titelID: report.itemID,
|
||||||
|
sekunden: report.time.seconds,
|
||||||
|
laufzeitSekunden: report.duration > .zero ? report.duration.seconds : nil,
|
||||||
|
zeitpunkt: Date()
|
||||||
|
)
|
||||||
|
schreiben()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Versucht, alles Liegengebliebene loszuwerden.
|
||||||
|
///
|
||||||
|
/// **Was weiterhin scheitert, bleibt liegen.** Ein halb geleerter
|
||||||
|
/// Briefkasten ist kein Fehler — beim nächsten Versuch geht der Rest.
|
||||||
|
///
|
||||||
|
/// Nacheinander und nicht nebeneinander: es sind selten mehr als eine
|
||||||
|
/// Handvoll, und zwanzig gleichzeitige Anfragen an einen Server, der
|
||||||
|
/// gerade erst wieder da ist, wären der falsche erste Gruß.
|
||||||
|
public func zustellen(über backend: (any Fortschrittsempfaenger)?) async {
|
||||||
|
guard let backend, !offen.isEmpty else { return }
|
||||||
|
let kennung = backend.identity
|
||||||
|
|
||||||
|
// **Nicht ewig aufheben.** Ein Stand von vor einem halben Jahr sagt
|
||||||
|
// nichts mehr darüber, wo jemand heute weiterschauen will — und ein
|
||||||
|
// Server, der nie wiederkommt, hinterliesse sonst eine Datei, die
|
||||||
|
// nur wächst.
|
||||||
|
let frist = Date().addingTimeInterval(-30 * 24 * 60 * 60)
|
||||||
|
offen = offen.filter { $0.value.zeitpunkt > frist }
|
||||||
|
|
||||||
|
for eintrag in offen.values.sorted(by: { $0.zeitpunkt < $1.zeitpunkt })
|
||||||
|
where eintrag.server == kennung {
|
||||||
|
let angekommen = await backend.reportProgress(PlaybackReport(
|
||||||
|
itemID: eintrag.titelID,
|
||||||
|
state: .stopped,
|
||||||
|
time: .seconds(eintrag.sekunden),
|
||||||
|
duration: .seconds(eintrag.laufzeitSekunden ?? 0)
|
||||||
|
))
|
||||||
|
guard angekommen else { break }
|
||||||
|
// **Nur den, der tatsächlich ankam.** Währenddessen kann
|
||||||
|
// derselbe Titel neu zurückgelegt worden sein; den zu
|
||||||
|
// entfernen hiesse, den neueren Stand wegzuwerfen.
|
||||||
|
if offen[eintrag.titelID] == eintrag { offen[eintrag.titelID] = nil }
|
||||||
|
}
|
||||||
|
schreiben()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alles vergessen — beim Abmelden.
|
||||||
|
public func leeren() {
|
||||||
|
offen = [:]
|
||||||
|
try? FileManager.default.removeItem(at: ablage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Platte
|
||||||
|
|
||||||
|
private func schreiben() {
|
||||||
|
do {
|
||||||
|
try JSONEncoder().encode(offen).write(to: ablage, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
logger.warning("Nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func lesen(from url: URL) -> [String: Eintrag]? {
|
||||||
|
guard let daten = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return try? JSONDecoder().decode([String: Eintrag].self, from: daten)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,41 @@ public actor LibraryCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Reihen der Startseite.
|
||||||
|
///
|
||||||
|
/// **Weil es sonst nichts gibt, womit sie sich füllen liesse.** Die
|
||||||
|
/// Mediathekenliste und die Titel darin liegen schon auf der Platte; die
|
||||||
|
/// Startseite dagegen entstand bei jedem Aufruf neu am Server, und ohne
|
||||||
|
/// ihn blieb sie leer. Im Flugzeug ist das der Unterschied zwischen
|
||||||
|
/// „sehen, was es gibt" und einem Fehlerbildschirm.
|
||||||
|
public func homeRows(server: String) -> [MediaRowGroup]? {
|
||||||
|
guard let daten = try? Data(contentsOf: homeRowsFile(server: server)) else { return nil }
|
||||||
|
return try? JSONDecoder().decode([MediaRowGroup].self, from: daten)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func storeHomeRows(_ rows: [MediaRowGroup], server: String) {
|
||||||
|
// Je Reihe eine Bildschirmbreite, mehr sieht ohnehin niemand, bevor
|
||||||
|
// der Server wieder antwortet.
|
||||||
|
let gekürzt = rows.map {
|
||||||
|
MediaRowGroup(
|
||||||
|
id: $0.id,
|
||||||
|
title: $0.title,
|
||||||
|
moreKey: $0.moreKey,
|
||||||
|
libraryID: $0.libraryID,
|
||||||
|
items: $0.items.prefix(20).map(\.listEssentials)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let ziel = homeRowsFile(server: server)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
logger.warning("Startseite nicht gespeichert: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Aufräumen
|
// MARK: - Aufräumen
|
||||||
|
|
||||||
/// Alles zu einem Server vergessen — beim Abmelden.
|
/// Alles zu einem Server vergessen — beim Abmelden.
|
||||||
@@ -139,6 +174,10 @@ public actor LibraryCache {
|
|||||||
directory(server: server).appending(path: "libraries.json")
|
directory(server: server).appending(path: "libraries.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func homeRowsFile(server: String) -> URL {
|
||||||
|
directory(server: server).appending(path: "home.json")
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein Name, der als Datei taugt.
|
/// Ein Name, der als Datei taugt.
|
||||||
///
|
///
|
||||||
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
|
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import Foundation
|
|||||||
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
|
||||||
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
|
||||||
/// der alles umfasst, wäre kein Nenner mehr.
|
/// der alles umfasst, wäre kein Nenner mehr.
|
||||||
public protocol MediaBackend: Sendable {
|
/// Wer Sehfortschritt entgegennimmt.
|
||||||
/// Wie der Server heißt, so wie er sich selbst nennt.
|
///
|
||||||
nonisolated var serverName: String { get }
|
/// **Ein Ausschnitt aus `MediaBackend`, und zwar genau der, den die
|
||||||
|
/// `Fortschrittspost` braucht.** Ohne ihn müsste jeder, der das Nachreichen
|
||||||
|
/// prüfen will, einen vollständigen Server nachbauen — vierzig Methoden für
|
||||||
|
/// die zwei, die zur Sache gehören.
|
||||||
|
public protocol Fortschrittsempfaenger: Sendable {
|
||||||
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
|
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
|
||||||
/// wiedererkennen muss.
|
/// wiedererkennen muss.
|
||||||
///
|
///
|
||||||
@@ -17,6 +21,24 @@ public protocol MediaBackend: Sendable {
|
|||||||
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
|
||||||
/// eine Kennung und geht nirgends an die Oberfläche.
|
/// eine Kennung und geht nirgends an die Oberfläche.
|
||||||
nonisolated var identity: String { get }
|
nonisolated var identity: String { get }
|
||||||
|
|
||||||
|
/// Meldet, wo man gerade steht.
|
||||||
|
///
|
||||||
|
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
||||||
|
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
||||||
|
/// wäre die schlechtere Hälfte des Tauschs.
|
||||||
|
///
|
||||||
|
/// **Gibt aber zurück, ob sie ankam.** Im Flugzeug geht jede einzelne
|
||||||
|
/// verloren, und am Ende stünde ein zur Hälfte gesehener Film auf dem
|
||||||
|
/// Server weiter bei null. Der Aufrufer legt sie dann beiseite und
|
||||||
|
/// meldet sie nach — siehe `Fortschrittspost`.
|
||||||
|
@discardableResult
|
||||||
|
func reportProgress(_ report: PlaybackReport) async -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol MediaBackend: Sendable, Fortschrittsempfaenger {
|
||||||
|
/// Wie der Server heißt, so wie er sich selbst nennt.
|
||||||
|
nonisolated var serverName: String { get }
|
||||||
/// Was dieser Server darüber hinaus beherrscht.
|
/// Was dieser Server darüber hinaus beherrscht.
|
||||||
nonisolated var capabilities: Set<BackendCapability> { get }
|
nonisolated var capabilities: Set<BackendCapability> { get }
|
||||||
|
|
||||||
@@ -128,13 +150,6 @@ public protocol MediaBackend: Sendable {
|
|||||||
/// niemand mehr zusieht.
|
/// niemand mehr zusieht.
|
||||||
func endPlayback(sessionID: String) async
|
func endPlayback(sessionID: String) async
|
||||||
|
|
||||||
/// Meldet, wo man gerade steht.
|
|
||||||
///
|
|
||||||
/// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
|
|
||||||
/// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
|
|
||||||
/// wäre die schlechtere Hälfte des Tauschs.
|
|
||||||
func reportProgress(_ report: PlaybackReport) async
|
|
||||||
|
|
||||||
// MARK: - Zustand melden
|
// MARK: - Zustand melden
|
||||||
|
|
||||||
func markWatched(id: String) async throws(MediaBackendError)
|
func markWatched(id: String) async throws(MediaBackendError)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
|
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
|
||||||
public struct MediaRowGroup: Sendable, Equatable, Identifiable {
|
public struct MediaRowGroup: Sendable, Equatable, Identifiable, Codable {
|
||||||
/// Eine stabile Kennung für diese Reihe.
|
/// Eine stabile Kennung für diese Reihe.
|
||||||
///
|
///
|
||||||
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
|
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import MediaKit
|
||||||
|
|
||||||
|
/// Ein Server, der auf Ansage annimmt oder nicht.
|
||||||
|
private final class Briefkasten: Fortschrittsempfaenger, @unchecked Sendable {
|
||||||
|
nonisolated let identity: String
|
||||||
|
/// Ob Meldungen angenommen werden.
|
||||||
|
var nimmtAn: Bool
|
||||||
|
private(set) var angekommen: [PlaybackReport] = []
|
||||||
|
|
||||||
|
init(identity: String = "server-a", nimmtAn: Bool = true) {
|
||||||
|
self.identity = identity
|
||||||
|
self.nimmtAn = nimmtAn
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||||
|
guard nimmtAn else { return false }
|
||||||
|
angekommen.append(report)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bericht(_ titel: String, sekunden: Double) -> PlaybackReport {
|
||||||
|
PlaybackReport(
|
||||||
|
itemID: titel,
|
||||||
|
state: .stopped,
|
||||||
|
time: .seconds(sekunden),
|
||||||
|
duration: .seconds(3600)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite("Nachgereichter Fortschritt")
|
||||||
|
@MainActor
|
||||||
|
struct FortschrittspostTests {
|
||||||
|
/// Eine eigene Datei je Prüfung — sonst sähe die zweite, was die erste
|
||||||
|
/// liegen gelassen hat.
|
||||||
|
private func ablage() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "post-\(UUID().uuidString).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Was ankam, wird nicht aufgehoben")
|
||||||
|
func nichtsLiegenLassen() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
let server = Briefkasten()
|
||||||
|
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
|
||||||
|
#expect(post.wartend == 1)
|
||||||
|
|
||||||
|
await post.zustellen(über: server)
|
||||||
|
#expect(post.wartend == 0)
|
||||||
|
#expect(server.angekommen.count == 1)
|
||||||
|
#expect(server.angekommen.first?.itemID == "film")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Was nicht ankommt, bleibt liegen")
|
||||||
|
func liegenbleiben() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
let server = Briefkasten(nimmtAn: false)
|
||||||
|
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
|
||||||
|
await post.zustellen(über: server)
|
||||||
|
#expect(post.wartend == 1)
|
||||||
|
|
||||||
|
server.nimmtAn = true
|
||||||
|
await post.zustellen(über: server)
|
||||||
|
#expect(post.wartend == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Je Titel nur der letzte Stand")
|
||||||
|
func nurDerLetzteStand() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
let server = Briefkasten()
|
||||||
|
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 60), server: server.identity)
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 1800), server: server.identity)
|
||||||
|
#expect(post.wartend == 1)
|
||||||
|
|
||||||
|
await post.zustellen(über: server)
|
||||||
|
#expect(server.angekommen.count == 1)
|
||||||
|
#expect(server.angekommen.first?.time == .seconds(1800))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein fremder Server bekommt nichts")
|
||||||
|
func nichtAnDenFalschen() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
|
||||||
|
|
||||||
|
let anderer = Briefkasten(identity: "server-b")
|
||||||
|
await post.zustellen(über: anderer)
|
||||||
|
#expect(anderer.angekommen.isEmpty)
|
||||||
|
#expect(post.wartend == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Der Briefkasten überlebt den Neustart")
|
||||||
|
func aufDerPlatte() async {
|
||||||
|
let datei = ablage()
|
||||||
|
let erste = Fortschrittspost(ablage: datei)
|
||||||
|
erste.zuruecklegen(bericht("folge", sekunden: 900), server: "server-a")
|
||||||
|
|
||||||
|
let zweite = Fortschrittspost(ablage: datei)
|
||||||
|
#expect(zweite.wartend == 1)
|
||||||
|
|
||||||
|
let server = Briefkasten()
|
||||||
|
await zweite.zustellen(über: server)
|
||||||
|
#expect(server.angekommen.first?.time == .seconds(900))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Leeren wirft alles weg")
|
||||||
|
func leeren() async {
|
||||||
|
let post = Fortschrittspost(ablage: ablage())
|
||||||
|
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
|
||||||
|
post.leeren()
|
||||||
|
#expect(post.wartend == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import MediaKit
|
||||||
|
|
||||||
|
@Suite("Die gemerkte Startseite")
|
||||||
|
struct LibraryCacheHomeTests {
|
||||||
|
private func speicher() -> LibraryCache {
|
||||||
|
LibraryCache(root: FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "cache-\(UUID().uuidString)", directoryHint: .isDirectory))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func titel(_ nummer: Int) -> MediaItem {
|
||||||
|
MediaItem(id: "t\(nummer)", kind: .movie, title: "Titel \(nummer)", thumb: "/bild/\(nummer)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Was gespeichert wurde, kommt zurück")
|
||||||
|
func hinUndZurueck() async {
|
||||||
|
let cache = speicher()
|
||||||
|
let reihen = [
|
||||||
|
MediaRowGroup(id: "continue", title: "Weiterschauen",
|
||||||
|
libraryID: "mediathek", items: [titel(1), titel(2)]),
|
||||||
|
MediaRowGroup(id: "latest.1", title: "Neu", moreKey: "/mehr", items: [titel(3)]),
|
||||||
|
]
|
||||||
|
|
||||||
|
await cache.storeHomeRows(reihen, server: "server-a")
|
||||||
|
let gemerkt = await cache.homeRows(server: "server-a")
|
||||||
|
|
||||||
|
#expect(gemerkt?.map(\.id) == ["continue", "latest.1"])
|
||||||
|
#expect(gemerkt?.first?.title == "Weiterschauen")
|
||||||
|
#expect(gemerkt?.first?.libraryID == "mediathek")
|
||||||
|
#expect(gemerkt?.last?.moreKey == "/mehr")
|
||||||
|
#expect(gemerkt?.first?.items.map(\.id) == ["t1", "t2"])
|
||||||
|
// Ohne den Bildpfad wäre der Abzug wertlos: die Plakate liegen
|
||||||
|
// bereits auf der Platte, nur findet sie dann niemand.
|
||||||
|
#expect(gemerkt?.first?.items.first?.thumb == "/bild/1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein anderer Server hat seine eigene Startseite")
|
||||||
|
func getrenntJeServer() async {
|
||||||
|
let cache = speicher()
|
||||||
|
await cache.storeHomeRows(
|
||||||
|
[MediaRowGroup(id: "continue", title: "Weiterschauen", items: [titel(1)])],
|
||||||
|
server: "server-a"
|
||||||
|
)
|
||||||
|
let fremd = await cache.homeRows(server: "server-b")
|
||||||
|
#expect(fremd == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne gespeicherten Stand kommt nichts")
|
||||||
|
func nochNichtsDa() async {
|
||||||
|
let gemerkt = await speicher().homeRows(server: "server-a")
|
||||||
|
#expect(gemerkt == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Lange Reihen werden gekürzt")
|
||||||
|
func nichtAlles() async {
|
||||||
|
let cache = speicher()
|
||||||
|
let lang = (1...80).map(titel)
|
||||||
|
await cache.storeHomeRows(
|
||||||
|
[MediaRowGroup(id: "latest", title: "Neu", items: lang)], server: "server-a"
|
||||||
|
)
|
||||||
|
let gemerkt = await cache.homeRows(server: "server-a")
|
||||||
|
#expect(gemerkt?.first?.items.count == 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user