Einstellungen: sechs Bildschirme statt einer Liste — und was sie steuern
Deine Einstellungs-Bilder sind eine lange Liste. Ich habe sie durchgegangen
und getrennt: was etwas steuert, ist gebaut; was eine Einstellung für eine
Sache wäre, die es nicht gibt, ist nicht gebaut. Ein Schalter, der nichts
bewirkt, ist schlimmer als kein Schalter.
**Die Einstellungen sind jetzt eine Übersicht.** Vorher stand alles
untereinander auf einem Bildschirm — Serverangaben, Qualität, Wiedergabe, drei
Verweise, Downloads, Version, Abmelden. Das war beim Bauen bequem und beim
Suchen anstrengend. Jetzt führt jede Zeile in einen Bildschirm, der zu einer
Frage gehört: Server, Erscheinungsbild, Startseite, Wiedergabe, Ton &
Untertitel, Downloads, Diagnose.
**Neu, und jedes davon greift irgendwo:**
- Zurück- und Vorspulen getrennt einstellbar. Die Knöpfe im Player und die
Doppeltipp-Flächen tragen danach auch das passende Symbol.
- Startgeschwindigkeit — die Engine kann es längst, gefragt wurde nie.
- „Im Querformat sperren" abschaltbar. Für die allermeisten Filme richtig, für
den, der im Bett auf der Seite liegt, das Gegenteil.
- „Gewählte Qualität merken": die Stufe gilt dann auch für den nächsten Titel.
Aufbewahrt wird eine **Zahl**, keine Sprosse — welche Sprossen es gibt, hängt
von der Quelle ab, und eine 720p-Datei kennt keine Zwanzig-Megabit-Stufe.
Dann kommt die nächstgelegene, nicht „automatisch".
- „Bitrate im Qualitätsmenü zeigen".
- Gleichzeitige Downloads. Der Wert gab es schon in der Engine, nur ohne
Bedienung und ohne dass er einen Neustart überlebte.
- Aufmacher: wie viele Titel, und ob sie aus Angefangenem oder nur aus
Neuzugängen kommen.
- „Weiterschauen und Als Nächstes zusammenlegen" und „Gesehenes aus
Neuzugängen ausblenden" — beides in der Ansicht, nicht im Server: das sind
Fragen der Darstellung, und so gelten sie für Plex und Jellyfin gleich.
**Verkleinert herunterladen geht jetzt auch bei Jellyfin.** Da hatte ich zu
früh aufgegeben: ich hatte nur nach der Adresse zum *Abspielen* gesucht, und
die ist eine Abspielliste mit hunderten Schnipseln — als Datei unbrauchbar.
`/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als eine Datei heraus. Die
Größe bleibt eine Schätzung; eine Umrechnung, die erst beim Abruf beginnt,
kennt ihre eigene Länge nicht.
**Die Kontozeile ist jetzt eine Karte, die nach oben aufklappt**, kein
Systemmenü mehr. Ein Menü legt sich als eigene Ebene über alles und springt
dorthin, wo Platz ist; die Karte bleibt, wo sie ist, und lässt die Zeile mit
dem Namen stehen — man sieht also weiter, um wessen Konto es geht.
**Nicht gebaut, mit Grund:** Trailer im Karussell, Poster- und Sammlungsstil,
Untertitel-Aussehen, sekundäre Untertitel, lokale Intros, Modular Home,
App-Symbol. Für all das fehlt das, was die Einstellung steuern würde — es gibt
in Flixy weder Trailer noch Sammlungen, und der Player zeichnet Untertitel
nicht selbst.
263 Tests grün. **Nicht angesehen**: der Simulator-Tunnel ist weiterhin
abgestürzt und ließ sich nicht wieder aufbauen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f6c6d8816
commit
dace5d1543
@@ -33,7 +33,24 @@
|
|||||||
"comment": "A label for a 720p movie.",
|
"comment": "A label for a 720p movie.",
|
||||||
"isCommentAutoGenerated": true
|
"isCommentAutoGenerated": true
|
||||||
},
|
},
|
||||||
|
"account.hint": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Öffnet Einstellungen und Abmelden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Opens settings and sign out"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"account.settings": {
|
"account.settings": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -1813,6 +1830,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"home.combineContinue": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Weiterschauen und Als Nächstes zusammenlegen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Merge Continue and Next Up"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"home.empty.description": {
|
"home.empty.description": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -1846,6 +1879,134 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"home.hero": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Aufmacher"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Featured"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home.heroCount": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Titel im Wechsel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Titles in rotation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home.heroExplanation": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Aufmacher wandert alle sieben Sekunden weiter und hört damit auf, sobald du ihn berührst."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The featured area moves on every seven seconds and stops as soon as you touch it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home.heroSource": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Inhalt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home.heroSource.continueFirst": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Angefangenes zuerst"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Started titles first"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home.heroSource.latestOnly": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nur Neuzugänge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "New arrivals only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home.hideWatched": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Gesehenes aus Neuzugängen ausblenden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Hide watched titles from new arrivals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home.rowsHeader": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Reihen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Rows"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"homeLayout.empty": {
|
"homeLayout.empty": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -1911,6 +2072,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"languages.audio": {
|
"languages.audio": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -1975,6 +2137,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"languages.subtitles": {
|
"languages.subtitles": {
|
||||||
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -3606,6 +3769,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.concurrent": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Höchstens gleichzeitig"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "At most at once"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.concurrentExplanation": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Eine ganze Staffel auf einmal zu laden teilt die Leitung so auf, dass keine Folge fertig wird. Unterwegs ist eine fertige Folge mehr wert als zehn halbe."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Loading a whole season at once splits the line so that no episode finishes. On the move, one finished episode beats ten half ones."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.concurrentHeader": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Gleichzeitige Downloads"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Concurrent downloads"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.connection": {
|
"settings.connection": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -3674,6 +3885,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.controls": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Steuerung"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Controls"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.controlsExplanation": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zurück und vor lassen sich getrennt einstellen: zurück springt man, weil man etwas nicht verstanden hat — vor, weil man etwas überspringen will."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Back and forward are set separately: you jump back because you missed something, and forward because you want to skip something."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.defaultRate": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Startgeschwindigkeit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Starting speed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.deleteAfterDay": {
|
"settings.deleteAfterDay": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -3771,6 +4030,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.episodes": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Folgen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Episodes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.forward": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Vorspulen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Fast-forward"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.keepCount %@": {
|
"settings.keepCount %@": {
|
||||||
"comment": "A label that lets the user choose how many episodes of a series to keep after they've finished watching them all. The argument is the number of episodes to keep.",
|
"comment": "A label that lets the user choose how many episodes of a series to keep after they've finished watching them all. The argument is the number of episodes to keep.",
|
||||||
"isCommentAutoGenerated": true
|
"isCommentAutoGenerated": true
|
||||||
@@ -3841,6 +4132,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.lockLandscape": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Im Querformat sperren"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Lock to landscape"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.lockLandscapeExplanation": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Player dreht beim Öffnen ins Querformat und bleibt dort. Abgeschaltet folgt er dem Gerät — für den, der im Liegen schaut."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The player rotates to landscape when it opens and stays there. Switched off it follows the device — for watching while lying down."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.playback": {
|
"settings.playback": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -3924,6 +4247,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.rateNormal": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Normal"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Normal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.rememberQuality": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Gewählte Qualität merken"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Remember the chosen quality"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.rememberQualityExplanation": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Die im Player gewählte Stufe gilt dann auch für den nächsten Titel. Gibt es sie dort nicht, wird die nächstgelegene genommen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The step you pick in the player then applies to the next title as well. If it isn't available there, the closest one is used."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.rewind": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zurückspulen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Rewind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.scanExplanation": {
|
"settings.scanExplanation": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -3940,6 +4327,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.seconds %lld": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld Sekunden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld seconds"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.section.look": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Darstellung"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Appearance"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.section.playing": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wiedergabe & Offline"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Playback & offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.server": {
|
"settings.server": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -3991,6 +4426,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.showBitrate": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Bitrate im Qualitätsmenü zeigen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Show the bitrate in the quality menu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.signOutConfirm": {
|
"settings.signOutConfirm": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
|
|||||||
@@ -15,17 +15,51 @@ final class HomeLayoutSettings {
|
|||||||
private enum Key {
|
private enum Key {
|
||||||
static let hidden = "flixy.home.hiddenHubs"
|
static let hidden = "flixy.home.hiddenHubs"
|
||||||
static let order = "flixy.home.hubOrder"
|
static let order = "flixy.home.hubOrder"
|
||||||
|
static let heroCount = "flixy.home.heroCount"
|
||||||
|
static let heroSource = "flixy.home.heroSource"
|
||||||
|
static let combinesContinueRows = "flixy.home.combinesContinueRows"
|
||||||
|
static let hidesWatchedInLatest = "flixy.home.hidesWatchedInLatest"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Woraus sich der Aufmacher speist.
|
||||||
|
enum HeroSource: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
/// Angefangenes zuerst, danach aufgefüllt.
|
||||||
|
case continueFirst
|
||||||
|
/// Nur Neuzugänge — für den, der den Aufmacher als Schaufenster
|
||||||
|
/// versteht und nicht als Fortsetzung.
|
||||||
|
case latestOnly
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie viele Titel der Aufmacher durchwechselt.
|
||||||
|
static let heroCountChoices = [1, 3, 5, 7, 10]
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private(set) var arrangement: Arrangement
|
private(set) var arrangement: Arrangement
|
||||||
|
|
||||||
|
var heroCount: Int { didSet { defaults.set(heroCount, forKey: Key.heroCount) } }
|
||||||
|
var heroSource: HeroSource { didSet { defaults.set(heroSource.rawValue, forKey: Key.heroSource) } }
|
||||||
|
/// Ob „Weiterschauen" und „Als Nächstes" eine Reihe sind.
|
||||||
|
///
|
||||||
|
/// Getrennt ist genauer, zusammen ist kürzer. Beides ist vertretbar, und
|
||||||
|
/// welches richtig ist, hängt davon ab, wie viele Serien jemand parallel
|
||||||
|
/// schaut.
|
||||||
|
var combinesContinueRows: Bool { didSet { defaults.set(combinesContinueRows, forKey: Key.combinesContinueRows) } }
|
||||||
|
/// Ob Gesehenes aus „Zuletzt hinzugefügt" verschwindet.
|
||||||
|
var hidesWatchedInLatest: Bool { didSet { defaults.set(hidesWatchedInLatest, forKey: Key.hidesWatchedInLatest) } }
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.arrangement = Arrangement(
|
self.arrangement = Arrangement(
|
||||||
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
|
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
|
||||||
order: defaults.stringArray(forKey: Key.order) ?? []
|
order: defaults.stringArray(forKey: Key.order) ?? []
|
||||||
)
|
)
|
||||||
|
self.heroCount = defaults.object(forKey: Key.heroCount) as? Int ?? 5
|
||||||
|
self.heroSource = defaults.string(forKey: Key.heroSource)
|
||||||
|
.flatMap(HeroSource.init(rawValue:)) ?? .continueFirst
|
||||||
|
self.combinesContinueRows = defaults.object(forKey: Key.combinesContinueRows) as? Bool ?? false
|
||||||
|
self.hidesWatchedInLatest = defaults.object(forKey: Key.hidesWatchedInLatest) as? Bool ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isHidden(_ identifier: String) -> Bool {
|
func isHidden(_ identifier: String) -> Bool {
|
||||||
|
|||||||
@@ -55,16 +55,22 @@ final class OrientationLock {
|
|||||||
|
|
||||||
/// Dreht beim Erscheinen ins Querformat und beim Verschwinden zurück.
|
/// Dreht beim Erscheinen ins Querformat und beim Verschwinden zurück.
|
||||||
struct LandscapeWhileVisible: ViewModifier {
|
struct LandscapeWhileVisible: ViewModifier {
|
||||||
|
/// Ob überhaupt gesperrt wird.
|
||||||
|
///
|
||||||
|
/// Für die allermeisten Filme ist das Querformat richtig — und für den,
|
||||||
|
/// der im Bett auf der Seite liegt, das Gegenteil davon.
|
||||||
|
let isEnabled: Bool
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.onAppear { OrientationLock.shared.lockLandscape() }
|
.onAppear { if isEnabled { OrientationLock.shared.lockLandscape() } }
|
||||||
.onDisappear { OrientationLock.shared.releaseLandscape() }
|
.onDisappear { OrientationLock.shared.releaseLandscape() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
/// Für den Vollbildplayer: Film ist Querformat.
|
/// Für den Vollbildplayer: Film ist Querformat.
|
||||||
func landscapeWhileVisible() -> some View {
|
func landscapeWhileVisible(_ isEnabled: Bool = true) -> some View {
|
||||||
modifier(LandscapeWhileVisible())
|
modifier(LandscapeWhileVisible(isEnabled: isEnabled))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,24 @@ final class PlaybackSettings {
|
|||||||
static let autoPlayNext = "flixy.playback.autoPlayNext"
|
static let autoPlayNext = "flixy.playback.autoPlayNext"
|
||||||
static let preferredAudioLanguage = "flixy.playback.audioLanguage"
|
static let preferredAudioLanguage = "flixy.playback.audioLanguage"
|
||||||
static let preferredSubtitleLanguage = "flixy.playback.subtitleLanguage"
|
static let preferredSubtitleLanguage = "flixy.playback.subtitleLanguage"
|
||||||
|
static let locksLandscape = "flixy.playback.locksLandscape"
|
||||||
|
static let rewindSeconds = "flixy.playback.rewindSeconds"
|
||||||
|
static let forwardSeconds = "flixy.playback.forwardSeconds"
|
||||||
|
static let defaultRate = "flixy.playback.defaultRate"
|
||||||
|
static let remembersQuality = "flixy.playback.remembersQuality"
|
||||||
|
static let rememberedQuality = "flixy.playback.rememberedQuality"
|
||||||
|
static let showsBitrate = "flixy.playback.showsBitrate"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Sprünge, die es zur Auswahl gibt.
|
||||||
|
///
|
||||||
|
/// Nicht frei einstellbar: ein Schieberegler für Sekunden lädt dazu ein,
|
||||||
|
/// 23 einzustellen, und dafür gibt es kein Symbol. Diese fünf haben eins.
|
||||||
|
static let skipChoices = [5, 10, 15, 30, 45]
|
||||||
|
|
||||||
|
/// Die Geschwindigkeiten, mit denen eine Wiedergabe beginnen kann.
|
||||||
|
static let rateChoices: [Double] = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
|
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
|
||||||
@@ -48,6 +64,37 @@ final class PlaybackSettings {
|
|||||||
var preferredAudioLanguage: String { didSet { defaults.set(preferredAudioLanguage, forKey: Key.preferredAudioLanguage) } }
|
var preferredAudioLanguage: String { didSet { defaults.set(preferredAudioLanguage, forKey: Key.preferredAudioLanguage) } }
|
||||||
var preferredSubtitleLanguage: String { didSet { defaults.set(preferredSubtitleLanguage, forKey: Key.preferredSubtitleLanguage) } }
|
var preferredSubtitleLanguage: String { didSet { defaults.set(preferredSubtitleLanguage, forKey: Key.preferredSubtitleLanguage) } }
|
||||||
|
|
||||||
|
/// Ob der Player beim Öffnen ins Querformat dreht und dort bleibt.
|
||||||
|
///
|
||||||
|
/// Für die allermeisten Filme richtig — und für den, der im Bett auf der
|
||||||
|
/// Seite liegt, das Gegenteil davon. Deshalb abschaltbar.
|
||||||
|
var locksLandscape: Bool { didSet { defaults.set(locksLandscape, forKey: Key.locksLandscape) } }
|
||||||
|
/// Wie weit der Rücksprung geht, in Sekunden.
|
||||||
|
var rewindSeconds: Int { didSet { defaults.set(rewindSeconds, forKey: Key.rewindSeconds) } }
|
||||||
|
/// Wie weit der Vorsprung geht. Getrennt vom Rücksprung, weil die beiden
|
||||||
|
/// verschiedene Zwecke haben: zurück, weil man etwas nicht verstanden hat;
|
||||||
|
/// vor, weil man etwas überspringen will.
|
||||||
|
var forwardSeconds: Int { didSet { defaults.set(forwardSeconds, forKey: Key.forwardSeconds) } }
|
||||||
|
/// Mit welcher Geschwindigkeit eine Wiedergabe beginnt.
|
||||||
|
var defaultRate: Double { didSet { defaults.set(defaultRate, forKey: Key.defaultRate) } }
|
||||||
|
/// Ob die im Player gewählte Stufe für den nächsten Titel gilt.
|
||||||
|
var remembersQuality: Bool { didSet { defaults.set(remembersQuality, forKey: Key.remembersQuality) } }
|
||||||
|
/// Ob im Qualitätsmenü steht, was gerade tatsächlich ankommt.
|
||||||
|
var showsBitrate: Bool { didSet { defaults.set(showsBitrate, forKey: Key.showsBitrate) } }
|
||||||
|
|
||||||
|
/// Die zuletzt gewählte Stufe, als Zahl.
|
||||||
|
///
|
||||||
|
/// Hier und nicht im Player: der lebt nur so lange wie ein Film. Was über
|
||||||
|
/// mehrere Filme hinweg gelten soll, gehört in die Einstellungen.
|
||||||
|
var rememberedQuality: Int? {
|
||||||
|
get {
|
||||||
|
guard remembersQuality else { return nil }
|
||||||
|
let value = defaults.integer(forKey: Key.rememberedQuality)
|
||||||
|
return value == 0 ? nil : value
|
||||||
|
}
|
||||||
|
set { defaults.set(newValue ?? 0, forKey: Key.rememberedQuality) }
|
||||||
|
}
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
init(defaults: UserDefaults = .standard) {
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
|
|
||||||
@@ -62,6 +109,13 @@ final class PlaybackSettings {
|
|||||||
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
||||||
self.preferredAudioLanguage = defaults.string(forKey: Key.preferredAudioLanguage) ?? ""
|
self.preferredAudioLanguage = defaults.string(forKey: Key.preferredAudioLanguage) ?? ""
|
||||||
self.preferredSubtitleLanguage = defaults.string(forKey: Key.preferredSubtitleLanguage) ?? ""
|
self.preferredSubtitleLanguage = defaults.string(forKey: Key.preferredSubtitleLanguage) ?? ""
|
||||||
|
|
||||||
|
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
|
||||||
|
self.rewindSeconds = defaults.object(forKey: Key.rewindSeconds) as? Int ?? 10
|
||||||
|
self.forwardSeconds = defaults.object(forKey: Key.forwardSeconds) as? Int ?? 10
|
||||||
|
self.defaultRate = defaults.object(forKey: Key.defaultRate) as? Double ?? 1
|
||||||
|
self.remembersQuality = defaults.object(forKey: Key.remembersQuality) as? Bool ?? false
|
||||||
|
self.showsBitrate = defaults.object(forKey: Key.showsBitrate) as? Bool ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Richtlinie für die aktuelle Lage.
|
/// Die Richtlinie für die aktuelle Lage.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .loaded(let allRows):
|
case .loaded(let allRows):
|
||||||
let hubs = layout.apply(to: allRows)
|
let hubs = layout.apply(to: prepared(allRows))
|
||||||
if hubs.isEmpty {
|
if hubs.isEmpty {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("home.empty.title", systemImage: "film.stack")
|
Label("home.empty.title", systemImage: "film.stack")
|
||||||
@@ -72,26 +72,83 @@ struct HomeView: View {
|
|||||||
.scrollIndicators(.hidden)
|
.scrollIndicators(.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wie viele Titel der Aufmacher durchwechselt.
|
/// Bringt die Reihen vom Server in die Form, die eingestellt ist.
|
||||||
private static let heroCount = 5
|
///
|
||||||
|
/// Hier und nicht im Backend: „Weiterschauen und Als Nächstes zusammen"
|
||||||
|
/// und „Gesehenes ausblenden" sind Fragen der Darstellung, keine des
|
||||||
|
/// Servers. Beides hier zu erledigen hält den Vertrag schmal und gilt für
|
||||||
|
/// Plex und Jellyfin gleichermaßen.
|
||||||
|
private func prepared(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
|
||||||
|
var rows = rows
|
||||||
|
|
||||||
|
if layout.hidesWatchedInLatest {
|
||||||
|
rows = rows.map { row in
|
||||||
|
guard row.id.hasPrefix("latest") else { return row }
|
||||||
|
return MediaRowGroup(
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
moreKey: row.moreKey,
|
||||||
|
items: row.items.filter { !$0.isWatched }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if layout.combinesContinueRows {
|
||||||
|
rows = combiningContinueRows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.filter { !$0.items.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt „Weiterschauen" und „Als Nächstes" in eine Reihe.
|
||||||
|
///
|
||||||
|
/// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre
|
||||||
|
/// sie für die gespeicherte Reihenfolge eine neue und stünde plötzlich
|
||||||
|
/// wieder ganz unten.
|
||||||
|
private func combiningContinueRows(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
|
||||||
|
let continuing = rows.filter(\.isContinueWatching)
|
||||||
|
guard continuing.count > 1, let first = continuing.first else { return rows }
|
||||||
|
|
||||||
|
var seen = Set<String>()
|
||||||
|
let merged = continuing.flatMap(\.items).filter { seen.insert($0.id).inserted }
|
||||||
|
|
||||||
|
var result: [MediaRowGroup] = []
|
||||||
|
for row in rows {
|
||||||
|
guard row.isContinueWatching else {
|
||||||
|
result.append(row)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard row.id == first.id else { continue }
|
||||||
|
result.append(MediaRowGroup(id: row.id, title: row.title, items: merged))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/// Was oben groß gezeigt wird.
|
/// Was oben groß gezeigt wird.
|
||||||
///
|
///
|
||||||
/// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen.
|
/// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen.
|
||||||
/// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen
|
/// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen
|
||||||
/// Folge gäbe es sonst nichts zu blättern.
|
/// Folge gäbe es sonst nichts zu blättern. Wer den Aufmacher lieber als
|
||||||
|
/// Schaufenster versteht, stellt auf Neuzugänge um.
|
||||||
private func heroItems(in hubs: [MediaRowGroup]) -> [MediaItem] {
|
private func heroItems(in hubs: [MediaRowGroup]) -> [MediaItem] {
|
||||||
let continuing = hubs.first(where: \.isContinueWatching)?.items ?? []
|
let candidates: [MediaItem]
|
||||||
let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items)
|
switch layout.heroSource {
|
||||||
|
case .continueFirst:
|
||||||
|
let continuing = hubs.first(where: \.isContinueWatching)?.items ?? []
|
||||||
|
let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items)
|
||||||
|
candidates = continuing + rest
|
||||||
|
case .latestOnly:
|
||||||
|
candidates = hubs.filter { $0.id.hasPrefix("latest") }.flatMap(\.items)
|
||||||
|
}
|
||||||
|
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
let candidates = (continuing + rest).filter { seen.insert($0.id).inserted }
|
let unique = candidates.filter { seen.insert($0.id).inserted }
|
||||||
|
|
||||||
// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text.
|
// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text.
|
||||||
// Lieber einen Titel überspringen als eine leere Bühne zeigen — es sei
|
// Lieber einen Titel überspringen als eine leere Bühne zeigen — es sei
|
||||||
// denn, keiner hätte eines.
|
// denn, keiner hätte eines.
|
||||||
let withArtwork = candidates.filter { $0.art != nil || $0.seriesArt != nil }
|
let withArtwork = unique.filter { $0.art != nil || $0.seriesArt != nil }
|
||||||
return Array((withArtwork.isEmpty ? candidates : withArtwork).prefix(Self.heroCount))
|
return Array((withArtwork.isEmpty ? unique : withArtwork).prefix(layout.heroCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ import FlixyDesign
|
|||||||
|
|
||||||
/// Wer angemeldet ist — unten in der Seitenleiste.
|
/// Wer angemeldet ist — unten in der Seitenleiste.
|
||||||
///
|
///
|
||||||
/// Der Platz ist nicht zufällig: dort sucht man sein Konto, weil es auf jedem
|
/// Der Platz ist nicht zufällig: dort sucht man sein Konto, weil es in jeder
|
||||||
/// Schreibtisch und in jeder App der letzte Eintrag ist. Und von dort führt
|
/// App der letzte Eintrag ist. Und von dort führt der Weg in die
|
||||||
/// der Weg in die Einstellungen, statt dass sie als gleichrangiger Eintrag
|
/// Einstellungen, statt dass sie als gleichrangiger Eintrag zwischen den
|
||||||
/// zwischen den Mediatheken stehen.
|
/// Mediatheken stehen.
|
||||||
///
|
///
|
||||||
/// Auf dem iPhone gibt es diese Leiste nicht — dort gehört das Konto in die
|
/// **Eine Karte, die nach oben aufklappt**, kein Systemmenü. Der Unterschied
|
||||||
/// Einstellungen selbst, und die haben ihren eigenen Eintrag.
|
/// ist nicht nur Geschmack: ein Menü legt sich als eigene Ebene über alles und
|
||||||
|
/// springt dorthin, wo Platz ist. Die Karte bleibt, wo sie ist, wächst nach
|
||||||
|
/// oben und lässt die Zeile mit dem Namen die ganze Zeit stehen — man sieht
|
||||||
|
/// also weiter, um wessen Konto es geht.
|
||||||
|
///
|
||||||
|
/// Auf dem iPhone gibt es diese Leiste nicht; dort haben die Einstellungen
|
||||||
|
/// ihren eigenen Eintrag.
|
||||||
struct AccountBar: View {
|
struct AccountBar: View {
|
||||||
@Binding var selection: SidebarDestination
|
@Binding var selection: SidebarDestination
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
@State private var isExpanded = false
|
||||||
@State private var showsSignOutConfirmation = false
|
@State private var showsSignOutConfirmation = false
|
||||||
|
|
||||||
private var title: String {
|
private var title: String {
|
||||||
@@ -28,16 +35,44 @@ struct AccountBar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Menu {
|
VStack(spacing: 0) {
|
||||||
Button("account.settings", systemImage: "gearshape") {
|
if isExpanded {
|
||||||
selection = .settings
|
action("account.settings", symbol: "gearshape") {
|
||||||
|
collapse()
|
||||||
|
selection = .settings
|
||||||
|
}
|
||||||
|
separator
|
||||||
|
|
||||||
|
action(
|
||||||
|
"action.signOut",
|
||||||
|
symbol: "rectangle.portrait.and.arrow.right",
|
||||||
|
isDestructive: true
|
||||||
|
) {
|
||||||
|
collapse()
|
||||||
|
showsSignOutConfirmation = true
|
||||||
|
}
|
||||||
|
separator
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
accountRow
|
||||||
|
}
|
||||||
|
.glassEffect(.regular, in: .rect(cornerRadius: FlixyRadius.sheet))
|
||||||
|
.animation(.snappy(duration: 0.25), value: isExpanded)
|
||||||
|
.confirmationDialog(
|
||||||
|
Text("settings.signOutConfirm"),
|
||||||
|
isPresented: $showsSignOutConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("action.signOut", role: .destructive) { session.signOut() }
|
||||||
|
Button("action.cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("settings.signOutExplanation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button("action.signOut", systemImage: "rectangle.portrait.and.arrow.right", role: .destructive) {
|
private var accountRow: some View {
|
||||||
showsSignOutConfirmation = true
|
Button {
|
||||||
}
|
withAnimation(.snappy(duration: 0.25)) { isExpanded.toggle() }
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlixySpacing.snug) {
|
HStack(spacing: FlixySpacing.snug) {
|
||||||
avatar
|
avatar
|
||||||
@@ -58,25 +93,40 @@ struct AccountBar: View {
|
|||||||
|
|
||||||
Spacer(minLength: FlixySpacing.snug)
|
Spacer(minLength: FlixySpacing.snug)
|
||||||
|
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
// Zeigt nach oben, wenn zugeklappt: dorthin wächst die Karte.
|
||||||
.font(.caption2)
|
Image(systemName: isExpanded ? "chevron.down" : "chevron.up")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(Color.flixyTextMuted)
|
.foregroundStyle(Color.flixyTextMuted)
|
||||||
}
|
}
|
||||||
.padding(.vertical, FlixySpacing.tight)
|
.padding(.horizontal, FlixySpacing.regular)
|
||||||
|
.padding(.vertical, FlixySpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(Text(title))
|
.accessibilityLabel(Text(title))
|
||||||
.confirmationDialog(
|
.accessibilityHint(Text("account.hint"))
|
||||||
Text("settings.signOutConfirm"),
|
}
|
||||||
isPresented: $showsSignOutConfirmation,
|
|
||||||
titleVisibility: .visible
|
private func action(
|
||||||
) {
|
_ title: LocalizedStringKey,
|
||||||
Button("action.signOut", role: .destructive) { session.signOut() }
|
symbol: String,
|
||||||
Button("action.cancel", role: .cancel) {}
|
isDestructive: Bool = false,
|
||||||
} message: {
|
perform: @escaping () -> Void
|
||||||
Text("settings.signOutExplanation")
|
) -> some View {
|
||||||
|
Button(action: perform) {
|
||||||
|
Label(title, systemImage: symbol)
|
||||||
|
.font(.flixyBody)
|
||||||
|
.foregroundStyle(isDestructive ? Color.flixyDestructive : Color.flixyTextPrimary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, FlixySpacing.regular)
|
||||||
|
.padding(.vertical, FlixySpacing.regular)
|
||||||
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var separator: some View {
|
||||||
|
Divider().overlay(Color.flixySeparator)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der erste Buchstabe auf einer Scheibe.
|
/// Der erste Buchstabe auf einer Scheibe.
|
||||||
@@ -91,4 +141,8 @@ struct AccountBar: View {
|
|||||||
.frame(width: 26, height: 26)
|
.frame(width: 26, height: 26)
|
||||||
.background(accent, in: .circle)
|
.background(accent, in: .circle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func collapse() {
|
||||||
|
withAnimation(.snappy(duration: 0.25)) { isExpanded = false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,7 +218,18 @@ final class PlayerModel {
|
|||||||
currentMedia = media
|
currentMedia = media
|
||||||
currentPart = part
|
currentPart = part
|
||||||
|
|
||||||
let decision = PlaybackDecider().decide(profile: media.profile(file: part), policy: policy)
|
// Die zuletzt gewählte Stufe, falls sie gemerkt werden soll. **Vor**
|
||||||
|
// der Entscheidung, nicht danach: hinterher umzustellen hieße, den
|
||||||
|
// Strom zweimal aufzubauen und den Anfang zweimal zu puffern.
|
||||||
|
if let stored = settings.rememberedQuality,
|
||||||
|
let restored = PlayerQuality.restored(from: stored, forSourceHeight: media.height) {
|
||||||
|
quality = restored
|
||||||
|
}
|
||||||
|
|
||||||
|
let decision = PlaybackDecider().decide(
|
||||||
|
profile: media.profile(file: part),
|
||||||
|
policy: quality.policy(basedOn: policy)
|
||||||
|
)
|
||||||
self.decision = decision
|
self.decision = decision
|
||||||
|
|
||||||
guard let source = await source(for: decision, item: item, media: media, part: part, backend: backend) else {
|
guard let source = await source(for: decision, item: item, media: media, part: part, backend: backend) else {
|
||||||
@@ -253,6 +264,9 @@ final class PlayerModel {
|
|||||||
let resumeAt = currentTime
|
let resumeAt = currentTime
|
||||||
resumeTarget = resumeAt
|
resumeTarget = resumeAt
|
||||||
quality = newQuality
|
quality = newQuality
|
||||||
|
// Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen
|
||||||
|
// einen Film.
|
||||||
|
if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue }
|
||||||
|
|
||||||
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
||||||
// der Server zwei Streams gleichzeitig für einen Zuschauer.
|
// der Server zwei Streams gleichzeitig für einen Zuschauer.
|
||||||
@@ -374,6 +388,9 @@ final class PlayerModel {
|
|||||||
diagnostics.note("Engine öffnet Quelle")
|
diagnostics.note("Engine öffnet Quelle")
|
||||||
try await engine.load(source)
|
try await engine.load(source)
|
||||||
engine.play()
|
engine.play()
|
||||||
|
// Erst nach dem Start: vorher hat die Engine noch keinen Gegenstand,
|
||||||
|
// dessen Geschwindigkeit sich setzen ließe.
|
||||||
|
if settings.defaultRate != 1 { engine.setRate(settings.defaultRate) }
|
||||||
phase = .playing
|
phase = .playing
|
||||||
diagnostics.note("Engine gestartet")
|
diagnostics.note("Engine gestartet")
|
||||||
// Weder offline noch live gibt es einen Fortschritt zu melden.
|
// Weder offline noch live gibt es einen Fortschritt zu melden.
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ struct PlayerView: View {
|
|||||||
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
|
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
|
||||||
.statusBarHidden(true)
|
.statusBarHidden(true)
|
||||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||||
.landscapeWhileVisible()
|
.landscapeWhileVisible(settings.locksLandscape)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
// Für alles außerhalb der Sprungflächen: Tippen zeigt und verbirgt
|
// Für alles außerhalb der Sprungflächen: Tippen zeigt und verbirgt
|
||||||
// die Bedienelemente wie bisher.
|
// die Bedienelemente wie bisher.
|
||||||
@@ -230,10 +230,10 @@ struct PlayerView: View {
|
|||||||
private func transport(for model: PlayerModel) -> some View {
|
private func transport(for model: PlayerModel) -> some View {
|
||||||
HStack(spacing: FlixySpacing.section * 2) {
|
HStack(spacing: FlixySpacing.section * 2) {
|
||||||
Button {
|
Button {
|
||||||
model.skip(by: .seconds(-10))
|
model.skip(by: .seconds(-settings.rewindSeconds))
|
||||||
scheduleControlsHide()
|
scheduleControlsHide()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gobackward.10").font(.largeTitle)
|
Image(systemName: "gobackward.\(settings.rewindSeconds)").font(.largeTitle)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.back10"))
|
.accessibilityLabel(Text("player.back10"))
|
||||||
|
|
||||||
@@ -247,10 +247,10 @@ struct PlayerView: View {
|
|||||||
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
|
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
model.skip(by: .seconds(10))
|
model.skip(by: .seconds(settings.forwardSeconds))
|
||||||
scheduleControlsHide()
|
scheduleControlsHide()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "goforward.10").font(.largeTitle)
|
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.forward10"))
|
.accessibilityLabel(Text("player.forward10"))
|
||||||
}
|
}
|
||||||
@@ -411,9 +411,15 @@ struct PlayerView: View {
|
|||||||
let height = geometry.size.height * 0.45
|
let height = geometry.size.height * 0.45
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
seekZone(seconds: -10, symbol: "gobackward.10").frame(width: width)
|
seekZone(
|
||||||
|
seconds: Double(-settings.rewindSeconds),
|
||||||
|
symbol: "gobackward.\(settings.rewindSeconds)"
|
||||||
|
).frame(width: width)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
seekZone(seconds: 10, symbol: "goforward.10").frame(width: width)
|
seekZone(
|
||||||
|
seconds: Double(settings.forwardSeconds),
|
||||||
|
symbol: "goforward.\(settings.forwardSeconds)"
|
||||||
|
).frame(width: width)
|
||||||
}
|
}
|
||||||
.frame(height: height)
|
.frame(height: height)
|
||||||
// Etwas unterhalb der Mitte: oben bleibt die Kopfzeile frei,
|
// Etwas unterhalb der Mitte: oben bleibt die Kopfzeile frei,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct QualitySheet: View {
|
|||||||
let model: PlayerModel
|
let model: PlayerModel
|
||||||
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var showsEveryRung = false
|
@State private var showsEveryRung = false
|
||||||
@@ -66,7 +67,7 @@ struct QualitySheet: View {
|
|||||||
/// Was gerade läuft — gemessen, nicht gewünscht.
|
/// Was gerade läuft — gemessen, nicht gewünscht.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var current: some View {
|
private var current: some View {
|
||||||
if let statistics = model.statistics {
|
if settings.showsBitrate, let statistics = model.statistics {
|
||||||
Section {
|
Section {
|
||||||
if let observed = statistics.observedBitrate {
|
if let observed = statistics.observedBitrate {
|
||||||
LabeledContent("quality.current.bitrate", value: bitrateText(observed))
|
LabeledContent("quality.current.bitrate", value: bitrateText(observed))
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlixyDesign
|
||||||
|
import FlixyDownloads
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Was beim Herunterladen gilt.
|
||||||
|
///
|
||||||
|
/// Die Löschfrist steht bewusst mit ausgeschriebenen Fristen da statt mit
|
||||||
|
/// einem Schieberegler: „Nach 7 Tagen" ist eine Entscheidung, die man bewusst
|
||||||
|
/// trifft — beim Löschen von Filmen soll niemand versehentlich auf „sofort"
|
||||||
|
/// rutschen.
|
||||||
|
struct DownloadSettingsView: View {
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var downloads = downloads
|
||||||
|
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
|
||||||
|
|
||||||
|
Picker("settings.storageLimit", selection: storageLimitBinding) {
|
||||||
|
Text("settings.storageUnlimited").tag(Int64(0))
|
||||||
|
ForEach([Int64(5), 10, 20, 30, 50, 100], id: \.self) { gigabytes in
|
||||||
|
Text("\(gigabytes) GB").tag(gigabytes * 1_000_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("settings.downloadsExplanation")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Stepper(value: $downloads.maximumConcurrentDownloads, in: 1...5) {
|
||||||
|
LabeledContent(
|
||||||
|
"settings.concurrent",
|
||||||
|
value: downloads.maximumConcurrentDownloads.formatted()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("settings.concurrentHeader")
|
||||||
|
} footer: {
|
||||||
|
// Zwei statt aller auf einmal: eine ganze Staffel parallel zu
|
||||||
|
// laden teilt die Bandbreite so auf, dass keine Folge fertig
|
||||||
|
// wird — und unterwegs ist eine fertige Folge mehr wert als
|
||||||
|
// zehn halbe.
|
||||||
|
Text("settings.concurrentExplanation")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("settings.deleteWatched", selection: deleteAfterBinding) {
|
||||||
|
Text("settings.deleteNever").tag(Double(-1))
|
||||||
|
Text("settings.deleteImmediately").tag(Double(0))
|
||||||
|
Text("settings.deleteAfterDay").tag(Duration.afterOneDay.seconds)
|
||||||
|
Text("settings.deleteAfterWeek").tag(Duration.afterOneWeek.seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("settings.keepUnwatched", selection: keepUnwatchedBinding) {
|
||||||
|
Text("settings.keepOff").tag(0)
|
||||||
|
ForEach([1, 2, 3, 5], id: \.self) { count in
|
||||||
|
Text("settings.keepCount \(count)").tag(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("settings.episodes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(accent)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color.flixyCanvas)
|
||||||
|
.navigationTitle(Text("sidebar.downloads"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storageLimitBinding: Binding<Int64> {
|
||||||
|
Binding(
|
||||||
|
get: { downloads.storageLimitBytes ?? 0 },
|
||||||
|
set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteAfterBinding: Binding<Double> {
|
||||||
|
Binding(
|
||||||
|
get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 },
|
||||||
|
set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var keepUnwatchedBinding: Binding<Int> {
|
||||||
|
Binding(
|
||||||
|
get: { downloads.rules.keepUnwatchedEpisodes ?? 0 },
|
||||||
|
set: { downloads.rules.keepUnwatchedEpisodes = $0 > 0 ? $0 : nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,19 +12,7 @@ struct HomeLayoutView: View {
|
|||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
list
|
||||||
if isLoading {
|
|
||||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else if rows.isEmpty {
|
|
||||||
ContentUnavailableView {
|
|
||||||
Label("home.empty.title", systemImage: "rectangle.stack")
|
|
||||||
} description: {
|
|
||||||
Text("homeLayout.empty")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.background(Color.flixyCanvas)
|
.background(Color.flixyCanvas)
|
||||||
.navigationTitle(Text("homeLayout.title"))
|
.navigationTitle(Text("homeLayout.title"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -34,6 +22,42 @@ struct HomeLayoutView: View {
|
|||||||
|
|
||||||
private var list: some View {
|
private var list: some View {
|
||||||
List {
|
List {
|
||||||
|
@Bindable var layout = layout
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("home.heroSource", selection: $layout.heroSource) {
|
||||||
|
ForEach(HomeLayoutSettings.HeroSource.allCases) { source in
|
||||||
|
Text(source.label).tag(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("home.heroCount", selection: $layout.heroCount) {
|
||||||
|
ForEach(HomeLayoutSettings.heroCountChoices, id: \.self) { count in
|
||||||
|
Text(count.formatted()).tag(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("home.hero")
|
||||||
|
} footer: {
|
||||||
|
Text("home.heroExplanation")
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.flixySurface)
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
||||||
|
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.flixySurface)
|
||||||
|
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
} else if rows.isEmpty {
|
||||||
|
Text("homeLayout.empty")
|
||||||
|
.foregroundStyle(Color.flixyTextSecondary)
|
||||||
|
.listRowBackground(Color.flixySurface)
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ForEach($rows) { $row in
|
ForEach($rows) { $row in
|
||||||
Toggle(isOn: Binding(
|
Toggle(isOn: Binding(
|
||||||
@@ -52,6 +76,8 @@ struct HomeLayoutView: View {
|
|||||||
rows.move(fromOffsets: indices, toOffset: destination)
|
rows.move(fromOffsets: indices, toOffset: destination)
|
||||||
layout.setOrder(rows.map(\.id))
|
layout.setOrder(rows.map(\.id))
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("home.rowsHeader")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("homeLayout.explanation")
|
Text("homeLayout.explanation")
|
||||||
}
|
}
|
||||||
@@ -76,3 +102,12 @@ struct HomeLayoutView: View {
|
|||||||
rows = layout.rows(from: hubs)
|
rows = layout.rows(from: hubs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension HomeLayoutSettings.HeroSource {
|
||||||
|
var label: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .continueFirst: "home.heroSource.continueFirst"
|
||||||
|
case .latestOnly: "home.heroSource.latestOnly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlixyDesign
|
||||||
|
import FlixyPlayback
|
||||||
|
|
||||||
|
/// Alles, was die Wiedergabe betrifft.
|
||||||
|
///
|
||||||
|
/// Vorher stand das verstreut in der langen Liste der Einstellungen, zwischen
|
||||||
|
/// Serverangaben und Downloads. Zusammen ergibt es einen Bildschirm, den man
|
||||||
|
/// einmal durchgeht und dann in Ruhe lässt.
|
||||||
|
struct PlaybackSettingsView: View {
|
||||||
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var settings = settings
|
||||||
|
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Picker("settings.qualityWifi", selection: $settings.wifiQuality) {
|
||||||
|
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||||
|
Text(quality.label).tag(quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("settings.qualityCellular", selection: $settings.cellularQuality) {
|
||||||
|
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||||
|
Text(quality.label).tag(quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("settings.quality")
|
||||||
|
} footer: {
|
||||||
|
Text("settings.qualityExplanation")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("settings.rememberQuality", isOn: $settings.remembersQuality)
|
||||||
|
Toggle("settings.showBitrate", isOn: $settings.showsBitrate)
|
||||||
|
} footer: {
|
||||||
|
Text("settings.rememberQualityExplanation")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("settings.rewind", selection: $settings.rewindSeconds) {
|
||||||
|
ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in
|
||||||
|
Text("settings.seconds \(seconds)").tag(seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("settings.forward", selection: $settings.forwardSeconds) {
|
||||||
|
ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in
|
||||||
|
Text("settings.seconds \(seconds)").tag(seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Picker("settings.defaultRate", selection: $settings.defaultRate) {
|
||||||
|
ForEach(PlaybackSettings.rateChoices, id: \.self) { rate in
|
||||||
|
Text(rateLabel(rate)).tag(rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("settings.controls")
|
||||||
|
} footer: {
|
||||||
|
// Zurück und vor getrennt, weil sie verschiedene Zwecke haben:
|
||||||
|
// zurück, weil man etwas nicht verstanden hat; vor, weil man
|
||||||
|
// etwas überspringen will.
|
||||||
|
Text("settings.controlsExplanation")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("settings.lockLandscape", isOn: $settings.locksLandscape)
|
||||||
|
} footer: {
|
||||||
|
Text("settings.lockLandscapeExplanation")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
||||||
|
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
||||||
|
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
||||||
|
} header: {
|
||||||
|
Text("settings.episodes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(accent)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color.flixyCanvas)
|
||||||
|
.navigationTitle(Text("settings.playback"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rateLabel(_ rate: Double) -> String {
|
||||||
|
rate == 1
|
||||||
|
? String(localized: "settings.rateNormal")
|
||||||
|
: rate.formatted(.number.precision(.fractionLength(0...2))) + "×"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PlaybackSettings.Quality {
|
||||||
|
var label: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .original: "quality.original"
|
||||||
|
case .high: "quality.high"
|
||||||
|
case .medium: "quality.medium"
|
||||||
|
case .low: "quality.low"
|
||||||
|
case .minimal: "quality.minimal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlixyDesign
|
||||||
|
import MediaKit
|
||||||
|
import PlexKit
|
||||||
|
|
||||||
|
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
||||||
|
struct ServerSettingsView: View {
|
||||||
|
@Environment(FlixySession.self) private var session
|
||||||
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("settings.server") {
|
||||||
|
LabeledContent("settings.serverName", value: session.serverName)
|
||||||
|
// Welche Art von Server — bei zwei möglichen ist das keine
|
||||||
|
// Nebensache mehr, sondern die erste Frage bei jeder
|
||||||
|
// Rückmeldung.
|
||||||
|
LabeledContent("settings.serverKind", value: kindLabel)
|
||||||
|
if let connection = session.server?.connection {
|
||||||
|
LabeledContent("settings.connection", value: connectionLabel(connection))
|
||||||
|
}
|
||||||
|
if let address = session.serverAddress {
|
||||||
|
LabeledContent("settings.address", value: address)
|
||||||
|
.font(.flixyCaption)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
if let accountName = session.accountName {
|
||||||
|
LabeledContent("settings.account", value: accountName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(session.libraries) { library in
|
||||||
|
LibraryRow(library: library)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("settings.libraries")
|
||||||
|
} footer: {
|
||||||
|
// Der Suchlauf stand einmal im Dreipunktmenü der Mediathek —
|
||||||
|
// einen Tipp neben „Filter" und „Ansicht wechseln". Er
|
||||||
|
// beschäftigt den Server minutenlang und gehört nicht in eine
|
||||||
|
// Reihe mit Dingen, die man im Vorbeigehen antippt.
|
||||||
|
if session.backend?.capabilities.contains(.libraryScan) == true {
|
||||||
|
Text("settings.scanExplanation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(accent)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color.flixyCanvas)
|
||||||
|
.navigationTitle(Text("settings.server"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var kindLabel: String {
|
||||||
|
switch session.kind {
|
||||||
|
case .plex: "Plex"
|
||||||
|
case .jellyfin: "Jellyfin"
|
||||||
|
case nil: "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
|
||||||
|
/// Ruckeln ist das die erste Frage.
|
||||||
|
private func connectionLabel(_ connection: PlexConnection) -> String {
|
||||||
|
if connection.local { return String(localized: "settings.connectionLocal") }
|
||||||
|
if connection.relay { return String(localized: "settings.connectionRelay") }
|
||||||
|
return String(localized: "settings.connectionRemote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben.
|
||||||
|
struct LibraryRow: View {
|
||||||
|
let library: MediaLibrary
|
||||||
|
|
||||||
|
@Environment(FlixySession.self) private var session
|
||||||
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
@State private var phase: Phase = .idle
|
||||||
|
|
||||||
|
private enum Phase { case idle, running, done }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LabeledContent {
|
||||||
|
HStack(spacing: FlixySpacing.regular) {
|
||||||
|
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
|
||||||
|
// „Filme" stand sonst als „Filme — Filme" da.
|
||||||
|
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
|
||||||
|
Text(library.kind.label)
|
||||||
|
.foregroundStyle(Color.flixyTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.backend?.capabilities.contains(.libraryScan) == true {
|
||||||
|
Button {
|
||||||
|
Task { await scan() }
|
||||||
|
} label: {
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(phase == .running)
|
||||||
|
.accessibilityLabel(Text("library.scan"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(library.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var symbol: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
case .running:
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
case .done:
|
||||||
|
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stößt einen Suchlauf auf dem Server an.
|
||||||
|
///
|
||||||
|
/// Der Server meldet nicht, wann er fertig ist. Deshalb steht hier kein
|
||||||
|
/// Fortschritt, sondern nur ein Haken dafür, dass der Auftrag angekommen
|
||||||
|
/// ist — ein Balken, der etwas anderes behauptet, wäre erfunden.
|
||||||
|
private func scan() async {
|
||||||
|
guard let backend = session.backend, phase != .running else { return }
|
||||||
|
phase = .running
|
||||||
|
try? await backend.refreshLibrary(library)
|
||||||
|
phase = .done
|
||||||
|
try? await Task.sleep(for: .seconds(4))
|
||||||
|
phase = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MediaLibrary.Kind {
|
||||||
|
/// Wie diese Art von Mediathek heißt.
|
||||||
|
///
|
||||||
|
/// Übersetzt statt durchgereicht: die Server liefern „movies" und
|
||||||
|
/// „tvshows", und das großgeschrieben anzuzeigen war eine englische
|
||||||
|
/// Beschriftung in einer deutschen Oberfläche.
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .movie: String(localized: "library.kind.movie")
|
||||||
|
case .show: String(localized: "library.kind.show")
|
||||||
|
case .music: String(localized: "library.kind.music")
|
||||||
|
case .photo: String(localized: "library.kind.photo")
|
||||||
|
case .liveTV: String(localized: "library.kind.liveTV")
|
||||||
|
case .other: String(localized: "library.kind.other")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,112 +1,51 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import FlixyDesign
|
||||||
import FlixyDownloads
|
|
||||||
import FlixyPlayback
|
|
||||||
import MediaKit
|
|
||||||
import PlexKit
|
|
||||||
|
|
||||||
/// Die Einstellungen.
|
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
|
||||||
///
|
///
|
||||||
/// In Phase 1 bewusst schmal: Serverangaben und Abmelden. Akzentfarbe,
|
/// Vorher stand alles untereinander auf einem Bildschirm: Serverangaben,
|
||||||
/// Qualitätsregler und Download-Einstellungen kommen mit den Phasen, in denen
|
/// Qualität, Wiedergabe, drei Verweise, Downloads, Version, Abmelden. Das
|
||||||
/// es das Geregelte auch gibt.
|
/// war beim Bauen bequem und beim Suchen anstrengend — je mehr Einstellungen
|
||||||
|
/// dazukamen, desto weiter rutschte alles nach unten.
|
||||||
|
///
|
||||||
|
/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
|
||||||
@State private var showsSignOutConfirmation = false
|
@State private var showsSignOutConfirmation = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
if session.backend != nil {
|
if session.backend != nil {
|
||||||
Section("settings.server") {
|
|
||||||
LabeledContent("settings.serverName", value: session.serverName)
|
|
||||||
// Welche Art von Server — bei zwei möglichen ist das keine
|
|
||||||
// Nebensache mehr, sondern die erste Frage bei jeder
|
|
||||||
// Rückmeldung.
|
|
||||||
LabeledContent("settings.serverKind", value: kindLabel)
|
|
||||||
if let connection = session.server?.connection {
|
|
||||||
LabeledContent("settings.connection", value: connectionLabel(connection))
|
|
||||||
}
|
|
||||||
if let address = session.serverAddress {
|
|
||||||
LabeledContent("settings.address", value: address)
|
|
||||||
.font(.flixyCaption)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
|
||||||
if let accountName = session.accountName {
|
|
||||||
LabeledContent("settings.account", value: accountName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ForEach(session.libraries) { library in
|
NavigationLink {
|
||||||
LibraryRow(library: library)
|
ServerSettingsView()
|
||||||
}
|
} label: {
|
||||||
} header: {
|
LabeledContent {
|
||||||
Text("settings.libraries")
|
Text(session.serverName)
|
||||||
} footer: {
|
.foregroundStyle(Color.flixyTextSecondary)
|
||||||
// Der Suchlauf stand vorher im Dreipunktmenü der Mediathek
|
} label: {
|
||||||
// — einen Tipp neben „Filter" und „Ansicht wechseln". Er
|
Label("settings.server", systemImage: "externaldrive.connected.to.line.below")
|
||||||
// beschäftigt den Server minutenlang und gehört nicht in
|
}
|
||||||
// eine Reihe mit Dingen, die man im Vorbeigehen antippt.
|
|
||||||
if session.backend?.capabilities.contains(.libraryScan) == true {
|
|
||||||
Text("settings.scanExplanation")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("settings.section.look") {
|
||||||
|
row("appearance.title", symbol: "paintpalette") { AppearanceView() }
|
||||||
|
row("homeLayout.title", symbol: "rectangle.stack") { HomeLayoutView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("settings.section.playing") {
|
||||||
|
row("settings.playback", symbol: "play.rectangle") { PlaybackSettingsView() }
|
||||||
|
row("languages.title", symbol: "captions.bubble") { LanguagesView() }
|
||||||
|
row("sidebar.downloads", symbol: "arrow.down.circle") { DownloadSettingsView() }
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("settings.qualityWifi", selection: Bindable(settings).wifiQuality) {
|
row("diagnostics.title", symbol: "stethoscope") { DiagnosticsView() }
|
||||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
|
||||||
Text(quality.label).tag(quality)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Picker("settings.qualityCellular", selection: Bindable(settings).cellularQuality) {
|
|
||||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
|
||||||
Text(quality.label).tag(quality)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("settings.quality")
|
|
||||||
} footer: {
|
|
||||||
Text("settings.qualityExplanation")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("settings.playback") {
|
|
||||||
Toggle("settings.autoSkipIntro", isOn: Bindable(settings).autoSkipIntro)
|
|
||||||
Toggle("settings.autoSkipCredits", isOn: Bindable(settings).autoSkipCredits)
|
|
||||||
Toggle("settings.autoPlayNext", isOn: Bindable(settings).autoPlayNext)
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
NavigationLink {
|
|
||||||
AppearanceView()
|
|
||||||
} label: {
|
|
||||||
Label("appearance.title", systemImage: "paintpalette")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink {
|
|
||||||
LanguagesView()
|
|
||||||
} label: {
|
|
||||||
Label("languages.title", systemImage: "captions.bubble")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink {
|
|
||||||
HomeLayoutView()
|
|
||||||
} label: {
|
|
||||||
Label("homeLayout.title", systemImage: "rectangle.stack")
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink {
|
|
||||||
DiagnosticsView()
|
|
||||||
} label: {
|
|
||||||
Label("diagnostics.title", systemImage: "stethoscope")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadSection
|
|
||||||
|
|
||||||
Section("settings.app") {
|
Section("settings.app") {
|
||||||
LabeledContent("settings.version", value: Branding.version)
|
LabeledContent("settings.version", value: Branding.version)
|
||||||
LabeledContent("settings.build", value: Branding.buildStamp)
|
LabeledContent("settings.build", value: Branding.buildStamp)
|
||||||
@@ -141,174 +80,15 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Download-Einstellungen.
|
private func row<Destination: View>(
|
||||||
///
|
_ title: LocalizedStringKey,
|
||||||
/// Die Löschfrist steht bewusst mit ausgeschriebenen Fristen da statt mit
|
symbol: String,
|
||||||
/// einem Schieberegler: „Nach 7 Tagen" ist eine Entscheidung, die man
|
@ViewBuilder destination: @escaping () -> Destination
|
||||||
/// bewusst trifft — beim Löschen von Filmen soll niemand versehentlich
|
) -> some View {
|
||||||
/// auf „sofort" rutschen.
|
NavigationLink {
|
||||||
@ViewBuilder
|
destination()
|
||||||
private var downloadSection: some View {
|
|
||||||
@Bindable var downloads = downloads
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Picker("settings.storageLimit", selection: storageLimitBinding) {
|
|
||||||
Text("settings.storageUnlimited").tag(Int64(0))
|
|
||||||
ForEach([Int64(5), 10, 20, 30, 50, 100], id: \.self) { gigabytes in
|
|
||||||
Text("\(gigabytes) GB").tag(gigabytes * 1_000_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Picker("settings.deleteWatched", selection: deleteAfterBinding) {
|
|
||||||
Text("settings.deleteNever").tag(Double(-1))
|
|
||||||
Text("settings.deleteImmediately").tag(Double(0))
|
|
||||||
Text("settings.deleteAfterDay").tag(Duration.afterOneDay.seconds)
|
|
||||||
Text("settings.deleteAfterWeek").tag(Duration.afterOneWeek.seconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
Picker("settings.keepUnwatched", selection: keepUnwatchedBinding) {
|
|
||||||
Text("settings.keepOff").tag(0)
|
|
||||||
ForEach([1, 2, 3, 5], id: \.self) { count in
|
|
||||||
Text("settings.keepCount \(count)").tag(count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
|
|
||||||
} header: {
|
|
||||||
Text("sidebar.downloads")
|
|
||||||
} footer: {
|
|
||||||
Text("settings.downloadsExplanation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var storageLimitBinding: Binding<Int64> {
|
|
||||||
Binding(
|
|
||||||
get: { downloads.storageLimitBytes ?? 0 },
|
|
||||||
set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var deleteAfterBinding: Binding<Double> {
|
|
||||||
Binding(
|
|
||||||
get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 },
|
|
||||||
set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var keepUnwatchedBinding: Binding<Int> {
|
|
||||||
Binding(
|
|
||||||
get: { downloads.rules.keepUnwatchedEpisodes ?? 0 },
|
|
||||||
set: { downloads.rules.keepUnwatchedEpisodes = $0 > 0 ? $0 : nil }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
|
|
||||||
/// Ruckeln ist das die erste Frage.
|
|
||||||
/// Welche Serverart gerade bedient wird.
|
|
||||||
private var kindLabel: String {
|
|
||||||
switch session.kind {
|
|
||||||
case .plex: "Plex"
|
|
||||||
case .jellyfin: "Jellyfin"
|
|
||||||
case nil: "—"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectionLabel(_ connection: PlexConnection) -> String {
|
|
||||||
if connection.local { return String(localized: "settings.connectionLocal") }
|
|
||||||
if connection.relay { return String(localized: "settings.connectionRelay") }
|
|
||||||
return String(localized: "settings.connectionRemote")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PlaybackSettings.Quality {
|
|
||||||
var label: LocalizedStringKey {
|
|
||||||
switch self {
|
|
||||||
case .original: "quality.original"
|
|
||||||
case .high: "quality.high"
|
|
||||||
case .medium: "quality.medium"
|
|
||||||
case .low: "quality.low"
|
|
||||||
case .minimal: "quality.minimal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension MediaLibrary.Kind {
|
|
||||||
/// Wie diese Art von Mediathek heißt.
|
|
||||||
///
|
|
||||||
/// Übersetzt statt durchgereicht: die Server liefern „movies" und
|
|
||||||
/// „tvshows", und das großgeschrieben anzuzeigen war eine englische
|
|
||||||
/// Beschriftung in einer deutschen Oberfläche.
|
|
||||||
var label: String {
|
|
||||||
switch self {
|
|
||||||
case .movie: String(localized: "library.kind.movie")
|
|
||||||
case .show: String(localized: "library.kind.show")
|
|
||||||
case .music: String(localized: "library.kind.music")
|
|
||||||
case .photo: String(localized: "library.kind.photo")
|
|
||||||
case .liveTV: String(localized: "library.kind.liveTV")
|
|
||||||
case .other: String(localized: "library.kind.other")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben.
|
|
||||||
private struct LibraryRow: View {
|
|
||||||
let library: MediaLibrary
|
|
||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
|
||||||
@Environment(\.flixyAccent) private var accent
|
|
||||||
@State private var phase: Phase = .idle
|
|
||||||
|
|
||||||
private enum Phase { case idle, running, done }
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
LabeledContent {
|
|
||||||
HStack(spacing: FlixySpacing.regular) {
|
|
||||||
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
|
|
||||||
// „Filme" stand sonst als „Filme — Filme" da.
|
|
||||||
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
|
|
||||||
Text(library.kind.label)
|
|
||||||
.foregroundStyle(Color.flixyTextSecondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if session.backend?.capabilities.contains(.libraryScan) == true {
|
|
||||||
Button {
|
|
||||||
Task { await scan() }
|
|
||||||
} label: {
|
|
||||||
symbol
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
.disabled(phase == .running)
|
|
||||||
.accessibilityLabel(Text("library.scan"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Text(library.title)
|
Label(title, systemImage: symbol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var symbol: some View {
|
|
||||||
switch phase {
|
|
||||||
case .idle:
|
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
|
||||||
case .running:
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
case .done:
|
|
||||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stößt einen Suchlauf auf dem Server an.
|
|
||||||
///
|
|
||||||
/// Der Server meldet nicht, wann er fertig ist. Deshalb steht hier kein
|
|
||||||
/// Fortschritt, sondern nur ein Haken dafür, dass der Auftrag angekommen
|
|
||||||
/// ist — ein Balken, der etwas anderes behauptet, wäre erfunden.
|
|
||||||
private func scan() async {
|
|
||||||
guard let backend = session.backend, phase != .running else { return }
|
|
||||||
phase = .running
|
|
||||||
try? await backend.refreshLibrary(library)
|
|
||||||
phase = .done
|
|
||||||
try? await Task.sleep(for: .seconds(4))
|
|
||||||
phase = .idle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ public final class DownloadCoordinator {
|
|||||||
/// Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden teilt
|
/// Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden teilt
|
||||||
/// die Bandbreite so auf, dass keine Folge fertig wird — und unterwegs ist
|
/// die Bandbreite so auf, dass keine Folge fertig wird — und unterwegs ist
|
||||||
/// eine fertige Folge mehr wert als zehn halbe.
|
/// eine fertige Folge mehr wert als zehn halbe.
|
||||||
public var maximumConcurrentDownloads = 2
|
public var maximumConcurrentDownloads: Int {
|
||||||
|
didSet { defaults.set(maximumConcurrentDownloads, forKey: Key.maximumConcurrent) }
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Regeln fürs automatische Löschen und Nachladen.
|
/// Die Regeln fürs automatische Löschen und Nachladen.
|
||||||
public var rules: DownloadRules {
|
public var rules: DownloadRules {
|
||||||
@@ -110,6 +112,7 @@ public final class DownloadCoordinator {
|
|||||||
static let storageLimit = "flixy.downloads.storageLimit"
|
static let storageLimit = "flixy.downloads.storageLimit"
|
||||||
static let deleteAfter = "flixy.downloads.deleteWatchedAfter"
|
static let deleteAfter = "flixy.downloads.deleteWatchedAfter"
|
||||||
static let keepUnwatched = "flixy.downloads.keepUnwatched"
|
static let keepUnwatched = "flixy.downloads.keepUnwatched"
|
||||||
|
static let maximumConcurrent = "flixy.downloads.maximumConcurrent"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Container, nicht nur der Kontext.
|
/// Der Container, nicht nur der Kontext.
|
||||||
@@ -176,6 +179,9 @@ public final class DownloadCoordinator {
|
|||||||
self.fileStore = fileStore
|
self.fileStore = fileStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.isWiFiOnly = defaults.object(forKey: Key.wifiOnly) as? Bool ?? true
|
self.isWiFiOnly = defaults.object(forKey: Key.wifiOnly) as? Bool ?? true
|
||||||
|
// Zwei statt aller auf einmal: eine ganze Staffel parallel zu laden
|
||||||
|
// teilt die Bandbreite so auf, dass keine Folge fertig wird.
|
||||||
|
self.maximumConcurrentDownloads = defaults.object(forKey: Key.maximumConcurrent) as? Int ?? 2
|
||||||
let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0
|
let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0
|
||||||
self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil
|
self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,44 @@ public enum PlayerQuality: Hashable, Sendable, Identifiable {
|
|||||||
Rung(bitrate: 64, height: 160),
|
Rung(bitrate: 64, height: 160),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// Eine Zahl, die sich aufbewahren lässt.
|
||||||
|
///
|
||||||
|
/// Über mehrere Filme hinweg gilt nur die **Absicht** — „ungefähr so viel
|
||||||
|
/// Bitrate" —, nicht die genaue Sprosse: welche Sprossen es gibt, hängt
|
||||||
|
/// von der Quelle ab. Deshalb eine Zahl und kein Aufzählungswert.
|
||||||
|
public var storageValue: Int {
|
||||||
|
switch self {
|
||||||
|
case .automatic: 0
|
||||||
|
case .original: -1
|
||||||
|
case .audioOnly: -2
|
||||||
|
case .limited(let rung): rung.bitrate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Stufe zu einer aufbewahrten Zahl, so nah wie möglich.
|
||||||
|
///
|
||||||
|
/// Gibt es die Sprosse für diese Quelle nicht, wird die nächstgelegene
|
||||||
|
/// genommen — eine 720p-Datei kennt keine 20-Megabit-Stufe, und dann
|
||||||
|
/// stillschweigend auf „automatisch" zurückzufallen wäre eine andere
|
||||||
|
/// Antwort als die, um die gebeten wurde.
|
||||||
|
public static func restored(from value: Int, forSourceHeight height: Int?) -> PlayerQuality? {
|
||||||
|
switch value {
|
||||||
|
case 0: return nil
|
||||||
|
case -1: return .original
|
||||||
|
case -2: return .audioOnly
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
|
let rungs = available(forSourceHeight: height).compactMap { quality -> Rung? in
|
||||||
|
if case .limited(let rung) = quality { return rung }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let closest = rungs.min(by: {
|
||||||
|
abs($0.bitrate - value) < abs($1.bitrate - value)
|
||||||
|
}) else { return nil }
|
||||||
|
return .limited(closest)
|
||||||
|
}
|
||||||
|
|
||||||
public static var allCases: [PlayerQuality] {
|
public static var allCases: [PlayerQuality] {
|
||||||
[.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly]
|
[.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,17 +291,50 @@ public struct JellyfinBackend: MediaBackend {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eine verkleinerte Fassung zum Herunterladen.
|
||||||
|
///
|
||||||
|
/// **Fortlaufendes MP4, nicht HLS.** Zum Abspielen liefert Jellyfin eine
|
||||||
|
/// Abspielliste mit hunderten Schnipseln; das ist für einen Strom richtig
|
||||||
|
/// und für eine Datei, die auf dem Gerät liegen soll, unbrauchbar. Der
|
||||||
|
/// Endpunkt `/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als **eine**
|
||||||
|
/// Datei heraus.
|
||||||
|
///
|
||||||
|
/// Hier wird die Adresse selbst gebaut statt über `/PlaybackInfo` erfragt —
|
||||||
|
/// anders als beim Abspielen. Der Grund ist die Signatur: diese Stelle darf
|
||||||
|
/// keine Anfrage stellen, weil die Oberfläche die Adresse beim Zeichnen
|
||||||
|
/// braucht. Das ist vertretbar, weil hier nichts zu entscheiden ist: die
|
||||||
|
/// Stufe steht fest, der Server soll sie liefern.
|
||||||
|
///
|
||||||
|
/// Die Größe bleibt eine Schätzung. Eine Umrechnung, die erst beim Abruf
|
||||||
|
/// beginnt, kennt ihre eigene Länge nicht — der Fortschrittsbalken rechnet
|
||||||
|
/// deshalb gegen die geschätzten Bytes, nicht gegen eine Zusage des Servers.
|
||||||
public nonisolated func compressedDownloadURL(
|
public nonisolated func compressedDownloadURL(
|
||||||
itemID: String,
|
itemID: String,
|
||||||
versionIndex: Int,
|
versionIndex: Int,
|
||||||
maximumBitrate: Int,
|
maximumBitrate: Int,
|
||||||
resolution: String?
|
resolution: String?
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
// Jellyfin liefert die Adresse für eine umgerechnete Fassung erst nach
|
var query = [
|
||||||
// einer Anfrage an `/PlaybackInfo` — das geht hier nicht, weil diese
|
URLQueryItem(name: "videoCodec", value: "h264"),
|
||||||
// Methode keine Anfrage stellen darf. `nil` heißt: nur Original
|
URLQueryItem(name: "audioCodec", value: "aac"),
|
||||||
// anbieten, statt eine Adresse zu erfinden, die nicht antwortet.
|
// Jellyfin rechnet in Bit, die App in Kilobit.
|
||||||
nil
|
URLQueryItem(name: "videoBitRate", value: String(maximumBitrate * 1_000)),
|
||||||
|
URLQueryItem(name: "audioBitRate", value: "128000"),
|
||||||
|
// Zwei Kanäle: ein Handy hat keine Rückkanäle, und 5.1 auf zwei
|
||||||
|
// Lautsprecher heruntergemischt klingt schlechter als von
|
||||||
|
// vornherein Stereo.
|
||||||
|
URLQueryItem(name: "TranscodingMaxAudioChannels", value: "2"),
|
||||||
|
URLQueryItem(name: "TranscodingContainer", value: "mp4"),
|
||||||
|
]
|
||||||
|
if let height = resolution?.split(separator: "x").last.flatMap({ Int($0) }) {
|
||||||
|
query.append(URLQueryItem(name: "maxHeight", value: String(height)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.url(
|
||||||
|
path: "/Videos/\(itemID)/stream.mp4",
|
||||||
|
query: query,
|
||||||
|
includingToken: account.token
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Zustand melden
|
// MARK: - Zustand melden
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import FlixyPlayback
|
||||||
|
|
||||||
|
/// Die gemerkte Qualitätsstufe über mehrere Filme hinweg.
|
||||||
|
///
|
||||||
|
/// Aufbewahrt wird eine Zahl, keine Sprosse: welche Sprossen es gibt, hängt
|
||||||
|
/// von der Quelle ab. Eine 720p-Datei kennt keine Zwanzig-Megabit-Stufe — und
|
||||||
|
/// dann stillschweigend auf „automatisch" zurückzufallen wäre eine andere
|
||||||
|
/// Antwort als die, um die gebeten wurde.
|
||||||
|
@Suite("Gemerkte Qualität")
|
||||||
|
struct QualityMemoryTests {
|
||||||
|
|
||||||
|
@Test("Null heißt: nichts gemerkt")
|
||||||
|
func returnsNilForAutomatic() {
|
||||||
|
#expect(PlayerQuality.automatic.storageValue == 0)
|
||||||
|
#expect(PlayerQuality.restored(from: 0, forSourceHeight: 1080) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Original und Nur-Ton kommen unverändert zurück")
|
||||||
|
func restoresSpecialCases() {
|
||||||
|
#expect(PlayerQuality.restored(from: PlayerQuality.original.storageValue,
|
||||||
|
forSourceHeight: 1080) == .original)
|
||||||
|
#expect(PlayerQuality.restored(from: PlayerQuality.audioOnly.storageValue,
|
||||||
|
forSourceHeight: 1080) == .audioOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine vorhandene Sprosse kommt genau zurück")
|
||||||
|
func restoresExactRung() {
|
||||||
|
let chosen = PlayerQuality.limited(.init(bitrate: 3_000, height: 720))
|
||||||
|
|
||||||
|
let restored = PlayerQuality.restored(from: chosen.storageValue, forSourceHeight: 1080)
|
||||||
|
|
||||||
|
#expect(restored == chosen)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Gibt es die Sprosse nicht, kommt die nächstgelegene")
|
||||||
|
func fallsBackToClosestRung() {
|
||||||
|
// Zwanzig Megabit gibt es nur für 1080p. Bei einer 480p-Quelle ist die
|
||||||
|
// beste erreichbare Stufe eine andere — aber nicht „gar keine".
|
||||||
|
let restored = PlayerQuality.restored(from: 20_000, forSourceHeight: 480)
|
||||||
|
|
||||||
|
guard case .limited(let rung) = restored else {
|
||||||
|
Issue.record("keine Stufe zurückbekommen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(rung.height <= 480)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,27 @@ struct PlaybackTests {
|
|||||||
#expect(fields["IsPaused"] as? Bool == false)
|
#expect(fields["IsPaused"] as? Bool == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Verkleinert herunterladen ergibt eine einzelne Datei")
|
||||||
|
func buildsCompressedDownloadURL() throws {
|
||||||
|
let url = try #require(backend(RecordingTransport()).compressedDownloadURL(
|
||||||
|
itemID: "film-1",
|
||||||
|
versionIndex: 0,
|
||||||
|
maximumBitrate: 4_000,
|
||||||
|
resolution: "1280x720"
|
||||||
|
))
|
||||||
|
|
||||||
|
// `.mp4`, nicht `.m3u8`: eine Abspielliste mit hunderten Schnipseln
|
||||||
|
// lässt sich nicht als Datei ablegen.
|
||||||
|
#expect(url.path == "/Videos/film-1/stream.mp4")
|
||||||
|
|
||||||
|
let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
|
||||||
|
// In Bit, nicht in Kilobit — eine Null zu wenig, und aus vier Megabit
|
||||||
|
// werden vier Kilobit.
|
||||||
|
#expect(query.contains(URLQueryItem(name: "videoBitRate", value: "4000000")))
|
||||||
|
#expect(query.contains(URLQueryItem(name: "maxHeight", value: "720")))
|
||||||
|
#expect(query.contains(URLQueryItem(name: "ApiKey", value: "tok")))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Der erste Bericht legt die Sitzung an")
|
@Test("Der erste Bericht legt die Sitzung an")
|
||||||
func reportsStartSeparately() async throws {
|
func reportsStartSeparately() async throws {
|
||||||
let transport = RecordingTransport()
|
let transport = RecordingTransport()
|
||||||
|
|||||||
Reference in New Issue
Block a user