Compare commits
191
Commits
a7cf6c5def
...
main
@@ -0,0 +1,26 @@
|
|||||||
|
# Der Bau-Kontext ist die Wurzel des Projekts, seit die Oberfläche
|
||||||
|
# mitgebaut wird. Diese Datei gehört deshalb hierher und nicht nach server/ —
|
||||||
|
# Docker sucht sie neben dem Kontext, nicht neben dem Dockerfile.
|
||||||
|
|
||||||
|
# **Der Grund für diese Datei.** Ohne sie überschreibt `COPY server/ ./server/`
|
||||||
|
# die obj/-Verzeichnisse, die `dotnet restore` im Container gerade angelegt
|
||||||
|
# hat, mit denen vom Arbeitsrechner. Die zeigen auf osx-arm64, der Bau will
|
||||||
|
# linux-arm64, und `dotnet publish` bricht mit NETSDK1047 ab.
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/*.user
|
||||||
|
|
||||||
|
# Gebaute Oberfläche und ihre Abhängigkeiten: der web-Abschnitt baut beides neu.
|
||||||
|
**/node_modules/
|
||||||
|
web/app/dist/
|
||||||
|
|
||||||
|
# Laufzeitdaten eines lokalen Starts.
|
||||||
|
server/data/
|
||||||
|
server/cache/
|
||||||
|
server/transcode/
|
||||||
|
|
||||||
|
# Was im Server-Image nichts verloren hat.
|
||||||
|
.git/
|
||||||
|
.build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcodeproj/
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Baut das Flaro-Image und legt es in die Registry dieses Gitea.
|
||||||
|
#
|
||||||
|
# **Zur Zeit schläft diese Datei.** Gebaut wird auf dem Arbeitsrechner mit
|
||||||
|
# `Tools/image-veroeffentlichen.sh` — der Rechner ist für die Apps ohnehin
|
||||||
|
# nötig, weil Xcode nirgendwo sonst läuft.
|
||||||
|
#
|
||||||
|
# **Weil Gitea nicht selbst baut.** Es verteilt Arbeit an einen Act-Runner,
|
||||||
|
# und der müsste auf dem NAS laufen. Dort scheitert er: sein Zweck ist das
|
||||||
|
# Bauen von Images, also braucht er Zugriff auf Docker, und die Docker-App
|
||||||
|
# des UGREEN lehnt den Einhängepunkt `/var/run/docker.sock` ab. Übrig
|
||||||
|
# blieben `privileged: true` oder der Weg über SSH an der Oberfläche vorbei
|
||||||
|
# — beides gibt dem Runner faktisch Root auf dem NAS.
|
||||||
|
#
|
||||||
|
# **Deshalb nur `workflow_dispatch`.** Mit einem Push-Auslöser legt Gitea
|
||||||
|
# bei jedem Push einen Lauf an, den niemand abarbeitet; nach einer Woche
|
||||||
|
# steht die Actions-Seite voll mit Leichen. Kommt irgendwann ein Runner
|
||||||
|
# dazu, lässt sich der Lauf von Hand starten — und der Auslöser unten ist
|
||||||
|
# mit drei Zeilen wieder da.
|
||||||
|
|
||||||
|
name: Image bauen und veröffentlichen
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Wieder automatisch, sobald ein Runner existiert:
|
||||||
|
# push:
|
||||||
|
# branches: [main]
|
||||||
|
# tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Quelltext holen
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# **buildx, nicht das eingebaute `docker build`.** Nur damit lassen
|
||||||
|
# sich zwei Architekturen in einem Manifest ablegen; ohne das bekommt
|
||||||
|
# ein Intel-NAS ein arm64-Image und meldet „exec format error".
|
||||||
|
- name: buildx vorbereiten
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: QEMU für die fremde Architektur
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: An der Registry anmelden
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY || 'git.scarriffle.com' }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: Marken bestimmen
|
||||||
|
id: marken
|
||||||
|
run: |
|
||||||
|
REGISTRY="${{ vars.REGISTRY || 'git.scarriffle.com' }}"
|
||||||
|
# **Der Name des Pakets, nicht der des Repos.** Gitea legt Pakete
|
||||||
|
# am Besitzer ab, nicht am Repository — sie dürfen deshalb anders
|
||||||
|
# heissen. Hier ist das nötig: das Repo heisst noch „halo", das
|
||||||
|
# Programm aber Flaro, und ein Image namens `halo` wäre die eine
|
||||||
|
# Stelle, an der der alte Name überlebt.
|
||||||
|
#
|
||||||
|
# Kleinbuchstaben, weil die Registry nichts anderes annimmt.
|
||||||
|
BESITZER=$(echo "${{ gitea.repository_owner }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
BILD="$REGISTRY/$BESITZER/${{ vars.IMAGE_NAME || 'flaro' }}"
|
||||||
|
|
||||||
|
MARKEN="$BILD:latest,$BILD:${GITHUB_SHA::7}"
|
||||||
|
# Auf einem Git-Tag zusätzlich dessen Namen — damit sich eine
|
||||||
|
# Fassung festnageln lässt, statt immer „latest" zu ziehen.
|
||||||
|
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||||
|
MARKEN="$MARKEN,$BILD:${GITHUB_REF_NAME#v}"
|
||||||
|
fi
|
||||||
|
echo "marken=$MARKEN" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Gebaut wird: $MARKEN"
|
||||||
|
|
||||||
|
- name: Bauen und hochladen
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: server/Dockerfile
|
||||||
|
# **Beide Architekturen.** amd64 für die Intel-NAS, arm64 für
|
||||||
|
# ARM-Geräte und für den Apple-Rechner.
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.marken.outputs.marken }}
|
||||||
|
# Zwischenergebnisse behalten: ohne das lädt jeder Lauf alle
|
||||||
|
# Pakete neu, und ein Bau dauert statt zwei Minuten zwölf.
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m11.0721 5.13152c.3321-.84203 1.5237-.84203 1.8558 0l1.6803 4.26031 4.2603 1.68027c.842.3321.842 1.5237 0 1.8558l-4.2603 1.6803-1.6803 4.2603c-.3321.842-1.5237.842-1.8558 0l-1.68027-4.2603-4.26031-1.6803c-.84203-.3321-.84203-1.5237 0-1.8558l4.26031-1.68027zm.9279 1.73562-1.2928 3.27806c-.1014.2571-.3049.4606-.562.562l-3.27806 1.2928 3.27806 1.2928c.2571.1014.4606.3049.562.562l1.2928 3.2781 1.2928-3.2781c.1014-.2571.3049-.4606.562-.562l3.2781-1.2928-3.2781-1.2928c-.2571-.1014-.4606-.3049-.562-.562z" fill="rgb(0,0,0)" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 673 B |
@@ -1 +0,0 @@
|
|||||||
<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-name="Layer 1"><linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="-52.588" x2="390.826" y1="178.158" y2="434.163"><stop offset="0" stop-color="#55d6ff"/><stop offset="1" stop-color="#ce55ff"/></linearGradient><path d="m485.036 256a54.706 54.706 0 0 1 -27.636 47.868l-347.527 200.644a54.742 54.742 0 0 1 -55.272 0 54.713 54.713 0 0 1 -27.637-47.868v-401.288a54.718 54.718 0 0 1 27.636-47.869 54.75 54.75 0 0 1 55.273 0l347.527 200.645a54.708 54.708 0 0 1 27.636 47.868z" fill="url(#linear-gradient)"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 675 B |
@@ -2,7 +2,7 @@
|
|||||||
"fill-specializations" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"value" : {
|
"value" : {
|
||||||
"automatic-gradient" : "display-p3:0.44346,0.27892,0.96783,1.00000"
|
"automatic-gradient" : "display-p3:0.56409,0.38672,0.96783,1.00000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -17,15 +17,15 @@
|
|||||||
"layers" : [
|
"layers" : [
|
||||||
{
|
{
|
||||||
"fill" : {
|
"fill" : {
|
||||||
"automatic-gradient" : "display-p3:0.73918,0.46760,0.96783,1.00000"
|
"automatic-gradient" : "display-p3:0.51566,0.11264,0.96783,1.00000"
|
||||||
},
|
},
|
||||||
"image-name" : "play.svg",
|
"image-name" : "flare (1).svg",
|
||||||
"name" : "play",
|
"name" : "flare (1)",
|
||||||
"position" : {
|
"position" : {
|
||||||
"scale" : 1.18,
|
"scale" : 100,
|
||||||
"translation-in-points" : [
|
"translation-in-points" : [
|
||||||
49.7265625,
|
0,
|
||||||
1.468530916781674e-05
|
0
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import UIKit
|
|||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
import FlaroDownloads
|
import FlaroDownloads
|
||||||
import FlaroPlayback
|
import FlaroPlayback
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
@@ -35,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
|
||||||
@@ -45,6 +49,7 @@ struct FlaroApp: App {
|
|||||||
client: Branding.appName,
|
client: Branding.appName,
|
||||||
version: Branding.version
|
version: Branding.version
|
||||||
),
|
),
|
||||||
|
flaroIdentity: FlaroIdentity.current(),
|
||||||
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -90,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.
|
||||||
//
|
//
|
||||||
@@ -115,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)
|
||||||
|
|||||||
@@ -25,6 +25,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%@: %@": {
|
||||||
|
"comment": "A label that shows the audio or subtitle language. The argument is the language name.",
|
||||||
|
"isCommentAutoGenerated": true,
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "new",
|
||||||
|
"value": "%1$@: %2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"4K": {
|
"4K": {
|
||||||
"comment": "A label for a 4K video.",
|
"comment": "A label for a 4K video.",
|
||||||
"isCommentAutoGenerated": true
|
"isCommentAutoGenerated": true
|
||||||
@@ -108,6 +120,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"action.back": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zurück"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"action.cancel": {
|
"action.cancel": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -1224,6 +1253,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"downloads.episodeCount %lld": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld Folgen geladen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "%lld episodes downloaded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloads.failed": {
|
"downloads.failed": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -1705,7 +1750,7 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Staffel %lld · Episode %lld"
|
"value": "Staffel %lld · Folge %lld"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
@@ -1869,6 +1914,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filter.downloadedOnly": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nur Heruntergeladene"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Downloaded only"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"filter.filters": {
|
"filter.filters": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -2105,6 +2167,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"home.downloaded": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Heruntergeladen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Downloaded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"home.empty.description": {
|
"home.empty.description": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2322,6 +2400,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.rowsHeader": {
|
"home.rowsHeader": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2451,6 +2545,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.audioShort": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ton"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Audio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.avoidAudioDescription": {
|
"languages.avoidAudioDescription": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -2604,6 +2715,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.forThisTitle": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Tonsprache für diesen Titel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Audio language for this title"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.headerExplanation": {
|
"languages.headerExplanation": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -2638,6 +2766,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.none": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Keine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "None"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"languages.partial %@": {
|
||||||
|
"comment": "Eine kleine Schriftart, die",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
},
|
||||||
|
"languages.partial %lld": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nur Sprachen, die in jeder Folge vorkommen. %lld weitere gibt es nur in einem Teil."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Only languages present in every episode. %lld more exist in some episodes only."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.serverDefault": {
|
"languages.serverDefault": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2687,6 +2853,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.subtitlesForThisSeries": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Untertitel für diese Serie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Subtitles for this series"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"languages.subtitlesForThisTitle": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Untertitel für diesen Titel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Subtitles for this title"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.subtitlesHeader": {
|
"languages.subtitlesHeader": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -2719,6 +2919,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"languages.subtitlesShort": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Untertitel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Subtitles"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"languages.title": {
|
"languages.title": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -3854,23 +4071,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.jellyfinPending": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Die Wiedergabe von Jellyfin ist noch nicht fertig — sie ist der nächste Schritt."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Playback from Jellyfin isn't finished yet — it's the next step."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"player.loadFailed": {
|
"player.loadFailed": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -3905,7 +4105,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.nextEpisode": {
|
"player.nextEpisode": {
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
@@ -4041,6 +4240,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"player.previousEpisode": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Vorige Folge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Previous episode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"player.quality": {
|
"player.quality": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -5039,6 +5254,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"season.short %@": {
|
||||||
|
"comment": "A label that shows the number of the selected season.",
|
||||||
|
"isCommentAutoGenerated": true
|
||||||
|
},
|
||||||
"season.short %lld": {
|
"season.short %lld": {
|
||||||
"comment": "Platzhalter: Staffelnummer",
|
"comment": "Platzhalter: Staffelnummer",
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
@@ -5912,6 +6131,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.skipExtraScenes": {
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Zusätzliche Szenen mit überspringen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Skip extra scenes too"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.storageLimit": {
|
"settings.storageLimit": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -6254,6 +6489,312 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"signIn.flaro.addressHint": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Schema und Port darfst du weglassen — Flaro probiert die üblichen durch."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "You can leave out the scheme and port — Flaro tries the usual ones."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.addressMissing": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Trag eine Adresse ein."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Enter an address."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.addressPlaceholder": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "z. B. 192.168.1.50 oder medien.example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "e.g. 192.168.1.50 or media.example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.addressPrompt": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wo steht dein Server?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Where is your server?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.changeAddress": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Andere Adresse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Change address"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.codeExpired": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Code ist abgelaufen. Fordere einen neuen an."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The code has expired. Request a new one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.codeHint": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Öffne Flaro auf deinem Telefon und gib diesen Code ein."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Open Flaro on your phone and enter this code."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.credentialsMissing": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Benutzername und Passwort fehlen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Username and password are missing."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.findServer": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Server suchen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Find server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.needsSetup": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Dieser Server ist noch nicht eingerichtet. Öffne ihn im Browser und lege ein Konto an."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "This server isn't set up yet. Open it in a browser and create an account."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.notFound": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Unter dieser Adresse antwortet kein Flaro-Server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No Flaro server answers at this address."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.searching": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Server wird gesucht …"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Looking for the server…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.signIn": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Anmelden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Sign in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.signingIn": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Wird angemeldet …"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Signing in…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.title": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Flaro-Server"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Flaro Server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.useCode": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Stattdessen Code"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Use a code instead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.usePassword": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Stattdessen Passwort"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Use a password instead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.flaro.waitingForApproval": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Warte auf Bestätigung"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Waiting for approval"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"signIn.jellyfin.addressHint": {
|
"signIn.jellyfin.addressHint": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -6403,23 +6944,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signIn.jellyfin.password": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Passwort"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"signIn.jellyfin.passwordNote": {
|
"signIn.jellyfin.passwordNote": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -6568,23 +7092,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"signIn.jellyfin.userName": {
|
|
||||||
"extractionState": "stale",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Benutzername"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Username"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"signIn.jellyfin.waitingForApproval": {
|
"signIn.jellyfin.waitingForApproval": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -6601,6 +7108,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"signIn.password": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Passwort"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"signIn.privacy": {
|
"signIn.privacy": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -6634,6 +7158,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"signIn.userName": {
|
||||||
|
"extractionState": "stale",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Benutzername"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Username"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"signIn.withFlaro": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Mit Flaro verbinden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Connect to Flaro"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"signIn.withJellyfin": {
|
"signIn.withJellyfin": {
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
@@ -7514,6 +8072,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"upcoming.from %@": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Ab %@ Uhr"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "From %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"upcoming.title": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Bald ist es soweit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Coming soon"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.1"
|
"version": "1.1"
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ struct DownloadService {
|
|||||||
// hinterher niemanden mehr, der das nachholen könnte — und seit es
|
// hinterher niemanden mehr, der das nachholen könnte — und seit es
|
||||||
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
|
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
|
||||||
// schwarz. Also wird schon beim Herunterladen umgepackt.
|
// schwarz. Also wird schon beim Herunterladen umgepackt.
|
||||||
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
|
let umpacken = quality == .original && !kannAlleSpuren
|
||||||
|
&& !systemPlayerOpens(media: media, part: part)
|
||||||
|
|
||||||
let url: URL?
|
let url: URL?
|
||||||
switch quality {
|
switch quality {
|
||||||
@@ -195,9 +196,76 @@ struct DownloadService {
|
|||||||
quality: DownloadQuality = .original,
|
quality: DownloadQuality = .original,
|
||||||
audioStream: MediaStream? = nil
|
audioStream: MediaStream? = nil
|
||||||
) throws(DownloadRefusal) -> DownloadItem {
|
) throws(DownloadRefusal) -> DownloadItem {
|
||||||
try coordinator.enqueue(
|
let eintrag = try coordinator.enqueue(
|
||||||
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
|
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
|
||||||
)
|
)
|
||||||
|
plakatHolen(for: item)
|
||||||
|
return eintrag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt das Plakat mit herunter.
|
||||||
|
///
|
||||||
|
/// **Ohne Verbindung gibt es keine Adresse mehr, von der eines käme.**
|
||||||
|
/// Ein Download ohne Bild ist offline eine graue Fläche mit einem Titel
|
||||||
|
/// darunter — und genau dann, wenn man etwas zum Ansehen sucht, ist das
|
||||||
|
/// Bild die halbe Auskunft. Ein paar Kilobyte je Eintrag.
|
||||||
|
///
|
||||||
|
/// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den
|
||||||
|
/// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte.
|
||||||
|
func plakatHolen(for item: MediaItem) {
|
||||||
|
if let adresse = backend?.rawImageURL(path: item.thumb) {
|
||||||
|
Task { await coordinator.fetchArtwork(for: item.id, from: adresse) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Und das Plakat der Serie, unter deren Schlüssel.**
|
||||||
|
//
|
||||||
|
// `thumb` ist bei einer Folge ihr Szenenbild — quer, und hochkant
|
||||||
|
// beschnitten zeigt es einen zufälligen Ausschnitt. Die Downloads
|
||||||
|
// stehen aber als Serie in der Reihe, und eine Serienkachel ist ein
|
||||||
|
// Plakat. Ohne diesen zweiten Griff gäbe es für die Serie gar kein
|
||||||
|
// Bild: heruntergeladen wird sie nie, nur ihre Folgen.
|
||||||
|
if let serie = item.seriesID,
|
||||||
|
let pfad = item.seriesThumb ?? item.parentThumb,
|
||||||
|
let adresse = backend?.rawImageURL(path: pfad) {
|
||||||
|
Task { await coordinator.fetchArtwork(for: serie, from: adresse) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt Plakate nach, die früheren Downloads fehlen.
|
||||||
|
///
|
||||||
|
/// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand
|
||||||
|
/// im Modell, gesetzt hat ihn nie jemand — alles, was vorher
|
||||||
|
/// heruntergeladen wurde, hat kein Bild und stünde in der neuen Reihe
|
||||||
|
/// als graue Fläche.
|
||||||
|
///
|
||||||
|
/// Einmal je Serverwechsel, nur für die Einträge ohne Bild; danach
|
||||||
|
/// findet die Schleife nichts mehr.
|
||||||
|
func plakateNachholen() async {
|
||||||
|
guard let backend else { return }
|
||||||
|
for eintrag in coordinator.items where fehltEinBild(eintrag) {
|
||||||
|
guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue }
|
||||||
|
plakatHolen(for: titel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob zu diesem Eintrag noch ein Bild fehlt — seines oder das seiner
|
||||||
|
/// Serie.
|
||||||
|
private func fehltEinBild(_ eintrag: DownloadItem) -> Bool {
|
||||||
|
if coordinator.artworkURL(forRatingKey: eintrag.ratingKey) == nil { return true }
|
||||||
|
if let serie = eintrag.seriesKey,
|
||||||
|
coordinator.artworkURL(forRatingKey: serie) == nil { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
|
||||||
|
///
|
||||||
|
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros
|
||||||
|
/// Download liefert eine MP4 mit jeder Ton- und Textspur, auch wenn dafür
|
||||||
|
/// umgepackt werden muss. Dann entfällt die Spurwahl, und das Umpacken
|
||||||
|
/// mit genau einer Spur entfällt gleich mit — es gäbe nichts zu
|
||||||
|
/// verbessern, nur etwas zu verlieren.
|
||||||
|
private var kannAlleSpuren: Bool {
|
||||||
|
backend?.capabilities.contains(.allTracksDownload) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
|
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
|
||||||
@@ -211,6 +279,12 @@ struct DownloadService {
|
|||||||
/// falsche Weg.
|
/// falsche Weg.
|
||||||
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
|
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
|
||||||
guard quality == .original else { return false }
|
guard quality == .original else { return false }
|
||||||
|
// **Kann der Server alles mitgeben, gibt es nichts zu entscheiden.**
|
||||||
|
// Die Frage ist nur deshalb da, weil Jellyfins Transcoder eine
|
||||||
|
// Tonspur je Sitzung abbildet. Wo das nicht gilt, wäre sie eine
|
||||||
|
// Entscheidung, die der Nutzer treffen soll, obwohl beide Antworten
|
||||||
|
// zum selben Ergebnis führen.
|
||||||
|
guard !kannAlleSpuren else { return false }
|
||||||
guard let media = mediaIndex < item.versions.count
|
guard let media = mediaIndex < item.versions.count
|
||||||
? item.versions[mediaIndex] : item.preferredVersion,
|
? item.versions[mediaIndex] : item.preferredVersion,
|
||||||
let part = media.files.first
|
let part = media.files.first
|
||||||
@@ -276,6 +350,7 @@ struct DownloadService {
|
|||||||
_ = try coordinator.enqueue(
|
_ = try coordinator.enqueue(
|
||||||
try plan(for: detailed, quality: quality, audioStream: ton)
|
try plan(for: detailed, quality: quality, audioStream: ton)
|
||||||
)
|
)
|
||||||
|
plakatHolen(for: detailed)
|
||||||
accepted += 1
|
accepted += 1
|
||||||
} catch DownloadRefusal.noDownloadableFile {
|
} catch DownloadRefusal.noDownloadableFile {
|
||||||
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import Observation
|
import Observation
|
||||||
@@ -31,12 +32,41 @@ final class FlaroSession {
|
|||||||
// stillschweigend als „ungleich" behandelt.
|
// stillschweigend als „ungleich" behandelt.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mit welcher Art Server gesprochen wird.
|
||||||
|
///
|
||||||
|
/// **Diese Unterscheidung gab es schon einmal** — damals für Plex und
|
||||||
|
/// Jellyfin — und sie wurde beim Entfernen von Plex gestrichen, weil ein
|
||||||
|
/// Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt
|
||||||
|
/// es wieder zwei, und es ist wieder eine Unterscheidung.
|
||||||
|
///
|
||||||
|
/// **Einer zur Zeit, nicht beide.** Zwei gleichzeitig hiessen: zwei
|
||||||
|
/// Startseiten, zwei Mediatheken, zwei Suchergebnisse — und bei jedem
|
||||||
|
/// Titel die Frage, woher er stammt.
|
||||||
|
enum ServerKind: String, CaseIterable, Sendable, Identifiable {
|
||||||
|
case jellyfin, flaro
|
||||||
|
var id: String { rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
private enum Key {
|
private enum Key {
|
||||||
|
/// Welche Art zuletzt angemeldet war.
|
||||||
|
///
|
||||||
|
/// Nicht `flaro.backend.kind`: so hiess der Schlüssel zu Plex-Zeiten,
|
||||||
|
/// und der steht in der Aufräumliste weiter unten. Denselben Namen
|
||||||
|
/// mit anderer Bedeutung zu benutzen wäre eine Falle für den
|
||||||
|
/// Nächsten, der die Liste liest.
|
||||||
|
static let activeKind = "flaro.backend.active"
|
||||||
|
|
||||||
static let jellyfinAddress = "flaro.jellyfin.address"
|
static let jellyfinAddress = "flaro.jellyfin.address"
|
||||||
static let jellyfinUserID = "flaro.jellyfin.userID"
|
static let jellyfinUserID = "flaro.jellyfin.userID"
|
||||||
static let jellyfinUserName = "flaro.jellyfin.userName"
|
static let jellyfinUserName = "flaro.jellyfin.userName"
|
||||||
static let jellyfinServerName = "flaro.jellyfin.serverName"
|
static let jellyfinServerName = "flaro.jellyfin.serverName"
|
||||||
static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator"
|
static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator"
|
||||||
|
|
||||||
|
static let flaroAddress = "flaro.flaroServer.address"
|
||||||
|
static let flaroUserID = "flaro.flaroServer.userID"
|
||||||
|
static let flaroUserName = "flaro.flaroServer.userName"
|
||||||
|
static let flaroServerName = "flaro.flaroServer.serverName"
|
||||||
|
static let flaroIsAdministrator = "flaro.flaroServer.isAdministrator"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
|
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
|
||||||
@@ -45,6 +75,7 @@ final class FlaroSession {
|
|||||||
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
|
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
|
||||||
/// später daneben passt, ohne diesen zu verdrängen.
|
/// später daneben passt, ohne diesen zu verdrängen.
|
||||||
private static let jellyfinTokenAccount = "jellyfin.server"
|
private static let jellyfinTokenAccount = "jellyfin.server"
|
||||||
|
private static let flaroTokenAccount = "flaro.server"
|
||||||
|
|
||||||
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
|
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
|
||||||
///
|
///
|
||||||
@@ -90,8 +121,24 @@ final class FlaroSession {
|
|||||||
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
|
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
|
||||||
private(set) var jellyfinAccount: JellyfinAccount?
|
private(set) var jellyfinAccount: JellyfinAccount?
|
||||||
|
|
||||||
|
/// Das angemeldete Flaro-Konto, solange Flaro aktiv ist.
|
||||||
|
private(set) var flaroAccount: FlaroAccount?
|
||||||
|
|
||||||
|
/// Mit welcher Art Server gerade gesprochen wird.
|
||||||
|
private(set) var activeKind: ServerKind?
|
||||||
|
|
||||||
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
||||||
var accountName: String? { jellyfinAccount?.userName }
|
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
|
||||||
|
|
||||||
|
/// Das Profilbild des angemeldeten Kontos, als Adresse.
|
||||||
|
///
|
||||||
|
/// **Nur Flaro hat eines.** Jellyfin gibt kein Profilbild heraus; dort
|
||||||
|
/// bleibt es beim Buchstaben, und das ist keine Lücke, sondern der Stand
|
||||||
|
/// der Dinge.
|
||||||
|
var accountAvatarURL: URL? {
|
||||||
|
guard let schluessel = flaroAccount?.avatarImage else { return nil }
|
||||||
|
return backend?.rawImageURL(path: schluessel)
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
|
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
|
||||||
/// erscheinen gar nicht erst.
|
/// erscheinen gar nicht erst.
|
||||||
@@ -101,7 +148,12 @@ final class FlaroSession {
|
|||||||
var serverName: String { backend?.serverName ?? "" }
|
var serverName: String { backend?.serverName ?? "" }
|
||||||
|
|
||||||
/// Unter welcher Adresse er erreicht wird.
|
/// Unter welcher Adresse er erreicht wird.
|
||||||
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
|
var serverAddress: String? {
|
||||||
|
switch activeKind {
|
||||||
|
case .flaro: defaults.string(forKey: Key.flaroAddress)
|
||||||
|
case .jellyfin, nil: defaults.string(forKey: Key.jellyfinAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Wie die Verbindung zum Server zu beschreiben ist.
|
/// Wie die Verbindung zum Server zu beschreiben ist.
|
||||||
///
|
///
|
||||||
@@ -136,30 +188,28 @@ final class FlaroSession {
|
|||||||
|
|
||||||
/// Warum gerade nicht abgespielt werden kann.
|
/// Warum gerade nicht abgespielt werden kann.
|
||||||
///
|
///
|
||||||
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
|
/// **Nur noch ein Fall, und das ist eine Bereinigung.** Hier stand eine
|
||||||
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
|
/// Fallunterscheidung aus der Plex-Zeit: war Jellyfin aktiv, hiess es
|
||||||
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
|
/// „die Wiedergabe von Jellyfin ist noch nicht fertig". Sie ist längst
|
||||||
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
|
/// fertig — und der Satz war ohnehin unerreichbar, denn der Player zeigt
|
||||||
///
|
/// diese Meldung ausschliesslich dann, wenn gar kein Server da ist.
|
||||||
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
|
|
||||||
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
|
|
||||||
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
|
|
||||||
var playbackUnavailableMessage: String {
|
var playbackUnavailableMessage: String {
|
||||||
backend != nil
|
String(localized: "error.serverUnreachable")
|
||||||
? String(localized: "player.jellyfinPending")
|
|
||||||
: String(localized: "error.serverUnreachable")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let jellyfinIdentity: JellyfinIdentity
|
private let jellyfinIdentity: JellyfinIdentity
|
||||||
|
private let flaroIdentity: FlaroIdentity
|
||||||
private let tokenStore: any TokenStoring
|
private let tokenStore: any TokenStoring
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
init(
|
init(
|
||||||
jellyfinIdentity: JellyfinIdentity,
|
jellyfinIdentity: JellyfinIdentity,
|
||||||
|
flaroIdentity: FlaroIdentity,
|
||||||
tokenStore: any TokenStoring,
|
tokenStore: any TokenStoring,
|
||||||
defaults: UserDefaults = .standard
|
defaults: UserDefaults = .standard
|
||||||
) {
|
) {
|
||||||
self.jellyfinIdentity = jellyfinIdentity
|
self.jellyfinIdentity = jellyfinIdentity
|
||||||
|
self.flaroIdentity = flaroIdentity
|
||||||
self.tokenStore = tokenStore
|
self.tokenStore = tokenStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
}
|
}
|
||||||
@@ -174,7 +224,13 @@ final class FlaroSession {
|
|||||||
if await adoptStubServerIfRequested() { return }
|
if await adoptStubServerIfRequested() { return }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if await restoreJellyfin() { return }
|
// **Die gespeicherte Art zuerst.** Wer ohne sie startet, war vor
|
||||||
|
// dieser Fassung angemeldet — damals gab es nur Jellyfin, und genau
|
||||||
|
// das ist dann gemeint.
|
||||||
|
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
|
||||||
|
case .flaro: if await restoreFlaro() { return }
|
||||||
|
case .jellyfin, nil: if await restoreJellyfin() { return }
|
||||||
|
}
|
||||||
phase = .signedOut
|
phase = .signedOut
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,12 +268,24 @@ final class FlaroSession {
|
|||||||
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
|
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
|
||||||
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
|
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
|
||||||
func signOut() {
|
func signOut() {
|
||||||
|
// **Dem Flaro-Server sagen, dass dieses Gerät geht.** Jellyfin
|
||||||
|
// erfährt es über das Verfallen des Tokens; Flaro zieht es auf
|
||||||
|
// Zuruf zurück, und ein nicht zurückgezogenes Token stünde sonst
|
||||||
|
// bis zum Ablauf in der Geräteliste.
|
||||||
|
if let client = flaroClient {
|
||||||
|
Task { await FlaroSignIn.signOut(client: client) }
|
||||||
|
}
|
||||||
forgetJellyfin()
|
forgetJellyfin()
|
||||||
|
forgetFlaro()
|
||||||
|
defaults.removeObject(forKey: Key.activeKind)
|
||||||
clearActiveServer()
|
clearActiveServer()
|
||||||
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
|
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,14 +344,14 @@ final class FlaroSession {
|
|||||||
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
|
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
|
||||||
clearActiveServer()
|
clearActiveServer()
|
||||||
jellyfinAccount = account
|
jellyfinAccount = account
|
||||||
|
activeKind = .jellyfin
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
isLocalConnection = await backend.isInLocalNetwork()
|
isLocalConnection = await backend.isInLocalNetwork()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
libraries = try await loadLibraries(from: backend)
|
libraries = try await loadLibraries(from: backend)
|
||||||
} catch {
|
} catch {
|
||||||
self.backend = nil
|
clearActiveServer()
|
||||||
jellyfinAccount = nil
|
|
||||||
phase = .failed(message: ErrorText.describe(error))
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -320,6 +388,7 @@ final class FlaroSession {
|
|||||||
|
|
||||||
clearActiveServer()
|
clearActiveServer()
|
||||||
jellyfinAccount = account
|
jellyfinAccount = account
|
||||||
|
activeKind = .jellyfin
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
|
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
|
||||||
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
|
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
|
||||||
@@ -341,7 +410,130 @@ final class FlaroSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Verbinden mit Flaro
|
||||||
|
|
||||||
|
/// Übernimmt eine fertige Flaro-Anmeldung.
|
||||||
|
///
|
||||||
|
/// Dieselbe Form wie bei Jellyfin, und das ist der Beweis für die Naht:
|
||||||
|
/// was danach kommt — Mediatheken laden, Phase setzen, merken — steht
|
||||||
|
/// zweimal genau gleich da, weil `MediaBackend` dazwischen liegt.
|
||||||
|
func adoptFlaro(client: FlaroClient, account: FlaroAccount) async {
|
||||||
|
phase = .connecting(serverName: account.serverName)
|
||||||
|
|
||||||
|
let backend = FlaroBackend(client: client, account: account)
|
||||||
|
clearActiveServer()
|
||||||
|
flaroAccount = account
|
||||||
|
flaroClient = client
|
||||||
|
activeKind = .flaro
|
||||||
|
self.backend = backend
|
||||||
|
// **Flaro fragt danach nicht.** Jellyfin meldet, ob die Verbindung
|
||||||
|
// im selben Netz liegt; Flaro hat dafür keinen Endpunkt und rechnet
|
||||||
|
// auch nichts daran fest. Im Zweifel vorsichtig: lieber einmal zu
|
||||||
|
// stark gedeckelt als eine Wiedergabe, die unterwegs nicht anläuft.
|
||||||
|
isLocalConnection = false
|
||||||
|
|
||||||
|
do {
|
||||||
|
libraries = try await loadLibraries(from: backend)
|
||||||
|
} catch {
|
||||||
|
clearActiveServer()
|
||||||
|
phase = .failed(message: ErrorText.describe(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberFlaro(account: account, token: nil)
|
||||||
|
phase = .ready
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dasselbe mit frischem Token — nach einer Anmeldung mit Passwort.
|
||||||
|
func adoptFlaro(client: FlaroClient, account: FlaroAccount, token: String) async {
|
||||||
|
await adoptFlaro(client: client, account: account)
|
||||||
|
guard phase == .ready else { return }
|
||||||
|
rememberFlaro(account: account, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nimmt eine gespeicherte Flaro-Anmeldung wieder auf.
|
||||||
|
private func restoreFlaro() async -> Bool {
|
||||||
|
guard let address = defaults.string(forKey: Key.flaroAddress),
|
||||||
|
let baseURL = URL(string: address),
|
||||||
|
let token = tokenStore.token(for: Self.flaroTokenAccount)
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
phase = .connecting(serverName: defaults.string(forKey: Key.flaroServerName) ?? "")
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (account, client) = try await FlaroSignIn.restore(
|
||||||
|
baseURL: baseURL, token: token, identity: flaroIdentity)
|
||||||
|
await adoptFlaro(client: client, account: account)
|
||||||
|
return true
|
||||||
|
} catch MediaBackendError.unauthorized {
|
||||||
|
// Am Server zurückgezogen. Ihn zu behalten erzeugte bei jedem
|
||||||
|
// Start denselben Fehler.
|
||||||
|
forgetFlaro()
|
||||||
|
clearActiveServer()
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
|
||||||
|
// Server das, und es ist kein Grund, jemanden abzumelden.
|
||||||
|
//
|
||||||
|
// **Und auch kein Grund, vor der Tür stehen zu bleiben.** Vorher
|
||||||
|
// 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))
|
||||||
|
activeKind = .flaro
|
||||||
|
}
|
||||||
|
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?) {
|
||||||
|
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
|
||||||
|
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
|
||||||
|
defaults.set(account.userID, forKey: Key.flaroUserID)
|
||||||
|
defaults.set(account.userName, forKey: Key.flaroUserName)
|
||||||
|
defaults.set(account.serverName, forKey: Key.flaroServerName)
|
||||||
|
defaults.set(account.isAdministrator, forKey: Key.flaroIsAdministrator)
|
||||||
|
if let token { try? tokenStore.store(token, for: Self.flaroTokenAccount) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func forgetFlaro() {
|
||||||
|
for key in [Key.flaroAddress, Key.flaroUserID, Key.flaroUserName,
|
||||||
|
Key.flaroServerName, Key.flaroIsAdministrator] {
|
||||||
|
defaults.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
tokenStore.remove(for: Self.flaroTokenAccount)
|
||||||
|
}
|
||||||
|
|
||||||
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
|
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
|
||||||
|
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.activeKind)
|
||||||
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
|
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
|
||||||
defaults.set(account.userID, forKey: Key.jellyfinUserID)
|
defaults.set(account.userID, forKey: Key.jellyfinUserID)
|
||||||
defaults.set(account.userName, forKey: Key.jellyfinUserName)
|
defaults.set(account.userName, forKey: Key.jellyfinUserName)
|
||||||
@@ -385,12 +577,18 @@ final class FlaroSession {
|
|||||||
private func clearActiveServer() {
|
private func clearActiveServer() {
|
||||||
backend = nil
|
backend = nil
|
||||||
jellyfinAccount = nil
|
jellyfinAccount = nil
|
||||||
|
flaroAccount = nil
|
||||||
|
flaroClient = nil
|
||||||
|
activeKind = nil
|
||||||
libraries = []
|
libraries = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
|
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
|
||||||
func retry() async {
|
func retry() async {
|
||||||
if await restoreJellyfin() { return }
|
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
|
||||||
|
case .flaro: if await restoreFlaro() { return }
|
||||||
|
case .jellyfin, nil: if await restoreJellyfin() { return }
|
||||||
|
}
|
||||||
phase = .signedOut
|
phase = .signedOut
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,4 +598,13 @@ final class FlaroSession {
|
|||||||
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
|
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
|
||||||
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
|
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
|
||||||
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
|
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
|
||||||
|
|
||||||
|
/// Dasselbe für Flaro.
|
||||||
|
var flaroDeviceIdentity: FlaroIdentity { flaroIdentity }
|
||||||
|
|
||||||
|
/// Der Client des aktiven Flaro-Servers — zum Abmelden.
|
||||||
|
///
|
||||||
|
/// Nicht `@Observable`-sichtbar: keine Ansicht liest ihn, und eine
|
||||||
|
/// Änderung daran soll kein Neuzeichnen auslösen.
|
||||||
|
@ObservationIgnored private var flaroClient: FlaroClient?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import Foundation
|
||||||
|
import FlaroServerKit
|
||||||
|
import MediaKit
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
/// Führt durch die Anmeldung an einem Flaro-Server.
|
||||||
|
///
|
||||||
|
/// Zwei Fragen am Telefon: wo steht der Server, und wer bist du.
|
||||||
|
///
|
||||||
|
/// **Am Fernseher andersherum.** Dort steht der sechsstellige Code vorn:
|
||||||
|
/// es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
|
||||||
|
/// das Gerät, auf dem man bestätigt, liegt sowieso auf dem Sofa. Am Telefon
|
||||||
|
/// führt derselbe Weg nirgendwohin — der Code braucht ein zweites, bereits
|
||||||
|
/// angemeldetes Gerät, und das erste ist gerade dieses hier.
|
||||||
|
///
|
||||||
|
/// Welcher Fall gilt, weiss die Oberfläche; dieses Modell bekommt es im
|
||||||
|
/// Initialisierer gesagt.
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class FlaroSignInModel {
|
||||||
|
enum Step: Equatable {
|
||||||
|
case address
|
||||||
|
case checking
|
||||||
|
/// Der Code steht auf dem Bildschirm, jetzt wird gewartet.
|
||||||
|
case code(String)
|
||||||
|
case credentials
|
||||||
|
case signingIn
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PreferredMethod { case credentials, code }
|
||||||
|
|
||||||
|
var address = ""
|
||||||
|
var userName = ""
|
||||||
|
var password = ""
|
||||||
|
|
||||||
|
private(set) var step: Step = .address
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
/// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse
|
||||||
|
/// stimmt, noch bevor jemand Anmeldedaten eintippt.
|
||||||
|
private(set) var serverName: String?
|
||||||
|
/// Der Pflichthinweis auf die Metadatenquelle, schon vor der Anmeldung.
|
||||||
|
private(set) var attributions: [FlaroSignIn.MetadataAttribution] = []
|
||||||
|
|
||||||
|
var onAuthenticated: ((FlaroClient, FlaroAccount, String) -> Void)?
|
||||||
|
|
||||||
|
private let identity: FlaroIdentity
|
||||||
|
private let preferred: PreferredMethod
|
||||||
|
private var baseURL: URL?
|
||||||
|
private var pollTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Der Vorgabewert hält iOS unverändert.
|
||||||
|
init(identity: FlaroIdentity, preferring: PreferredMethod = .credentials) {
|
||||||
|
self.identity = identity
|
||||||
|
self.preferred = preferring
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein `deinit`, der abbricht: `pollTask` gehört dem Hauptakteur, und
|
||||||
|
// ein `deinit` läuft nicht dort. Abgebrochen wird über `cancel()` beim
|
||||||
|
// Verschwinden der Ansicht — wie beim Jellyfin-Modell auch.
|
||||||
|
|
||||||
|
/// Hält die Abfrageschleife an.
|
||||||
|
///
|
||||||
|
/// Ohne das läuft sie nach dem Wegwischen des Sheets noch minutenlang
|
||||||
|
/// weiter — unsichtbar und für nichts.
|
||||||
|
func cancel() {
|
||||||
|
pollTask?.cancel()
|
||||||
|
pollTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schritt 1: den Server finden
|
||||||
|
|
||||||
|
func findServer() async {
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let kandidaten = FlaroAddress.candidates(for: address)
|
||||||
|
guard !kandidaten.isEmpty else {
|
||||||
|
errorMessage = String(localized: "signIn.flaro.addressMissing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step = .checking
|
||||||
|
|
||||||
|
for kandidat in kandidaten {
|
||||||
|
guard let gruss = try? await FlaroSignIn.greet(
|
||||||
|
baseURL: kandidat, identity: identity) else { continue }
|
||||||
|
|
||||||
|
// **Ein frischer Server ist kein falsches Passwort.** Wer hier
|
||||||
|
// weitermachte, bekäme „Benutzername oder Passwort stimmt nicht"
|
||||||
|
// für ein Konto, das es noch gar nicht gibt.
|
||||||
|
guard !gruss.needsSetup else {
|
||||||
|
baseURL = kandidat
|
||||||
|
serverName = gruss.serverName
|
||||||
|
step = .address
|
||||||
|
errorMessage = String(localized: "signIn.flaro.needsSetup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL = kandidat
|
||||||
|
address = kandidat.absoluteString
|
||||||
|
serverName = gruss.serverName
|
||||||
|
attributions = gruss.attributions
|
||||||
|
|
||||||
|
if preferred == .code {
|
||||||
|
await beginCode()
|
||||||
|
} else {
|
||||||
|
step = .credentials
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step = .address
|
||||||
|
errorMessage = String(localized: "signIn.flaro.notFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zurück zur Adresse — etwa, weil der gefundene Server der falsche ist.
|
||||||
|
func changeAddress() {
|
||||||
|
cancel()
|
||||||
|
baseURL = nil
|
||||||
|
serverName = nil
|
||||||
|
errorMessage = nil
|
||||||
|
step = .address
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schritt 2a: der Code
|
||||||
|
|
||||||
|
/// Fordert einen Code an und wartet auf die Bestätigung.
|
||||||
|
func beginCode() async {
|
||||||
|
guard let baseURL else { return }
|
||||||
|
cancel()
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let offen: FlaroSignIn.PendingCode
|
||||||
|
do {
|
||||||
|
offen = try await FlaroSignIn.requestCode(baseURL: baseURL, identity: identity)
|
||||||
|
} catch {
|
||||||
|
step = .credentials
|
||||||
|
errorMessage = ErrorText.describe(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step = .code(offen.code)
|
||||||
|
|
||||||
|
// **Nachfragen, nicht offen halten.** Eine Anfrage, die fünf Minuten
|
||||||
|
// auf eine Bestätigung wartet, läuft hinter jedem Reverse Proxy ins
|
||||||
|
// Zeitlimit.
|
||||||
|
pollTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
guard let self else { return }
|
||||||
|
guard await self.checkCode(baseURL: baseURL, pending: offen) else { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Runde der Abfrage. `false` heisst: es gibt nichts mehr zu warten.
|
||||||
|
private func checkCode(baseURL: URL, pending: FlaroSignIn.PendingCode) async -> Bool {
|
||||||
|
guard let antwort = try? await FlaroSignIn.pollCode(
|
||||||
|
baseURL: baseURL, pending: pending, identity: identity)
|
||||||
|
else { return true } // Ein Aussetzer ist kein Abbruch.
|
||||||
|
|
||||||
|
switch antwort.state {
|
||||||
|
case .pending:
|
||||||
|
return true
|
||||||
|
case .expired:
|
||||||
|
errorMessage = String(localized: "signIn.flaro.codeExpired")
|
||||||
|
step = .credentials
|
||||||
|
return false
|
||||||
|
case .approved(let token, let konto):
|
||||||
|
guard let client = antwort.client else { return false }
|
||||||
|
onAuthenticated?(client, konto, token)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zurück zum Passwort — für Server, auf denen niemand bestätigen kann.
|
||||||
|
func useCredentials() {
|
||||||
|
cancel()
|
||||||
|
errorMessage = nil
|
||||||
|
step = .credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schritt 2b: anmelden
|
||||||
|
|
||||||
|
func signIn() async {
|
||||||
|
guard let baseURL else { return }
|
||||||
|
cancel()
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let name = userName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty, !password.isEmpty else {
|
||||||
|
errorMessage = String(localized: "signIn.flaro.credentialsMissing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
step = .signingIn
|
||||||
|
do {
|
||||||
|
let (token, konto, client) = try await FlaroSignIn.signIn(
|
||||||
|
baseURL: baseURL, userName: name, password: password, identity: identity)
|
||||||
|
// **Das Passwort sofort wegwerfen.** Es wird für die Dauer einer
|
||||||
|
// Anfrage gehalten und danach nicht mehr gebraucht; es stehen zu
|
||||||
|
// lassen hiesse, es in einem Speicherabbild wiederzufinden.
|
||||||
|
password = ""
|
||||||
|
onAuthenticated?(client, konto, token)
|
||||||
|
} catch {
|
||||||
|
password = ""
|
||||||
|
step = .credentials
|
||||||
|
errorMessage = ErrorText.describe(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,7 +101,10 @@ enum HeroArtwork {
|
|||||||
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
|
||||||
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
|
||||||
// das der Serie — und erst danach das Vorschaubild der Folge.
|
// das der Serie — und erst danach das Vorschaubild der Folge.
|
||||||
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
// **Das Aufmacherbild zuerst.** Es ist das, was für genau diese
|
||||||
|
// Fläche ausgesucht wurde; der Hintergrund ist die Vorgabe
|
||||||
|
// dahinter.
|
||||||
|
: [item.heroArt, item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
|
||||||
|
|
||||||
var gesehen = Set<String>()
|
var gesehen = Set<String>()
|
||||||
return pfade
|
return pfade
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import FlaroDownloads
|
||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Was auf dem Gerät liegt, als gewöhnliche Titel.
|
||||||
|
///
|
||||||
|
/// **Damit ein Download aussieht wie alles andere.** Bisher waren Downloads
|
||||||
|
/// eine eigene Welt: eine eigene Liste, eine eigene Zeile, ein eigener
|
||||||
|
/// Bereich. Wer etwas ansehen wollte, musste erst wissen, dass er dorthin
|
||||||
|
/// wechseln muss. Als `MediaItem` fügt sich ein Download in jede Reihe und
|
||||||
|
/// jedes Raster ein, das es ohnehin gibt — Kachel, Fortschrittsbalken,
|
||||||
|
/// Kontextmenü inbegriffen.
|
||||||
|
///
|
||||||
|
/// **Und es ist die Grundlage fürs Offline-Arbeiten.** Ohne Server gibt es
|
||||||
|
/// keine andere Quelle für einen Titel; alles, was dann noch dasteht, kommt
|
||||||
|
/// von hier.
|
||||||
|
@MainActor
|
||||||
|
enum Heruntergeladenes {
|
||||||
|
/// Die fertigen Downloads, neueste zuerst.
|
||||||
|
///
|
||||||
|
/// **Nur fertige.** Ein halb geladener Film ist nichts zum Ansehen; er
|
||||||
|
/// steht im Downloads-Bereich mit seinem Balken, und dort gehört er hin.
|
||||||
|
static func fertige(in coordinator: DownloadCoordinator) -> [DownloadItem] {
|
||||||
|
coordinator.items
|
||||||
|
.filter(\.isComplete)
|
||||||
|
.sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was auf dem Gerät liegt — Filme einzeln, Serien als **eine** Kachel.
|
||||||
|
///
|
||||||
|
/// **Zwanzig Folgen sind keine zwanzig Titel.** Vorher stand jede
|
||||||
|
/// einzeln in der Reihe, und eine heruntergeladene Staffel schob alles
|
||||||
|
/// andere aus dem Bild. Was man im Kopf hat, ist die Serie; welche
|
||||||
|
/// Folgen davon da sind, ist die Frage danach — und die beantwortet die
|
||||||
|
/// Seite der Serie.
|
||||||
|
static func reihe(in coordinator: DownloadCoordinator) -> [MediaItem] {
|
||||||
|
var ergebnis: [MediaItem] = []
|
||||||
|
var gesehen = Set<String>()
|
||||||
|
|
||||||
|
for eintrag in fertige(in: coordinator) {
|
||||||
|
guard let serie = eintrag.seriesKey else {
|
||||||
|
ergebnis.append(titel(eintrag, in: coordinator))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Die Reihenfolge der Folgen ist die der Serie: die zuletzt
|
||||||
|
// fertige Folge bestimmt, wo die Serie steht.
|
||||||
|
guard gesehen.insert(serie).inserted else { continue }
|
||||||
|
ergebnis.append(self.serie(serie, in: coordinator))
|
||||||
|
}
|
||||||
|
return ergebnis
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Serie, von der Folgen auf dem Gerät liegen.
|
||||||
|
static func serie(_ schluessel: String, in coordinator: DownloadCoordinator) -> MediaItem {
|
||||||
|
let folgen = folgenEintraege(derSerie: schluessel, in: coordinator)
|
||||||
|
return MediaItem(
|
||||||
|
id: schluessel,
|
||||||
|
kind: .show,
|
||||||
|
title: folgen.first?.seriesTitle ?? folgen.first?.title ?? "",
|
||||||
|
// **Erst das Plakat der Serie, dann irgendein Folgenbild.** Ein
|
||||||
|
// Szenenbild hochkant ist ein zufälliger Ausschnitt — aber immer
|
||||||
|
// noch besser als eine graue Fläche.
|
||||||
|
thumb: coordinator.artworkURL(forRatingKey: schluessel)?.absoluteString
|
||||||
|
?? folgen.lazy
|
||||||
|
.compactMap { coordinator.artworkURL(forRatingKey: $0.ratingKey)?.absoluteString }
|
||||||
|
.first
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie viele Folgen dieser Serie hier liegen — als Zeile unter der
|
||||||
|
/// Kachel.
|
||||||
|
///
|
||||||
|
/// `nil` für alles, was keine Serie mit Downloads ist: dann gilt, was
|
||||||
|
/// sonst unter einer Kachel steht.
|
||||||
|
static func untertitel(für titel: MediaItem, in coordinator: DownloadCoordinator) -> String? {
|
||||||
|
guard titel.kind == .show else { return nil }
|
||||||
|
let anzahl = folgenEintraege(derSerie: titel.id, in: coordinator).count
|
||||||
|
guard anzahl > 0 else { return nil }
|
||||||
|
return String(localized: "downloads.episodeCount \(anzahl)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die heruntergeladenen Folgen einer Serie, aufsteigend sortiert.
|
||||||
|
///
|
||||||
|
/// Für die Seite der Serie, wenn kein Server antwortet: dann ist das
|
||||||
|
/// hier die ganze Liste, die es gibt.
|
||||||
|
static func folgen(derSerie schluessel: String, in coordinator: DownloadCoordinator) -> [MediaItem] {
|
||||||
|
folgenEintraege(derSerie: schluessel, in: coordinator)
|
||||||
|
.sorted {
|
||||||
|
($0.seasonIndex ?? 0, $0.episodeIndex ?? 0) < ($1.seasonIndex ?? 0, $1.episodeIndex ?? 0)
|
||||||
|
}
|
||||||
|
.map { titel($0, in: coordinator) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func folgenEintraege(
|
||||||
|
derSerie schluessel: String,
|
||||||
|
in coordinator: DownloadCoordinator
|
||||||
|
) -> [DownloadItem] {
|
||||||
|
fertige(in: coordinator).filter { $0.seriesKey == schluessel }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Download als Titel.
|
||||||
|
///
|
||||||
|
/// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst
|
||||||
|
/// ein Pfad, den erst ein Server zu einer Adresse macht; hier steht
|
||||||
|
/// stattdessen die fertige lokale Adresse. Ansichten, die `rawImageURL`
|
||||||
|
/// darauf anwenden, müssen das wissen — siehe `bildAdresse`.
|
||||||
|
static func titel(_ eintrag: DownloadItem, in coordinator: DownloadCoordinator) -> MediaItem {
|
||||||
|
MediaItem(
|
||||||
|
id: eintrag.ratingKey,
|
||||||
|
kind: eintrag.seriesKey == nil ? .movie : .episode,
|
||||||
|
title: eintrag.title,
|
||||||
|
duration: eintrag.durationMilliseconds,
|
||||||
|
thumb: coordinator.artworkURL(forRatingKey: eintrag.ratingKey)?.absoluteString,
|
||||||
|
index: eintrag.episodeIndex,
|
||||||
|
parentIndex: eintrag.seasonIndex,
|
||||||
|
parentTitle: eintrag.seriesTitle,
|
||||||
|
seriesTitle: eintrag.seriesTitle,
|
||||||
|
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
|
||||||
|
)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Adresse eines Bildes, das schon eine ist.
|
||||||
|
///
|
||||||
|
/// **Ein Umweg, der nötig ist.** Die Kacheln bauen ihre Bildadresse über
|
||||||
|
/// den Server (`rawImageURL`), weil `thumb` dort ein Pfad ist. Bei einem
|
||||||
|
/// Download steht darin bereits eine `file:`-Adresse — durch denselben
|
||||||
|
/// Bau geschickt käme Unsinn heraus.
|
||||||
|
static func bildAdresse(_ titel: MediaItem) -> URL? {
|
||||||
|
guard let roh = titel.thumb, roh.hasPrefix("file:") else { return nil }
|
||||||
|
return URL(string: roh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob dieser Titel auf dem Gerät liegt.
|
||||||
|
///
|
||||||
|
/// **Eine Serie gilt als da, wenn Folgen von ihr da sind.** Die Serie
|
||||||
|
/// selbst wird nie heruntergeladen — ihre Folgen schon, und wer die
|
||||||
|
/// Kachel sieht, will wissen, ob er etwas davon ansehen kann.
|
||||||
|
static func liegtBereit(_ titelID: String, in coordinator: DownloadCoordinator) -> Bool {
|
||||||
|
coordinator.items.contains {
|
||||||
|
$0.isComplete && ($0.ratingKey == titelID || $0.seriesKey == titelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
@@ -19,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
|
||||||
@@ -29,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.
|
||||||
@@ -38,10 +65,33 @@ final class HomeFeed {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Kennung der Downloadreihe.
|
||||||
|
///
|
||||||
|
/// Fest und nicht vom Server: nur so lässt sie sich wie jede andere
|
||||||
|
/// Reihe verschieben oder ausblenden.
|
||||||
|
static let downloadsRow = "flaro.downloads"
|
||||||
|
|
||||||
/// Die Reihen in der eingestellten Form.
|
/// Die Reihen in der eingestellten Form.
|
||||||
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
|
///
|
||||||
guard case .loaded(let alle) = state else { return [] }
|
/// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
|
||||||
return layout.apply(to: prepared(alle, layout: layout))
|
/// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede
|
||||||
|
/// 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] {
|
||||||
|
var reihen: [MediaRowGroup] = []
|
||||||
|
if case .loaded(let alle) = state { reihen = prepared(alle, layout: layout) }
|
||||||
|
if !downloaded.isEmpty {
|
||||||
|
reihen.insert(
|
||||||
|
MediaRowGroup(
|
||||||
|
id: Self.downloadsRow,
|
||||||
|
title: String(localized: "home.downloaded"),
|
||||||
|
items: downloaded
|
||||||
|
),
|
||||||
|
at: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return layout.apply(to: reihen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Ordnen
|
// MARK: - Ordnen
|
||||||
@@ -155,8 +205,8 @@ final class HomeFeed {
|
|||||||
case .continueFirst:
|
case .continueFirst:
|
||||||
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
// Innerhalb der Gruppen ziehen, nicht über sie hinweg:
|
||||||
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
// Angefangenes bleibt vorn, wechselt aber untereinander.
|
||||||
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil })
|
let ordered = HeroSelection.ordered(unique.filter(\.isResumable))
|
||||||
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil })
|
+ HeroSelection.ordered(unique.filter { !$0.isResumable })
|
||||||
return Array(ordered.prefix(layout.heroCount))
|
return Array(ordered.prefix(layout.heroCount))
|
||||||
case .latestOnly:
|
case .latestOnly:
|
||||||
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ final class LanguagePreferences {
|
|||||||
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
||||||
static let perLibrary = "flaro.languages.library"
|
static let perLibrary = "flaro.languages.library"
|
||||||
static let perSeries = "flaro.languages.series"
|
static let perSeries = "flaro.languages.series"
|
||||||
|
static let perSeriesSubtitles = "flaro.languages.seriesSubtitles"
|
||||||
static let rememberedTracks = "flaro.languages.seriesTrack"
|
static let rememberedTracks = "flaro.languages.seriesTrack"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,14 @@ final class LanguagePreferences {
|
|||||||
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
|
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
|
||||||
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
|
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
|
||||||
/// abzulegen hiesse dort, den Fehler festzuschreiben.
|
/// abzulegen hiesse dort, den Fehler festzuschreiben.
|
||||||
|
/// Die Untertitelsprache je Titel — Serie oder Film.
|
||||||
|
///
|
||||||
|
/// **Ein Topf für beide.** Die Kennung eines Films kollidiert mit keiner
|
||||||
|
/// Serie; zwei Wörterbücher für dieselbe Frage liefen auseinander.
|
||||||
|
private var perSeriesSubtitles: [String: String] {
|
||||||
|
didSet { defaults.set(perSeriesSubtitles, forKey: Key.perSeriesSubtitles) }
|
||||||
|
}
|
||||||
|
|
||||||
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
|
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
|
||||||
didSet {
|
didSet {
|
||||||
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
|
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
|
||||||
@@ -117,6 +126,8 @@ final class LanguagePreferences {
|
|||||||
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
|
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
|
||||||
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
|
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
|
||||||
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
|
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
|
||||||
|
self.perSeriesSubtitles =
|
||||||
|
defaults.dictionary(forKey: Key.perSeriesSubtitles) as? [String: String] ?? [:]
|
||||||
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
|
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
|
||||||
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
|
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
|
||||||
} ?? [:]
|
} ?? [:]
|
||||||
@@ -136,6 +147,34 @@ final class LanguagePreferences {
|
|||||||
perSeries[id] = code
|
perSeries[id] = code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was „keine Untertitel" im Speicher heisst.
|
||||||
|
///
|
||||||
|
/// **Abgewählt ist eine Aussage, kein Fehlen einer.** Wer sie für diese
|
||||||
|
/// Serie abgeschaltet hat, will sie auch in Folge 7 nicht; ein fehlender
|
||||||
|
/// Eintrag hiesse dagegen „nie etwas gesagt", und dann darf eine
|
||||||
|
/// erzwungene Spur durchaus erscheinen.
|
||||||
|
static let noSubtitles = "keine"
|
||||||
|
|
||||||
|
func subtitleLanguage(forSeries id: String) -> String? { perSeriesSubtitles[id] }
|
||||||
|
|
||||||
|
func setSubtitleLanguage(_ code: String?, forSeries id: String) {
|
||||||
|
perSeriesSubtitles[id] = code
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Untertitelsprache für diesen Titel gilt — `nil` heisst: keine.
|
||||||
|
///
|
||||||
|
/// **Die Ebene schlägt das Allgemeine.** Dieselbe Rangfolge wie beim Ton,
|
||||||
|
/// nur ohne Mediathek: eine Untertitelsprache je Mediathek hat noch
|
||||||
|
/// niemand verlangt, und eine Einstellung, die niemand sucht, ist eine
|
||||||
|
/// Stelle mehr, an der etwas auseinanderlaufen kann.
|
||||||
|
func subtitleWish(titleID: String?) -> String? {
|
||||||
|
if let eigen = titleID.flatMap({ perSeriesSubtitles[$0] }) {
|
||||||
|
return eigen == Self.noSubtitles ? nil : eigen
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitleLanguage.isEmpty ? nil : subtitleLanguage
|
||||||
|
}
|
||||||
|
|
||||||
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
|
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
|
||||||
rememberedTracks[id]
|
rememberedTracks[id]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ final class PlaybackSettings {
|
|||||||
static let cellularQuality = "flaro.playback.quality.cellular"
|
static let cellularQuality = "flaro.playback.quality.cellular"
|
||||||
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
||||||
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
||||||
|
static let skipExtraScenes = "flaro.playback.skipExtraScenes"
|
||||||
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
||||||
static let locksLandscape = "flaro.playback.locksLandscape"
|
static let locksLandscape = "flaro.playback.locksLandscape"
|
||||||
static let rewindSeconds = "flaro.playback.rewindSeconds"
|
static let rewindSeconds = "flaro.playback.rewindSeconds"
|
||||||
@@ -58,6 +59,14 @@ final class PlaybackSettings {
|
|||||||
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
|
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
|
||||||
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
|
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
|
||||||
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
|
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
|
||||||
|
|
||||||
|
/// Ob „Abspann überspringen" auch über Post-Credit-Szenen hinweggeht.
|
||||||
|
///
|
||||||
|
/// **Eine Gerätesache, keine Servereinstellung.** Ob jemand die Szene
|
||||||
|
/// sehen will, entscheidet er am Gerät, auf dem er sitzt — wie bei
|
||||||
|
/// `autoSkipIntro`. Vorgabe aus: wer den Knopf drückt, landet bei der
|
||||||
|
/// Szene und kann sie wegdrücken, wenn er mag.
|
||||||
|
var skipExtraScenes: Bool { didSet { defaults.set(skipExtraScenes, forKey: Key.skipExtraScenes) } }
|
||||||
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
|
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
|
||||||
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
|
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
|
||||||
|
|
||||||
@@ -117,6 +126,7 @@ final class PlaybackSettings {
|
|||||||
|
|
||||||
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
|
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
|
||||||
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
|
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
|
||||||
|
self.skipExtraScenes = defaults.object(forKey: Key.skipExtraScenes) as? Bool ?? false
|
||||||
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
||||||
|
|
||||||
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
|
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ enum TopShelfWriter {
|
|||||||
width: posterWidth,
|
width: posterWidth,
|
||||||
height: posterHeight
|
height: posterHeight
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct ItemActionEntries: View {
|
|||||||
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
|
||||||
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
|
||||||
// Fehler bei sich.
|
// Fehler bei sich.
|
||||||
if item.watchProgress != nil {
|
if item.isResumable {
|
||||||
Button {
|
Button {
|
||||||
run { try await $0.removeFromContinueWatching(item) }
|
run { try await $0.removeFromContinueWatching(item) }
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -17,14 +17,18 @@ import MediaKit
|
|||||||
/// und wäre für VoiceOver gar nicht mehr da.
|
/// und wäre für VoiceOver gar nicht mehr da.
|
||||||
struct QuickPlayTile<Card: View>: View {
|
struct QuickPlayTile<Card: View>: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
/// Ob ein Tipp auf die Detailseite führt, statt abzuspielen.
|
/// Ob ein Tipp sofort abspielt, statt auf die Detailseite zu führen.
|
||||||
///
|
///
|
||||||
/// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will —
|
/// **Nur beim Weiterschauen.** Dort ist die Absicht eindeutig: der Titel
|
||||||
/// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf
|
/// steht in dieser Reihe, *weil* man ihn angefangen hat.
|
||||||
/// der Startseite richtig (die Kachel stand schon da, man kennt sie) und
|
///
|
||||||
/// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des
|
/// **Überall sonst führt der Tipp auf die Seite.** Vorher war es
|
||||||
/// Servers, keine Entscheidung des Nutzers.
|
/// umgekehrt, mit dem Argument „wer eine Kachel antippt, weiss meistens
|
||||||
var opensDetails: Bool = false
|
/// schon, was er sehen will". Im Gebrauch stimmte das nicht: wer nur
|
||||||
|
/// nachsehen will, wie viele Staffeln eine Serie hat, bekam den Film.
|
||||||
|
/// Ein Umweg von einem Schritt ist ärgerlich; eine Wiedergabe, die man
|
||||||
|
/// nicht wollte, ist schlimmer.
|
||||||
|
var startsPlayback: Bool = false
|
||||||
@ViewBuilder let card: () -> Card
|
@ViewBuilder let card: () -> Card
|
||||||
|
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@@ -57,7 +61,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
/// Ob es überhaupt etwas zu starten gibt.
|
/// Ob es überhaupt etwas zu starten gibt.
|
||||||
///
|
///
|
||||||
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
|
||||||
private var isPlayable: Bool { item.kind != .collection }
|
/// Ein geplanter Titel ebenso: der Server lehnt ihn bis zum Termin ab,
|
||||||
|
/// und ein Tipp auf die Kachel startete trotzdem — gemessen am Simulator,
|
||||||
|
/// der Spieler kam bis „Wird vorbereitet …". Dort führt der Tipp jetzt
|
||||||
|
/// auf die Seite, wo der Termin steht.
|
||||||
|
private var isPlayable: Bool { item.kind != .collection && !item.isUpcoming }
|
||||||
|
|
||||||
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
|
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
|
||||||
|
|
||||||
@@ -65,8 +73,9 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
///
|
///
|
||||||
/// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg
|
/// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg
|
||||||
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
|
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
|
||||||
/// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt.
|
/// Sammlung hat nichts abzuspielen, und überall ausser beim
|
||||||
private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable }
|
/// Weiterschauen ist die Seite das Ziel.
|
||||||
|
private var tapOpensDetails: Bool { isMac || !startsPlayback || !isPlayable }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
kachel
|
kachel
|
||||||
@@ -80,6 +89,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
.overlay(alignment: .topTrailing) {
|
.overlay(alignment: .topTrailing) {
|
||||||
if isPlayable, isMac { playButton }
|
if isPlayable, isMac { playButton }
|
||||||
}
|
}
|
||||||
|
// **Links, weil rechts schon etwas sitzt** — am Mac der Abspielknopf
|
||||||
|
// beim Überfahren, unten der Fortschrittsbalken.
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if Heruntergeladenes.liegtBereit(item.id, in: downloads) { downloadMarke }
|
||||||
|
}
|
||||||
.onContinuousHover { phase in
|
.onContinuousHover { phase in
|
||||||
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
|
||||||
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
// der iOS-Bau und läuft trotzdem auf dem Mac.
|
||||||
@@ -95,11 +109,9 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
// Kachel steht.
|
// Kachel steht.
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if isPlayable {
|
if isPlayable {
|
||||||
// **Am Telefon und am iPad führt der Weg zur Detailseite
|
// **Nur dort, wo der Tipp abspielt.** Sonst führt er
|
||||||
// hierher.** Antippen spielt dort ab; wer stattdessen
|
// schon auf die Seite, und ein zweiter Weg dorthin im Menü
|
||||||
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
|
// wäre ein Eintrag, der nichts Neues anbietet.
|
||||||
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
|
|
||||||
// Starten gibt es den Knopf beim Überfahren.
|
|
||||||
if !tapOpensDetails {
|
if !tapOpensDetails {
|
||||||
Button("hero.details", systemImage: "info.circle") { push(route) }
|
Button("hero.details", systemImage: "info.circle") { push(route) }
|
||||||
Divider()
|
Divider()
|
||||||
@@ -195,14 +207,11 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
|
|
||||||
/// Die Kachel selbst — und was ein Antippen bedeutet.
|
/// Die Kachel selbst — und was ein Antippen bedeutet.
|
||||||
///
|
///
|
||||||
/// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss
|
/// **Der Tipp führt auf die Detailseite**, am Telefon wie am iPad wie am
|
||||||
/// meistens schon, was er sehen will; der Umweg über die Detailseite
|
/// Mac. Nur in „Weiterschauen" spielt er ab: dort steht der Titel,
|
||||||
/// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über
|
/// *weil* man ihn angefangen hat. Abspielen ist sonst einen Schritt
|
||||||
/// langes Drücken, wo auch „gesehen" und „entfernen" stehen.
|
/// weiter — über den Knopf auf der Seite, über langes Drücken, oder am
|
||||||
///
|
/// Mac über den Knopf beim Überfahren.
|
||||||
/// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den
|
|
||||||
/// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche
|
|
||||||
/// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film.
|
|
||||||
///
|
///
|
||||||
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
|
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
|
||||||
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
|
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
|
||||||
@@ -243,6 +252,20 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das Zeichen für „liegt auf diesem Gerät".
|
||||||
|
///
|
||||||
|
/// **Damit man es sieht, ohne danach zu suchen.** Bisher stand ein
|
||||||
|
/// Download nur im Downloads-Bereich; in der Mediathek sah eine
|
||||||
|
/// heruntergeladene Folge aus wie jede andere, und wer wissen wollte,
|
||||||
|
/// was im Flugzeug läuft, musste zwei Listen vergleichen.
|
||||||
|
private var downloadMarke: some View {
|
||||||
|
Image(systemName: "arrow.down.circle.fill")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.white, Color.black.opacity(0.55))
|
||||||
|
.padding(FlaroSpacing.snug)
|
||||||
|
.accessibilityLabel(Text("downloads.available"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
|
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
|
||||||
///
|
///
|
||||||
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
|
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Steht dort, wo sonst „Abspielen" stünde.
|
||||||
|
///
|
||||||
|
/// **Ein Knopf, der nichts tut, ist schlimmer als keiner.** Ein geplanter
|
||||||
|
/// Titel steht in der Mediathek, damit man weiss, dass er kommt — das ist der
|
||||||
|
/// Sinn des Planens. Der Server lehnt ihn bis zum Termin ab; stünde der Knopf
|
||||||
|
/// trotzdem da, wäre die Reihenfolge: draufdrücken, freuen, Absage lesen.
|
||||||
|
///
|
||||||
|
/// Ein Baustein für Aufmacher und Detailseite, weil sonst an der einen Stelle
|
||||||
|
/// der Termin stünde und an der anderen weiter der Knopf.
|
||||||
|
struct UpcomingNotice: View {
|
||||||
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
|
let date: Date
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("upcoming.title")
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
Text("upcoming.from \(Self.formatted(date))")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
|
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „Dienstag, 6. Oktober 2026 um 18:00" — in der Sprache des Geräts.
|
||||||
|
static func formatted(_ date: Date) -> String {
|
||||||
|
date.formatted(
|
||||||
|
.dateTime.weekday(.wide).day().month(.wide).year().hour().minute())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,44 @@ 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.
|
||||||
|
///
|
||||||
|
/// **Auch eine Serie**, obwohl keine Serie je heruntergeladen wird: in
|
||||||
|
/// der Downloadreihe steht sie als eine Kachel, und ein Tipp darauf
|
||||||
|
/// führt hierher. Ohne diesen Zweig endete er ohne Verbindung im
|
||||||
|
/// Nichts.
|
||||||
|
private var vomGeraet: MediaItem? {
|
||||||
|
if let eintrag = downloads.items.first(
|
||||||
|
where: { $0.isComplete && $0.ratingKey == route.ratingKey }
|
||||||
|
) {
|
||||||
|
return Heruntergeladenes.titel(eintrag, in: downloads)
|
||||||
|
}
|
||||||
|
guard downloads.items.contains(
|
||||||
|
where: { $0.isComplete && $0.seriesKey == route.ratingKey }
|
||||||
|
) else { return nil }
|
||||||
|
return Heruntergeladenes.serie(route.ratingKey, in: downloads)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
|
||||||
/// einzelne Folge.
|
/// einzelne Folge.
|
||||||
@@ -67,6 +68,41 @@ struct PlayableDetailView: View {
|
|||||||
return item.versions[selectedVersion]
|
return item.versions[selectedVersion]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Welche Sprachen in dieser Datei liegen.
|
||||||
|
///
|
||||||
|
/// **Kein Serverweg nötig.** Bei einem Film ist die Datei der Titel; ihre
|
||||||
|
/// Spuren stehen in der Antwort, die diese Seite ohnehin geholt hat. Nur
|
||||||
|
/// bei einer Serie muss gerechnet werden, und dafür gibt es den
|
||||||
|
/// Endpunkt.
|
||||||
|
private var sprachen: AvailableLanguages? {
|
||||||
|
guard let part = currentMedia?.files.first else { return nil }
|
||||||
|
|
||||||
|
return AvailableLanguages(
|
||||||
|
audio: optionen(part.audioStreams),
|
||||||
|
subtitles: optionen(part.subtitleStreams),
|
||||||
|
partial: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aus Spuren werden Sprachen — jede einmal, auch bei zwei Tonspuren.
|
||||||
|
private func optionen(_ spuren: [MediaStream]) -> [LanguageOption] {
|
||||||
|
var gesehen = Set<String>()
|
||||||
|
var liste: [LanguageOption] = []
|
||||||
|
|
||||||
|
for spur in spuren {
|
||||||
|
guard let roh = spur.languageCode ?? spur.language,
|
||||||
|
let code = LanguageCode.normalized(roh),
|
||||||
|
gesehen.insert(code).inserted
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
let name = Locale.current.localizedString(forLanguageCode: code)?.capitalized
|
||||||
|
?? spur.language
|
||||||
|
?? code.uppercased()
|
||||||
|
liste.append(LanguageOption(code: code, name: name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HeroBanner(
|
HeroBanner(
|
||||||
title: item.rowTitle,
|
title: item.rowTitle,
|
||||||
@@ -97,35 +133,76 @@ struct PlayableDetailView: View {
|
|||||||
// Karussell auf dem Telefon quer ist.
|
// Karussell auf dem Telefon quer ist.
|
||||||
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
||||||
) {
|
) {
|
||||||
Button {
|
// Geplant heisst sichtbar, nicht abspielbar. Weder Abspielen
|
||||||
playback.play(item, mediaIndex: selectedVersion)
|
// noch Herunterladen — der Server lehnt beides bis zum Termin ab.
|
||||||
} label: {
|
if item.isUpcoming, let ab = item.availableFrom {
|
||||||
Label(
|
UpcomingNotice(date: ab)
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume",
|
} else {
|
||||||
systemImage: "play.fill"
|
|
||||||
)
|
|
||||||
.font(.flaroCardTitle)
|
|
||||||
.padding(.horizontal, FlaroSpacing.loose)
|
|
||||||
.padding(.vertical, FlaroSpacing.snug)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(accent)
|
|
||||||
.disabled(currentMedia?.files.first == nil)
|
|
||||||
|
|
||||||
if let progress = item.watchProgress, progress > 0 {
|
|
||||||
Button {
|
Button {
|
||||||
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
playback.play(item, mediaIndex: selectedVersion)
|
||||||
} label: {
|
} label: {
|
||||||
Label("detail.playFromStart", systemImage: "gobackward")
|
Label(
|
||||||
.labelStyle(.iconOnly)
|
item.isResumable ? "hero.resume" : "hero.play",
|
||||||
.font(.flaroCardTitle)
|
systemImage: "play.fill"
|
||||||
.padding(FlaroSpacing.snug)
|
)
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(Color.flaroTextSecondary)
|
.tint(accent)
|
||||||
}
|
.disabled(currentMedia?.files.first == nil)
|
||||||
|
|
||||||
DownloadButton(item: item, mediaIndex: selectedVersion)
|
// „Von vorn" nur, wenn es ein „weiter" gibt — dieselbe
|
||||||
|
// Entscheidung wie beim Knopf daneben.
|
||||||
|
if item.isResumable {
|
||||||
|
Button {
|
||||||
|
playback.play(item, mediaIndex: selectedVersion, resuming: false)
|
||||||
|
} label: {
|
||||||
|
Label("detail.playFromStart", systemImage: "gobackward")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
.padding(FlaroSpacing.snug)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(Color.flaroTextSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadButton(item: item, mediaIndex: selectedVersion)
|
||||||
|
|
||||||
|
// **Die Sprache vor dem Start, nicht erst mitten im Film.**
|
||||||
|
//
|
||||||
|
// Im Spieler umzustellen heisst, den Strom neu aufzubauen —
|
||||||
|
// und bei einem Film, den man immer im Original sehen will,
|
||||||
|
// jedes Mal von Neuem. Welche Spuren es gibt, steht hier
|
||||||
|
// ohnehin schon, also gehört die Wahl neben den Knopf, der
|
||||||
|
// sie anwendet.
|
||||||
|
//
|
||||||
|
// **Nur bei einem Film.** Bei einer Folge gilt, was für die
|
||||||
|
// Serie eingestellt ist; dieselbe Frage zwanzigmal zu
|
||||||
|
// stellen wäre keine Wahl, sondern eine Prüfung der Geduld.
|
||||||
|
if item.kind != .episode, let angebot = sprachen {
|
||||||
|
if !angebot.audio.isEmpty {
|
||||||
|
AudioLanguagePicker(
|
||||||
|
scope: .series(item.id),
|
||||||
|
title: "languages.audioShort",
|
||||||
|
kind: .audio,
|
||||||
|
form: .knopf,
|
||||||
|
available: angebot.audio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !angebot.subtitles.isEmpty {
|
||||||
|
AudioLanguagePicker(
|
||||||
|
scope: .series(item.id),
|
||||||
|
title: "languages.subtitlesShort",
|
||||||
|
kind: .subtitles,
|
||||||
|
form: .knopf,
|
||||||
|
available: angebot.subtitles
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ struct SeriesDetailView: View {
|
|||||||
@State private var isWorking = false
|
@State private var isWorking = false
|
||||||
@State private var summary: String?
|
@State private var summary: String?
|
||||||
|
|
||||||
|
/// Welche Staffel die Liste zeigt — `nil` heisst: die erste.
|
||||||
|
///
|
||||||
|
/// **Weil der Server die Folgen am Stück liefert.** Eine Serie mit zwei
|
||||||
|
/// Staffeln ergab eine Liste, in der nach Folge 8 wieder eine Folge 1
|
||||||
|
/// kam, ohne dass irgendwo stünde, dass dazwischen eine Staffel endet.
|
||||||
|
@State private var gewaehlteStaffel: Int?
|
||||||
|
|
||||||
|
/// Welche Sprachen diese Serie durchgehend führt — `nil`, solange es
|
||||||
|
/// niemand weiss.
|
||||||
|
@State private var sprachen: AvailableLanguages?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
@@ -54,11 +65,35 @@ struct SeriesDetailView: View {
|
|||||||
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
// dieselbe Einstellung zu haben, von denen einer nie gelesen
|
||||||
// wird.
|
// wird.
|
||||||
if item.kind == .show {
|
if item.kind == .show {
|
||||||
AudioLanguagePicker(
|
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||||
scope: .series(item.id),
|
AudioLanguagePicker(
|
||||||
title: "languages.forThisSeries"
|
scope: .series(item.id),
|
||||||
)
|
title: "languages.forThisSeries",
|
||||||
|
kind: .audio,
|
||||||
|
available: sprachen?.audio
|
||||||
|
)
|
||||||
|
|
||||||
|
AudioLanguagePicker(
|
||||||
|
scope: .series(item.id),
|
||||||
|
title: "languages.subtitlesForThisSeries",
|
||||||
|
kind: .subtitles,
|
||||||
|
available: sprachen?.subtitles
|
||||||
|
)
|
||||||
|
|
||||||
|
// **Was fehlt, wird gesagt.** Eine gekürzte Liste
|
||||||
|
// sieht sonst aus wie die ganze Wahrheit, und wer
|
||||||
|
// eine Sprache vermisst, die er in Staffel 1 gesehen
|
||||||
|
// hat, hält es für einen Fehler.
|
||||||
|
if let partial = sprachen?.partial, partial > 0 {
|
||||||
|
Text("languages.partial \(partial)")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
.task(id: item.id) {
|
||||||
|
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
childList
|
childList
|
||||||
@@ -172,23 +207,33 @@ struct SeriesDetailView: View {
|
|||||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
case .loaded(let entries):
|
case .loaded(let entries):
|
||||||
|
let nummern = staffelnummern(in: entries)
|
||||||
|
let sichtbar = sichtbareFolgen(in: entries, staffeln: nummern)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
||||||
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
||||||
// als von allem anderen auf dieser Seite.
|
// als von allem anderen auf dieser Seite.
|
||||||
HStack {
|
HStack {
|
||||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
if nummern.count > 1 {
|
||||||
.font(.flaroRowHeader)
|
staffelwahl(nummern)
|
||||||
.foregroundStyle(Color.flaroTextPrimary)
|
} else {
|
||||||
|
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if entries.contains(where: { $0.kind == .episode }) {
|
if sichtbar.contains(where: { $0.kind == .episode }) {
|
||||||
selectionControls(for: entries)
|
// **Nur die gezeigte Staffel.** „Alle auswählen" soll
|
||||||
|
// das meinen, was dasteht — sonst lädt ein Griff
|
||||||
|
// sechzehn Folgen herunter statt acht.
|
||||||
|
selectionControls(for: sichtbar)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(entries) { entry in
|
ForEach(sichtbar) { entry in
|
||||||
row(for: entry)
|
row(for: entry)
|
||||||
Divider().overlay(Color.flaroSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
}
|
}
|
||||||
@@ -197,6 +242,55 @@ struct SeriesDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Welche Staffeln in dieser Liste vorkommen.
|
||||||
|
///
|
||||||
|
/// Leer, wenn die Einträge selbst Staffeln sind — dann gibt es nichts zu
|
||||||
|
/// wählen, die Liste **ist** die Wahl.
|
||||||
|
private func staffelnummern(in entries: [MediaItem]) -> [Int] {
|
||||||
|
Set(entries.compactMap { $0.kind == .episode ? $0.parentIndex : nil }).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sichtbareFolgen(in entries: [MediaItem], staffeln: [Int]) -> [MediaItem] {
|
||||||
|
guard staffeln.count > 1 else { return entries }
|
||||||
|
let gewaehlt = gewaehlteStaffel ?? staffeln[0]
|
||||||
|
return entries.filter { $0.kind != .episode || $0.parentIndex == gewaehlt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Staffel als Menü — an der Stelle, an der sonst „Folgen" steht.
|
||||||
|
///
|
||||||
|
/// **Kein `Picker`.** Diese Seite ist eine `ScrollView`, kein `Form`;
|
||||||
|
/// dort hat ein `Picker` kein Aussehen, auf das man sich verlassen kann.
|
||||||
|
/// Dieselbe Begründung wie bei `AudioLanguagePicker`.
|
||||||
|
private func staffelwahl(_ nummern: [Int]) -> some View {
|
||||||
|
let gewaehlt = gewaehlteStaffel ?? nummern[0]
|
||||||
|
|
||||||
|
return Menu {
|
||||||
|
ForEach(nummern, id: \.self) { nummer in
|
||||||
|
Button {
|
||||||
|
gewaehlteStaffel = nummer
|
||||||
|
// Eine Auswahl aus der vorigen Staffel meint hier nichts
|
||||||
|
// mehr — stehen zu lassen, was man nicht mehr sieht,
|
||||||
|
// lädt unsichtbare Folgen herunter.
|
||||||
|
selection.removeAll()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
String(localized: "season.short \(nummer)"),
|
||||||
|
systemImage: nummer == gewaehlt ? "checkmark" : ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: FlaroSpacing.tight) {
|
||||||
|
Text("season.short \(gewaehlt)")
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
|
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func row(for entry: MediaItem) -> some View {
|
private func row(for entry: MediaItem) -> some View {
|
||||||
@@ -376,11 +470,31 @@ struct SeriesDetailView: View {
|
|||||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holt die Staffeln oder Folgen — und sonst, was auf dem Gerät liegt.
|
||||||
|
///
|
||||||
|
/// **Sonst wäre die Seite einer heruntergeladenen Serie leer.** Sie ist
|
||||||
|
/// das Ziel, auf das die Downloadreihe zeigt; dort anzukommen und einen
|
||||||
|
/// Fehler zu lesen, während die Folgen daneben auf der Platte liegen,
|
||||||
|
/// wäre die Umkehrung dessen, wofür man sie geladen hat.
|
||||||
private func loadChildren() async {
|
private func loadChildren() async {
|
||||||
guard let server = session.backend else { return }
|
guard let server = session.backend else {
|
||||||
|
children = vomGeraet.isEmpty
|
||||||
|
? .failed(message: String(localized: "error.serverUnreachable"))
|
||||||
|
: .loaded(vomGeraet)
|
||||||
|
return
|
||||||
|
}
|
||||||
children = await LoadState.load {
|
children = await LoadState.load {
|
||||||
try await server.children(of: item.id)
|
try await server.children(of: item.id)
|
||||||
}
|
}
|
||||||
|
if case .failed = children, !vomGeraet.isEmpty {
|
||||||
|
children = .loaded(vomGeraet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Folgen dieser Serie, die auf dem Gerät liegen.
|
||||||
|
private var vomGeraet: [MediaItem] {
|
||||||
|
guard item.kind == .show else { return [] }
|
||||||
|
return Heruntergeladenes.folgen(derSerie: item.id, in: downloads)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,7 +517,7 @@ private struct ChildRow: View {
|
|||||||
.frame(width: item.kind == .season ? 48 : 96)
|
.frame(width: item.kind == .season ? 48 : 96)
|
||||||
.clipShape(.rect(cornerRadius: 4))
|
.clipShape(.rect(cornerRadius: 4))
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if let progress = item.watchProgress {
|
if let progress = item.resumeProgress {
|
||||||
WatchProgressBar(fraction: progress)
|
WatchProgressBar(fraction: progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,21 +47,28 @@ struct HeroSection: View {
|
|||||||
// Bildschirm leer.
|
// Bildschirm leer.
|
||||||
fillsAvailableSpace: sizeClass != .compact
|
fillsAvailableSpace: sizeClass != .compact
|
||||||
) {
|
) {
|
||||||
Button {
|
// Geplant heisst sichtbar, nicht abspielbar — statt des Knopfes
|
||||||
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher
|
// der Termin.
|
||||||
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert
|
if item.isUpcoming, let ab = item.availableFrom {
|
||||||
// neue Folgen als ihre Serie —, schickte der direkte Weg
|
UpcomingNotice(date: ab)
|
||||||
// deren eigene Kennung an den Player, und der scheiterte mit
|
} else {
|
||||||
// „keine abspielbare Datei". Eine Serie hat keine Fassungen.
|
Button {
|
||||||
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||||
} label: {
|
// Aufmacher eine Serie — und Jellyfins „Zuletzt
|
||||||
Label(playLabel, systemImage: "play.fill")
|
// hinzugefügt" liefert neue Folgen als ihre Serie —,
|
||||||
.font(.flaroCardTitle)
|
// schickte der direkte Weg deren eigene Kennung an den
|
||||||
.padding(.horizontal, FlaroSpacing.loose)
|
// Player, und der scheiterte mit „keine abspielbare
|
||||||
.padding(.vertical, FlaroSpacing.snug)
|
// Datei". Eine Serie hat keine Fassungen.
|
||||||
|
quickPlay.start(item, backend: session.backend, coordinator: playback)
|
||||||
|
} label: {
|
||||||
|
Label(playLabel, systemImage: "play.fill")
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accent)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(accent)
|
|
||||||
|
|
||||||
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
|
||||||
Label("hero.details", systemImage: "info.circle")
|
Label("hero.details", systemImage: "info.circle")
|
||||||
@@ -77,7 +84,7 @@ struct HeroSection: View {
|
|||||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
||||||
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
||||||
private var playLabel: LocalizedStringKey {
|
private var playLabel: LocalizedStringKey {
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
item.isResumable ? "hero.resume" : "hero.play"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metadata: String? { Self.metadata(for: item) }
|
private var metadata: String? { Self.metadata(for: item) }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||||
@@ -11,15 +12,26 @@ import MediaKit
|
|||||||
struct HomeView: View {
|
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(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.
|
||||||
|
///
|
||||||
|
/// Wird bei jeder Änderung an den Downloads neu berechnet, weil
|
||||||
|
/// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht
|
||||||
|
/// ohne Neuladen der Startseite da.
|
||||||
|
private var heruntergeladen: [MediaItem] {
|
||||||
|
Heruntergeladenes.reihe(in: downloads)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
switch feed.state {
|
switch feed.state {
|
||||||
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: {
|
||||||
@@ -28,8 +40,12 @@ 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.**
|
||||||
let hubs = feed.rows(for: layout)
|
// 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)
|
||||||
if hubs.isEmpty {
|
if hubs.isEmpty {
|
||||||
ContentUnavailableView {
|
ContentUnavailableView {
|
||||||
Label("home.empty.title", systemImage: "film.stack")
|
Label("home.empty.title", systemImage: "film.stack")
|
||||||
@@ -59,7 +75,18 @@ struct HomeView: View {
|
|||||||
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
||||||
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
.task(id: session.serverName) { await feed.load(from: session.backend) }
|
.task(id: session.serverName) {
|
||||||
|
await feed.load(from: session.backend)
|
||||||
|
// Siehe `DownloadService.plakateNachholen`: ältere Downloads
|
||||||
|
// haben kein Bild, und hier ist die Stelle, an der man es merken
|
||||||
|
// würde.
|
||||||
|
await DownloadService(backend: session.backend, coordinator: downloads)
|
||||||
|
.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:
|
||||||
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
||||||
@@ -69,13 +96,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) {
|
||||||
let featured = feed.heroItems(for: layout)
|
if ohneServer {
|
||||||
if !featured.isEmpty {
|
offlinehinweis
|
||||||
HeroCarousel(items: featured)
|
} else {
|
||||||
|
let featured = feed.heroItems(for: layout)
|
||||||
|
if !featured.isEmpty {
|
||||||
|
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)
|
||||||
@@ -83,4 +119,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine waagerechte Reihe von Titeln.
|
/// Eine waagerechte Reihe von Titeln.
|
||||||
struct MediaRow: View {
|
struct MediaRow: View {
|
||||||
let row: MediaRowGroup
|
let row: MediaRowGroup
|
||||||
/// Wird durchgereicht — siehe `QuickPlayTile`.
|
/// Ob ein Tipp sofort abspielt — siehe `QuickPlayTile`.
|
||||||
var opensDetails: Bool = false
|
///
|
||||||
|
/// **Die Reihe weiss es, nicht die Kachel.** „Weiterschauen" ist eine
|
||||||
|
/// Eigenschaft der Reihe; eine Kachel kennt nur ihren Titel.
|
||||||
|
private var startsPlayback: Bool { row.isContinueWatching }
|
||||||
|
|
||||||
@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
|
||||||
|
|
||||||
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
||||||
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
||||||
@@ -32,7 +37,7 @@ struct MediaRow: View {
|
|||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
LazyHStack(spacing: FlaroSpacing.regular) {
|
LazyHStack(spacing: FlaroSpacing.regular) {
|
||||||
ForEach(row.items) { item in
|
ForEach(row.items) { item in
|
||||||
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
|
QuickPlayTile(item: item, startsPlayback: startsPlayback) { card(for: item) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
@@ -55,27 +60,41 @@ struct MediaRow: View {
|
|||||||
if row.isContinueWatching {
|
if row.isContinueWatching {
|
||||||
LandscapeCard(
|
LandscapeCard(
|
||||||
title: item.rowTitle,
|
title: item.rowTitle,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: untertitel(for: item),
|
||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||||
path: item.rowArtworkPath,
|
path: item.rowArtworkPath,
|
||||||
width: 440,
|
width: 440,
|
||||||
height: 248
|
height: 248
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
.frame(width: landscapeWidth)
|
.frame(width: landscapeWidth)
|
||||||
} else {
|
} else {
|
||||||
PosterCard(
|
PosterCard(
|
||||||
title: item.rowTitle,
|
title: item.rowTitle,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: untertitel(for: item),
|
||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
|
||||||
path: item.posterArtworkPath,
|
path: item.posterArtworkPath,
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 450
|
height: 450
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
.frame(width: posterWidth)
|
.frame(width: posterWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was unter der Kachel steht.
|
||||||
|
///
|
||||||
|
/// **In der Downloadreihe zählt, wie viel da ist.** Eine Serie steht
|
||||||
|
/// dort als eine Kachel; „2019" darunter beantwortet nicht die Frage,
|
||||||
|
/// die man an dieser Stelle hat — nämlich wie viele Folgen man dabei
|
||||||
|
/// hat.
|
||||||
|
private func untertitel(for item: MediaItem) -> String? {
|
||||||
|
if row.id == HomeFeed.downloadsRow,
|
||||||
|
let zahl = Heruntergeladenes.untertitel(für: item, in: downloads) {
|
||||||
|
return zahl
|
||||||
|
}
|
||||||
|
return item.cardSubtitle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import MediaKit
|
|||||||
/// Filter und Sortierung einer Mediathek.
|
/// Filter und Sortierung einer Mediathek.
|
||||||
struct LibraryFilterSheet: View {
|
struct LibraryFilterSheet: View {
|
||||||
@Binding var query: MediaQuery
|
@Binding var query: MediaQuery
|
||||||
|
/// Steht getrennt von `query`, weil der Server ihn nicht auswerten kann —
|
||||||
|
/// siehe `LibraryView.nurHeruntergeladene`.
|
||||||
|
@Binding var nurHeruntergeladene: Bool
|
||||||
/// Die Mediathek selbst, nicht nur ihre Art.
|
/// Die Mediathek selbst, nicht nur ihre Art.
|
||||||
///
|
///
|
||||||
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
|
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
|
||||||
@@ -23,11 +26,18 @@ struct LibraryFilterSheet: View {
|
|||||||
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
|
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
|
||||||
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
||||||
@State private var draft: MediaQuery
|
@State private var draft: MediaQuery
|
||||||
|
@State private var entwurfHeruntergeladene: Bool
|
||||||
|
|
||||||
init(query: Binding<MediaQuery>, library: MediaLibrary) {
|
init(
|
||||||
|
query: Binding<MediaQuery>,
|
||||||
|
nurHeruntergeladene: Binding<Bool>,
|
||||||
|
library: MediaLibrary
|
||||||
|
) {
|
||||||
self._query = query
|
self._query = query
|
||||||
|
self._nurHeruntergeladene = nurHeruntergeladene
|
||||||
self.library = library
|
self.library = library
|
||||||
self._draft = State(initialValue: query.wrappedValue)
|
self._draft = State(initialValue: query.wrappedValue)
|
||||||
|
self._entwurfHeruntergeladene = State(initialValue: nurHeruntergeladene.wrappedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -45,6 +55,7 @@ struct LibraryFilterSheet: View {
|
|||||||
|
|
||||||
Section("filter.filters") {
|
Section("filter.filters") {
|
||||||
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
||||||
|
Toggle("filter.downloadedOnly", isOn: $entwurfHeruntergeladene)
|
||||||
|
|
||||||
Picker("filter.genre", selection: $draft.genre) {
|
Picker("filter.genre", selection: $draft.genre) {
|
||||||
Text("filter.any").tag(String?.none)
|
Text("filter.any").tag(String?.none)
|
||||||
@@ -80,8 +91,9 @@ struct LibraryFilterSheet: View {
|
|||||||
Section {
|
Section {
|
||||||
Button("filter.reset", role: .destructive) {
|
Button("filter.reset", role: .destructive) {
|
||||||
draft = MediaQuery(sort: draft.sort)
|
draft = MediaQuery(sort: draft.sort)
|
||||||
|
entwurfHeruntergeladene = false
|
||||||
}
|
}
|
||||||
.disabled(!draft.isFiltering)
|
.disabled(!draft.isFiltering && !entwurfHeruntergeladene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ausdrücklich, nicht nur geerbt.
|
// Ausdrücklich, nicht nur geerbt.
|
||||||
@@ -102,6 +114,7 @@ struct LibraryFilterSheet: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.apply") {
|
Button("action.apply") {
|
||||||
|
nurHeruntergeladene = entwurfHeruntergeladene
|
||||||
query = draft
|
query = draft
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine Mediathek zum Durchblättern.
|
/// Eine Mediathek zum Durchblättern.
|
||||||
@@ -10,6 +11,7 @@ struct LibraryView: View {
|
|||||||
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
|
||||||
/// zwischen Raster und Liste.
|
/// zwischen Raster und Liste.
|
||||||
@Environment(HomeLayoutSettings.self) private var sizing
|
@Environment(HomeLayoutSettings.self) private var sizing
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
|
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
@@ -25,6 +27,14 @@ struct LibraryView: View {
|
|||||||
@State private var loadMoreFailed = false
|
@State private var loadMoreFailed = false
|
||||||
@State private var isLoadingMore = false
|
@State private var isLoadingMore = false
|
||||||
@State private var showsFilters = false
|
@State private var showsFilters = false
|
||||||
|
/// Ob nur gezeigt wird, was auf diesem Gerät liegt.
|
||||||
|
///
|
||||||
|
/// **Kein Teil von `MediaQuery`**, und das ist der Kern der Sache: die
|
||||||
|
/// Abfrage wertet der Server aus, und der weiss nicht, was auf diesem
|
||||||
|
/// Gerät liegt. Steht der Filter an, wird gar nicht erst gefragt — die
|
||||||
|
/// Liste kommt aus den Downloads. Das ist genau, vollständig, und
|
||||||
|
/// funktioniert ohne Verbindung.
|
||||||
|
@State private var nurHeruntergeladene = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -48,8 +58,9 @@ struct LibraryView: View {
|
|||||||
} description: {
|
} description: {
|
||||||
Text("library.empty.description")
|
Text("library.empty.description")
|
||||||
} actions: {
|
} actions: {
|
||||||
if query.isFiltering {
|
if query.isFiltering || nurHeruntergeladene {
|
||||||
Button("library.clearFilters") {
|
Button("library.clearFilters") {
|
||||||
|
nurHeruntergeladene = false
|
||||||
query = MediaQuery(sort: query.sort)
|
query = MediaQuery(sort: query.sort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +75,11 @@ struct LibraryView: View {
|
|||||||
.flaroInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar { toolbar }
|
.toolbar { toolbar }
|
||||||
.sheet(isPresented: $showsFilters) {
|
.sheet(isPresented: $showsFilters) {
|
||||||
LibraryFilterSheet(query: $query, library: library)
|
LibraryFilterSheet(
|
||||||
|
query: $query,
|
||||||
|
nurHeruntergeladene: $nurHeruntergeladene,
|
||||||
|
library: library
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.task(id: library.id) {
|
.task(id: library.id) {
|
||||||
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
||||||
@@ -78,6 +93,11 @@ struct LibraryView: View {
|
|||||||
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
|
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
|
||||||
// verschiedenen Abfragen vermischen.
|
// verschiedenen Abfragen vermischen.
|
||||||
.onChange(of: query) { _, _ in Task { await reload() } }
|
.onChange(of: query) { _, _ in Task { await reload() } }
|
||||||
|
.onChange(of: nurHeruntergeladene) { _, _ in
|
||||||
|
items = []
|
||||||
|
page = nil
|
||||||
|
Task { await reload() }
|
||||||
|
}
|
||||||
.refreshable { await reload() }
|
.refreshable { await reload() }
|
||||||
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
||||||
.environment(\.flaroItemChanged, { await reload() })
|
.environment(\.flaroItemChanged, { await reload() })
|
||||||
@@ -104,9 +124,13 @@ struct LibraryView: View {
|
|||||||
QuickPlayTile(item: item) {
|
QuickPlayTile(item: item) {
|
||||||
PosterCard(
|
PosterCard(
|
||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.cardSubtitle,
|
subtitle: nurHeruntergeladene
|
||||||
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
? Heruntergeladenes.untertitel(für: item, in: downloads)
|
||||||
progress: item.watchProgress
|
?? item.cardSubtitle
|
||||||
|
: item.cardSubtitle,
|
||||||
|
artworkURL: Heruntergeladenes.bildAdresse(item)
|
||||||
|
?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
||||||
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.task { await loadMoreIfNeeded(reaching: item) }
|
.task { await loadMoreIfNeeded(reaching: item) }
|
||||||
@@ -232,7 +256,7 @@ struct LibraryView: View {
|
|||||||
Button {
|
Button {
|
||||||
showsFilters = true
|
showsFilters = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: query.isFiltering
|
Image(systemName: query.isFiltering || nurHeruntergeladene
|
||||||
? "line.3.horizontal.decrease.circle.fill"
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
: "line.3.horizontal.decrease.circle")
|
: "line.3.horizontal.decrease.circle")
|
||||||
}
|
}
|
||||||
@@ -276,6 +300,16 @@ struct LibraryView: View {
|
|||||||
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
||||||
/// Fehlerbildschirm.
|
/// Fehlerbildschirm.
|
||||||
private func reload() async {
|
private func reload() async {
|
||||||
|
// **Vor dem Server, nicht danach.** Was auf dem Gerät liegt, weiss
|
||||||
|
// nur das Gerät — und es weiss es auch im Flugmodus.
|
||||||
|
if nurHeruntergeladene {
|
||||||
|
items = geraeteliste
|
||||||
|
page = MediaPage(items: items, totalCount: items.count)
|
||||||
|
loadMoreFailed = false
|
||||||
|
state = .loaded(())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
guard let server = session.backend else { return }
|
guard let server = session.backend else { return }
|
||||||
let schlüssel = query.cacheKey
|
let schlüssel = query.cacheKey
|
||||||
let kennung = server.identity
|
let kennung = server.identity
|
||||||
@@ -319,6 +353,25 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Downloads, die in diese Mediathek gehören.
|
||||||
|
///
|
||||||
|
/// **Zugeordnet über die Art der Mediathek**, weil ein Download seine
|
||||||
|
/// Mediathek nicht kennt: er führt einen Serienschlüssel oder keinen,
|
||||||
|
/// und mehr braucht es nicht. Eine Folge gehört in eine Serien-,
|
||||||
|
/// ein Film in eine Filmmediathek.
|
||||||
|
///
|
||||||
|
/// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst
|
||||||
|
/// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen.
|
||||||
|
private var geraeteliste: [MediaItem] {
|
||||||
|
Heruntergeladenes.reihe(in: downloads).filter { titel in
|
||||||
|
switch library.kind {
|
||||||
|
case .movie: titel.kind != .show
|
||||||
|
case .show: titel.kind == .show
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
||||||
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
||||||
guard
|
guard
|
||||||
@@ -385,7 +438,8 @@ private struct LibraryListRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: FlaroSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
FlaroArtwork(
|
FlaroArtwork(
|
||||||
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
url: Heruntergeladenes.bildAdresse(item)
|
||||||
|
?? session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
|
||||||
aspectRatio: PosterCard.aspectRatio
|
aspectRatio: PosterCard.aspectRatio
|
||||||
)
|
)
|
||||||
.frame(width: 44)
|
.frame(width: 44)
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Der erste Buchstabe auf einer Scheibe.
|
/// Das eigene Bild auf einer Scheibe — oder der erste Buchstabe.
|
||||||
///
|
///
|
||||||
/// **Kein Platzhalterbild.** Jellyfin gibt kein Profilbild heraus, ohne dass
|
/// **Der Buchstabe ist keine Notlösung.** Jellyfin gibt kein Profilbild
|
||||||
/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein
|
/// heraus, und auch an einem Flaro-Konto muss keines hängen. Ein Buchstabe
|
||||||
/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich
|
/// ist ehrlicher als eine graue Silhouette, die bei jedem gleich aussieht —
|
||||||
/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man
|
/// und man erkennt an ihm wenigstens, mit welchem Konto man angemeldet ist.
|
||||||
/// angemeldet ist.
|
|
||||||
///
|
///
|
||||||
/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in
|
/// Steht ein Bild im Konto, tritt es an seine Stelle. Lädt es nicht, kommt
|
||||||
/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des
|
/// der Buchstabe zurück: eine leere Scheibe sagt weniger als ein P.
|
||||||
/// Einstellungen-Tabs der Weg dorthin liegt.
|
///
|
||||||
|
/// Stand einmal in `AccountBar` und wird inzwischen dreimal gebraucht: dort in
|
||||||
|
/// der Seitenleiste, auf dem iPhone oben rechts und in der Werkzeugleiste.
|
||||||
struct AccountAvatar: View {
|
struct AccountAvatar: View {
|
||||||
let name: String
|
let name: String
|
||||||
var diameter: CGFloat = 26
|
var diameter: CGFloat = 26
|
||||||
|
/// Das Bild des Kontos, falls eines hinterlegt ist.
|
||||||
|
var image: URL?
|
||||||
|
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
buchstabe
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.background(accent, in: .circle)
|
||||||
|
.overlay {
|
||||||
|
if let image {
|
||||||
|
AsyncImage(url: image) { bild in
|
||||||
|
bild.resizable().scaledToFill()
|
||||||
|
} placeholder: {
|
||||||
|
// Nichts: darunter liegt der Buchstabe und füllt die
|
||||||
|
// Lücke, bis das Bild da ist.
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.clipShape(.circle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buchstabe: some View {
|
||||||
Text(name.prefix(1).uppercased())
|
Text(name.prefix(1).uppercased())
|
||||||
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
||||||
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
||||||
@@ -26,8 +48,6 @@ struct AccountAvatar: View {
|
|||||||
// Fehler.
|
// Fehler.
|
||||||
.font(.system(size: diameter * 0.46, weight: .semibold))
|
.font(.system(size: diameter * 0.46, weight: .semibold))
|
||||||
.foregroundStyle(Color.flaroCanvas)
|
.foregroundStyle(Color.flaroCanvas)
|
||||||
.frame(width: diameter, height: diameter)
|
|
||||||
.background(accent, in: .circle)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +73,8 @@ struct AccountToolbarButton: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: openSettings) {
|
Button(action: openSettings) {
|
||||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||||
|
image: session.accountAvatarURL)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(Text("sidebar.settings"))
|
.accessibilityLabel(Text("sidebar.settings"))
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ struct AccountBar: View {
|
|||||||
isExpanded.toggle()
|
isExpanded.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: FlaroSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
AccountAvatar(name: title)
|
AccountAvatar(name: title, image: session.accountAvatarURL)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text(title)
|
Text(title)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -514,7 +516,8 @@ struct RootView: View {
|
|||||||
Button {
|
Button {
|
||||||
showsSettings = true
|
showsSettings = true
|
||||||
} label: {
|
} label: {
|
||||||
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34)
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||||
|
image: session.accountAvatarURL)
|
||||||
.padding(FlaroSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
// Damit er auch über einem hellen Aufmacher steht.
|
// Damit er auch über einem hellen Aufmacher steht.
|
||||||
.background(.black.opacity(0.28), in: .circle)
|
.background(.black.opacity(0.28), in: .circle)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
|
||||||
///
|
///
|
||||||
@@ -34,6 +35,11 @@ struct PlaybackScrubber: View {
|
|||||||
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
|
||||||
let onActivity: () -> Void
|
let onActivity: () -> Void
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
|
||||||
|
var vorschau: Vorschauplan?
|
||||||
|
/// Woher ein Blatt kommt.
|
||||||
|
var blatt: (Int) -> URL? = { _ in nil }
|
||||||
|
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
private static let trackHeight: CGFloat = 5
|
private static let trackHeight: CGFloat = 5
|
||||||
@@ -71,6 +77,22 @@ struct PlaybackScrubber: View {
|
|||||||
.offset(x: gespielt - Self.thumbSize / 2)
|
.offset(x: gespielt - Self.thumbSize / 2)
|
||||||
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
|
||||||
}
|
}
|
||||||
|
// **Die Vorschau steht über dem Griff, nicht darunter.** Unter
|
||||||
|
// dem Finger verdeckte sie genau die Stelle, die man sucht.
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
if let plan = vorschau,
|
||||||
|
let gezogen = scrubbing,
|
||||||
|
let stelle = plan.stelle(bei: gezogen) {
|
||||||
|
Vorschaukachel(plan: plan, stelle: stelle, blatt: blatt)
|
||||||
|
.offset(
|
||||||
|
x: min(
|
||||||
|
breite - plan.kachelBreite,
|
||||||
|
max(0, gespielt - plan.kachelBreite / 2)),
|
||||||
|
y: -Self.hitHeight / 2 - 8)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
.frame(height: Self.hitHeight)
|
.frame(height: Self.hitHeight)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.gesture(
|
.gesture(
|
||||||
@@ -117,3 +139,75 @@ struct PlaybackScrubber: View {
|
|||||||
return min(max(Double(x / width), 0), 1) * duration.seconds
|
return min(max(Double(x / width), 0), 1) * duration.seconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein Standbild aus dem Film, ausgeschnitten aus einem Blatt.
|
||||||
|
///
|
||||||
|
/// **Ausgeschnitten, nicht einzeln geladen.** Ein Blatt trägt hundert
|
||||||
|
/// Bilder; beim Ziehen über die Leiste wird nur der Ausschnitt verschoben,
|
||||||
|
/// und nach dem ersten Blatt kommt kein Abruf mehr. Einzelne Bilder zu
|
||||||
|
/// holen hiesse, bei jeder Fingerbewegung ins Netz zu greifen.
|
||||||
|
///
|
||||||
|
/// **Geladen wird von Hand, nicht über `FlaroArtwork`.** Jenes rechnet mit
|
||||||
|
/// Seitenverhältnissen und schneidet selbst zu — hier wird ein Blatt
|
||||||
|
/// gebraucht, das genau so gross dasteht, wie es ist, damit der Versatz
|
||||||
|
/// auf den Bildpunkt stimmt.
|
||||||
|
struct Vorschaukachel: View {
|
||||||
|
let plan: Vorschauplan
|
||||||
|
let stelle: Vorschauplan.Stelle
|
||||||
|
/// Woher das Blatt kommt — der Spieler weiss, welche Datei läuft.
|
||||||
|
let blatt: (Int) -> URL?
|
||||||
|
|
||||||
|
@State private var bild: PlatformImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Rechteck
|
||||||
|
.frame(width: plan.kachelBreite, height: plan.kachelHoehe)
|
||||||
|
.background(.black)
|
||||||
|
.clipShape(.rect(cornerRadius: FlaroRadius.card))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: FlaroRadius.card)
|
||||||
|
.stroke(.white.opacity(0.18), lineWidth: 1))
|
||||||
|
.shadow(color: .black.opacity(0.6), radius: 10, y: 4)
|
||||||
|
.task(id: stelle.blatt) { await laden() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var Rechteck: some View {
|
||||||
|
if let bild {
|
||||||
|
// `Image(platform:)` liegt in FlaroDesign und ist paketintern;
|
||||||
|
// dieselbe Fallunterscheidung, zwei Zeilen.
|
||||||
|
bildAnsicht(bild)
|
||||||
|
.resizable()
|
||||||
|
.frame(
|
||||||
|
width: plan.kachelBreite * Double(plan.spalten),
|
||||||
|
height: plan.kachelHoehe * Double(plan.zeilen))
|
||||||
|
// Der Ausschnitt entsteht durch Verschieben hinter einem
|
||||||
|
// Rahmen in Kachelgrösse — dieselbe Rechnung wie
|
||||||
|
// `background-position` im Web.
|
||||||
|
.offset(
|
||||||
|
x: -plan.kachelBreite * Double(stelle.spalte),
|
||||||
|
y: -plan.kachelHoehe * Double(stelle.zeile))
|
||||||
|
.frame(
|
||||||
|
width: plan.kachelBreite, height: plan.kachelHoehe,
|
||||||
|
alignment: .topLeading)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
Color.black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bildAnsicht(_ bild: PlatformImage) -> Image {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
Image(uiImage: bild)
|
||||||
|
#else
|
||||||
|
Image(nsImage: bild)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func laden() async {
|
||||||
|
guard let adresse = blatt(stelle.blatt) else { return }
|
||||||
|
// Der Lader hält Blätter im Speicher: beim Ziehen über die Leiste
|
||||||
|
// wird dasselbe Blatt hundertmal gebraucht.
|
||||||
|
bild = await ArtworkLoader.shared.image(for: adresse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,9 +61,20 @@ final class PlayerModel {
|
|||||||
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
||||||
/// Überspringen-Knopf.
|
/// Überspringen-Knopf.
|
||||||
private(set) var activeMarker: MediaMarker?
|
private(set) var activeMarker: MediaMarker?
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
|
||||||
|
///
|
||||||
|
/// **Einmal gefragt, bei „noch nicht" einmal nachgefasst.** Die Blätter
|
||||||
|
/// entstehen beim Abspielen im Hintergrund; nach zehn Sekunden sind sie
|
||||||
|
/// bei den meisten Dateien da. Weiter zu fragen hiesse, für eine
|
||||||
|
/// Annehmlichkeit dauernd am Server zu ziehen.
|
||||||
|
private(set) var vorschau: Vorschauplan?
|
||||||
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
||||||
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
||||||
private(set) var nextEpisode: MediaItem?
|
private(set) var nextEpisode: MediaItem?
|
||||||
|
/// Die Folge davor — für den Rückwärtsknopf. `nil` heisst: es ist die
|
||||||
|
/// erste, oder es ist gar keine Folge.
|
||||||
|
private(set) var previousEpisode: MediaItem?
|
||||||
private(set) var hasReachedEnd = false
|
private(set) var hasReachedEnd = false
|
||||||
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
|
||||||
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
|
||||||
@@ -105,6 +116,26 @@ final class PlayerModel {
|
|||||||
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
|
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
|
||||||
private(set) var bufferedUntil: Duration?
|
private(set) var bufferedUntil: Duration?
|
||||||
|
|
||||||
|
/// Ab welcher Stelle des Films der laufende Strom beginnt.
|
||||||
|
///
|
||||||
|
/// **Null ist der Normalfall.** Dann ist der Strom der ganze Film, und
|
||||||
|
/// die Uhr des Spielers ist die Uhr des Films.
|
||||||
|
///
|
||||||
|
/// **Darüber heisst: der Server hat schon gesprungen.** Flaros
|
||||||
|
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
|
||||||
|
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
|
||||||
|
/// null — ohne diesen Wert stünde bei „Fortsetzen ab 1:32" eine Null auf
|
||||||
|
/// der Uhr, und genau die würde auch gemeldet.
|
||||||
|
private var streamOffset: Duration = .zero
|
||||||
|
|
||||||
|
/// Bis wohin der laufende Strom reicht.
|
||||||
|
///
|
||||||
|
/// **Nicht dasselbe wie <see cref="duration"/>.** Das ist die Länge des
|
||||||
|
/// Films; dies ist, was davon in diesem Strom liegt. Bei einer
|
||||||
|
/// wachsenden Abspielliste wächst es mit. Ein Sprung darüber hinaus
|
||||||
|
/// braucht einen neuen Strom, keinen Sprung.
|
||||||
|
private var streamEnd: Duration?
|
||||||
|
|
||||||
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
|
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
|
||||||
///
|
///
|
||||||
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
|
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
|
||||||
@@ -211,6 +242,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
|
||||||
@@ -461,6 +495,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,
|
||||||
@@ -474,6 +510,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
|
||||||
@@ -499,6 +536,18 @@ final class PlayerModel {
|
|||||||
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
||||||
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
|
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
|
||||||
await startLocal(url: localURL)
|
await startLocal(url: localURL)
|
||||||
|
// **Die Angaben trotzdem holen, nur eben hinterher.**
|
||||||
|
//
|
||||||
|
// Hier wurde früher schlicht zurückgekehrt — und damit blieb
|
||||||
|
// `item` leer. Daran hing mehr, als es aussieht: keine Marken,
|
||||||
|
// also kein „Intro überspringen"; keine nächste Folge, also
|
||||||
|
// keine Knöpfe zum Blättern. Bei allem, was heruntergeladen
|
||||||
|
// war, fehlte das, und nur dort — weshalb es so lange aussah
|
||||||
|
// wie ein Fehler in der Erkennung.
|
||||||
|
//
|
||||||
|
// Nebenher und nicht davor: das Bild soll sofort laufen, und
|
||||||
|
// ohne Verbindung gibt es ohnehin nichts zu holen.
|
||||||
|
metadatenNachladen()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,6 +606,7 @@ final class PlayerModel {
|
|||||||
|
|
||||||
await load(source, decision: decision)
|
await load(source, decision: decision)
|
||||||
preloadNextEpisode()
|
preloadNextEpisode()
|
||||||
|
vorschauHolen()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stellt auf eine andere Qualitätsstufe um.
|
/// Stellt auf eine andere Qualitätsstufe um.
|
||||||
@@ -718,7 +768,58 @@ final class PlayerModel {
|
|||||||
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
|
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holt Titelangaben nach, während die lokale Datei schon läuft.
|
||||||
|
///
|
||||||
|
/// Scheitert es — kein Server, kein Netz —, bleibt es, wie es war: die
|
||||||
|
/// Datei läuft, es gibt bloss keine Marken und keine Nachbarfolgen.
|
||||||
|
/// Welche Datei läuft — die Vorschau hängt an ihr, nicht am Titel.
|
||||||
|
///
|
||||||
|
/// `MediaVersion.id` ist die Kennung der Mediendatei; eine Fassung
|
||||||
|
/// besteht aus genau einer.
|
||||||
|
private var vorschaudatei: String?
|
||||||
|
|
||||||
|
/// Holt den Vorschauplan — einmal sofort, einmal nach zehn Sekunden.
|
||||||
|
private func vorschauHolen() {
|
||||||
|
guard vorschau == nil, let backend, let item else { return }
|
||||||
|
guard let datei = version(of: item)?.id else { return }
|
||||||
|
vorschaudatei = datei
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
for warten in [Duration.zero, .seconds(10)] {
|
||||||
|
if warten > .zero { try? await Task.sleep(for: warten) }
|
||||||
|
guard let self, self.vorschau == nil else { return }
|
||||||
|
if let plan = await backend.vorschauplan(fileId: datei) {
|
||||||
|
self.vorschau = plan
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Woher ein Vorschaublatt kommt.
|
||||||
|
func vorschaublatt(_ nummer: Int) -> URL? {
|
||||||
|
guard let backend, let datei = vorschaudatei else { return nil }
|
||||||
|
return backend.vorschaublatt(fileId: datei, blatt: nummer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metadatenNachladen() {
|
||||||
|
guard item == nil, let backend else { return }
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let geholt = try? await backend.item(id: self?.request.ratingKey ?? "") else { return }
|
||||||
|
guard let self, self.item == nil else { return }
|
||||||
|
self.item = geholt
|
||||||
|
self.updateActiveMarker()
|
||||||
|
self.preloadNextEpisode()
|
||||||
|
self.vorschauHolen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func startLocal(url: URL) async {
|
private func startLocal(url: URL) async {
|
||||||
|
// Eine Datei auf dem Gerät ist der ganze Film; es gibt keinen
|
||||||
|
// Vorlauf, den der Spieler aufrechnen müsste.
|
||||||
|
streamOffset = .zero
|
||||||
|
streamEnd = nil
|
||||||
|
|
||||||
let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
|
let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
|
||||||
|
|
||||||
let decision = PlaybackDecider().decide(
|
let decision = PlaybackDecider().decide(
|
||||||
@@ -839,6 +940,26 @@ final class PlayerModel {
|
|||||||
playbackSession = target.sessionID
|
playbackSession = target.sessionID
|
||||||
playbackVersionID = target.versionID
|
playbackVersionID = target.versionID
|
||||||
|
|
||||||
|
// **Wer schon gesprungen ist, springt nicht noch einmal.**
|
||||||
|
//
|
||||||
|
// Flaro legt den Strom an der gewünschten Stelle an; Jellyfin
|
||||||
|
// liefert den ganzen Film und lässt den Spieler springen. Beides
|
||||||
|
// zugleich hiesse bei einundzwanzig Minuten Fortschritt, bei
|
||||||
|
// zweiundvierzig zu landen.
|
||||||
|
streamOffset = target.startOffset
|
||||||
|
streamEnd = nil
|
||||||
|
|
||||||
|
// **Die Länge kommt aus der Datei, nicht vom Player.**
|
||||||
|
//
|
||||||
|
// Eine wachsende Abspielliste hat für AVPlayer keine Länge — sie
|
||||||
|
// trägt kein `EXT-X-ENDLIST`, also bleibt die Dauer unbestimmt
|
||||||
|
// und `durationResolved` kommt nie. Ohne diese Zeile blieb
|
||||||
|
// `duration` auf nichts, und die Ansicht rechnet dann mit einer
|
||||||
|
// Sekunde: der Regler stand bei 1:50:07 am Anschlag.
|
||||||
|
if let bekannt = part.duration ?? item.duration {
|
||||||
|
duration = .milliseconds(bekannt)
|
||||||
|
}
|
||||||
|
|
||||||
// **Der Vorrat läuft voraus, nicht der Player.**
|
// **Der Vorrat läuft voraus, nicht der Player.**
|
||||||
//
|
//
|
||||||
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
|
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
|
||||||
@@ -856,7 +977,7 @@ final class PlayerModel {
|
|||||||
return PlaybackSource(
|
return PlaybackSource(
|
||||||
url: adresse,
|
url: adresse,
|
||||||
origin: .remote,
|
origin: .remote,
|
||||||
startTime: start,
|
startTime: target.startOffset > .zero ? .zero : start,
|
||||||
// **Mit eigenem Vorrat ein kleiner Puffer.**
|
// **Mit eigenem Vorrat ein kleiner Puffer.**
|
||||||
//
|
//
|
||||||
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
|
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
|
||||||
@@ -1504,6 +1625,10 @@ final class PlayerModel {
|
|||||||
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
|
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
|
||||||
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
|
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
|
||||||
// Kreisel.
|
// Kreisel.
|
||||||
|
// **Vor dem Aufräumen entscheiden.** Die Antwort hängt daran, wie
|
||||||
|
// weit der Vorrat reicht — und der wird gleich verworfen.
|
||||||
|
let neuerStrom = ausserhalbDesStroms(time)
|
||||||
|
|
||||||
currentTime = time
|
currentTime = time
|
||||||
seekTarget = time
|
seekTarget = time
|
||||||
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
|
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
|
||||||
@@ -1512,7 +1637,55 @@ final class PlayerModel {
|
|||||||
NowPlaying.shared.update(from: self)
|
NowPlaying.shared.update(from: self)
|
||||||
updateActiveMarker()
|
updateActiveMarker()
|
||||||
|
|
||||||
Task { await engine.seek(to: time) }
|
// **Ausserhalb dieses Stroms springt nicht der Spieler, sondern der
|
||||||
|
// Server.**
|
||||||
|
//
|
||||||
|
// Flaros Abspielliste beginnt an der gewünschten Stelle und wächst
|
||||||
|
// von dort vorwärts. Was davor liegt und was noch nicht gerechnet
|
||||||
|
// ist, gibt es darin nicht; ein Sprung dorthin ginge ins Leere und
|
||||||
|
// der Spieler bliebe mit einem Kreisel stehen. Also wird der Strom
|
||||||
|
// neu aufgebaut — `restart` steigt bei `currentTime` wieder ein, und
|
||||||
|
// das ist genau die Stelle, die eben gesetzt wurde.
|
||||||
|
if neuerStrom {
|
||||||
|
Task { await restart() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { await engine.seek(to: time - streamOffset) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob diese Stelle in dem Strom liegt, der gerade läuft.
|
||||||
|
///
|
||||||
|
/// **Innerhalb des Erzeugten springt der Spieler selbst.** Dafür eine
|
||||||
|
/// neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
|
||||||
|
/// zurückzugehen.
|
||||||
|
private func ausserhalbDesStroms(_ time: Duration) -> Bool {
|
||||||
|
// **Eine Datei mit bekannter Länge: überallhin.** `streamEnd` steht
|
||||||
|
// nur, wenn der Player eine Dauer ermitteln konnte, und das kann er
|
||||||
|
// genau dann, wenn das Ganze schon dasteht — eine Mediendatei oder
|
||||||
|
// eine fertige Abspielliste.
|
||||||
|
if streamEnd != nil, streamOffset == .zero { return false }
|
||||||
|
|
||||||
|
// Vor dem Anfang des Stroms gibt es in ihm nichts.
|
||||||
|
if time < streamOffset { return true }
|
||||||
|
|
||||||
|
// **Eine wachsende Liste reicht so weit, wie Daten da sind.**
|
||||||
|
//
|
||||||
|
// Hier stand ein Vergleich mit `streamEnd`, und der ging nie auf:
|
||||||
|
// bei einer wachsenden Liste bleibt die Dauer unbestimmt, also ist
|
||||||
|
// `streamEnd` nichts, und jeder Sprung galt als innerhalb. AVPlayer
|
||||||
|
// bekam ein Ziel, zu dem es keine Schnipsel gibt, verwarf es, und
|
||||||
|
// der Regler sprang zurück — beim Ziehen sichtbar, im Pausieren
|
||||||
|
// nicht, weil die Liste dort weiterwuchs und ihn irgendwann einholte.
|
||||||
|
//
|
||||||
|
// **Weiss niemand, wie weit es reicht, wird neu gebaut.** Ein neuer
|
||||||
|
// Strom dauert ein paar Sekunden; ein Sprung, der nichts tut, ist
|
||||||
|
// kaputt.
|
||||||
|
guard let bis = bufferedUntil else { return true }
|
||||||
|
|
||||||
|
// Eine halbe Sekunde Nachsicht an der Kante: dort einen neuen Strom
|
||||||
|
// zu bauen wäre Aufwand für nichts.
|
||||||
|
return time > bis + .milliseconds(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
||||||
@@ -1523,9 +1696,20 @@ final class PlayerModel {
|
|||||||
|
|
||||||
/// Springt ans Ende der laufenden Marke.
|
/// Springt ans Ende der laufenden Marke.
|
||||||
func skipActiveMarker() {
|
func skipActiveMarker() {
|
||||||
guard let marker = activeMarker else { return }
|
guard let marker = activeMarker, let item else { return }
|
||||||
skippedMarkers.insert(marker.id)
|
skippedMarkers.insert(marker.id)
|
||||||
seek(to: .milliseconds(marker.endMilliseconds))
|
|
||||||
|
// **Mit dem Schalter in einem Zug bis ans Ende.** Sonst endet der
|
||||||
|
// Sprung an dieser Marke, also bei der Post-Credit-Szene — wer sie
|
||||||
|
// nicht sehen will, stellt es einmal um und drückt einmal.
|
||||||
|
let ziel = settings.skipExtraScenes
|
||||||
|
? item.markers
|
||||||
|
.filter { $0.kind == marker.kind && $0.startMilliseconds >= marker.startMilliseconds }
|
||||||
|
.map(\.endMilliseconds)
|
||||||
|
.max() ?? marker.endMilliseconds
|
||||||
|
: marker.endMilliseconds
|
||||||
|
|
||||||
|
seek(to: .milliseconds(ziel))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
|
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
|
||||||
@@ -1660,26 +1844,43 @@ final class PlayerModel {
|
|||||||
private func report(state: PlaybackReportState) async {
|
private func report(state: PlaybackReportState) async {
|
||||||
guard let backend, let item else { return }
|
guard let backend, let item else { return }
|
||||||
|
|
||||||
// Die Laufzeit der Engine ist die genauere, aber sie steht erst ein
|
// **Die Laufzeit des Titels schlägt die der Engine.**
|
||||||
// paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die
|
//
|
||||||
// des Servers — sonst fiele ausgerechnet der Bericht aus, der die
|
// Vorher war es umgekehrt, mit dem Argument, die Engine messe
|
||||||
// Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade".
|
// genauer. Sie misst aber am Strom und nicht am Film: bei einem
|
||||||
guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) })
|
// gerechneten Strom ist das eine Playlist, die noch wächst, und
|
||||||
|
// anfangs sieht die Engine davon nur einen Stummel. Gemeldet hat sie
|
||||||
|
// ihn als Laufzeit — und daraus machte der Server die Laufzeit des
|
||||||
|
// Films: Stelle zusammengestaucht, Häkchen „gesehen" gesetzt, Titel
|
||||||
|
// aus „Weiterschauen" verschwunden. Gemessen an einem Film, der bei
|
||||||
|
// 46 Minuten stand und danach wieder bei null anfing.
|
||||||
|
guard let total = item.duration.map({ Duration.milliseconds($0) }) ?? duration
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
// Beim Beginn steht die Zeit noch auf null — das ist keine fehlende
|
// **Keine Null melden, die der Spieler gar nicht meint.**
|
||||||
// Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären
|
//
|
||||||
// dagegen wertlos und würden einen gemerkten Fortschritt überschreiben.
|
// Beim Öffnen steht die Zeit der Engine auf null, obwohl der Spieler
|
||||||
guard currentTime > .zero || state == .started else { return }
|
// gleich an einer ganz anderen Stelle einsteigt. Diese Meldung darf
|
||||||
|
// nicht ausfallen — erst sie legt beim Server die Sitzung an, unter
|
||||||
|
// der der Titel als „läuft gerade" erscheint. Sie muss nur die
|
||||||
|
// Stelle nennen, die gemeint ist, statt der, die noch nicht gesetzt
|
||||||
|
// ist.
|
||||||
|
let stelle = currentTime > .zero ? currentTime : request.startTime
|
||||||
|
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: currentTime,
|
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
|
||||||
@@ -1688,14 +1889,14 @@ final class PlayerModel {
|
|||||||
guard let item else { return }
|
guard let item else { return }
|
||||||
let milliseconds = currentTime.milliseconds
|
let milliseconds = currentTime.milliseconds
|
||||||
|
|
||||||
activeMarker = item.markers.first { marker in
|
// **Was der Server schickt, gilt.** Hier stand eine eigene Regel:
|
||||||
guard marker.contains(milliseconds) else { return false }
|
// bei einem Film wurde die Abspannmarke verworfen, weil
|
||||||
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
|
// „überspringen" dort gleichbedeutend mit „beenden" war. Das ist
|
||||||
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
|
// eine Entscheidung über Marken, und die trifft der Server — er
|
||||||
// „Film beenden".
|
// schickt sie nur, wenn er einen Abspann gefunden hat, und seit
|
||||||
if marker.isCredits && item.kind != .episode { return false }
|
// ein Abspann geteilt sein darf, führt der Knopf zur
|
||||||
return true
|
// Post-Credit-Szene statt ins Nichts.
|
||||||
}
|
activeMarker = item.markers.first { $0.contains(milliseconds) }
|
||||||
|
|
||||||
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
|
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
|
||||||
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|
||||||
@@ -1703,21 +1904,43 @@ final class PlayerModel {
|
|||||||
if shouldSkip { skipActiveMarker() }
|
if shouldSkip { skipActiveMarker() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Beide Nachbarn, nicht nur der nächste.**
|
||||||
|
///
|
||||||
|
/// Die nächste Folge wurde schon immer vorgeladen — für den Knopf am
|
||||||
|
/// Ende. Seit es im Spieler Knöpfe zum Blättern gibt, gilt dasselbe für
|
||||||
|
/// die vorige: wer eine zu weit gesprungen ist, soll zurück können, ohne
|
||||||
|
/// den Spieler zu verlassen.
|
||||||
private func preloadNextEpisode() {
|
private func preloadNextEpisode() {
|
||||||
guard let item, item.kind == .episode, let backend else { return }
|
guard let item, item.kind == .episode, let backend else { return }
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
let next = try? await backend.nextEpisode(after: item)
|
let next = try? await backend.nextEpisode(after: item)
|
||||||
self?.nextEpisode = next
|
self?.nextEpisode = next
|
||||||
}
|
}
|
||||||
|
Task { [weak self] in
|
||||||
|
let vorige = try? await backend.previousEpisode(before: item)
|
||||||
|
self?.previousEpisode = vorige
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
|
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
|
||||||
/// Koordinator weiter.
|
/// Koordinator weiter.
|
||||||
func nextEpisodeRequest() -> PlaybackRequest? {
|
func nextEpisodeRequest() -> PlaybackRequest? { wunsch(für: nextEpisode) }
|
||||||
guard let nextEpisode else { return nil }
|
|
||||||
|
/// Dasselbe rückwärts.
|
||||||
|
func previousEpisodeRequest() -> PlaybackRequest? { wunsch(für: previousEpisode) }
|
||||||
|
|
||||||
|
/// **Von vorn, nicht von der gemerkten Stelle.**
|
||||||
|
///
|
||||||
|
/// Wer blättert, will die Folge sehen. Beim Vorwärtsblättern ist sie
|
||||||
|
/// ohnehin meist ungesehen; beim Zurückblättern stünde man sonst im
|
||||||
|
/// Abspann der Folge, die man gerade noch einmal anfangen wollte. Wer
|
||||||
|
/// fortsetzen will, nimmt den Weg über die Serienseite — dort steht die
|
||||||
|
/// Stelle dabei.
|
||||||
|
private func wunsch(für folge: MediaItem?) -> PlaybackRequest? {
|
||||||
|
guard let folge else { return nil }
|
||||||
return PlaybackRequest(
|
return PlaybackRequest(
|
||||||
ratingKey: nextEpisode.id,
|
ratingKey: folge.id,
|
||||||
title: nextEpisode.playbackTitle,
|
title: folge.playbackTitle,
|
||||||
mediaIndex: 0,
|
mediaIndex: 0,
|
||||||
startTime: .zero
|
startTime: .zero
|
||||||
)
|
)
|
||||||
@@ -1859,7 +2082,14 @@ final class PlayerModel {
|
|||||||
String(describing: failure),
|
String(describing: failure),
|
||||||
].joined(separator: "\n\n"))
|
].joined(separator: "\n\n"))
|
||||||
}
|
}
|
||||||
case .timeChanged(let time):
|
case .timeChanged(let stromzeit):
|
||||||
|
// **Die Uhr des Spielers ist nicht die Uhr des Films.**
|
||||||
|
//
|
||||||
|
// Beginnt der Strom bei 1:32, zählt der Spieler trotzdem
|
||||||
|
// ab null. Ab hier wird in Filmzeit gerechnet — alles
|
||||||
|
// darunter, von der Anzeige bis zur Fortschrittsmeldung,
|
||||||
|
// meint denselben Punkt im Film.
|
||||||
|
let time = streamOffset + stromzeit
|
||||||
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
|
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
|
||||||
// Player noch ein paar Mal die alte Stelle, bis er dort
|
// Player noch ein paar Mal die alte Stelle, bis er dort
|
||||||
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
|
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
|
||||||
@@ -1889,7 +2119,22 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
updateActiveMarker()
|
updateActiveMarker()
|
||||||
case .durationResolved(let value):
|
case .durationResolved(let value):
|
||||||
duration = value
|
streamEnd = streamOffset + value
|
||||||
|
// **Die Länge des Films, nicht die des Stroms.**
|
||||||
|
//
|
||||||
|
// Eine wachsende Abspielliste meldet anfangs ein paar
|
||||||
|
// Sekunden. Als Filmlänge genommen stünde der Regler
|
||||||
|
// sofort am Ende, und ein Sprung nach vorn wäre
|
||||||
|
// unmöglich.
|
||||||
|
// **Die Datei weiss es genau, der Titel oft gar nicht.**
|
||||||
|
// `item.duration` kommt aus der Liste, und dort stehen
|
||||||
|
// keine Dateien — bei einem Titel, der aus einer Reihe
|
||||||
|
// geöffnet wurde, ist es nichts. Dann blieb nur die Länge
|
||||||
|
// des Stroms, und die ist am Anfang ein paar Sekunden:
|
||||||
|
// Regler am Ende, obwohl noch zwanzig Minuten kommen.
|
||||||
|
duration = currentPart?.duration.map { Duration.milliseconds($0) }
|
||||||
|
?? item?.duration.map { Duration.milliseconds($0) }
|
||||||
|
?? streamEnd
|
||||||
NowPlaying.shared.update(from: self)
|
NowPlaying.shared.update(from: self)
|
||||||
case .reachedEnd:
|
case .reachedEnd:
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
@@ -1931,14 +2176,52 @@ final class PlayerModel {
|
|||||||
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
|
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
|
||||||
/// hatte, schaltete die Untertitel ein und sah nichts.
|
/// hatte, schaltete die Untertitel ein und sah nichts.
|
||||||
///
|
///
|
||||||
/// **Die erste ist die richtige.** Rechnet der Server um, liegt genau
|
/// **Die erste ist nicht mehr die richtige.**
|
||||||
/// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln.
|
///
|
||||||
|
/// Das galt, solange der Server genau eine Spur auslieferte — die
|
||||||
|
/// bestellte. Flaro legt seit dem Strom **alle** Textspuren als eigene
|
||||||
|
/// Spuren in die Abspielliste, und dann ist die erste schlicht die
|
||||||
|
/// erste: wer Deutsch wählte, sah Englisch.
|
||||||
|
///
|
||||||
|
/// Gesucht wird deshalb die Spur, die zur Bestellung passt — erst über
|
||||||
|
/// das Sprachkürzel, dann über den Namen. Findet sich keine, bleibt es
|
||||||
|
/// bei der ersten: bei einem umgerechneten Strom gibt es nur sie, und
|
||||||
|
/// gar keinen Untertitel zu zeigen wäre schlechter als den einen.
|
||||||
private func showOrderedSubtitle() {
|
private func showOrderedSubtitle() {
|
||||||
guard selectedSubtitleStream != nil, selectedSubtitleTrack == nil else { return }
|
guard let bestellt = selectedSubtitleStream, selectedSubtitleTrack == nil else { return }
|
||||||
guard let erste = subtitleTracks.first else { return }
|
guard let treffer = passendeSpur(zu: bestellt) ?? subtitleTracks.first else { return }
|
||||||
|
|
||||||
diagnostics.note("Untertitel eingeschaltet: \(erste.displayName)")
|
diagnostics.note("Untertitel eingeschaltet: \(treffer.displayName)")
|
||||||
selectSubtitleTrack(erste)
|
selectSubtitleTrack(treffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Spur im Strom, die zu der bestellten aus den Metadaten gehört.
|
||||||
|
private func passendeSpur(zu bestellt: MediaStream) -> MediaTrack? {
|
||||||
|
// **Die Sprache zuerst.** Sie steht in der Abspielliste als
|
||||||
|
// `LANGUAGE` und ist das, was ein Spieler verlässlich durchreicht.
|
||||||
|
if let code = bestellt.languageCode ?? bestellt.language,
|
||||||
|
let treffer = subtitleTracks.first(where: {
|
||||||
|
LanguageCode.matches(code, $0.languageCode)
|
||||||
|
&& $0.isForced == bestellt.isForced
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return treffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Dann der Name.** Zwei Spuren in derselben Sprache — Dialoge und
|
||||||
|
// Schilder etwa — unterscheiden sich nur darüber.
|
||||||
|
if let name = bestellt.displayTitle,
|
||||||
|
let treffer = subtitleTracks.first(where: { $0.displayName == name })
|
||||||
|
{
|
||||||
|
return treffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Sprachkürzel bleibt die Sprache allein.
|
||||||
|
if let code = bestellt.languageCode ?? bestellt.language {
|
||||||
|
return subtitleTracks.first { LanguageCode.matches(code, $0.languageCode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wählt die Untertitelspur, sofern eine gewünscht ist.
|
/// Wählt die Untertitelspur, sofern eine gewünscht ist.
|
||||||
@@ -1952,9 +2235,15 @@ final class PlayerModel {
|
|||||||
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
|
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
|
||||||
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
||||||
private func applyPreferredLanguages() {
|
private func applyPreferredLanguages() {
|
||||||
guard selectedSubtitleTrack == nil, !languages.subtitleLanguage.isEmpty,
|
// **Die Ebene schlägt das Allgemeine.** Hier stand nur die
|
||||||
|
// allgemeine Sprache; eine Serie, die man im Original mit deutschen
|
||||||
|
// Untertiteln sieht, liess sich damit nicht einstellen, ohne sie für
|
||||||
|
// alles andere auch einzuschalten. Bei einer Folge gilt die Serie,
|
||||||
|
// bei einem Film der Titel selbst.
|
||||||
|
guard selectedSubtitleTrack == nil,
|
||||||
|
let wunsch = languages.subtitleWish(titleID: titelbereich),
|
||||||
let treffer = subtitleTracks.first(where: {
|
let treffer = subtitleTracks.first(where: {
|
||||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
LanguageCode.matches(wunsch, $0.languageCode)
|
||||||
})
|
})
|
||||||
else { return }
|
else { return }
|
||||||
selectSubtitleTrack(treffer)
|
selectSubtitleTrack(treffer)
|
||||||
@@ -1972,6 +2261,14 @@ final class PlayerModel {
|
|||||||
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
|
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
|
||||||
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
|
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
|
||||||
/// kommt.
|
/// kommt.
|
||||||
|
/// Unter welcher Kennung die Vorwahl dieses Titels liegt.
|
||||||
|
///
|
||||||
|
/// **Bei einer Folge die Serie, bei einem Film er selbst.** „Diese Serie
|
||||||
|
/// auf Japanisch" gilt für alle Folgen; „dieser Film auf Englisch" gilt
|
||||||
|
/// für ihn. Beides ist dieselbe Frage auf der Ebene, die der Benutzer
|
||||||
|
/// vor sich hatte, als er sie beantwortet hat.
|
||||||
|
private var titelbereich: String? { item?.seriesID ?? item?.id }
|
||||||
|
|
||||||
private func chooseAudioStream(
|
private func chooseAudioStream(
|
||||||
in part: MediaFile,
|
in part: MediaFile,
|
||||||
of item: MediaItem,
|
of item: MediaItem,
|
||||||
@@ -1981,10 +2278,9 @@ final class PlayerModel {
|
|||||||
subtitleStreams = part.subtitleStreams
|
subtitleStreams = part.subtitleStreams
|
||||||
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
|
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
|
||||||
// wenn es einen passenden gibt, und sonst bleibt es ohne.
|
// wenn es einen passenden gibt, und sonst bleibt es ohne.
|
||||||
selectedSubtitleStream = languages.subtitleLanguage.isEmpty ? nil
|
selectedSubtitleStream = languages.subtitleWish(titleID: titelbereich).flatMap { wunsch in
|
||||||
: subtitleStreams.first {
|
subtitleStreams.first { LanguageCode.matches(wunsch, $0.languageCode) }
|
||||||
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
guard !audioStreams.isEmpty else {
|
guard !audioStreams.isEmpty else {
|
||||||
selectedAudioStream = nil
|
selectedAudioStream = nil
|
||||||
@@ -2000,7 +2296,12 @@ final class PlayerModel {
|
|||||||
|
|
||||||
selectedAudioStream = AudioTrackChooser.choose(
|
selectedAudioStream = AudioTrackChooser.choose(
|
||||||
from: part.streams,
|
from: part.streams,
|
||||||
wish: languages.wish(seriesID: item.seriesID, libraryID: libraryID)
|
// **Bei einem Film seine eigene Kennung.** Hier stand nur
|
||||||
|
// `item.seriesID`, und die ist bei einem Film nichts: die
|
||||||
|
// Vorwahl auf der Filmseite wurde abgelegt und nie wieder
|
||||||
|
// gelesen. Untertitel gingen durch, Ton nicht — weil die beiden
|
||||||
|
// an verschiedenen Stellen nachsehen.
|
||||||
|
wish: languages.wish(seriesID: titelbereich, libraryID: libraryID)
|
||||||
)
|
)
|
||||||
|
|
||||||
if let gewählt = selectedAudioStream {
|
if let gewählt = selectedAudioStream {
|
||||||
@@ -2020,8 +2321,8 @@ final class PlayerModel {
|
|||||||
guard stream != selectedAudioStream else { return }
|
guard stream != selectedAudioStream else { return }
|
||||||
selectedAudioStream = stream
|
selectedAudioStream = stream
|
||||||
|
|
||||||
if let seriesID = item?.seriesID {
|
if let bereich = titelbereich {
|
||||||
languages.remember(stream, forSeries: seriesID)
|
languages.remember(stream, forSeries: bereich)
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
|
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
|
||||||
@@ -2069,16 +2370,16 @@ final class PlayerModel {
|
|||||||
|
|
||||||
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
|
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
|
||||||
var rememberedTrackName: String? {
|
var rememberedTrackName: String? {
|
||||||
guard let seriesID = item?.seriesID,
|
guard let bereich = titelbereich,
|
||||||
let signatur = languages.rememberedTrack(forSeries: seriesID)
|
let signatur = languages.rememberedTrack(forSeries: bereich)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return signatur.title ?? signatur.languageCode
|
return signatur.title ?? signatur.languageCode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vergisst die gemerkte Spur dieser Serie.
|
/// Vergisst die gemerkte Spur dieser Serie.
|
||||||
func forgetRememberedTrack() {
|
func forgetRememberedTrack() {
|
||||||
guard let seriesID = item?.seriesID else { return }
|
guard let bereich = titelbereich else { return }
|
||||||
languages.forgetTrack(forSeries: seriesID)
|
languages.forgetTrack(forSeries: bereich)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func playerSubtitle(for item: MediaItem) -> String? {
|
private func playerSubtitle(for item: MediaItem) -> String? {
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ struct PlayerView: View {
|
|||||||
@State private var trackPicker: TrackPickerSheet.Kind?
|
@State private var trackPicker: TrackPickerSheet.Kind?
|
||||||
@State private var showsQualityChoice = false
|
@State private var showsQualityChoice = false
|
||||||
@State private var hideTask: Task<Void, Never>?
|
@State private var hideTask: Task<Void, Never>?
|
||||||
|
/// Ob die Marken-Knöpfe inzwischen an der Bedienung hängen.
|
||||||
|
///
|
||||||
|
/// **Erst für sich, dann mit der Bedienung.** Eine neue Marke zeigt
|
||||||
|
/// ihre Knöpfe von selbst — sonst müsste man erst das Bild antippen,
|
||||||
|
/// um das Intro überspringen zu können, und dann hätte man die
|
||||||
|
/// Bedienung ohnehin. Sobald man sie aber einmal eingeblendet hat,
|
||||||
|
/// gehören die Knöpfe dazu und gehen mit ihr. Keine Frist, kein
|
||||||
|
/// Zeitgeber: der Zuschauer sagt selbst, wann er genug gesehen hat.
|
||||||
|
@State private var markenGebunden = false
|
||||||
@State private var scrubbing: Double?
|
@State private var scrubbing: Double?
|
||||||
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
/// Welche Richtung gerade als Hinweis eingeblendet ist.
|
||||||
@State private var seekHint: Double?
|
@State private var seekHint: Double?
|
||||||
@@ -106,8 +115,29 @@ struct PlayerView: View {
|
|||||||
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
|
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
|
||||||
// der Hintergrund dahinter, und wer ihn anklickt, klickt
|
// der Hintergrund dahinter, und wer ihn anklickt, klickt
|
||||||
// nicht aufs Bild — das muss niemand mehr feststellen.
|
// nicht aufs Bild — das muss niemand mehr feststellen.
|
||||||
|
// **Im Vollbild über alles, sonst nicht unter die
|
||||||
|
// Insel.**
|
||||||
|
//
|
||||||
|
// Bei 21∶9 auf einem Telefon reicht das Bild über die
|
||||||
|
// ganze Breite — und damit hinter die Dynamic Island, die
|
||||||
|
// links einen Streifen davon verdeckt. Wer Vollbild
|
||||||
|
// wählt, hat sich fürs Beschneiden entschieden; dort ist
|
||||||
|
// Rand an Rand folgerichtig. Im normalen Modus ist der
|
||||||
|
// Anspruch gerade, dass nichts fehlt — dann darf auch
|
||||||
|
// nichts verdeckt sein. Plex macht es genauso.
|
||||||
Color.clear
|
Color.clear
|
||||||
.ignoresSafeArea()
|
//
|
||||||
|
// **Ein Modifikator mit leeren Kanten, kein `if`.**
|
||||||
|
// Hier stand ein eigener `ViewModifier`, der
|
||||||
|
// zwischen `ignoresSafeArea()` und gar nichts
|
||||||
|
// umschaltete — und genau das ist in SwiftUI ein
|
||||||
|
// Zweig: beim Umschalten wurde der Baum darunter
|
||||||
|
// neu aufgebaut, die Bildebene mit ihm, und der
|
||||||
|
// Spieler stand ohne sie da. Schwarz, sobald
|
||||||
|
// jemand auf „Bildfüllend" wechselte. `edges: []`
|
||||||
|
// ignoriert nichts und ist doch derselbe
|
||||||
|
// Modifikator — die Ansicht bleibt dieselbe.
|
||||||
|
.ignoresSafeArea(.container, edges: model.videoFit == .fill ? .all : [])
|
||||||
.overlay {
|
.overlay {
|
||||||
surface(for: model)
|
surface(for: model)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
@@ -341,6 +371,17 @@ struct PlayerView: View {
|
|||||||
.environment(settings)
|
.environment(settings)
|
||||||
.environment(\.flaroAccent, accent)
|
.environment(\.flaroAccent, accent)
|
||||||
}
|
}
|
||||||
|
// Eine neue Marke ist ein neues Angebot: sie steht wieder für sich,
|
||||||
|
// bis die Bedienung das nächste Mal auftaucht.
|
||||||
|
.onChange(of: model.activeMarker?.id, initial: true) { _, _ in
|
||||||
|
markenGebunden = false
|
||||||
|
}
|
||||||
|
// **Der Übergang zählt, nicht der Zustand.** Beim Start liegt die
|
||||||
|
// Bedienung ohnehin da; das als „einmal gezeigt" zu werten hiesse,
|
||||||
|
// dem Intro seinen Knopf zu nehmen, bevor es ihn je hatte.
|
||||||
|
.onChange(of: showsControls) { vorher, jetzt in
|
||||||
|
if !vorher && jetzt { markenGebunden = true }
|
||||||
|
}
|
||||||
.onChange(of: showsQualityChoice) { _, isShown in
|
.onChange(of: showsQualityChoice) { _, isShown in
|
||||||
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
// Nach dem Schließen die Bedienelemente stehen lassen und die
|
||||||
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
|
||||||
@@ -594,7 +635,25 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func transport(for model: PlayerModel) -> some View {
|
private func transport(for model: PlayerModel) -> some View {
|
||||||
HStack(spacing: FlaroSpacing.section * 2) {
|
// **Enger, seit zwei Knöpfe dazukommen.** Bei fünf Knöpfen in einer
|
||||||
|
// Reihe wird der alte Abstand auf einem Telefon im Hochformat zu
|
||||||
|
// breit; bei drei bleibt er, wie er war.
|
||||||
|
let folgen = model.previousEpisode != nil || model.nextEpisode != nil
|
||||||
|
|
||||||
|
return HStack(spacing: folgen ? FlaroSpacing.section : FlaroSpacing.section * 2) {
|
||||||
|
// **Eine Folge zurück — nur bei Serien, nur wenn es eine gibt.**
|
||||||
|
//
|
||||||
|
// Bei einem Film bleibt die Reihe, wie sie war: ein Knopf, der
|
||||||
|
// nie etwas tut, ist schlechter als keiner.
|
||||||
|
if model.previousEpisode != nil {
|
||||||
|
Button {
|
||||||
|
if let wunsch = model.previousEpisodeRequest() { playback.play(wunsch) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "backward.end.fill").font(.title2)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(Text("player.previousEpisode"))
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
model.skip(by: .seconds(-settings.rewindSeconds))
|
model.skip(by: .seconds(-settings.rewindSeconds))
|
||||||
scheduleControlsHide()
|
scheduleControlsHide()
|
||||||
@@ -638,6 +697,15 @@ struct PlayerView: View {
|
|||||||
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.forward10"))
|
.accessibilityLabel(Text("player.forward10"))
|
||||||
|
|
||||||
|
if model.nextEpisode != nil {
|
||||||
|
Button {
|
||||||
|
if let wunsch = model.nextEpisodeRequest() { playback.play(wunsch) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "forward.end.fill").font(.title2)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(Text("player.nextEpisode"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
@@ -662,7 +730,9 @@ struct PlayerView: View {
|
|||||||
// springen lässt das Bild flackern und hält bei großen
|
// springen lässt das Bild flackern und hält bei großen
|
||||||
// Dateien den Puffer dauerhaft leer.
|
// Dateien den Puffer dauerhaft leer.
|
||||||
onCommit: { model.seek(to: $0) },
|
onCommit: { model.seek(to: $0) },
|
||||||
onActivity: { scheduleControlsHide() }
|
onActivity: { scheduleControlsHide() },
|
||||||
|
vorschau: model.vorschau,
|
||||||
|
blatt: { model.vorschaublatt($0) }
|
||||||
)
|
)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
@@ -683,12 +753,31 @@ struct PlayerView: View {
|
|||||||
|
|
||||||
/// Überspringen-Knopf und „Nächste Folge".
|
/// Überspringen-Knopf und „Nächste Folge".
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
/// Ob jetzt „Nächste Folge" dransteht.
|
||||||
|
///
|
||||||
|
/// Am Ende immer, im Abspann auch — dort ist es die Antwort auf die
|
||||||
|
/// Frage, die der Abspann stellt. Gibt es keine nächste Folge, bleibt
|
||||||
|
/// es beim Überspringen.
|
||||||
|
private func naechsteAnbieten(_ model: PlayerModel) -> Bool {
|
||||||
|
guard model.nextEpisodeRequest() != nil else { return false }
|
||||||
|
return model.hasReachedEnd || model.activeMarker?.isCredits == true
|
||||||
|
}
|
||||||
|
|
||||||
private func overlayActions(for model: PlayerModel) -> some View {
|
private func overlayActions(for model: PlayerModel) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
|
// **Im Abspann ist „überspringen" die falsche Frage — aber
|
||||||
|
// nicht die einzige.**
|
||||||
|
//
|
||||||
|
// Was man dort meistens will, ist die nächste Folge; ans
|
||||||
|
// Ende dieser zu springen bringt niemanden weiter. Wer den
|
||||||
|
// Abspann aber sehen wollte und es sich anders überlegt,
|
||||||
|
// soll ihn trotzdem überspringen können. Zwei Antworten,
|
||||||
|
// zwei Knöpfe — welche gemeint ist, weiss nur der
|
||||||
|
// Zuschauer.
|
||||||
|
if naechsteAnbieten(model) {
|
||||||
Button {
|
Button {
|
||||||
if let next = model.nextEpisodeRequest() {
|
if let next = model.nextEpisodeRequest() {
|
||||||
playback.play(next)
|
playback.play(next)
|
||||||
@@ -701,7 +790,9 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
} else if let marker = model.activeMarker {
|
}
|
||||||
|
|
||||||
|
if let marker = model.activeMarker {
|
||||||
Button {
|
Button {
|
||||||
model.skipActiveMarker()
|
model.skipActiveMarker()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -721,9 +812,28 @@ struct PlayerView: View {
|
|||||||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||||||
// die Bedienelemente eingeblendet sind.
|
// die Bedienelemente eingeblendet sind.
|
||||||
.padding(.bottom, (showsControls ? 110 : FlaroSpacing.section) + safeArea.bottom)
|
.padding(.bottom, (showsControls ? 110 : FlaroSpacing.section) + safeArea.bottom)
|
||||||
|
// **Am Ende bleibt es stehen, sonst nicht.**
|
||||||
|
//
|
||||||
|
// Ist der Film aus, ist der Knopf die einzige Frage, die noch
|
||||||
|
// offen ist — der darf warten. Mitten in einer Marke ist er ein
|
||||||
|
// Angebot, und ein Angebot, das nicht angenommen wird, nimmt
|
||||||
|
// man zurück.
|
||||||
|
.opacity(markenSichtbar(model) ? 1 : 0)
|
||||||
|
.allowsHitTesting(markenSichtbar(model))
|
||||||
}
|
}
|
||||||
.animation(.snappy, value: model.activeMarker?.id)
|
.animation(.snappy, value: model.activeMarker?.id)
|
||||||
.animation(.snappy, value: model.hasReachedEnd)
|
.animation(.snappy, value: model.hasReachedEnd)
|
||||||
|
.animation(.easeIn(duration: 0.28), value: markenGebunden)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Dieselbe Regel für Vorspann und Abspann, Web wie App.**
|
||||||
|
///
|
||||||
|
/// Sichtbar, solange die Knöpfe noch für sich stehen; danach genau
|
||||||
|
/// dann, wenn die Bedienung da ist. Am Ende des Films bleiben sie
|
||||||
|
/// trotzdem: dort ist „Nächste Folge" die einzige noch offene Frage,
|
||||||
|
/// und es gibt kein Bild mehr, das sie verdecken könnten.
|
||||||
|
private func markenSichtbar(_ model: PlayerModel) -> Bool {
|
||||||
|
model.hasReachedEnd || showsControls || !markenGebunden
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Abdeckung während des Umstellens.
|
/// Die Abdeckung während des Umstellens.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct SearchView: View {
|
|||||||
// weiß aber noch nicht, was er sehen will; der erste
|
// weiß aber noch nicht, was er sehen will; der erste
|
||||||
// Treffer ist eine Vermutung des Servers. Starten geht
|
// Treffer ist eine Vermutung des Servers. Starten geht
|
||||||
// weiter, über langes Drücken.
|
// weiter, über langes Drücken.
|
||||||
MediaRow(row: hub, opensDetails: true)
|
MediaRow(row: hub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, FlaroSpacing.loose)
|
.padding(.vertical, FlaroSpacing.loose)
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
|
/// „Welche Sprache gilt hier?" — an mehreren Orten dieselbe Frage.
|
||||||
///
|
///
|
||||||
/// **Warum ein gemeinsamer Typ.** Die Frage steht einmal auf der Serienseite
|
/// **Warum ein gemeinsamer Typ.** Die Frage steht auf der Serienseite, auf
|
||||||
/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe:
|
/// der Filmseite und bei der Mediathek, und die Antwort bedeutet jedes Mal
|
||||||
/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei
|
/// dasselbe: diese Ebene setzt sich gegen die darüber durch, oder sie tut es
|
||||||
/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer
|
/// nicht. Mehrere Auswahlknöpfe mit demselben Inhalt liefen unweigerlich
|
||||||
/// bekäme eine Sprache dazu, der andere nicht.
|
/// auseinander — einer bekäme eine Sprache dazu, der andere nicht.
|
||||||
|
///
|
||||||
|
/// **Ton und Untertitel im selben Typ.** Sie unterscheiden sich in genau
|
||||||
|
/// zwei Punkten: woher der Wert kommt, und ob es „keine" gibt. Ein zweiter
|
||||||
|
/// Typ dafür wäre dieselbe Datei mit zwei geänderten Zeilen.
|
||||||
///
|
///
|
||||||
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
||||||
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
||||||
@@ -15,25 +20,98 @@ import FlaroDesign
|
|||||||
struct AudioLanguagePicker: View {
|
struct AudioLanguagePicker: View {
|
||||||
/// Für welche Ebene gewählt wird.
|
/// Für welche Ebene gewählt wird.
|
||||||
enum Scope {
|
enum Scope {
|
||||||
|
/// Eine Serie oder ein Film — beides eine Kennung, beides ein Titel.
|
||||||
case series(String)
|
case series(String)
|
||||||
case library(String)
|
case library(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ton oder Text.
|
||||||
|
enum Kind {
|
||||||
|
case audio
|
||||||
|
case subtitles
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie der Knopf aussieht.
|
||||||
|
enum Form {
|
||||||
|
/// Eine Zeile: Beschriftung links, Wahl rechts. Für Listen.
|
||||||
|
case zeile
|
||||||
|
/// Ein Knopf wie „Ton: Deutsch". Für die Reihe neben „Abspielen".
|
||||||
|
case knopf
|
||||||
|
}
|
||||||
|
|
||||||
let scope: Scope
|
let scope: Scope
|
||||||
let title: LocalizedStringKey
|
let title: LocalizedStringKey
|
||||||
|
var kind: Kind = .audio
|
||||||
|
var form: Form = .zeile
|
||||||
|
|
||||||
|
/// Welche Sprachen es wirklich gibt — `nil` heisst: alle anbieten.
|
||||||
|
///
|
||||||
|
/// **Eine Liste mit zwanzig Sprachen für eine Serie mit zweien** ist
|
||||||
|
/// keine Auswahl, sondern eine Suche. Weiss niemand, welche es gibt,
|
||||||
|
/// bleibt es bei der vollen Liste: eine zu lange ist besser als eine
|
||||||
|
/// leere.
|
||||||
|
var available: [LanguageOption]?
|
||||||
|
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: FlaroSpacing.regular) {
|
switch form {
|
||||||
Text(title)
|
case .zeile:
|
||||||
.font(.flaroBody)
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Text(title)
|
||||||
|
.font(.flaroBody)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
|
Spacer(minLength: FlaroSpacing.regular)
|
||||||
|
|
||||||
|
menü { auswahl }
|
||||||
|
}
|
||||||
|
|
||||||
|
case .knopf:
|
||||||
|
// **Beschriftung und Wert in einem.** Neben „Abspielen" ist kein
|
||||||
|
// Platz für eine Zeile mit Spalten; „Ton: Deutsch" sagt dasselbe
|
||||||
|
// in einem Wort mehr.
|
||||||
|
menü {
|
||||||
|
HStack(spacing: FlaroSpacing.tight) {
|
||||||
|
Image(systemName: kind == .audio ? "waveform" : "captions.bubble")
|
||||||
|
Text("\(Text(title)): \(currentLabel)")
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
// **Eine Zeile, keine Säule.** Umbrochen wird die
|
||||||
|
// Reihe, nicht der einzelne Knopf — sonst steht
|
||||||
|
// „Untertitel" wieder Buchstabe für Buchstabe
|
||||||
|
// untereinander.
|
||||||
|
.lineLimit(1)
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
|
.background(Color.flaroElevated, in: Capsule())
|
||||||
.foregroundStyle(Color.flaroTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(minLength: FlaroSpacing.regular)
|
/// Das Menü selbst — zweimal dieselben Einträge, zwei Aufhänger.
|
||||||
|
private func menü(@ViewBuilder label: () -> some View) -> some View {
|
||||||
|
Menu { auswahlEintraege } label: { label() }
|
||||||
|
}
|
||||||
|
|
||||||
Menu {
|
private var auswahl: some View {
|
||||||
|
HStack(spacing: FlaroSpacing.tight) {
|
||||||
|
Text(currentLabel)
|
||||||
|
.font(.flaroBody)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var auswahlEintraege: some View {
|
||||||
|
Group {
|
||||||
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
||||||
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
||||||
// durchreichen.
|
// durchreichen.
|
||||||
@@ -43,42 +121,65 @@ struct AudioLanguagePicker: View {
|
|||||||
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
// **„Keine" nur beim Untertitel.** Beim Ton gibt es sie
|
||||||
|
// nicht: ein Film ohne Tonspur ist kein Wunsch, sondern ein
|
||||||
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
|
// Fehler.
|
||||||
|
if kind == .subtitles {
|
||||||
Button {
|
Button {
|
||||||
set(code)
|
set(LanguagePreferences.noSubtitles)
|
||||||
} label: {
|
} label: {
|
||||||
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
|
Label(
|
||||||
|
"languages.none",
|
||||||
|
systemImage: current == LanguagePreferences.noSubtitles
|
||||||
|
? "checkmark" : ""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
|
||||||
HStack(spacing: FlaroSpacing.tight) {
|
Divider()
|
||||||
Text(currentLabel)
|
|
||||||
.font(.flaroBody)
|
ForEach(options, id: \.code) { option in
|
||||||
Image(systemName: "chevron.up.chevron.down")
|
Button {
|
||||||
.font(.flaroCaption)
|
set(option.code)
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
option.name,
|
||||||
|
systemImage: current == option.code ? "checkmark" : ""
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(accent)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Was zur Wahl steht — das Vorhandene, sonst die allgemeine Liste.
|
||||||
|
private var options: [LanguageOption] {
|
||||||
|
available ?? LanguagePreferences.offeredCodes.map {
|
||||||
|
LanguageOption(code: $0, name: name(of: $0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var current: String? {
|
private var current: String? {
|
||||||
switch scope {
|
switch (scope, kind) {
|
||||||
case .series(let id): languages.audioLanguage(forSeries: id)
|
case (.series(let id), .audio): languages.audioLanguage(forSeries: id)
|
||||||
case .library(let id): languages.audioLanguage(forLibrary: id)
|
case (.series(let id), .subtitles): languages.subtitleLanguage(forSeries: id)
|
||||||
|
case (.library(let id), .audio): languages.audioLanguage(forLibrary: id)
|
||||||
|
// Je Mediathek gibt es keine Untertitelsprache — siehe `subtitleWish`.
|
||||||
|
case (.library, .subtitles): nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentLabel: String {
|
private var currentLabel: String {
|
||||||
current.map(name(of:)) ?? String(localized: "languages.inherit")
|
guard let current else { return String(localized: "languages.inherit") }
|
||||||
|
if current == LanguagePreferences.noSubtitles { return String(localized: "languages.none") }
|
||||||
|
return options.first { $0.code == current }?.name ?? name(of: current)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func set(_ code: String?) {
|
private func set(_ code: String?) {
|
||||||
switch scope {
|
switch (scope, kind) {
|
||||||
case .series(let id): languages.setAudioLanguage(code, forSeries: id)
|
case (.series(let id), .audio): languages.setAudioLanguage(code, forSeries: id)
|
||||||
case .library(let id): languages.setAudioLanguage(code, forLibrary: id)
|
case (.series(let id), .subtitles): languages.setSubtitleLanguage(code, forSeries: id)
|
||||||
|
case (.library(let id), .audio): languages.setAudioLanguage(code, forLibrary: id)
|
||||||
|
case (.library, .subtitles): break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ struct PlaybackSettingsView: View {
|
|||||||
Section {
|
Section {
|
||||||
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
||||||
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
||||||
|
Toggle("settings.skipExtraScenes", isOn: $settings.skipExtraScenes)
|
||||||
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
||||||
} header: {
|
} header: {
|
||||||
Text("settings.episodes")
|
Text("settings.episodes")
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
import FlaroServerKit
|
||||||
|
|
||||||
|
/// Die Anmeldung an einem Flaro-Server.
|
||||||
|
///
|
||||||
|
/// Zwei Schritte: wo steht er, und wer bist du. Mehr Fragen gibt es nicht —
|
||||||
|
/// und eine davon beantwortet der Server selbst, indem er seinen Namen nennt.
|
||||||
|
struct FlaroSignInView: View {
|
||||||
|
@Environment(FlaroSession.self) private var session
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
|
@State private var model: FlaroSignInModel?
|
||||||
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
|
private enum Field { case address, userName, password }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
if let model {
|
||||||
|
content(for: model)
|
||||||
|
.padding(.horizontal, FlaroSpacing.section)
|
||||||
|
.padding(.vertical, FlaroSpacing.section)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.background(Color.flaroCanvas)
|
||||||
|
.navigationTitle(Text("signIn.flaro.title"))
|
||||||
|
.flaroInlineTitle()
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("action.cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(accent)
|
||||||
|
.task {
|
||||||
|
guard model == nil else { return }
|
||||||
|
let fresh = FlaroSignInModel(identity: session.flaroDeviceIdentity)
|
||||||
|
fresh.onAuthenticated = { client, konto, token in
|
||||||
|
dismiss()
|
||||||
|
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
|
||||||
|
}
|
||||||
|
model = fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(for model: FlaroSignInModel) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
|
switch model.step {
|
||||||
|
case .address, .checking:
|
||||||
|
addressStep(model: model)
|
||||||
|
case .credentials, .signingIn:
|
||||||
|
credentialsStep(model: model)
|
||||||
|
case .code:
|
||||||
|
// **Am Telefon führt der Code nirgendwohin.** Er braucht ein
|
||||||
|
// zweites, bereits angemeldetes Gerät — und das erste ist
|
||||||
|
// gerade dieses hier. Das Modell bietet ihn deshalb nur an,
|
||||||
|
// wenn es mit `preferring: .code` gebaut wurde; am Fernseher
|
||||||
|
// ist das so, hier nicht. Der Fall steht trotzdem da, weil
|
||||||
|
// ein `default` die nächste neue Lage verschlucken würde.
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let message = model.errorMessage {
|
||||||
|
Text(message)
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroDestructive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func addressStep(model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
Text("signIn.flaro.addressPrompt")
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
|
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.textContentType(.URL)
|
||||||
|
#if os(iOS)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
#endif
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focusedField, equals: .address)
|
||||||
|
.onSubmit { Task { await model.findServer() } }
|
||||||
|
|
||||||
|
Text("signIn.flaro.addressHint")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await model.findServer() }
|
||||||
|
} label: {
|
||||||
|
if model.step == .checking {
|
||||||
|
ProgressView().frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Text("signIn.flaro.findServer").frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accent)
|
||||||
|
.disabled(model.step == .checking)
|
||||||
|
}
|
||||||
|
.onAppear { focusedField = .address }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func credentialsStep(model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
if let name = model.serverName {
|
||||||
|
// Der Name des Servers steht hier, damit sichtbar ist,
|
||||||
|
// **wo** man sich gleich anmeldet — eine Adresse allein sagt
|
||||||
|
// das nicht.
|
||||||
|
HStack {
|
||||||
|
Text(name)
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
Spacer()
|
||||||
|
Button("signIn.flaro.changeAddress") { model.changeAddress() }
|
||||||
|
.font(.flaroCaption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("signIn.userName", text: $model.userName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.textContentType(.username)
|
||||||
|
#if os(iOS)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
#endif
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.focused($focusedField, equals: .userName)
|
||||||
|
.onSubmit { focusedField = .password }
|
||||||
|
|
||||||
|
SecureField("signIn.password", text: $model.password)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.textContentType(.password)
|
||||||
|
.focused($focusedField, equals: .password)
|
||||||
|
.onSubmit { Task { await model.signIn() } }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await model.signIn() }
|
||||||
|
} label: {
|
||||||
|
if model.step == .signingIn {
|
||||||
|
ProgressView().frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
Text("signIn.flaro.signIn").frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accent)
|
||||||
|
.disabled(model.step == .signingIn)
|
||||||
|
|
||||||
|
// Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, und der
|
||||||
|
// Server liefert ihn ohne Anmeldung genau dafür.
|
||||||
|
ForEach(model.attributions, id: \.text) { hinweis in
|
||||||
|
Text(hinweis.text)
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { focusedField = .userName }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,7 +184,7 @@ struct JellyfinSignInView: View {
|
|||||||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
foundServerHeader(model: model)
|
foundServerHeader(model: model)
|
||||||
|
|
||||||
TextField("signIn.jellyfin.userName", text: $model.userName)
|
TextField("signIn.userName", text: $model.userName)
|
||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
.flaroPlainEntry()
|
.flaroPlainEntry()
|
||||||
.submitLabel(.next)
|
.submitLabel(.next)
|
||||||
@@ -203,7 +203,7 @@ struct JellyfinSignInView: View {
|
|||||||
.padding(FlaroSpacing.regular)
|
.padding(FlaroSpacing.regular)
|
||||||
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||||
|
|
||||||
SecureField("signIn.jellyfin.password", text: $model.password)
|
SecureField("signIn.password", text: $model.password)
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
.submitLabel(.go)
|
.submitLabel(.go)
|
||||||
.focused($focusedField, equals: .password)
|
.focused($focusedField, equals: .password)
|
||||||
|
|||||||
@@ -3,16 +3,23 @@ import FlaroDesign
|
|||||||
|
|
||||||
/// Der Anmeldebildschirm.
|
/// Der Anmeldebildschirm.
|
||||||
///
|
///
|
||||||
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
|
/// **Wieder zwei Knöpfe, und diesmal zu zwei Servern.** Hier standen schon
|
||||||
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
|
/// einmal zwei — Plex oben, Jellyfin darunter —, und nach dem Entfernen von
|
||||||
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
|
/// Plex blieb einer übrig. Jetzt gibt es Flaro, und die Frage stellt sich
|
||||||
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
|
/// neu: welcher steht oben?
|
||||||
///
|
///
|
||||||
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
|
/// **Flaro.** Nicht aus Eitelkeit, sondern weil die App dort mehr kann: der
|
||||||
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
|
/// Download bringt alle Tonspuren mit, der Fortschritt wird ab der ersten
|
||||||
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
|
/// Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu
|
||||||
|
/// anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf
|
||||||
|
/// auffälliger war.
|
||||||
|
///
|
||||||
|
/// Beide Server haben kein Verzeichnis, an dem die eigenen Instanzen hängen.
|
||||||
|
/// Die Adresse wird deshalb eingetippt; den Rest führen die beiden
|
||||||
|
/// Anmeldeansichten.
|
||||||
struct SignInView: View {
|
struct SignInView: View {
|
||||||
@Environment(\.flaroAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
@State private var showsFlaroSignIn = false
|
||||||
@State private var showsJellyfinSignIn = false
|
@State private var showsJellyfinSignIn = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -37,6 +44,17 @@ struct SignInView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(spacing: FlaroSpacing.regular) {
|
VStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button {
|
||||||
|
showsFlaroSignIn = true
|
||||||
|
} label: {
|
||||||
|
Text("signIn.withFlaro")
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(accent)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showsJellyfinSignIn = true
|
showsJellyfinSignIn = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -45,8 +63,7 @@ struct SignInView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, FlaroSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.bordered)
|
||||||
.tint(accent)
|
|
||||||
|
|
||||||
Text("signIn.privacy")
|
Text("signIn.privacy")
|
||||||
.font(.flaroCaption)
|
.font(.flaroCaption)
|
||||||
@@ -60,6 +77,9 @@ struct SignInView: View {
|
|||||||
.frame(maxWidth: 420)
|
.frame(maxWidth: 420)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.flaroCanvas)
|
.background(Color.flaroCanvas)
|
||||||
|
.sheet(isPresented: $showsFlaroSignIn) {
|
||||||
|
FlaroSignInView()
|
||||||
|
}
|
||||||
.sheet(isPresented: $showsJellyfinSignIn) {
|
.sheet(isPresented: $showsJellyfinSignIn) {
|
||||||
JellyfinSignInView()
|
JellyfinSignInView()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
import FlaroPlayback
|
import FlaroPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
|
|
||||||
/// Die App auf dem Fernseher.
|
/// Die App auf dem Fernseher.
|
||||||
@@ -45,6 +46,7 @@ struct FlaroTVApp: App {
|
|||||||
client: Branding.appName,
|
client: Branding.appName,
|
||||||
version: Branding.version
|
version: Branding.version
|
||||||
),
|
),
|
||||||
|
flaroIdentity: FlaroIdentity.current(),
|
||||||
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
tokenStore: KeychainTokenStore(service: Branding.keychainService)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ struct TVHeroSection: View {
|
|||||||
|
|
||||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
|
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
|
||||||
private var abspielTitel: LocalizedStringKey {
|
private var abspielTitel: LocalizedStringKey {
|
||||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
item.isResumable ? "hero.resume" : "hero.play"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var metadaten: String? {
|
private var metadaten: String? {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ struct TVMediaRow: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.rowArtworkPath, width: 880, height: 496
|
path: item.rowArtworkPath, width: 880, height: 496
|
||||||
),
|
),
|
||||||
progress: item.watchProgress,
|
progress: item.resumeProgress,
|
||||||
onFocusChange: { if $0 { onFocus(item) } }
|
onFocusChange: { if $0 { onFocus(item) } }
|
||||||
) { onSelect(item) }
|
) { onSelect(item) }
|
||||||
} else {
|
} else {
|
||||||
@@ -65,7 +65,7 @@ struct TVMediaRow: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.thumb, width: 560, height: 840
|
path: item.thumb, width: 560, height: 840
|
||||||
),
|
),
|
||||||
progress: item.watchProgress,
|
progress: item.resumeProgress,
|
||||||
onFocusChange: { if $0 { onFocus(item) } }
|
onFocusChange: { if $0 { onFocus(item) } }
|
||||||
) { onSelect(item) }
|
) { onSelect(item) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ struct TVLibraryView: View {
|
|||||||
artworkURL: session.backend?.artworkURL(
|
artworkURL: session.backend?.artworkURL(
|
||||||
path: item.posterArtworkPath, width: 560, height: 840
|
path: item.posterArtworkPath, width: 560, height: 840
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
) {
|
) {
|
||||||
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
// **Über QuickPlay, nicht unmittelbar.** Steht im
|
||||||
// Raster eine Serie, hat die keine Fassungen; der
|
// Raster eine Serie, hat die keine Fassungen; der
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
import FlaroServerKit
|
||||||
|
|
||||||
|
/// Die Anmeldung an einem Flaro-Server, am Fernseher.
|
||||||
|
///
|
||||||
|
/// Dasselbe Modell wie am Telefon, nur mit `preferring: .code`: sechs Ziffern
|
||||||
|
/// auf dem Bildschirm, bestätigt am Telefon. Das Passwort bleibt der Rückfall
|
||||||
|
/// — für den Fall, dass gerade kein angemeldetes Gerät zur Hand ist.
|
||||||
|
struct TVFlaroSignInView: View {
|
||||||
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
|
let zurueck: () -> Void
|
||||||
|
|
||||||
|
@State private var model: FlaroSignInModel?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let model {
|
||||||
|
inhalt(for: model)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
guard model == nil else { return }
|
||||||
|
let frisch = FlaroSignInModel(
|
||||||
|
identity: session.flaroDeviceIdentity, preferring: .code)
|
||||||
|
frisch.onAuthenticated = { client, konto, token in
|
||||||
|
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
|
||||||
|
}
|
||||||
|
model = frisch
|
||||||
|
}
|
||||||
|
.onDisappear { model?.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func inhalt(for model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
|
Text(Branding.appName).font(.flaroHero)
|
||||||
|
Text("signIn.flaro.title").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
switch model.step {
|
||||||
|
case .address:
|
||||||
|
adresse(model: model)
|
||||||
|
case .checking:
|
||||||
|
warten("signIn.flaro.searching")
|
||||||
|
case .code(let code):
|
||||||
|
codeAnzeigen(model: model, code: code)
|
||||||
|
case .credentials:
|
||||||
|
zugangsdaten(model: model)
|
||||||
|
case .signingIn:
|
||||||
|
warten("signIn.flaro.signingIn")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fehler = model.errorMessage {
|
||||||
|
Text(fehler).font(.flaroBody).foregroundStyle(Color.flaroDestructive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(FlaroSpacing.section)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func adresse(model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
Text("signIn.flaro.addressPrompt").font(.flaroRowHeader)
|
||||||
|
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
|
||||||
|
.flaroPlainEntry(isAddress: true)
|
||||||
|
Text("signIn.flaro.addressHint")
|
||||||
|
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button("signIn.flaro.findServer") { Task { await model.findServer() } }
|
||||||
|
Button("action.back", action: zurueck)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func codeAnzeigen(model: FlaroSignInModel, code: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
if let name = model.serverName {
|
||||||
|
Text(name).font(.flaroRowHeader)
|
||||||
|
}
|
||||||
|
// **Der Code ist hier der Inhalt, nicht eine Beschriftung.**
|
||||||
|
// Er wird aus drei Metern abgelesen und in ein Telefon getippt;
|
||||||
|
// in Fliesstextgrösse ist das eine Zumutung.
|
||||||
|
Text(code)
|
||||||
|
.font(.system(size: 96, weight: .bold, design: .monospaced))
|
||||||
|
.kerning(12)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("signIn.flaro.codeHint").font(.flaroBody)
|
||||||
|
Label("signIn.flaro.waitingForApproval", systemImage: "hourglass")
|
||||||
|
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button("signIn.flaro.usePassword") { model.useCredentials() }
|
||||||
|
Button("signIn.flaro.changeAddress") { model.changeAddress() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func zugangsdaten(model: FlaroSignInModel) -> some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
|
if let name = model.serverName {
|
||||||
|
Text(name).font(.flaroRowHeader)
|
||||||
|
}
|
||||||
|
TextField("signIn.userName", text: $model.userName)
|
||||||
|
.flaroPlainEntry()
|
||||||
|
SecureField("signIn.password", text: $model.password)
|
||||||
|
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button("signIn.flaro.signIn") { Task { await model.signIn() } }
|
||||||
|
Button("signIn.flaro.useCode") { Task { await model.beginCode() } }
|
||||||
|
Button("signIn.flaro.changeAddress") { model.changeAddress() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func warten(_ text: LocalizedStringKey) -> some View {
|
||||||
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
|
ProgressView()
|
||||||
|
Text(text).font(.flaroBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,51 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlaroDesign
|
import FlaroDesign
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
|
|
||||||
/// Die Anmeldung am Fernseher.
|
/// Die Anmeldung am Fernseher.
|
||||||
///
|
///
|
||||||
/// **Dieselben vier Schritte wie am Telefon, andere Reihenfolge.** Das Modell
|
/// **Erst die Frage, welcher Server.** Seit es zwei gibt, steht sie vorn —
|
||||||
/// `JellyfinSignInModel` ist unverändert dasselbe — nur bekommt es hier
|
/// und Flaro oben, weil die App dort mehr kann.
|
||||||
/// `preferring: .quickConnect`. Vor einem Fernseher ist der Code der bequemere
|
|
||||||
/// Weg: es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
|
|
||||||
/// das Gerät, auf dem man den Code bestätigt, liegt sowieso auf dem Sofa.
|
|
||||||
///
|
///
|
||||||
/// Der Weg über Benutzername und Passwort bleibt erreichbar — für Server, auf
|
/// Danach dieselben Schritte wie am Telefon, nur andere Reihenfolge: beide
|
||||||
/// denen Quick Connect abgeschaltet ist.
|
/// Modelle bekommen hier den Code-Weg voran. Vor einem Fernseher ist er der
|
||||||
|
/// bequemere: es gibt keinen Passwortspeicher, getippt wird mit dem
|
||||||
|
/// Steuerkreuz, und das Gerät, auf dem man bestätigt, liegt sowieso auf dem
|
||||||
|
/// Sofa. Benutzername und Passwort bleiben erreichbar.
|
||||||
struct TVSignInView: View {
|
struct TVSignInView: View {
|
||||||
@Environment(FlaroSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
|
@State private var art: FlaroSession.ServerKind?
|
||||||
@State private var model: JellyfinSignInModel?
|
@State private var model: JellyfinSignInModel?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.flaroCanvas.ignoresSafeArea()
|
Color.flaroCanvas.ignoresSafeArea()
|
||||||
|
|
||||||
if let model {
|
Group {
|
||||||
inhalt(for: model)
|
switch art {
|
||||||
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
|
case nil:
|
||||||
// liest sich aus drei Metern nicht mehr als Zeile.
|
auswahl()
|
||||||
.frame(maxWidth: 900)
|
case .flaro:
|
||||||
} else {
|
TVFlaroSignInView(zurueck: { art = nil })
|
||||||
ProgressView()
|
case .jellyfin:
|
||||||
|
if let model {
|
||||||
|
inhalt(for: model)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
|
||||||
|
// liest sich aus drei Metern nicht mehr als Zeile.
|
||||||
|
.frame(maxWidth: 900)
|
||||||
}
|
}
|
||||||
.task {
|
.onChange(of: art) { _, neu in
|
||||||
|
guard neu == .jellyfin, model == nil else { return }
|
||||||
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
|
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
|
||||||
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
|
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
|
||||||
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
|
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
|
||||||
guard model == nil else { return }
|
|
||||||
let frisch = JellyfinSignInModel(
|
let frisch = JellyfinSignInModel(
|
||||||
identity: session.jellyfinDeviceIdentity,
|
identity: session.jellyfinDeviceIdentity,
|
||||||
preferring: .quickConnect
|
preferring: .quickConnect
|
||||||
@@ -51,6 +62,19 @@ struct TVSignInView: View {
|
|||||||
.onDisappear { model?.cancel() }
|
.onDisappear { model?.cancel() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func auswahl() -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
|
Text(Branding.appName).font(.flaroHero)
|
||||||
|
Text("signIn.tagline").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
VStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Button("signIn.withFlaro") { art = .flaro }
|
||||||
|
Button("signIn.withJellyfin") { art = .jellyfin }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(FlaroSpacing.section)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func inhalt(for model: JellyfinSignInModel) -> some View {
|
private func inhalt(for model: JellyfinSignInModel) -> some View {
|
||||||
@Bindable var model = model
|
@Bindable var model = model
|
||||||
@@ -129,9 +153,9 @@ struct TVSignInView: View {
|
|||||||
if let name = model.serverName {
|
if let name = model.serverName {
|
||||||
Text(name).font(.flaroRowHeader)
|
Text(name).font(.flaroRowHeader)
|
||||||
}
|
}
|
||||||
TextField("signIn.jellyfin.userName", text: $model.userName)
|
TextField("signIn.userName", text: $model.userName)
|
||||||
.flaroPlainEntry()
|
.flaroPlainEntry()
|
||||||
SecureField("signIn.jellyfin.password", text: $model.password)
|
SecureField("signIn.password", text: $model.password)
|
||||||
Text("signIn.jellyfin.passwordNote")
|
Text("signIn.jellyfin.passwordNote")
|
||||||
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
|
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.library(name: "MediaKit", targets: ["MediaKit"]),
|
.library(name: "MediaKit", targets: ["MediaKit"]),
|
||||||
.library(name: "JellyfinKit", targets: ["JellyfinKit"]),
|
.library(name: "JellyfinKit", targets: ["JellyfinKit"]),
|
||||||
|
.library(name: "FlaroServerKit", targets: ["FlaroServerKit"]),
|
||||||
.library(name: "FlaroDesign", targets: ["FlaroDesign"]),
|
.library(name: "FlaroDesign", targets: ["FlaroDesign"]),
|
||||||
.library(name: "FlaroPlayback", targets: ["FlaroPlayback"]),
|
.library(name: "FlaroPlayback", targets: ["FlaroPlayback"]),
|
||||||
.library(name: "FlaroDownloads", targets: ["FlaroDownloads"]),
|
.library(name: "FlaroDownloads", targets: ["FlaroDownloads"]),
|
||||||
@@ -39,6 +40,12 @@ let package = Package(
|
|||||||
resources: [.copy("Fixtures")]
|
resources: [.copy("Fixtures")]
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Flaros eigener Server. Daneben, nicht darin — genau der Steckplatz,
|
||||||
|
// den Plex geräumt hat. Dass die Naht zwei Server zugleich trägt, war
|
||||||
|
// schon einmal bewiesen; hier wird es wieder gebraucht.
|
||||||
|
.target(name: "FlaroServerKit", dependencies: ["MediaKit"], resources: [.process("Localizable.xcstrings")]),
|
||||||
|
.testTarget(name: "FlaroServerKitTests", dependencies: ["FlaroServerKit"]),
|
||||||
|
|
||||||
// Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche,
|
// Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche,
|
||||||
// ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt
|
// ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt
|
||||||
// eines halben Tages, und das Upload-Kontingent bleibt für Builds.
|
// eines halben Tages, und das Upload-Kontingent bleibt für Builds.
|
||||||
|
|||||||
@@ -101,6 +101,19 @@ public actor ArtworkLoader {
|
|||||||
using session: URLSession,
|
using session: URLSession,
|
||||||
attempts: Int = 3
|
attempts: Int = 3
|
||||||
) async -> PlatformImage? {
|
) async -> PlatformImage? {
|
||||||
|
// **Eine Datei auf der Platte ist kein Server.**
|
||||||
|
//
|
||||||
|
// Hier stand nur der Weg über HTTP, und die Antwort einer
|
||||||
|
// `file:`-Adresse ist keine `HTTPURLResponse` — die Prüfung darunter
|
||||||
|
// gab also `nil` zurück, immer. Sichtbar wurde das erst, als die
|
||||||
|
// Downloads ihre Plakate mitbrachten: sie lagen auf der Platte, und
|
||||||
|
// die Kacheln blieben trotzdem grau. Kein Wiederholen, kein
|
||||||
|
// Zeitablauf: eine Datei ist entweder da oder nicht.
|
||||||
|
if url.isFileURL {
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return PlatformImage(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
for attempt in 1...attempts {
|
for attempt in 1...attempts {
|
||||||
if Task.isCancelled { return nil }
|
if Task.isCancelled { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Eine Reihe, die umbricht statt zu quetschen.
|
||||||
|
///
|
||||||
|
/// **Der Anlass.** Im Kopf einer Detailseite stehen Abspielen, Von vorn,
|
||||||
|
/// Herunterladen und zwei Sprachknöpfe nebeneinander. In einem `HStack`
|
||||||
|
/// teilen sie sich die Breite — auf einem Telefon wurde daraus eine Reihe
|
||||||
|
/// von Säulen aus einzelnen Buchstaben, untereinander gesetzt. Lesbar war
|
||||||
|
/// nichts mehr, und angetippt hätte man trotzdem das Richtige.
|
||||||
|
///
|
||||||
|
/// **Umbrechen ist die Antwort, nicht Weglassen.** Ein Knopf, der auf einem
|
||||||
|
/// schmalen Gerät verschwindet, ist eine zweite Oberfläche, die gepflegt
|
||||||
|
/// werden will. Zwei Zeilen sind kein Mangel; sie sind, was dort hinpasst.
|
||||||
|
///
|
||||||
|
/// **Warum eigener `Layout` und kein `ViewThatFits`.** Das wählt zwischen
|
||||||
|
/// fertigen Entwürfen — man müsste jede Aufteilung von Hand hinschreiben,
|
||||||
|
/// und bei fünf Knöpfen sind das fünf. Hier wird gerechnet.
|
||||||
|
public struct Flusszeile: Layout {
|
||||||
|
private let abstand: CGFloat
|
||||||
|
private let zeilenabstand: CGFloat
|
||||||
|
|
||||||
|
public init(abstand: CGFloat = FlaroSpacing.regular, zeilenabstand: CGFloat? = nil) {
|
||||||
|
self.abstand = abstand
|
||||||
|
self.zeilenabstand = zeilenabstand ?? abstand
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sizeThatFits(
|
||||||
|
proposal: ProposedViewSize,
|
||||||
|
subviews: Subviews,
|
||||||
|
cache: inout Void
|
||||||
|
) -> CGSize {
|
||||||
|
let breite = proposal.width ?? .infinity
|
||||||
|
let zeilen = umbrechen(subviews, in: breite)
|
||||||
|
|
||||||
|
let hoehe = zeilen.reduce(CGFloat.zero) { $0 + $1.hoehe }
|
||||||
|
+ CGFloat(max(0, zeilen.count - 1)) * zeilenabstand
|
||||||
|
|
||||||
|
// **Die breiteste Zeile, nicht die angebotene Breite.** Sonst nimmt
|
||||||
|
// die Reihe das ganze Fenster ein, auch wenn zwei Knöpfe darin
|
||||||
|
// stehen — und alles daneben rutscht weg.
|
||||||
|
let genutzt = zeilen.map(\.breite).max() ?? 0
|
||||||
|
return CGSize(width: min(genutzt, breite), height: hoehe)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func placeSubviews(
|
||||||
|
in bounds: CGRect,
|
||||||
|
proposal: ProposedViewSize,
|
||||||
|
subviews: Subviews,
|
||||||
|
cache: inout Void
|
||||||
|
) {
|
||||||
|
var y = bounds.minY
|
||||||
|
|
||||||
|
for zeile in umbrechen(subviews, in: bounds.width) {
|
||||||
|
var x = bounds.minX
|
||||||
|
|
||||||
|
for index in zeile.teile {
|
||||||
|
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||||
|
subviews[index].place(
|
||||||
|
at: CGPoint(x: x, y: y + (zeile.hoehe - groesse.height) / 2),
|
||||||
|
proposal: ProposedViewSize(groesse)
|
||||||
|
)
|
||||||
|
x += groesse.width + abstand
|
||||||
|
}
|
||||||
|
|
||||||
|
y += zeile.hoehe + zeilenabstand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Zeile {
|
||||||
|
var teile: [Int] = []
|
||||||
|
var breite: CGFloat = 0
|
||||||
|
var hoehe: CGFloat = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func umbrechen(_ subviews: Subviews, in breite: CGFloat) -> [Zeile] {
|
||||||
|
var zeilen: [Zeile] = []
|
||||||
|
var jetzt = Zeile()
|
||||||
|
|
||||||
|
for index in subviews.indices {
|
||||||
|
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||||
|
let dazu = jetzt.teile.isEmpty ? groesse.width : jetzt.breite + abstand + groesse.width
|
||||||
|
|
||||||
|
// **Das erste Element bleibt immer stehen.** Ein Knopf, der
|
||||||
|
// allein schon breiter ist als die Zeile, käme sonst in eine
|
||||||
|
// leere Zeile und von dort in die nächste, endlos.
|
||||||
|
if !jetzt.teile.isEmpty, dazu > breite {
|
||||||
|
zeilen.append(jetzt)
|
||||||
|
jetzt = Zeile()
|
||||||
|
jetzt.teile = [index]
|
||||||
|
jetzt.breite = groesse.width
|
||||||
|
jetzt.hoehe = groesse.height
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jetzt.teile.append(index)
|
||||||
|
jetzt.breite = dazu
|
||||||
|
jetzt.hoehe = max(jetzt.hoehe, groesse.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !jetzt.teile.isEmpty { zeilen.append(jetzt) }
|
||||||
|
return zeilen
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -291,7 +291,10 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
.frame(maxWidth: 640 * scale, alignment: .leading)
|
.frame(maxWidth: 640 * scale, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: FlaroSpacing.regular) {
|
// **Umbrechen statt quetschen.** In einem `HStack` teilen sich
|
||||||
|
// fünf Knöpfe die Breite; auf einem Telefon wurde daraus eine
|
||||||
|
// Reihe von Säulen aus einzelnen Buchstaben.
|
||||||
|
Flusszeile {
|
||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
.padding(.top, FlaroSpacing.tight)
|
.padding(.top, FlaroSpacing.tight)
|
||||||
|
|||||||
@@ -395,9 +395,16 @@ public final class DownloadCoordinator {
|
|||||||
forgetTransfer(item.ratingKey)
|
forgetTransfer(item.ratingKey)
|
||||||
fileStore.remove(fileName: item.localFileName)
|
fileStore.remove(fileName: item.localFileName)
|
||||||
fileStore.remove(fileName: item.artworkFileName)
|
fileStore.remove(fileName: item.artworkFileName)
|
||||||
|
let serie = item.seriesKey
|
||||||
context.delete(item)
|
context.delete(item)
|
||||||
save()
|
save()
|
||||||
reloadItems()
|
reloadItems()
|
||||||
|
|
||||||
|
// **Das Serienplakat geht mit der letzten Folge.** Es hängt an
|
||||||
|
// keinem Eintrag — ohne diese Zeile bliebe es für immer liegen.
|
||||||
|
if let serie, !items.contains(where: { $0.seriesKey == serie }) {
|
||||||
|
fileStore.remove(fileName: fileStore.artworkFileName(forRatingKey: serie))
|
||||||
|
}
|
||||||
refreshUsage()
|
refreshUsage()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +429,58 @@ public final class DownloadCoordinator {
|
|||||||
return fileStore.url(forFileName: fileName)
|
return fileStore.url(forFileName: fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Das lokale Plakat eines Downloads — `nil`, wenn keines geholt wurde.
|
||||||
|
///
|
||||||
|
/// **Weil eine Reihe ohne Bilder keine Reihe ist.** Ohne Verbindung gibt
|
||||||
|
/// es keine Adresse, von der ein Plakat nachzuladen wäre; was nicht beim
|
||||||
|
/// Herunterladen mitkam, bleibt eine graue Fläche.
|
||||||
|
/// **Nach der Datei gefragt, nicht nach dem Eintrag.** Hier wurde erst
|
||||||
|
/// der Download gesucht und dann sein Dateiname gelesen — damit gab es
|
||||||
|
/// nur Bilder zu Dingen, die selbst heruntergeladen wurden. Das Plakat
|
||||||
|
/// einer **Serie** gehört aber keiner einzelnen Folge; es liegt unter
|
||||||
|
/// dem Serienschlüssel, zu dem es keinen Eintrag gibt und nie geben
|
||||||
|
/// wird.
|
||||||
|
public func artworkURL(forRatingKey key: String) -> URL? {
|
||||||
|
let name = fileStore.artworkFileName(forRatingKey: key)
|
||||||
|
guard fileStore.exists(fileName: name) else { return nil }
|
||||||
|
return fileStore.url(forFileName: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holt das Plakat zu einem Eintrag nach und legt es ab.
|
||||||
|
///
|
||||||
|
/// **Beim Anlegen, nicht erst beim Fertigwerden.** Die Liste zeigt den
|
||||||
|
/// Eintrag, sobald er in der Warteschlange steht; bis dahin wäre sonst
|
||||||
|
/// nichts zu sehen. Es sind ein paar Kilobyte.
|
||||||
|
/// Der Schlüssel muss zu keinem Download gehören: unter dem Schlüssel
|
||||||
|
/// einer Serie liegt ihr Plakat, obwohl die Serie selbst nie
|
||||||
|
/// heruntergeladen wird.
|
||||||
|
public func fetchArtwork(for ratingKey: String, from url: URL) async {
|
||||||
|
let name = fileStore.artworkFileName(forRatingKey: ratingKey)
|
||||||
|
guard !fileStore.exists(fileName: name) else {
|
||||||
|
vermerken(name, bei: ratingKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let (daten, antwort) = try? await URLSession.shared.data(from: url),
|
||||||
|
(antwort as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true,
|
||||||
|
!daten.isEmpty
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
guard fileStore.store(daten, as: name) != nil else { return }
|
||||||
|
vermerken(name, bei: ratingKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hält am Eintrag fest, dass es ein Bild gibt — damit es beim Entfernen
|
||||||
|
/// mitgeht. Zum Serienschlüssel gibt es keinen Eintrag; dann bleibt die
|
||||||
|
/// Datei stehen, bis die letzte Folge der Serie geht.
|
||||||
|
private func vermerken(_ name: String, bei ratingKey: String) {
|
||||||
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||||
|
item.artworkFileName != name
|
||||||
|
else { return }
|
||||||
|
item.artworkFileName = name
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
|
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
|
||||||
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
|
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
|
||||||
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
|
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
|
||||||
@@ -458,6 +517,13 @@ public final class DownloadCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wie oft je Eintrag schon auf ein Umpacken gewartet wurde.
|
||||||
|
///
|
||||||
|
/// **Im Arbeitsspeicher und nicht im Eintrag.** Nach einem Neustart
|
||||||
|
/// beginnt das Zählen von vorn — und das ist richtig: der Server hat
|
||||||
|
/// inzwischen vermutlich fertig gepackt.
|
||||||
|
private var wartend: [String: Int] = [:]
|
||||||
|
|
||||||
private func start(_ item: DownloadItem) {
|
private func start(_ item: DownloadItem) {
|
||||||
guard let url = URL(string: item.sourceURLString) else {
|
guard let url = URL(string: item.sourceURLString) else {
|
||||||
fail(item, message: "Ungültige Adresse")
|
fail(item, message: "Ungültige Adresse")
|
||||||
@@ -526,6 +592,7 @@ public final class DownloadCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
func handleCompletion(ratingKey: String, temporaryFile: URL) {
|
||||||
|
wartend[ratingKey] = nil
|
||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
|
||||||
let sourceURL = URL(string: item.sourceURLString)
|
let sourceURL = URL(string: item.sourceURLString)
|
||||||
else { return }
|
else { return }
|
||||||
@@ -608,6 +675,49 @@ public final class DownloadCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Der Server hat die Anfrage abgelehnt.
|
/// Der Server hat die Anfrage abgelehnt.
|
||||||
|
/// Wie oft auf ein Umpacken gewartet wird, bevor aufgegeben wird.
|
||||||
|
///
|
||||||
|
/// **Grosszügig, und das mit Absicht.** Ein Dreistünder in 4K braucht auf
|
||||||
|
/// einem NAS Minuten; abzubrechen, während die Arbeit läuft, wäre die
|
||||||
|
/// eine Antwort, die niemandem hilft. Zwanzig Versuche im Abstand von
|
||||||
|
/// fünfzehn Sekunden sind fünf Minuten.
|
||||||
|
private static let hoechstensWarten = 20
|
||||||
|
private static let wartezeit = Duration.seconds(15)
|
||||||
|
|
||||||
|
/// Der Server packt noch um — später noch einmal fragen.
|
||||||
|
///
|
||||||
|
/// **Nicht sofort.** Ein Umpacken dauert Minuten, und im Sekundentakt
|
||||||
|
/// nachzufragen hiesse, dieselbe Datei hundertmal anzufangen und wieder
|
||||||
|
/// wegzuwerfen.
|
||||||
|
func handleNotReady(ratingKey: String) {
|
||||||
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||||
|
|
||||||
|
let versuche = (wartend[ratingKey] ?? 0) + 1
|
||||||
|
wartend[ratingKey] = versuche
|
||||||
|
|
||||||
|
guard versuche <= Self.hoechstensWarten else {
|
||||||
|
wartend[ratingKey] = nil
|
||||||
|
fail(item, message: String(localized: "downloads.failed.notReady", bundle: .module))
|
||||||
|
Task { await startNextIfPossible() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Zurück in die Warteschlange, nicht in den Fehler.** Es ist
|
||||||
|
// nichts schiefgegangen; es ist nur noch nicht fertig.
|
||||||
|
item.state = .waiting
|
||||||
|
save()
|
||||||
|
|
||||||
|
// **Nicht `resume`.** Das nimmt nur auf, was angehalten oder
|
||||||
|
// gescheitert ist — ein wartender Eintrag lief dort in die
|
||||||
|
// Schutzklausel und blieb für immer auf „wartet" stehen, während der
|
||||||
|
// Server längst fertig gepackt hatte. Die Warteschlange abzuarbeiten
|
||||||
|
// ist das, was hier gebraucht wird: `.waiting` steht darin.
|
||||||
|
Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: Self.wartezeit)
|
||||||
|
await self?.startNextIfPossible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleServerRejection(ratingKey: String, status: Int) {
|
func handleServerRejection(ratingKey: String, status: Int) {
|
||||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||||
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
|
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
|
||||||
@@ -740,6 +850,21 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch
|
|||||||
) {
|
) {
|
||||||
guard let key = downloadTask.taskDescription else { return }
|
guard let key = downloadTask.taskDescription else { return }
|
||||||
|
|
||||||
|
// **202 heisst: noch nicht, komm wieder.**
|
||||||
|
//
|
||||||
|
// Der Server packt eine Datei um, die so nicht ausgeliefert werden
|
||||||
|
// kann, und antwortet währenddessen mit 202 und einem kurzen Text.
|
||||||
|
// Das ist ein Erfolgscode, also ging er durch die Prüfung darunter
|
||||||
|
// glatt hindurch: 163 Byte Antworttext lagen danach als Film auf dem
|
||||||
|
// Gerät, „in einer Sekunde heruntergeladen".
|
||||||
|
if let response = downloadTask.response as? HTTPURLResponse,
|
||||||
|
response.statusCode == 202 {
|
||||||
|
Task { @MainActor [weak coordinator] in
|
||||||
|
coordinator?.handleNotReady(ratingKey: key)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
|
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
|
||||||
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
|
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
|
||||||
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
|
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
|
||||||
|
|||||||
@@ -58,6 +58,29 @@ public struct DownloadFileStore: Sendable {
|
|||||||
return "\(key).\(ext)"
|
return "\(key).\(ext)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Name des Plakats zu einem Titel.
|
||||||
|
///
|
||||||
|
/// **Eine eigene Datei, kein Anhängsel am Film.** Sie ist ein paar
|
||||||
|
/// Kilobyte gross und wird gebraucht, solange der Eintrag steht — auch
|
||||||
|
/// während der Film noch lädt und noch gar keine Datei hat.
|
||||||
|
public func artworkFileName(forRatingKey key: String) -> String { "\(key).bild" }
|
||||||
|
|
||||||
|
/// Legt ein geholtes Plakat ab. Schlägt es fehl, gibt es eben keines.
|
||||||
|
///
|
||||||
|
/// **Wirft nicht.** Ein Download ohne Bild ist ein Download; ein
|
||||||
|
/// Download, der am Bild scheitert, wäre keiner.
|
||||||
|
@discardableResult
|
||||||
|
public func store(_ data: Data, as name: String) -> URL? {
|
||||||
|
let ziel = url(forFileName: name)
|
||||||
|
do {
|
||||||
|
try createRootIfNeeded()
|
||||||
|
try data.write(to: ziel, options: .atomic)
|
||||||
|
return ziel
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
|
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
|
||||||
///
|
///
|
||||||
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage": "en",
|
"sourceLanguage": "en",
|
||||||
"strings": {
|
"strings": {
|
||||||
|
"downloads.failed.notReady": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Server packt diese Datei noch um. Versuch es in ein paar Minuten noch einmal."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The server is still repackaging this file. Try again in a few minutes."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloads.failed.status %lld": {
|
||||||
|
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The server refused the download with error %lld."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloads.failed.tooSmall %@": {
|
"downloads.failed.tooSmall %@": {
|
||||||
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -34,23 +68,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"downloads.failed.status %lld": {
|
|
||||||
"comment": "Der Server hat die Download-Anfrage abgelehnt",
|
|
||||||
"localizations": {
|
|
||||||
"de": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"en": {
|
|
||||||
"stringUnit": {
|
|
||||||
"state": "translated",
|
|
||||||
"value": "The server refused the download with error %lld."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.0"
|
"version": "1.0"
|
||||||
|
|||||||
@@ -125,11 +125,24 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
|||||||
|
|
||||||
pendingStartTime = source.startTime
|
pendingStartTime = source.startTime
|
||||||
|
|
||||||
|
// **Cookies muss man AVFoundation ausdrücklich mitgeben.**
|
||||||
|
//
|
||||||
|
// `URLSession` liest den gemeinsamen Speicher von allein, `AVURLAsset`
|
||||||
|
// nicht. Für Jellyfin ist das belanglos — dort steht der Zugang in
|
||||||
|
// der Adresse. Flaro schreibt ihn bewusst nicht dorthin: er stünde
|
||||||
|
// sonst in jedem Zugriffsprotokoll und in jedem weitergegebenen Link.
|
||||||
|
// Ohne diese Zeile bekäme der Spieler dort 401 und zeigte Schwarz.
|
||||||
|
//
|
||||||
|
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
|
||||||
|
// ändert sich nichts.
|
||||||
|
let cookies = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
|
||||||
|
|
||||||
let asset = AVURLAsset(url: source.url, options: [
|
let asset = AVURLAsset(url: source.url, options: [
|
||||||
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
|
||||||
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
|
||||||
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
|
||||||
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
AVURLAssetPreferPreciseDurationAndTimingKey: false,
|
||||||
|
AVURLAssetHTTPCookiesKey: cookies,
|
||||||
])
|
])
|
||||||
let item = AVPlayerItem(asset: asset)
|
let item = AVPlayerItem(asset: asset)
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ public actor StreamCache {
|
|||||||
configuration.urlCache = nil
|
configuration.urlCache = nil
|
||||||
configuration.httpMaximumConnectionsPerHost = 4
|
configuration.httpMaximumConnectionsPerHost = 4
|
||||||
configuration.timeoutIntervalForRequest = 30
|
configuration.timeoutIntervalForRequest = 30
|
||||||
|
|
||||||
|
// **Die Anmeldung muss mit.**
|
||||||
|
//
|
||||||
|
// Eine flüchtige Sitzung bringt ihren eigenen, leeren Cookie-Speicher
|
||||||
|
// mit — jede Anfrage von hier ging also unangemeldet hinaus. Bei
|
||||||
|
// Jellyfin fiel das nie auf: dort steht der Schlüssel in der Adresse.
|
||||||
|
// Flaro meldet über ein Cookie an, und im Protokoll stand genau das:
|
||||||
|
// `FlaroToken was challenged`, einmal je Wiedergabe, gefolgt von
|
||||||
|
// zwölf Sekunden Stille und einer zweiten Bestellung.
|
||||||
|
configuration.httpCookieStorage = HTTPCookieStorage.shared
|
||||||
|
configuration.httpShouldSetCookies = true
|
||||||
self.session = URLSession(configuration: configuration)
|
self.session = URLSession(configuration: configuration)
|
||||||
|
|
||||||
self.directory = FileManager.default.temporaryDirectory
|
self.directory = FileManager.default.temporaryDirectory
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Die Adressraterei für Flaro.
|
||||||
|
///
|
||||||
|
/// Der Inhalt steht in `ServerAddress`. Hier steht nur, was Flaro von
|
||||||
|
/// Jellyfin unterscheidet: der Port.
|
||||||
|
public enum FlaroAddress {
|
||||||
|
/// Der Standardport eines Flaro-Servers.
|
||||||
|
///
|
||||||
|
/// 8080, nicht 8096. Die Beispiel-Compose-Datei bildet ihn so ab, und
|
||||||
|
/// einen fremden Port zu erben hiesse, dass jede Anmeldung einmal ins
|
||||||
|
/// Leere läuft, bevor sie den richtigen findet.
|
||||||
|
public static let defaultPort = 8080
|
||||||
|
|
||||||
|
public static func candidates(for input: String) -> [URL] {
|
||||||
|
ServerAddress.candidates(for: input, defaultPort: defaultPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Ein Flaro-Server, gesehen durch `MediaBackend`.
|
||||||
|
///
|
||||||
|
/// Der zweite Server an derselben Naht, an der vorher Plex und Jellyfin
|
||||||
|
/// nebeneinander hingen. Was hier steht, ist die Übersetzung von Flaros
|
||||||
|
/// Schnittstelle in den Vertrag — nichts weiter: keine Oberfläche, keine
|
||||||
|
/// Entscheidung über Wiedergabe, keine Zwischenspeicher.
|
||||||
|
public actor FlaroBackend: MediaBackend {
|
||||||
|
private let client: FlaroClient
|
||||||
|
private let account: FlaroAccount
|
||||||
|
/// Die Mediatheken, einmal geholt.
|
||||||
|
///
|
||||||
|
/// Sie ändern sich nur, wenn jemand in der Verwaltung eine anlegt — und
|
||||||
|
/// jede Seite der Mediathek bräuchte sie sonst erneut, nur um zu wissen,
|
||||||
|
/// ob sie nach Filmen oder nach Serien fragt.
|
||||||
|
private var libraryMemo: [MediaLibrary]?
|
||||||
|
|
||||||
|
public init(client: FlaroClient, account: FlaroAccount) {
|
||||||
|
self.client = client
|
||||||
|
self.account = account
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Wer dieser Server ist
|
||||||
|
|
||||||
|
public nonisolated var serverName: String { account.serverName }
|
||||||
|
|
||||||
|
/// **Die Adresse, nicht der Name.** Zwei Server dürfen „Medien" heissen,
|
||||||
|
/// und derselbe darf umbenannt werden. Was hier steht, überlebt beides.
|
||||||
|
public nonisolated var identity: String { "flaro:" + account.baseURL.absoluteString }
|
||||||
|
|
||||||
|
public nonisolated var capabilities: Set<BackendCapability> {
|
||||||
|
[
|
||||||
|
.favourites,
|
||||||
|
.ratings,
|
||||||
|
.libraryScan,
|
||||||
|
.transcoding,
|
||||||
|
// **Der Grund, aus dem es diesen Server gibt.** Flaros Download
|
||||||
|
// bringt jede Ton- und Textspur mit; die App muss vorher nichts
|
||||||
|
// wählen und kann offline umstellen.
|
||||||
|
.allTracksDownload,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Blättern
|
||||||
|
|
||||||
|
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
|
||||||
|
if let libraryMemo { return libraryMemo }
|
||||||
|
let roh = try await client.send(FlaroRequest(path: "/api/v1/libraries"), as: [Wire.Library].self)
|
||||||
|
let liste = roh.map { $0.neutral() }
|
||||||
|
libraryMemo = liste
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
|
||||||
|
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||||
|
// **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der
|
||||||
|
// eine fertige Startseite liefert, und das ist Absicht: welche Reihen
|
||||||
|
// eine App zeigt, entscheidet die App. Der Server liefert die Zutaten.
|
||||||
|
async let weiter = reihe(pfad: "/api/v1/home/continue")
|
||||||
|
async let naechste = reihe(pfad: "/api/v1/home/next")
|
||||||
|
let mediatheken = (try? await libraries()) ?? []
|
||||||
|
|
||||||
|
var reihen: [MediaRowGroup] = []
|
||||||
|
|
||||||
|
if let eintraege = try? await weiter, !eintraege.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.continue",
|
||||||
|
title: String(localized: "flaro.row.continue", bundle: .module),
|
||||||
|
items: eintraege))
|
||||||
|
}
|
||||||
|
if let eintraege = try? await naechste, !eintraege.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.nextup",
|
||||||
|
title: String(localized: "flaro.row.nextUp", bundle: .module),
|
||||||
|
items: eintraege))
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.**
|
||||||
|
//
|
||||||
|
// Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem
|
||||||
|
// Anfang der Mediathek in der Reihenfolge der Datenbank — also immer
|
||||||
|
// demselben. Drei Dinge waren damit falsch: es war kein Neuzugang,
|
||||||
|
// es warf getrennte Mediatheken wieder zusammen, und die Kennungen
|
||||||
|
// begannen nicht mit `latest`. An dieser Vorsilbe hängen zwei
|
||||||
|
// Einstellungen — „Gesehenes in Neuzugängen ausblenden" und
|
||||||
|
// „Aufmacher nur aus Neuzugängen" —, und beide taten an einem
|
||||||
|
// Flaro-Server deshalb nichts.
|
||||||
|
// **Nebeneinander, nicht nacheinander.**
|
||||||
|
//
|
||||||
|
// Hier stand eine Schleife mit `await` darin: bei vier Mediatheken
|
||||||
|
// vier Rundreisen hintereinander, jede erst nach der vorigen. Die
|
||||||
|
// Weboberfläche holt dieselben Reihen in einem `Promise.all` — und
|
||||||
|
// sie holt sogar mehr davon. Genau das war der Unterschied, den man
|
||||||
|
// der App ansah.
|
||||||
|
//
|
||||||
|
// Ein Actor steht dem nicht im Weg: jede Anfrage gibt ihn beim
|
||||||
|
// Warten wieder frei, so wie die beiden darüber es schon tun.
|
||||||
|
let neuzugaenge = await withTaskGroup(of: (Int, MediaRowGroup?).self) { gruppe in
|
||||||
|
for (platz, mediathek) in mediatheken.enumerated() {
|
||||||
|
gruppe.addTask { (platz, await self.neuzugang(in: mediathek)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Die Reihenfolge der Mediatheken, nicht die der Antworten.**
|
||||||
|
// Sonst stünden die Reihen nach jedem Start woanders — je
|
||||||
|
// nachdem, welche Abfrage zuerst zurückkam.
|
||||||
|
var gesammelt: [(Int, MediaRowGroup?)] = []
|
||||||
|
for await ergebnis in gruppe { gesammelt.append(ergebnis) }
|
||||||
|
return gesammelt.sorted { $0.0 < $1.0 }.compactMap(\.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
reihen.append(contentsOf: neuzugaenge)
|
||||||
|
return reihen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Reihe „Zuletzt hinzugefügt" einer Mediathek — oder nichts.
|
||||||
|
///
|
||||||
|
/// **Ein Fehler bei einer Mediathek nimmt nicht die anderen mit.** Die
|
||||||
|
/// Startseite mit drei von vier Reihen ist besser als gar keine.
|
||||||
|
private func neuzugang(in mediathek: MediaLibrary) async -> MediaRowGroup? {
|
||||||
|
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
|
||||||
|
let abfrage = [
|
||||||
|
URLQueryItem(name: "library", value: mediathek.id),
|
||||||
|
URLQueryItem(name: "sort", value: "added"),
|
||||||
|
URLQueryItem(name: "take", value: "24"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let titel: [MediaItem]
|
||||||
|
if mediathek.kind == .show {
|
||||||
|
guard let seite = try? await client.send(
|
||||||
|
FlaroRequest(path: pfad, query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Series>.self) else { return nil }
|
||||||
|
titel = seite.items.map { $0.neutral() }
|
||||||
|
} else {
|
||||||
|
guard let seite = try? await client.send(
|
||||||
|
FlaroRequest(path: pfad, query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Movie>.self) else { return nil }
|
||||||
|
titel = seite.items.map { $0.neutral() }
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !titel.isEmpty else { return nil }
|
||||||
|
|
||||||
|
return MediaRowGroup(
|
||||||
|
id: "latest.\(mediathek.id)",
|
||||||
|
title: String(localized: "flaro.row.latestIn \(mediathek.title)", bundle: .module),
|
||||||
|
libraryID: mediathek.id,
|
||||||
|
items: titel)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func availableLanguages(inSeries id: String) async -> AvailableLanguages? {
|
||||||
|
guard let roh = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(id)/languages"),
|
||||||
|
as: Wire.Sprachauswahl.self)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return AvailableLanguages(
|
||||||
|
audio: roh.audio.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||||
|
subtitles: roh.subtitles.map { LanguageOption(code: $0.code, name: $0.name) },
|
||||||
|
partial: roh.partial)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func items(
|
||||||
|
inLibrary library: MediaLibrary,
|
||||||
|
query: MediaQuery,
|
||||||
|
offset: Int,
|
||||||
|
limit: Int
|
||||||
|
) async throws(MediaBackendError) -> MediaPage {
|
||||||
|
var abfrage = [
|
||||||
|
URLQueryItem(name: "library", value: library.id),
|
||||||
|
URLQueryItem(name: "skip", value: String(offset)),
|
||||||
|
URLQueryItem(name: "take", value: String(limit)),
|
||||||
|
]
|
||||||
|
if let text = query.searchText, !text.isEmpty {
|
||||||
|
abfrage.append(URLQueryItem(name: "search", value: text))
|
||||||
|
}
|
||||||
|
|
||||||
|
if library.kind == .show {
|
||||||
|
let seite = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series", query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Series>.self)
|
||||||
|
return MediaPage(
|
||||||
|
items: gesiebt(seite.items.map { $0.neutral() }, query),
|
||||||
|
offset: offset, totalCount: seite.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
let seite = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/movies", query: abfrage),
|
||||||
|
as: Wire.Page<Wire.Movie>.self)
|
||||||
|
return MediaPage(
|
||||||
|
items: gesiebt(seite.items.map { $0.neutral() }, query),
|
||||||
|
offset: offset, totalCount: seite.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
|
||||||
|
// **Erst Film, dann Serie, dann Folge.** Die App reicht eine Kennung
|
||||||
|
// durch und weiss nicht, was dahintersteckt — sie hat sie aus einer
|
||||||
|
// Liste. Drei Anfragen im schlechtesten Fall; der Normalfall ist eine.
|
||||||
|
if let film = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/movies/\(id)"), as: Wire.Movie.self) {
|
||||||
|
return film.neutral()
|
||||||
|
}
|
||||||
|
if let serie = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
|
||||||
|
return serie.neutral()
|
||||||
|
}
|
||||||
|
// **Und zuletzt eine Folge.**
|
||||||
|
//
|
||||||
|
// Sie fehlte, und das war der Grund für „Dieser Titel ist auf dem
|
||||||
|
// Server nicht mehr vorhanden" direkt von der Startseite: in
|
||||||
|
// „Weiterschauen" steht eine Folge mit ihrer eigenen Kennung, und die
|
||||||
|
// ist weder ein Film noch eine Serie.
|
||||||
|
if let folge = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/episodes/\(id)"), as: Wire.Episode.self) {
|
||||||
|
var serie: Wire.Series?
|
||||||
|
if let kennung = folge.seriesId {
|
||||||
|
serie = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(kennung)"), as: Wire.Series.self)
|
||||||
|
}
|
||||||
|
return folge.neutral(series: serie)
|
||||||
|
}
|
||||||
|
throw .notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
|
try await episodes(inSeries: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Folgen einer Serie — in einer Anfrage.
|
||||||
|
///
|
||||||
|
/// Der allgemeine Weg über Staffeln kostete bei zwölf Staffeln dreizehn
|
||||||
|
/// Anfragen. Flaro kann es in einer, also tut es das.
|
||||||
|
public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
|
let serie = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self)
|
||||||
|
let folgen = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series/\(id)/episodes"),
|
||||||
|
as: Wire.Page<Wire.Episode>.self)
|
||||||
|
return folgen.items.map { $0.neutral(series: serie) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||||
|
try await nachbar(von: item, weg: "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||||
|
try await nachbar(von: item, weg: "previous")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Folge vor oder nach dieser.
|
||||||
|
///
|
||||||
|
/// 204 heisst: es gibt keine. Kein Fehler, nur das Ende der Serie —
|
||||||
|
/// oder ihr Anfang.
|
||||||
|
private func nachbar(von item: MediaItem, weg: String) async throws(MediaBackendError) -> MediaItem? {
|
||||||
|
guard item.kind == .episode else { return nil }
|
||||||
|
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/\(weg)"))
|
||||||
|
guard !daten.isEmpty else { return nil }
|
||||||
|
// **Der eingerichtete Dekoder, nicht ein frischer.** Ein frischer
|
||||||
|
// kennt das Datumsformat des Servers nicht; sobald die Nachbarfolge
|
||||||
|
// ein `publishAt` oder ein `lastPlayedAt` mitbringt — und mit
|
||||||
|
// eingeschalteter Prüfung bringt sie es —, scheiterte das Lesen.
|
||||||
|
// Das `try?` schluckte den Fehler, und von aussen sah es aus, als
|
||||||
|
// gäbe es keine nächste Folge: der Knopf blieb weg.
|
||||||
|
guard let folge = try? FlaroClient.decoder.decode(
|
||||||
|
Wire.Episode.self, from: daten) else { return nil }
|
||||||
|
return folge.neutral()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
|
||||||
|
let abfrage = [
|
||||||
|
URLQueryItem(name: "search", value: text),
|
||||||
|
URLQueryItem(name: "take", value: String(limit)),
|
||||||
|
]
|
||||||
|
async let filme = client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/movies", query: abfrage), as: Wire.Page<Wire.Movie>.self)
|
||||||
|
async let serien = client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/series", query: abfrage), as: Wire.Page<Wire.Series>.self)
|
||||||
|
|
||||||
|
var reihen: [MediaRowGroup] = []
|
||||||
|
if let gefunden = try? await filme, !gefunden.items.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.search.movies",
|
||||||
|
title: String(localized: "flaro.row.movies", bundle: .module),
|
||||||
|
items: gefunden.items.map { $0.neutral() }))
|
||||||
|
}
|
||||||
|
if let gefunden = try? await serien, !gefunden.items.isEmpty {
|
||||||
|
reihen.append(MediaRowGroup(
|
||||||
|
id: "flaro.search.series",
|
||||||
|
title: String(localized: "flaro.row.series", bundle: .module),
|
||||||
|
items: gefunden.items.map { $0.neutral() }))
|
||||||
|
}
|
||||||
|
return reihen
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flaro kennt noch keine Schlagwörter.
|
||||||
|
///
|
||||||
|
/// Eine leere Liste, kein Fehler: die Oberfläche blendet den Filter dann
|
||||||
|
/// aus, statt einen Knopf zu zeigen, hinter dem nichts ist.
|
||||||
|
public func filterOptions(
|
||||||
|
inLibrary library: MediaLibrary,
|
||||||
|
field: MediaQuery.Field
|
||||||
|
) async throws(MediaBackendError) -> [MediaFilterOption] { [] }
|
||||||
|
|
||||||
|
public func libraryID(for item: MediaItem) async -> String? { nil }
|
||||||
|
|
||||||
|
// MARK: - Bilder
|
||||||
|
|
||||||
|
/// **Ohne Grössenangabe.** Flaro legt die Bilder so ab, wie TMDB sie
|
||||||
|
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
|
||||||
|
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
|
||||||
|
/// Rechenzeit, die auf einem NAS knapper ist.
|
||||||
|
/// Wie die Vorschaubilder einer Datei liegen.
|
||||||
|
///
|
||||||
|
/// **Der Server erzeugt sie beim Abspielen.** Steht noch nichts da,
|
||||||
|
/// antwortet er mit `fertig: false` — dann gibt es hier nichts, und
|
||||||
|
/// der Spieler fragt später noch einmal.
|
||||||
|
public func vorschauplan(fileId: String) async -> Vorschauplan? {
|
||||||
|
guard let auskunft = try? await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/files/\(fileId)/vorschau"),
|
||||||
|
as: Wire.Vorschau.self), auskunft.fertig
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return Vorschauplan(
|
||||||
|
intervall: auskunft.intervall,
|
||||||
|
spalten: auskunft.spalten,
|
||||||
|
zeilen: auskunft.zeilen,
|
||||||
|
kachelBreite: Double(auskunft.kachelBreite),
|
||||||
|
kachelHoehe: Double(auskunft.kachelHoehe),
|
||||||
|
blaetter: auskunft.blaetter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Ohne Kopfzeilen, wie bei den Plakaten.** Die Anmeldung hängt am
|
||||||
|
/// Cookie; der Bildlader holt die Adresse roh und kommt damit durch.
|
||||||
|
public nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? {
|
||||||
|
client.url(path: "/api/v1/files/\(fileId)/vorschau/\(blatt).jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||||
|
rawImageURL(path: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func rawImageURL(path: String?) -> URL? {
|
||||||
|
guard let path, !path.isEmpty else { return nil }
|
||||||
|
return client.url(path: "/api/v1/images/\(path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Herunterladen
|
||||||
|
|
||||||
|
/// **Eine Adresse für alles.** Flaro entscheidet selbst, ob die Datei
|
||||||
|
/// unverändert hinausgeht oder erst umgepackt wird — und packt dabei
|
||||||
|
/// *alle* Ton- und Textspuren mit ein.
|
||||||
|
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
|
||||||
|
client.url(path: "/api/v1/files/\(file.id)/download")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Dieselbe Adresse, und das ist der Punkt.**
|
||||||
|
///
|
||||||
|
/// Der Vertrag nimmt hier je eine Spurnummer entgegen, weil Jellyfins
|
||||||
|
/// Transcoder genau eine Tonspur je Sitzung abbildet. Flaro braucht die
|
||||||
|
/// Wahl nicht: was herauskommt, hat alles. Die Nummern werden deshalb
|
||||||
|
/// nicht ignoriert, sondern sind gegenstandslos — und die Oberfläche
|
||||||
|
/// fragt bei `.allTracksDownload` gar nicht erst danach.
|
||||||
|
public nonisolated func remuxDownloadURL(
|
||||||
|
itemID: String,
|
||||||
|
versionIndex: Int,
|
||||||
|
audioStreamIndex: Int?,
|
||||||
|
subtitleStreamIndex: Int?
|
||||||
|
) -> URL? { nil }
|
||||||
|
|
||||||
|
/// Noch nicht da: das Herunterrechnen auf eine Bitrate kommt mit Stufe 3.
|
||||||
|
public nonisolated func compressedDownloadURL(
|
||||||
|
itemID: String,
|
||||||
|
versionIndex: Int,
|
||||||
|
maximumBitrate: Int,
|
||||||
|
resolution: String?
|
||||||
|
) -> URL? { nil }
|
||||||
|
|
||||||
|
// MARK: - Abspielen
|
||||||
|
|
||||||
|
/// Wie lange auf ein Umpacken gewartet wird, bevor aufgegeben wird.
|
||||||
|
///
|
||||||
|
/// Grosszügig: ein Dreistünder in 4K braucht auf einem NAS Minuten, und
|
||||||
|
/// abzubrechen, während die Arbeit läuft, wäre die eine Antwort, die
|
||||||
|
/// niemandem hilft. Irgendwann muss es trotzdem eine geben — ein Spieler,
|
||||||
|
/// der unbegrenzt auf einen hängengebliebenen Prozess wartet, zeigt
|
||||||
|
/// Schwarz ohne Begründung.
|
||||||
|
private static let packWartezeit = Duration.seconds(600)
|
||||||
|
|
||||||
|
public func preparePlayback(
|
||||||
|
_ intent: PlaybackIntent
|
||||||
|
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||||
|
// **Erst der Strom, und nur bei dessen Scheitern die fertige Datei.**
|
||||||
|
//
|
||||||
|
// Hier stand ausschliesslich der zweite Weg: `/download`, und dann
|
||||||
|
// im Sekundentakt nachfragen, bis die ganze MP4 auf der Platte liegt.
|
||||||
|
// Gemessen an einem Spielfilm waren das dreissig Sekunden Schwarz,
|
||||||
|
// während die Weboberfläche nach einer Sekunde lief — die holt sich
|
||||||
|
// eine HLS-Sitzung, und deren erstes Stück ist beim Umpacken in
|
||||||
|
// deutlich unter einer Sekunde da.
|
||||||
|
if let strom = try? await client.send(
|
||||||
|
.json(.post, "/api/v1/files/\(intent.file.id)/stream", Wire.StreamRequest(
|
||||||
|
height: intent.transcode?.resolution.flatMap(Self.hoehe),
|
||||||
|
// Ohne diese Angabe wählt der Server, und bei einem
|
||||||
|
// umgerechneten Strom kommt danach nur noch eine Spur an.
|
||||||
|
audioStream: intent.audioStreamIndex,
|
||||||
|
startSeconds: max(0, intent.startTime.seconds),
|
||||||
|
// Nur wenn der Spieler es ausdrücklich verlangt: das Profil
|
||||||
|
// entscheidet der Server, und er entscheidet es besser.
|
||||||
|
transcode: intent.transcode?.prefersRemux == false
|
||||||
|
&& intent.transcode?.maximumBitrate != nil)),
|
||||||
|
as: Wire.Stromauskunft.self),
|
||||||
|
|
||||||
|
// **Die Hauptliste, wo es sie gibt.** Nur dort stehen die
|
||||||
|
// Textspuren; die Medienliste trägt bloss Bild und Ton.
|
||||||
|
let adresse = client.url(path: strom.masterUrl ?? strom.url)
|
||||||
|
{
|
||||||
|
return PlaybackTarget(
|
||||||
|
url: adresse,
|
||||||
|
// Eine Direktwiedergabe legt nichts an, das aufzuräumen wäre.
|
||||||
|
sessionID: strom.method == "direct" ? nil : strom.sessionId,
|
||||||
|
versionID: intent.file.id,
|
||||||
|
startOffset: .seconds(strom.startSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await mitgenommeneDatei(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der alte Weg: warten, bis die fertige Datei auf der Platte liegt.
|
||||||
|
///
|
||||||
|
/// **Bleibt als Rückfall.** Er ist langsam, aber er kommt ohne
|
||||||
|
/// Stromsitzung aus — und wenn die nicht zustande kommt, ist eine späte
|
||||||
|
/// Wiedergabe besser als keine.
|
||||||
|
private func mitgenommeneDatei(
|
||||||
|
_ intent: PlaybackIntent
|
||||||
|
) async throws(MediaBackendError) -> PlaybackTarget {
|
||||||
|
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
|
||||||
|
throw .transport(description: "Adresse nicht baubar")
|
||||||
|
}
|
||||||
|
|
||||||
|
var auskunft = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"),
|
||||||
|
as: Wire.Delivery.self)
|
||||||
|
|
||||||
|
if let fehler = auskunft.error { throw .refused(reason: fehler) }
|
||||||
|
|
||||||
|
// **Erst warten, dann die Adresse herausgeben.**
|
||||||
|
//
|
||||||
|
// Muss der Server umpacken, antwortet er auf den Download mit 202 und
|
||||||
|
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
||||||
|
// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner.
|
||||||
|
//
|
||||||
|
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
|
||||||
|
// wird nur nachgefragt, bis es so weit ist.
|
||||||
|
if auskunft.method != "directFile", auskunft.packageState != "ready" {
|
||||||
|
let bis = ContinuousClock.now + Self.packWartezeit
|
||||||
|
while ContinuousClock.now < bis {
|
||||||
|
// Ein HEAD stösst an und fragt zugleich nach. Die Datei dafür
|
||||||
|
// herunterzuladen wäre absurd.
|
||||||
|
_ = try? await client.send(FlaroRequest(
|
||||||
|
method: .head, path: "/api/v1/files/\(intent.file.id)/download"))
|
||||||
|
|
||||||
|
auskunft = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"),
|
||||||
|
as: Wire.Delivery.self)
|
||||||
|
|
||||||
|
if let fehler = auskunft.error { throw .refused(reason: fehler) }
|
||||||
|
if auskunft.packageState == "ready" { break }
|
||||||
|
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard auskunft.packageState == "ready" else {
|
||||||
|
throw .refused(reason: String(
|
||||||
|
localized: "flaro.remux.tooLong", bundle: .module))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „1080p" wird zu 1080 — der Server kennt nur Zahlen.
|
||||||
|
private static func hoehe(_ aufloesung: String) -> Int? {
|
||||||
|
Int(aufloesung.filter(\.isNumber))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schliesst die Sitzung, damit nichts weiterrechnet.
|
||||||
|
///
|
||||||
|
/// **Das war eine leere Methode**, und sie passte zu einer Zeit, in der
|
||||||
|
/// es gar keine Sitzung gab: eine fertige Datei auf der Platte rechnet
|
||||||
|
/// nicht weiter. Mit dem Strom gibt es wieder einen Prozess, der ohne
|
||||||
|
/// diesen Aufruf den ganzen Film zu Ende schreibt, während längst
|
||||||
|
/// niemand mehr zusieht.
|
||||||
|
public func endPlayback(sessionID: String) async {
|
||||||
|
_ = try? await client.send(FlaroRequest(
|
||||||
|
method: .delete, path: "/api/v1/stream/\(sessionID)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reportProgress(_ report: PlaybackReport) async -> Bool {
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
|
||||||
|
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
|
||||||
|
// hiesse, den ersten Bericht wegzuwerfen.
|
||||||
|
do {
|
||||||
|
try await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
|
||||||
|
Wire.ProgressRequest(
|
||||||
|
positionSeconds: report.time.seconds,
|
||||||
|
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Zustand melden
|
||||||
|
|
||||||
|
public func markWatched(id: String) async throws(MediaBackendError) {
|
||||||
|
try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: true)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func markUnwatched(id: String) async throws(MediaBackendError) {
|
||||||
|
try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: false)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aus „Weiterschauen" nehmen, ohne als gesehen zu gelten.
|
||||||
|
///
|
||||||
|
/// **Flaro hat dafür einen eigenen Weg**, und nur deshalb bleibt die
|
||||||
|
/// Stelle erhalten. Bei Jellyfin wird hier das Benutzerdatenblatt
|
||||||
|
/// geschrieben, und darin steht auch „gesehen" — wer es nicht mitschickt,
|
||||||
|
/// löscht es.
|
||||||
|
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
|
||||||
|
try await client.send(FlaroRequest(method: .post, path: "/api/v1/items/\(item.id)/dismiss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
|
||||||
|
try await client.send(.json(.put, "/api/v1/items/\(id)/favourite",
|
||||||
|
Wire.FavouriteRequest(favourite: isFavourite)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
|
||||||
|
try await client.send(.json(.put, "/api/v1/items/\(id)/rating",
|
||||||
|
Wire.RatingRequest(rating: rating > 0 ? rating : nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
|
||||||
|
try await client.send(FlaroRequest(method: .post, path: "/api/v1/libraries/\(library.id)/scan"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Gemeinsames
|
||||||
|
|
||||||
|
private func seite<T: Decodable & Sendable>(
|
||||||
|
pfad: String, take: Int, as type: T.Type
|
||||||
|
) async throws(MediaBackendError) -> Wire.Page<T> {
|
||||||
|
try await client.send(
|
||||||
|
FlaroRequest(path: pfad, query: [URLQueryItem(name: "take", value: String(take))]),
|
||||||
|
as: Wire.Page<T>.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reihe(pfad: String) async throws(MediaBackendError) -> [MediaItem] {
|
||||||
|
let seite = try await client.send(FlaroRequest(path: pfad), as: Wire.Page<Wire.Started>.self)
|
||||||
|
return seite.items.map { $0.neutral() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was der Server noch nicht kann, siebt die App — aber nur das.
|
||||||
|
///
|
||||||
|
/// **Nicht alles.** Gefiltert wird hier über eine *Seite*, nicht über die
|
||||||
|
/// Mediathek: wer bei fünftausend Titeln „nur ungesehene" wählt, bekommt
|
||||||
|
/// die ungesehenen der ersten fünfzig. Das ist ein Zwischenstand, kein
|
||||||
|
/// Entwurf — sobald Flaro die Einschränkung selbst kann, fällt das hier
|
||||||
|
/// weg. Es jetzt wegzulassen hiesse, einen Schalter anzubieten, der
|
||||||
|
/// nichts tut.
|
||||||
|
private func gesiebt(_ titel: [MediaItem], _ query: MediaQuery) -> [MediaItem] {
|
||||||
|
var liste = titel
|
||||||
|
if query.unwatchedOnly { liste = liste.filter { !$0.isWatched } }
|
||||||
|
if query.favouritesOnly { liste = liste.filter(\.isFavourite) }
|
||||||
|
return liste
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mit wem die App spricht und als wer.
|
||||||
|
public struct FlaroAccount: Sendable, Equatable {
|
||||||
|
public let baseURL: URL
|
||||||
|
public let serverName: String
|
||||||
|
public let userID: String
|
||||||
|
public let userName: String
|
||||||
|
public let isAdministrator: Bool
|
||||||
|
|
||||||
|
/// Das eigene Bild, als Schlüssel in die Bildablage — `nil`: der Buchstabe.
|
||||||
|
///
|
||||||
|
/// **Mit Vorgabewert, damit Jellyfin unberührt bleibt.** Dort gibt es
|
||||||
|
/// kein Profilbild, und ein Pflichtfeld hier zwänge jeden Aufrufer, sich
|
||||||
|
/// dazu zu äussern.
|
||||||
|
public let avatarImage: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
baseURL: URL,
|
||||||
|
serverName: String,
|
||||||
|
userID: String,
|
||||||
|
userName: String,
|
||||||
|
isAdministrator: Bool,
|
||||||
|
avatarImage: String? = nil
|
||||||
|
) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.serverName = serverName
|
||||||
|
self.userID = userID
|
||||||
|
self.userName = userName
|
||||||
|
self.isAdministrator = isAdministrator
|
||||||
|
self.avatarImage = avatarImage
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Wer wir für einen Flaro-Server sind.
|
||||||
|
///
|
||||||
|
/// Anders als bei Jellyfin steht davon nichts in einer Kopfzeile: Flaro nimmt
|
||||||
|
/// den Gerätenamen einmal bei der Anmeldung entgegen und führt das Gerät
|
||||||
|
/// danach über das Token. Die Kennung muss trotzdem über Starts hinweg
|
||||||
|
/// gleich bleiben — wechselt sie, meldet sich dasselbe Gerät als neues an,
|
||||||
|
/// und in der Geräteliste sammelt sich Müll.
|
||||||
|
public struct FlaroIdentity: Sendable, Equatable {
|
||||||
|
public let deviceName: String
|
||||||
|
public let deviceID: String
|
||||||
|
|
||||||
|
public init(deviceName: String, deviceID: String) {
|
||||||
|
self.deviceName = deviceName
|
||||||
|
self.deviceID = deviceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spricht mit einem Flaro-Server.
|
||||||
|
public actor FlaroClient {
|
||||||
|
public nonisolated let baseURL: URL
|
||||||
|
private let identity: FlaroIdentity
|
||||||
|
private let transport: any FlaroTransport
|
||||||
|
private let cookieJar: (any FlaroCookieJar)?
|
||||||
|
private var token: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
baseURL: URL,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
token: String? = nil,
|
||||||
|
transport: any FlaroTransport = URLSession.shared,
|
||||||
|
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||||
|
) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.identity = identity
|
||||||
|
self.token = token
|
||||||
|
self.transport = transport
|
||||||
|
self.cookieJar = cookieJar
|
||||||
|
if let token { plant(token) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setToken(_ token: String?) {
|
||||||
|
self.token = token
|
||||||
|
if let token { plant(token) } else { clearCookie() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated var deviceID: String { identity.deviceID }
|
||||||
|
public nonisolated var deviceName: String { identity.deviceName }
|
||||||
|
|
||||||
|
// MARK: - Das Cookie
|
||||||
|
|
||||||
|
/// Legt den Zugang als Cookie ab, damit auch fremde Lader ihn mitschicken.
|
||||||
|
///
|
||||||
|
/// **Warum überhaupt.** Flaro nimmt kein Token in der Adresse — es stünde
|
||||||
|
/// sonst in jedem Zugriffsprotokoll, in jedem Verlauf und in jedem
|
||||||
|
/// weitergegebenen Link. Jellyfin macht das anders, und `artworkURL`
|
||||||
|
/// heisst deshalb im Vertrag ausdrücklich „der Token steckt in der
|
||||||
|
/// Adresse selbst".
|
||||||
|
///
|
||||||
|
/// Bleibt der Weg, den der Browser längst geht: ein Cookie. Der
|
||||||
|
/// Bilderlader und die Hintergrundübertragung benutzen `URLSession` mit
|
||||||
|
/// der Standardkonfiguration und schicken es von sich aus mit; dem
|
||||||
|
/// Systemplayer wird es ausdrücklich mitgegeben (siehe
|
||||||
|
/// `playbackCookies`), weil AVFoundation den gemeinsamen Speicher nicht
|
||||||
|
/// von allein liest.
|
||||||
|
///
|
||||||
|
/// **Gesetzt, nicht abgewartet.** Der Server schickt es bei der Anmeldung
|
||||||
|
/// auch selbst — aber nach einem Neustart hat die App nur das Token aus
|
||||||
|
/// dem Schlüsselbund und meldet sich gar nicht erst neu an. Dann wäre
|
||||||
|
/// kein Cookie da, und jedes Plakat bliebe grau.
|
||||||
|
private nonisolated func plant(_ token: String) {
|
||||||
|
guard let cookie = Self.cookie(for: baseURL, token: token) else { return }
|
||||||
|
cookieJar?.store(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated func clearCookie() {
|
||||||
|
guard let host = baseURL.host() else { return }
|
||||||
|
cookieJar?.removeCookie(named: Self.cookieName, host: host)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let cookieName = "flaro_session"
|
||||||
|
|
||||||
|
nonisolated static func cookie(for baseURL: URL, token: String) -> HTTPCookie? {
|
||||||
|
guard let host = baseURL.host() else { return nil }
|
||||||
|
return HTTPCookie(properties: [
|
||||||
|
.name: cookieName,
|
||||||
|
.value: token,
|
||||||
|
.domain: host,
|
||||||
|
.path: "/",
|
||||||
|
// Ein Jahr, wie der Server es auch setzt. Kürzer hiesse: das
|
||||||
|
// Plakat lädt nicht mehr, obwohl das Token noch gilt.
|
||||||
|
.expires: Date(timeIntervalSinceNow: 365 * 24 * 3600),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Cookies, die der Systemplayer für diese Adresse mitschicken soll.
|
||||||
|
public nonisolated func cookies(for url: URL, token: String?) -> [HTTPCookie] {
|
||||||
|
guard let token, let cookie = Self.cookie(for: url, token: token) else { return [] }
|
||||||
|
return [cookie]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Adressen
|
||||||
|
|
||||||
|
/// Die Adresse eines Endpunkts.
|
||||||
|
///
|
||||||
|
/// **Ohne Token.** Das ist der Unterschied zu Jellyfin und kein Versehen —
|
||||||
|
/// wer die Adresse abruft, bringt das Cookie mit.
|
||||||
|
public nonisolated func url(path: String, query: [URLQueryItem] = []) -> URL? {
|
||||||
|
guard var components = URLComponents(
|
||||||
|
url: baseURL.appending(path: path),
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
) else { return nil }
|
||||||
|
components.queryItems = query.isEmpty ? nil : query
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Anfragen
|
||||||
|
|
||||||
|
public func send<Value: Decodable & Sendable>(
|
||||||
|
_ request: FlaroRequest,
|
||||||
|
as type: Value.Type
|
||||||
|
) async throws(MediaBackendError) -> Value {
|
||||||
|
let data = try await send(request)
|
||||||
|
do {
|
||||||
|
return try Self.decoder.decode(Value.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw .decoding(description: String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func send(_ request: FlaroRequest) async throws(MediaBackendError) -> Data {
|
||||||
|
guard var components = URLComponents(
|
||||||
|
url: baseURL.appending(path: request.path),
|
||||||
|
resolvingAgainstBaseURL: false
|
||||||
|
) else {
|
||||||
|
throw .transport(description: "Adresse nicht baubar")
|
||||||
|
}
|
||||||
|
components.queryItems = request.query.isEmpty ? nil : request.query
|
||||||
|
|
||||||
|
guard let url = components.url else {
|
||||||
|
throw .transport(description: "Adresse nicht baubar")
|
||||||
|
}
|
||||||
|
|
||||||
|
var urlRequest = URLRequest(url: url)
|
||||||
|
urlRequest.httpMethod = request.method.rawValue
|
||||||
|
urlRequest.timeoutInterval = request.timeout.seconds
|
||||||
|
// Die App schickt die Kopfzeile. Das Cookie ist der zweite Weg zum
|
||||||
|
// selben Token, für Lader, die keine Kopfzeilen können.
|
||||||
|
if let token { urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let body = request.body {
|
||||||
|
urlRequest.httpBody = body
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let response: URLResponse
|
||||||
|
do {
|
||||||
|
(data, response) = try await transport.data(for: urlRequest)
|
||||||
|
} catch {
|
||||||
|
throw .transport(description: error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw .transport(description: "Keine HTTP-Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch http.statusCode {
|
||||||
|
case 200..<300: return data
|
||||||
|
case 401, 403: throw .unauthorized
|
||||||
|
case 404: throw .notFound
|
||||||
|
default:
|
||||||
|
// **Flaro begründet seine Absagen.** Ein Problem-Dokument nach
|
||||||
|
// RFC 9457 trägt einen Satz, den der Server selbst formuliert
|
||||||
|
// hat — und der ist mehr wert als die Zahl davor. Genau daran
|
||||||
|
// hing beim Live-TV tagelang alles, weil die Antwort weggeworfen
|
||||||
|
// wurde.
|
||||||
|
if let grund = Self.begruendung(in: data) { throw .refused(reason: grund) }
|
||||||
|
throw .server(status: http.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Satz aus einem Problem-Dokument, falls einer dasteht.
|
||||||
|
private static func begruendung(in data: Data) -> String? {
|
||||||
|
struct Problem: Decodable { let detail: String?; let title: String? }
|
||||||
|
guard let problem = try? JSONDecoder().decode(Problem.self, from: data) else { return nil }
|
||||||
|
let text = problem.detail ?? problem.title
|
||||||
|
return text?.isEmpty == false ? text : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flaro schreibt klein und rechnet in Sekunden.
|
||||||
|
///
|
||||||
|
/// Kein `convertFromSnakeCase`: die Felder heissen schon so, wie sie
|
||||||
|
/// heissen sollen. ASP.NET Core schreibt sie in `camelCase` hinaus, und
|
||||||
|
/// Swift liest sie genauso.
|
||||||
|
///
|
||||||
|
/// Nicht `private`, damit die Prüfungen denselben Dekoder benutzen. Mit
|
||||||
|
/// einem frisch gebauten `JSONDecoder()` scheitern sie — und zwar an
|
||||||
|
/// genau dem Datumsformat, für das die Strategie unten da ist.
|
||||||
|
static let decoder: JSONDecoder = {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601WithFraction
|
||||||
|
return decoder
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Anfrage an Flaro.
|
||||||
|
public struct FlaroRequest: Sendable {
|
||||||
|
public enum Method: String, Sendable { case get = "GET", head = "HEAD", post = "POST", put = "PUT", delete = "DELETE" }
|
||||||
|
|
||||||
|
public let method: Method
|
||||||
|
public let path: String
|
||||||
|
public let query: [URLQueryItem]
|
||||||
|
public let body: Data?
|
||||||
|
public let timeout: Duration
|
||||||
|
|
||||||
|
public init(
|
||||||
|
method: Method = .get,
|
||||||
|
path: String,
|
||||||
|
query: [URLQueryItem] = [],
|
||||||
|
body: Data? = nil,
|
||||||
|
timeout: Duration = .seconds(30)
|
||||||
|
) {
|
||||||
|
self.method = method
|
||||||
|
self.path = path
|
||||||
|
self.query = query
|
||||||
|
self.body = body
|
||||||
|
self.timeout = timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Anfrage mit einem Rumpf aus einem beliebigen Wert.
|
||||||
|
public static func json(
|
||||||
|
_ method: Method, _ path: String, _ value: some Encodable
|
||||||
|
) -> FlaroRequest {
|
||||||
|
FlaroRequest(method: method, path: path, body: try? JSONEncoder().encode(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie Anfragen tatsächlich verschickt werden.
|
||||||
|
///
|
||||||
|
/// Dieselbe Trennung wie bei Jellyfin: Tests brauchen keinen Server, sondern
|
||||||
|
/// vorbereitete Antworten.
|
||||||
|
public protocol FlaroTransport: Sendable {
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension URLSession: FlaroTransport {}
|
||||||
|
|
||||||
|
/// Wo Cookies landen.
|
||||||
|
///
|
||||||
|
/// Eigenes Protokoll, damit ein Test nachsehen kann, ob eines abgelegt wurde,
|
||||||
|
/// ohne den Cookie-Speicher des ganzen Systems anzufassen.
|
||||||
|
public protocol FlaroCookieJar: Sendable {
|
||||||
|
func store(_ cookie: HTTPCookie)
|
||||||
|
func removeCookie(named name: String, host: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HTTPCookieStorage: FlaroCookieJar {
|
||||||
|
public func store(_ cookie: HTTPCookie) { setCookie(cookie) }
|
||||||
|
|
||||||
|
public func removeCookie(named name: String, host: String) {
|
||||||
|
cookies?
|
||||||
|
.filter { $0.name == name && $0.domain.hasSuffix(host) }
|
||||||
|
.forEach(deleteCookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension JSONDecoder.DateDecodingStrategy {
|
||||||
|
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
|
||||||
|
///
|
||||||
|
/// `.iso8601` kommt damit nicht zurecht und wirft — ein Datum, das
|
||||||
|
/// „2026-10-03T19:25:04.5209555+00:00" lautet, ist für den
|
||||||
|
/// Standarddekoder kein Datum. Der Fehler fällt erst dort auf, wo ein
|
||||||
|
/// Feld gelesen wird, das die meisten Antworten leer lassen.
|
||||||
|
static var iso8601WithFraction: Self {
|
||||||
|
.custom { decoder in
|
||||||
|
let text = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
let mitBruch = ISO8601DateFormatter()
|
||||||
|
mitBruch.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let datum = mitBruch.date(from: text) { return datum }
|
||||||
|
|
||||||
|
let ohne = ISO8601DateFormatter()
|
||||||
|
ohne.formatOptions = [.withInternetDateTime]
|
||||||
|
if let datum = ohne.date(from: text) { return datum }
|
||||||
|
|
||||||
|
throw DecodingError.dataCorrupted(.init(
|
||||||
|
codingPath: decoder.codingPath,
|
||||||
|
debugDescription: "Kein ISO-8601-Datum: \(text)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Was Flaro über die Leitung schickt.
|
||||||
|
///
|
||||||
|
/// Ein Spiegel der `Contracts.cs` auf der Serverseite, und nur das: hier wird
|
||||||
|
/// nichts gerechnet und nichts gedeutet. Die Übersetzung in die neutralen
|
||||||
|
/// Typen steht in `FlaroBackend+Mapping`.
|
||||||
|
///
|
||||||
|
/// **Die Felder heissen wie dort.** Keine `CodingKeys`, keine Umbenennung:
|
||||||
|
/// ASP.NET Core schreibt `camelCase`, Swift liest `camelCase`, und jede
|
||||||
|
/// Abweichung wäre eine Stelle, an der beim nächsten Serverfeld jemand
|
||||||
|
/// nachziehen müsste.
|
||||||
|
enum Wire {
|
||||||
|
struct Page<T: Decodable & Sendable>: Decodable, Sendable {
|
||||||
|
let items: [T]
|
||||||
|
let total: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SystemInfo: Decodable, Sendable {
|
||||||
|
let version: String?
|
||||||
|
let serverName: String?
|
||||||
|
let needsSetup: Bool
|
||||||
|
let attributions: [Attribution]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Attribution: Decodable, Sendable {
|
||||||
|
let text: String
|
||||||
|
let logoUrl: String
|
||||||
|
let url: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginResponse: Decodable, Sendable {
|
||||||
|
let token: String
|
||||||
|
let user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
struct User: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let userName: String
|
||||||
|
let isAdministrator: Bool
|
||||||
|
/// Das Profilbild, als Schlüssel in die Bildablage des Servers.
|
||||||
|
let avatarImage: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Library: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let kind: String
|
||||||
|
let path: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stream: Decodable, Sendable {
|
||||||
|
let index: Int
|
||||||
|
let kind: String
|
||||||
|
let codec: String?
|
||||||
|
let language: String?
|
||||||
|
let title: String?
|
||||||
|
let isDefault: Bool
|
||||||
|
let isForced: Bool
|
||||||
|
let channels: Int?
|
||||||
|
let width: Int?
|
||||||
|
let height: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct File: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let fileName: String
|
||||||
|
let sizeBytes: Int64
|
||||||
|
let container: String?
|
||||||
|
let durationSeconds: Double?
|
||||||
|
let streams: [Stream]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ItemState: Decodable, Sendable {
|
||||||
|
let positionSeconds: Double
|
||||||
|
let runtimeSeconds: Double?
|
||||||
|
let watched: Bool
|
||||||
|
let playCount: Int
|
||||||
|
let favourite: Bool
|
||||||
|
let rating: Int?
|
||||||
|
let dismissed: Bool
|
||||||
|
let lastPlayedAt: Date?
|
||||||
|
/// Ob der Server den Titel in „Weiterschauen" führt.
|
||||||
|
///
|
||||||
|
/// **Nicht dasselbe wie „Stelle über null".** Seit es eine
|
||||||
|
/// einstellbare Untergrenze gibt, sind das zwei Fragen: eine Sekunde
|
||||||
|
/// wird gemerkt, zählt aber nicht als angefangen.
|
||||||
|
let inProgress: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Movie: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let year: Int?
|
||||||
|
let overview: String?
|
||||||
|
let posterImage: String?
|
||||||
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
|
let files: [File]
|
||||||
|
let state: ItemState?
|
||||||
|
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
|
||||||
|
let publishAt: Date?
|
||||||
|
/// Der Abspann, sofern er im Bild gefunden wurde.
|
||||||
|
let markers: [Marker]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Series: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let year: Int?
|
||||||
|
let overview: String?
|
||||||
|
let posterImage: String?
|
||||||
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
|
let episodeCount: Int
|
||||||
|
let seasons: [Int]
|
||||||
|
let unwatchedCount: Int
|
||||||
|
let publishAt: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Episode: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let season: Int
|
||||||
|
let episode: Int
|
||||||
|
let title: String?
|
||||||
|
let overview: String?
|
||||||
|
let stillImage: String?
|
||||||
|
let files: [File]
|
||||||
|
let state: ItemState?
|
||||||
|
let publishAt: Date?
|
||||||
|
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
|
||||||
|
/// nicht darstellbar: Titel und Bild der Serie fehlten.
|
||||||
|
let seriesId: String?
|
||||||
|
/// Vorspann und Abspann, sofern der Server sie gefunden hat.
|
||||||
|
///
|
||||||
|
/// Fehlt bei älteren Servern ganz — deshalb optional und nicht eine
|
||||||
|
/// leere Liste: ein `[]` im Vertrag zwänge jeden Server, das Feld zu
|
||||||
|
/// schicken.
|
||||||
|
let markers: [Marker]?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein überspringbarer Abschnitt.
|
||||||
|
struct Marker: Decodable, Sendable {
|
||||||
|
/// „intro" oder „credits" — dieselben Wörter wie bei Jellyfin.
|
||||||
|
let kind: String
|
||||||
|
let startSeconds: Double
|
||||||
|
let endSeconds: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
|
||||||
|
struct Started: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let kind: String
|
||||||
|
let title: String
|
||||||
|
let subTitle: String?
|
||||||
|
let season: Int?
|
||||||
|
let episode: Int?
|
||||||
|
let year: Int?
|
||||||
|
let posterImage: String?
|
||||||
|
let backdropImage: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
|
||||||
|
let heroImage: String?
|
||||||
|
let state: ItemState
|
||||||
|
/// Zu welcher Serie die Folge gehört.
|
||||||
|
let seriesId: String?
|
||||||
|
/// Das breite Bild der Serie — für den Aufmacher, nicht für die
|
||||||
|
/// Kachel: die will das Standbild der Folge.
|
||||||
|
let seriesBackdropImage: String?
|
||||||
|
let publishAt: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CodeResponse: Decodable, Sendable {
|
||||||
|
let code: String
|
||||||
|
let handle: String
|
||||||
|
let expiresAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `state` ist „pending", „approved" oder „expired".
|
||||||
|
struct CodeState: Decodable, Sendable {
|
||||||
|
let state: String
|
||||||
|
let login: LoginResponse?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CodeRequest: Encodable, Sendable {
|
||||||
|
let deviceName: String
|
||||||
|
let deviceId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Delivery: Decodable, Sendable {
|
||||||
|
let method: String
|
||||||
|
let reasons: [String]
|
||||||
|
let audioToEncode: [Int]
|
||||||
|
let subtitlesDropped: [Int]
|
||||||
|
let packageState: String
|
||||||
|
let progress: Double
|
||||||
|
let error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Was hinausgeht --------------------------------------------------
|
||||||
|
|
||||||
|
/// Was das Gerät kann und was gewünscht ist.
|
||||||
|
///
|
||||||
|
/// **Ohne Codec-Angaben nimmt der Server Apples Profil an**, und das ist
|
||||||
|
/// hier genau richtig: dieses Gerät *ist* Apple. Eine eigene Liste wäre
|
||||||
|
/// eine zweite Wahrheit neben der, die der Server ohnehin pflegt.
|
||||||
|
struct StreamRequest: Encodable, Sendable {
|
||||||
|
var height: Int?
|
||||||
|
var audioStream: Int?
|
||||||
|
var startSeconds: Double
|
||||||
|
var transcode: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stromspur: Decodable, Sendable {
|
||||||
|
let index: Int
|
||||||
|
let label: String
|
||||||
|
let language: String?
|
||||||
|
let isDefault: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stromauskunft: Decodable, Sendable {
|
||||||
|
let sessionId: String
|
||||||
|
let url: String
|
||||||
|
/// Dieselbe Wiedergabe mit Untertitelspuren — fehlt bei Direktwiedergabe.
|
||||||
|
let masterUrl: String?
|
||||||
|
/// „direct", „remux" oder „transcode".
|
||||||
|
let method: String
|
||||||
|
/// Ab welcher Sekunde des Films dieser Strom beginnt.
|
||||||
|
let startSeconds: Double
|
||||||
|
let height: Int?
|
||||||
|
let audioStream: Int?
|
||||||
|
let audioStreams: [Stromspur]
|
||||||
|
let subtitleStreams: [Stromspur]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sprachangabe: Decodable, Sendable {
|
||||||
|
let code: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sprachauswahl: Decodable, Sendable {
|
||||||
|
let audio: [Sprachangabe]
|
||||||
|
let subtitles: [Sprachangabe]
|
||||||
|
let partial: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginRequest: Encodable, Sendable {
|
||||||
|
let userName: String
|
||||||
|
let password: String
|
||||||
|
let deviceName: String
|
||||||
|
let deviceId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProgressRequest: Encodable, Sendable {
|
||||||
|
let positionSeconds: Double
|
||||||
|
let runtimeSeconds: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchedRequest: Encodable, Sendable { let watched: Bool }
|
||||||
|
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
|
||||||
|
struct RatingRequest: Encodable, Sendable { let rating: Int? }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire {
|
||||||
|
/// Was der Server über die Vorschaubilder sagt.
|
||||||
|
struct Vorschau: Decodable, Sendable {
|
||||||
|
let fertig: Bool
|
||||||
|
let intervall: Double
|
||||||
|
let spalten: Int
|
||||||
|
let zeilen: Int
|
||||||
|
let kachelBreite: Int
|
||||||
|
let kachelHoehe: Int
|
||||||
|
let blaetter: Int
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extension FlaroIdentity {
|
||||||
|
private static let storageKey = "flaro.flaroServer.deviceID"
|
||||||
|
|
||||||
|
/// Baut die Kennung für das laufende Gerät und merkt sie sich.
|
||||||
|
///
|
||||||
|
/// **Eine eigene, nicht die von Jellyfin.** Beide Server führen eigene
|
||||||
|
/// Geräteverzeichnisse; dieselbe Kennung doppelt zu vergeben hiesse, dass
|
||||||
|
/// ein Widerruf auf dem einen Server im anderen verwirrt — und wer von
|
||||||
|
/// Jellyfin zu Flaro wechselt, hinterliesse auf dem alten Server ein
|
||||||
|
/// Gerät, das es dort gar nicht mehr gibt.
|
||||||
|
///
|
||||||
|
/// Gleichbleiben muss sie trotzdem: wechselt sie, gilt die App als neues
|
||||||
|
/// Gerät, und in der Liste steht bei jedem Start ein Eintrag mehr.
|
||||||
|
///
|
||||||
|
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist.
|
||||||
|
@MainActor
|
||||||
|
public static func current(defaults: UserDefaults = .standard) -> FlaroIdentity {
|
||||||
|
let deviceID: String
|
||||||
|
if let stored = defaults.string(forKey: storageKey) {
|
||||||
|
deviceID = stored
|
||||||
|
} else {
|
||||||
|
deviceID = UUID().uuidString
|
||||||
|
defaults.set(deviceID, forKey: storageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
let name = UIDevice.current.name
|
||||||
|
#else
|
||||||
|
let name = ProcessInfo.processInfo.hostName
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return FlaroIdentity(deviceName: name, deviceID: deviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Aus Flaros Antworten werden neutrale Typen.
|
||||||
|
///
|
||||||
|
/// Die einzige Stelle, an der Flaros Schreibweise in die der App übersetzt
|
||||||
|
/// wird. Alles darüber kennt nur noch `MediaItem`.
|
||||||
|
extension Wire.Movie {
|
||||||
|
func neutral() -> MediaItem {
|
||||||
|
MediaItem(
|
||||||
|
id: id,
|
||||||
|
kind: .movie,
|
||||||
|
title: title,
|
||||||
|
summary: overview,
|
||||||
|
year: year,
|
||||||
|
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||||
|
thumb: posterImage,
|
||||||
|
art: backdropImage,
|
||||||
|
heroArt: heroImage,
|
||||||
|
playCount: state?.playCount,
|
||||||
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
|
isFavourite: state?.favourite ?? false,
|
||||||
|
isInProgress: state?.inProgress,
|
||||||
|
availableFrom: publishAt,
|
||||||
|
versions: [Wire.fassung(id: id, files: files)],
|
||||||
|
// **Nur der Abspann.** Einen Vorspann gibt es bei einem Film
|
||||||
|
// nicht zu finden: erkannt wird er daran, dass er sich über
|
||||||
|
// die Folgen einer Staffel wiederholt.
|
||||||
|
markers: (markers ?? []).map {
|
||||||
|
MediaMarker(
|
||||||
|
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
|
||||||
|
startMilliseconds: Int($0.startSeconds * 1000),
|
||||||
|
endMilliseconds: Int($0.endSeconds * 1000))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Series {
|
||||||
|
func neutral() -> MediaItem {
|
||||||
|
MediaItem(
|
||||||
|
id: id,
|
||||||
|
kind: .show,
|
||||||
|
title: title,
|
||||||
|
summary: overview,
|
||||||
|
year: year,
|
||||||
|
thumb: posterImage,
|
||||||
|
art: backdropImage,
|
||||||
|
heroArt: heroImage,
|
||||||
|
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
|
||||||
|
// offen ist — die App zeigt daraus die Plakette, rechnet aber
|
||||||
|
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
|
||||||
|
// zu derselben Aussage zu haben.
|
||||||
|
availableFrom: publishAt,
|
||||||
|
episodeCount: episodeCount,
|
||||||
|
playedEpisodeCount: episodeCount - unwatchedCount,
|
||||||
|
childCount: seasons.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Episode {
|
||||||
|
func neutral(series: Wire.Series? = nil) -> MediaItem {
|
||||||
|
MediaItem(
|
||||||
|
id: id,
|
||||||
|
kind: .episode,
|
||||||
|
title: title ?? "Folge \(episode)",
|
||||||
|
summary: overview,
|
||||||
|
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
|
||||||
|
thumb: stillImage,
|
||||||
|
seriesThumb: series?.posterImage,
|
||||||
|
seriesArt: series?.backdropImage,
|
||||||
|
index: episode,
|
||||||
|
parentIndex: season,
|
||||||
|
seriesTitle: series?.title,
|
||||||
|
// Die Serie aus dem Vertrag, wenn sie nicht mitgegeben wurde:
|
||||||
|
// ohne sie führte der Weg zurück zur Serie ins Leere.
|
||||||
|
seriesID: series?.id ?? seriesId,
|
||||||
|
playCount: state?.playCount,
|
||||||
|
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
|
||||||
|
lastPlayedAt: state?.lastPlayedAt,
|
||||||
|
isFavourite: state?.favourite ?? false,
|
||||||
|
isInProgress: state?.inProgress,
|
||||||
|
availableFrom: publishAt,
|
||||||
|
versions: [Wire.fassung(id: id, files: files)],
|
||||||
|
// **Vorspann und Abspann, wenn der Server sie kennt.**
|
||||||
|
// Ältere Server schicken das Feld gar nicht; dann ist die
|
||||||
|
// Liste leer und die Spieler zeigen schlicht keinen Knopf.
|
||||||
|
markers: (markers ?? []).map {
|
||||||
|
MediaMarker(
|
||||||
|
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
|
||||||
|
startMilliseconds: Int($0.startSeconds * 1000),
|
||||||
|
endMilliseconds: Int($0.endSeconds * 1000))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Started {
|
||||||
|
/// Die Kachel aus „Weiterschauen" als Titel.
|
||||||
|
///
|
||||||
|
/// **Ohne Fassungen.** Die Reihe nennt keine Dateien, und das ist
|
||||||
|
/// richtig so: eine Kachel zeigt Plakat, Titel und Balken. Wer sie
|
||||||
|
/// antippt, holt den Titel einzeln — und bekommt dann alles.
|
||||||
|
func neutral() -> MediaItem {
|
||||||
|
let folge = kind == "episode"
|
||||||
|
return MediaItem(
|
||||||
|
id: id,
|
||||||
|
kind: folge ? .episode : .movie,
|
||||||
|
title: folge ? (subTitle ?? "Folge \(episode ?? 0)") : title,
|
||||||
|
year: year,
|
||||||
|
duration: state.runtimeSeconds.map { Int($0 * 1000) },
|
||||||
|
thumb: folge ? backdropImage : posterImage,
|
||||||
|
// **Für die Fläche über den halben Bildschirm das Bild der
|
||||||
|
// Serie.** `backdropImage` ist bei einer Folge ihr Standbild —
|
||||||
|
// richtig für die Kachel, falsch für den Aufmacher: wer der
|
||||||
|
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
|
||||||
|
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
|
||||||
|
seriesThumb: folge ? posterImage : nil,
|
||||||
|
heroArt: heroImage,
|
||||||
|
index: episode,
|
||||||
|
parentIndex: season,
|
||||||
|
seriesTitle: folge ? title : nil,
|
||||||
|
seriesID: folge ? seriesId : nil,
|
||||||
|
playCount: state.playCount,
|
||||||
|
resumeMilliseconds: Int(state.positionSeconds * 1000),
|
||||||
|
lastPlayedAt: state.lastPlayedAt,
|
||||||
|
isFavourite: state.favourite,
|
||||||
|
isInProgress: state.inProgress,
|
||||||
|
availableFrom: publishAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Library {
|
||||||
|
func neutral() -> MediaLibrary {
|
||||||
|
MediaLibrary(
|
||||||
|
id: id,
|
||||||
|
title: name,
|
||||||
|
// Flaro kennt genau zwei Arten. Was es nicht gibt, als `.other`
|
||||||
|
// durchzulassen wäre eine Vorsorge für einen Fall, den der
|
||||||
|
// Server gar nicht erzeugen kann.
|
||||||
|
kind: kind.lowercased() == "shows" ? .show : .movie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.File {
|
||||||
|
func neutral(itemID: String) -> MediaFile {
|
||||||
|
MediaFile(
|
||||||
|
id: id,
|
||||||
|
key: "/api/v1/files/\(id)/download",
|
||||||
|
itemID: itemID,
|
||||||
|
container: container,
|
||||||
|
size: sizeBytes,
|
||||||
|
duration: durationSeconds.map { Int($0 * 1000) },
|
||||||
|
file: fileName,
|
||||||
|
streams: streams.map { $0.neutral() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire.Stream {
|
||||||
|
func neutral() -> MediaStream {
|
||||||
|
let art: MediaStream.Kind = switch kind.lowercased() {
|
||||||
|
case "video": .video
|
||||||
|
case "subtitle": .subtitle
|
||||||
|
default: .audio
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaStream(
|
||||||
|
// **Die Spurnummer ist die Kennung.** `MediaStream.index` liest
|
||||||
|
// sie aus `id` zurück, und daran hängt die Spurwahl beim
|
||||||
|
// Umpacken. Eine eigene Kennung wäre hier eine, die niemand
|
||||||
|
// zurückrechnen kann.
|
||||||
|
id: String(index),
|
||||||
|
kind: art,
|
||||||
|
codec: codec,
|
||||||
|
language: language,
|
||||||
|
languageCode: language,
|
||||||
|
displayTitle: title ?? language,
|
||||||
|
channels: channels,
|
||||||
|
isSelected: isDefault,
|
||||||
|
isForced: isForced,
|
||||||
|
title: title,
|
||||||
|
isDefault: isDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Wire {
|
||||||
|
/// Eine Fassung aus den Dateien eines Titels.
|
||||||
|
///
|
||||||
|
/// **Alle Dateien in einer Fassung**, nicht eine Fassung je Datei: Flaro
|
||||||
|
/// führt mehrere Dateien zu einem Titel als Fassungen desselben Films —
|
||||||
|
/// 4K und 1080p nebeneinander — und `MediaVersion.files` ist genau dafür
|
||||||
|
/// da. Die Eckdaten kommen aus der ersten Datei; welche gespielt wird,
|
||||||
|
/// entscheidet die App.
|
||||||
|
static func fassung(id: String, files: [Wire.File]) -> MediaVersion {
|
||||||
|
let erste = files.first
|
||||||
|
let bild = erste?.streams.first { $0.kind.lowercased() == "video" }
|
||||||
|
let ton = erste?.streams.first { $0.kind.lowercased() == "audio" }
|
||||||
|
|
||||||
|
return MediaVersion(
|
||||||
|
id: erste?.id ?? id,
|
||||||
|
container: erste?.container,
|
||||||
|
videoCodec: bild?.codec,
|
||||||
|
audioCodec: ton?.codec,
|
||||||
|
videoResolution: bild?.height.map(String.init),
|
||||||
|
width: bild?.width,
|
||||||
|
height: bild?.height,
|
||||||
|
audioChannels: ton?.channels,
|
||||||
|
duration: erste?.durationSeconds.map { Int($0 * 1000) },
|
||||||
|
files: files.map { $0.neutral(itemID: id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Die Anmeldung an einem Flaro-Server.
|
||||||
|
///
|
||||||
|
/// Getrennt vom Backend, weil sie vorher stattfindet: hier gibt es noch
|
||||||
|
/// keinen Zugang und nichts, was `MediaBackend` versprechen könnte.
|
||||||
|
public enum FlaroSignIn {
|
||||||
|
/// Was der Server über sich sagt, bevor sich jemand anmeldet.
|
||||||
|
public struct Greeting: Sendable, Equatable {
|
||||||
|
public let serverName: String
|
||||||
|
public let version: String?
|
||||||
|
/// Ob der Server überhaupt schon eingerichtet ist.
|
||||||
|
///
|
||||||
|
/// **Ein eigener Fall, keine Fehlermeldung.** Wer einen frischen
|
||||||
|
/// Server anspricht, hat kein Konto — und „Benutzername oder
|
||||||
|
/// Passwort stimmt nicht" wäre dann die unbrauchbarste aller
|
||||||
|
/// Antworten.
|
||||||
|
public let needsSetup: Bool
|
||||||
|
/// Der Pflichthinweis auf die Metadatenquelle.
|
||||||
|
public let attributions: [MetadataAttribution]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MetadataAttribution: Sendable, Equatable {
|
||||||
|
public let text: String
|
||||||
|
public let logoURL: URL?
|
||||||
|
public let url: URL?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fragt einen Server, wer er ist — ohne Anmeldung.
|
||||||
|
///
|
||||||
|
/// Das ist zugleich die Prüfung, ob unter der eingegebenen Adresse
|
||||||
|
/// überhaupt ein Flaro steht. Ein Jellyfin antwortet hier mit 404, ein
|
||||||
|
/// Router mit seiner Anmeldeseite, und beides fällt beim Lesen auf.
|
||||||
|
public static func greet(
|
||||||
|
baseURL: URL,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared
|
||||||
|
) async throws(MediaBackendError) -> Greeting {
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
|
||||||
|
let auskunft = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/system/info"), as: Wire.SystemInfo.self)
|
||||||
|
|
||||||
|
return Greeting(
|
||||||
|
serverName: auskunft.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
|
version: auskunft.version,
|
||||||
|
needsSetup: auskunft.needsSetup,
|
||||||
|
attributions: (auskunft.attributions ?? []).map {
|
||||||
|
MetadataAttribution(
|
||||||
|
text: $0.text, logoURL: URL(string: $0.logoUrl), url: URL(string: $0.url))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet an und liefert den Zugang samt fertigem Backend.
|
||||||
|
public static func signIn(
|
||||||
|
baseURL: URL,
|
||||||
|
userName: String,
|
||||||
|
password: String,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared,
|
||||||
|
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||||
|
) async throws(MediaBackendError) -> (token: String, account: FlaroAccount, client: FlaroClient) {
|
||||||
|
let gruss = try await greet(baseURL: baseURL, identity: identity, transport: transport)
|
||||||
|
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
|
||||||
|
|
||||||
|
let antwort = try await client.send(
|
||||||
|
.json(.post, "/api/v1/auth/login", Wire.LoginRequest(
|
||||||
|
userName: userName,
|
||||||
|
password: password,
|
||||||
|
deviceName: identity.deviceName,
|
||||||
|
deviceId: identity.deviceID)),
|
||||||
|
as: Wire.LoginResponse.self)
|
||||||
|
|
||||||
|
// **Erst jetzt das Token setzen.** Vorher hätte die Anmeldung ihre
|
||||||
|
// eigene Kopfzeile mitgeschickt — harmlos, aber auch das Cookie wäre
|
||||||
|
// dann schon gesetzt, bevor feststeht, dass es gilt.
|
||||||
|
await client.setToken(antwort.token)
|
||||||
|
|
||||||
|
return (antwort.token, FlaroAccount(
|
||||||
|
baseURL: baseURL,
|
||||||
|
serverName: gruss.serverName,
|
||||||
|
userID: antwort.user.id,
|
||||||
|
userName: antwort.user.userName,
|
||||||
|
isAdministrator: antwort.user.isAdministrator,
|
||||||
|
avatarImage: antwort.user.avatarImage), client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nimmt ein gespeichertes Token wieder auf.
|
||||||
|
///
|
||||||
|
/// **Ohne Passwort und ohne neue Anmeldung.** Der Server führt das Gerät
|
||||||
|
/// über das Token; ein zweiter Anmeldevorgang legte ein zweites Gerät an,
|
||||||
|
/// und in der Liste stünde dann bei jedem Start eines mehr.
|
||||||
|
public static func restore(
|
||||||
|
baseURL: URL,
|
||||||
|
token: String,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared,
|
||||||
|
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||||
|
) async throws(MediaBackendError) -> (account: FlaroAccount, client: FlaroClient) {
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, token: token,
|
||||||
|
transport: transport, cookieJar: cookieJar)
|
||||||
|
|
||||||
|
let wer = try await client.send(FlaroRequest(path: "/api/v1/auth/me"), as: Wire.User.self)
|
||||||
|
let gruss = try? await greet(baseURL: baseURL, identity: identity, transport: transport)
|
||||||
|
|
||||||
|
return (FlaroAccount(
|
||||||
|
baseURL: baseURL,
|
||||||
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
|
userID: wer.id,
|
||||||
|
userName: wer.userName,
|
||||||
|
isAdministrator: wer.isAdministrator,
|
||||||
|
avatarImage: wer.avatarImage), client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Der Weg über einen Code
|
||||||
|
|
||||||
|
/// Ein angeforderter Code samt Abholschein.
|
||||||
|
public struct PendingCode: Sendable, Equatable {
|
||||||
|
/// Die sechs Ziffern, die auf dem Fernseher stehen.
|
||||||
|
public let code: String
|
||||||
|
/// **Der Abholschein, und er ist das eigentliche Geheimnis.**
|
||||||
|
///
|
||||||
|
/// Abgeholt wird das Token hiermit, nicht mit dem Code. Täte es der
|
||||||
|
/// Code allein, liessen sich eine Million Möglichkeiten durchprobieren
|
||||||
|
/// — und die sechs Ziffern stehen zum Mitlesen auf einem Bildschirm
|
||||||
|
/// im Wohnzimmer.
|
||||||
|
public let handle: String
|
||||||
|
public let expiresAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CodeState: Sendable, Equatable {
|
||||||
|
case pending
|
||||||
|
case approved(token: String, account: FlaroAccount)
|
||||||
|
case expired
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fordert einen Code an. Der Fernseher zeigt ihn, das Telefon bestätigt.
|
||||||
|
public static func requestCode(
|
||||||
|
baseURL: URL,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared
|
||||||
|
) async throws(MediaBackendError) -> PendingCode {
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
|
||||||
|
let antwort = try await client.send(
|
||||||
|
.json(.post, "/api/v1/auth/code", Wire.CodeRequest(
|
||||||
|
deviceName: identity.deviceName, deviceId: identity.deviceID)),
|
||||||
|
as: Wire.CodeResponse.self)
|
||||||
|
|
||||||
|
return PendingCode(
|
||||||
|
code: antwort.code, handle: antwort.handle, expiresAt: antwort.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fragt nach, ob jemand den Code inzwischen bestätigt hat.
|
||||||
|
public static func pollCode(
|
||||||
|
baseURL: URL,
|
||||||
|
pending: PendingCode,
|
||||||
|
identity: FlaroIdentity,
|
||||||
|
transport: any FlaroTransport = URLSession.shared,
|
||||||
|
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
|
||||||
|
) async throws(MediaBackendError) -> (state: CodeState, client: FlaroClient?) {
|
||||||
|
let client = FlaroClient(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
|
||||||
|
|
||||||
|
// **Mit dem Abholschein, nicht mit dem Code.** Siehe `PendingCode`.
|
||||||
|
let zustand = try await client.send(
|
||||||
|
FlaroRequest(path: "/api/v1/auth/code/\(pending.handle)"), as: Wire.CodeState.self)
|
||||||
|
|
||||||
|
switch zustand.state {
|
||||||
|
case "approved":
|
||||||
|
guard let anmeldung = zustand.login else { return (.expired, nil) }
|
||||||
|
await client.setToken(anmeldung.token)
|
||||||
|
let gruss = try? await greet(
|
||||||
|
baseURL: baseURL, identity: identity, transport: transport)
|
||||||
|
return (.approved(token: anmeldung.token, account: FlaroAccount(
|
||||||
|
baseURL: baseURL,
|
||||||
|
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
|
||||||
|
userID: anmeldung.user.id,
|
||||||
|
userName: anmeldung.user.userName,
|
||||||
|
isAdministrator: anmeldung.user.isAdministrator,
|
||||||
|
avatarImage: anmeldung.user.avatarImage)), client)
|
||||||
|
case "expired":
|
||||||
|
return (.expired, nil)
|
||||||
|
default:
|
||||||
|
return (.pending, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meldet dieses Gerät ab und widerruft sein Token.
|
||||||
|
public static func signOut(client: FlaroClient) async {
|
||||||
|
_ = try? await client.send(FlaroRequest(method: .post, path: "/api/v1/auth/logout"))
|
||||||
|
await client.setToken(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"flaro.remux.tooLong": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Das Umpacken dauert ungewöhnlich lange. Sieh im Serverprotokoll nach."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Remuxing is taking unusually long. Check the server log."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flaro.row.continue": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Weiterschauen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Continue Watching"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flaro.row.latestIn %@": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Neu in %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "New in %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flaro.row.movies": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Filme"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Movies"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flaro.row.nextUp": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Nächste Folge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Next Up"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flaro.row.series": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Serien"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Shows"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
@@ -1,65 +1,17 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
|
/// Die Adressraterei für Jellyfin.
|
||||||
///
|
///
|
||||||
/// Jellyfin hat kein Verzeichnis wie plex.tv — die Adresse kommt vom Nutzer.
|
/// Der Inhalt steht in `ServerAddress` — beide Server haben dasselbe Problem,
|
||||||
/// Und die tippt niemand vollständig: „192.168.1.50" ohne Schema, ohne Port,
|
/// und es unterscheidet sie nur der Standardport. Dieser Typ bleibt stehen,
|
||||||
/// oder „medien.example.com" ohne „https://". Dieser Typ rät nicht **eine**
|
/// weil Aufrufer und Prüfungen ihn beim Namen nennen und weil „welcher Port
|
||||||
/// Ergänzung, sondern nennt die plausiblen in der Reihenfolge, in der sie es
|
/// gilt hier" eine Auskunft über Jellyfin ist, nicht über Adressen.
|
||||||
/// wahrscheinlich sind; der Aufrufer probiert sie durch und nimmt die erste,
|
|
||||||
/// die sich als Jellyfin-Server ausweist.
|
|
||||||
///
|
|
||||||
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
|
|
||||||
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
|
|
||||||
/// Domänenname fast immer.
|
|
||||||
public enum JellyfinAddress {
|
public enum JellyfinAddress {
|
||||||
/// Der Standardport eines Jellyfin-Servers.
|
/// Der Standardport eines Jellyfin-Servers.
|
||||||
public static let defaultPort = 8096
|
public static let defaultPort = 8096
|
||||||
|
|
||||||
public static func candidates(for input: String) -> [URL] {
|
public static func candidates(for input: String) -> [URL] {
|
||||||
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
ServerAddress.candidates(for: input, defaultPort: defaultPort)
|
||||||
while text.hasSuffix("/") { text.removeLast() }
|
|
||||||
guard !text.isEmpty else { return [] }
|
|
||||||
|
|
||||||
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
|
|
||||||
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
|
|
||||||
// ausdrücklich „https" getippt hat.
|
|
||||||
if text.contains("://") {
|
|
||||||
return URL(string: text).map { [$0] } ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
|
|
||||||
// Doppelpunkte sonst als Port gelesen würden.
|
|
||||||
if text.hasPrefix("[") {
|
|
||||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
|
||||||
}
|
|
||||||
|
|
||||||
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
|
|
||||||
let host = String(authority.split(separator: ":").first ?? "")
|
|
||||||
let hasExplicitPort = authority.contains(":")
|
|
||||||
&& Int(authority.split(separator: ":").last ?? "") != nil
|
|
||||||
|
|
||||||
if hasExplicitPort {
|
|
||||||
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
|
|
||||||
// eigener Port fast immer ein Server ohne Zertifikat ist.
|
|
||||||
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
|
|
||||||
// — alles Dinge, die es nur im eigenen Netz gibt.
|
|
||||||
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|
|
||||||
|| host.hasSuffix(".local")
|
|
||||||
|| !host.contains(".")
|
|
||||||
|
|
||||||
let forms = isLocalLooking
|
|
||||||
? ["http://\(withDefaultPort(text))", "http://\(text)", "https://\(text)"]
|
|
||||||
: ["https://\(text)", "http://\(text)", "http://\(withDefaultPort(text))"]
|
|
||||||
return forms.compactMap(URL.init(string:))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
|
|
||||||
private static func withDefaultPort(_ text: String) -> String {
|
|
||||||
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(defaultPort)" }
|
|
||||||
return "\(text[text.startIndex..<slash]):\(defaultPort)\(text[slash...])"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
method: .post,
|
try await client.send(JellyfinRequest(
|
||||||
path: path,
|
method: .post,
|
||||||
body: body,
|
path: path,
|
||||||
timeout: .seconds(10)
|
body: body,
|
||||||
))
|
timeout: .seconds(10)
|
||||||
|
))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hilfen
|
// MARK: - Hilfen
|
||||||
|
|||||||
@@ -130,19 +130,34 @@ public func isInLocalNetwork() async -> Bool {
|
|||||||
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
|
||||||
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
|
||||||
// an Folgen vorbei.
|
// an Folgen vorbei.
|
||||||
for library in libraries {
|
//
|
||||||
let items = try? await latestItems(limit: 20, inLibrary: library.id)
|
// **Nebeneinander, nicht nacheinander.** Hier stand ein `await` in
|
||||||
rows.append(MediaRowGroup(
|
// der Schleife: bei vier Mediatheken vier Rundreisen hintereinander.
|
||||||
id: "latest.\(library.id)",
|
// Die Reihenfolge der Mediatheken bleibt trotzdem erhalten — sonst
|
||||||
title: String(
|
// stünden die Reihen nach jedem Start woanders.
|
||||||
localized: "row.latestIn \(library.title)",
|
let latest = await withTaskGroup(of: (Int, MediaRowGroup).self) { group in
|
||||||
bundle: .module
|
for (place, library) in libraries.enumerated() {
|
||||||
),
|
group.addTask {
|
||||||
libraryID: library.id,
|
let items = try? await self.latestItems(limit: 20, inLibrary: library.id)
|
||||||
items: items ?? []
|
return (place, MediaRowGroup(
|
||||||
))
|
id: "latest.\(library.id)",
|
||||||
|
title: String(
|
||||||
|
localized: "row.latestIn \(library.title)",
|
||||||
|
bundle: .module
|
||||||
|
),
|
||||||
|
libraryID: library.id,
|
||||||
|
items: items ?? []
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var collected: [(Int, MediaRowGroup)] = []
|
||||||
|
for await result in group { collected.append(result) }
|
||||||
|
return collected.sorted { $0.0 < $1.0 }.map(\.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rows.append(contentsOf: latest)
|
||||||
|
|
||||||
rows.append(MediaRowGroup(
|
rows.append(MediaRowGroup(
|
||||||
id: "favourites",
|
id: "favourites",
|
||||||
title: String(localized: "row.favourites", bundle: .module),
|
title: String(localized: "row.favourites", bundle: .module),
|
||||||
@@ -333,6 +348,20 @@ public func isInLocalNetwork() async -> Bool {
|
|||||||
return next.first { $0.id != item.id }
|
return next.first { $0.id != item.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die Folge davor.
|
||||||
|
///
|
||||||
|
/// **Über die Folgenliste der Serie, nicht über einen eigenen
|
||||||
|
/// Endpunkt.** Jellyfin hat ein „NextUp", aber kein Gegenstück dazu —
|
||||||
|
/// und die Liste gibt es ohnehin, weil die Serienseite sie zeigt. Eine
|
||||||
|
/// Anfrage für einen Knopf, der selten gedrückt wird, ist der bessere
|
||||||
|
/// Tausch gegenüber gar keinem Knopf.
|
||||||
|
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
|
||||||
|
guard let seriesID = item.seriesID else { return nil }
|
||||||
|
let folgen = try await episodes(inSeries: seriesID)
|
||||||
|
guard let stelle = folgen.firstIndex(where: { $0.id == item.id }), stelle > 0 else { return nil }
|
||||||
|
return folgen[stelle - 1]
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Bilder
|
// MARK: - Bilder
|
||||||
|
|
||||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
@@ -60,9 +82,26 @@ public protocol MediaBackend: Sendable {
|
|||||||
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
|
||||||
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
|
||||||
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
|
||||||
|
|
||||||
|
/// Welche Sprachen eine Serie durchgehend führt — für die Vorwahl.
|
||||||
|
func availableLanguages(inSeries id: String) async -> AvailableLanguages?
|
||||||
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
/// Die nächste Folge nach dieser, sofern es eine gibt.
|
||||||
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||||
|
|
||||||
|
/// Die Folge davor — für den Rückwärtsknopf im Spieler.
|
||||||
|
///
|
||||||
|
/// **Mit Vorgabe, anders als die nächste.** Die nächste Folge braucht
|
||||||
|
/// jeder Server: ohne sie gibt es kein Weiterlaufen am Ende. Die vorige
|
||||||
|
/// ist eine Bequemlichkeit, und ein Server, der sie nicht kennt, soll
|
||||||
|
/// deshalb nicht unbaubar werden.
|
||||||
|
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem?
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
|
||||||
|
func vorschauplan(fileId: String) async -> Vorschauplan?
|
||||||
|
|
||||||
|
/// Die Adresse eines Vorschaublattes.
|
||||||
|
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL?
|
||||||
|
|
||||||
// MARK: - Bilder
|
// MARK: - Bilder
|
||||||
|
|
||||||
/// Ein Bild in der gewünschten Größe.
|
/// Ein Bild in der gewünschten Größe.
|
||||||
@@ -125,13 +164,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)
|
||||||
@@ -173,6 +205,18 @@ public enum BackendCapability: String, Sendable, Hashable {
|
|||||||
/// Die Verwaltung des Servers aus der App heraus.
|
/// Die Verwaltung des Servers aus der App heraus.
|
||||||
case administration
|
case administration
|
||||||
case transcoding
|
case transcoding
|
||||||
|
/// Ein Download bringt **alle** Ton- und Textspuren mit.
|
||||||
|
///
|
||||||
|
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Jellyfins
|
||||||
|
/// Transcode-Endpunkt bildet eine Tonspur je Sitzung ab: was
|
||||||
|
/// heruntergeladen wird, ist einspurig, und die Sprachwahl ist offline
|
||||||
|
/// weg. Einmal hat die App dabei still die Audiodeskription genommen —
|
||||||
|
/// drei Gigabyte Mobilfunk für eine unbrauchbare Datei.
|
||||||
|
///
|
||||||
|
/// Kann der Server es besser, soll die App nicht so tun, als müsste
|
||||||
|
/// vorher gewählt werden: `remuxDownloadURL` entfällt, die Spurauswahl
|
||||||
|
/// im Sheet entfällt, und der gewöhnliche Download liefert alles.
|
||||||
|
case allTracksDownload
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Ausschnitt aus einer Mediathek.
|
/// Ein Ausschnitt aus einer Mediathek.
|
||||||
@@ -199,6 +243,19 @@ public struct MediaPage: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public extension MediaBackend {
|
public extension MediaBackend {
|
||||||
|
/// Vorgabe: kein Weg zurück.
|
||||||
|
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { nil }
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
|
||||||
|
///
|
||||||
|
/// **Mit Vorgabe, damit nicht jeder Server es können muss.** Jellyfin
|
||||||
|
/// hat sein eigenes Trickplay mit anderem Zuschnitt; bis das angebunden
|
||||||
|
/// ist, sagt es hier schlicht nichts.
|
||||||
|
func vorschauplan(fileId: String) async -> Vorschauplan? { nil }
|
||||||
|
|
||||||
|
/// Die Adresse eines Vorschaublattes.
|
||||||
|
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? { nil }
|
||||||
|
|
||||||
/// Vorgabe: der Server kann es nicht.
|
/// Vorgabe: der Server kann es nicht.
|
||||||
///
|
///
|
||||||
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
||||||
@@ -215,6 +272,13 @@ public extension MediaBackend {
|
|||||||
subtitleStreamIndex: Int?
|
subtitleStreamIndex: Int?
|
||||||
) -> URL? { nil }
|
) -> URL? { nil }
|
||||||
|
|
||||||
|
/// Welche Sprachen eine Serie durchgehend führt.
|
||||||
|
///
|
||||||
|
/// **`nil` heisst: der Server weiss es nicht.** Dann bleibt es bei der
|
||||||
|
/// vollen Liste — eine leere Auswahl anzubieten wäre schlimmer als eine
|
||||||
|
/// zu lange.
|
||||||
|
func availableLanguages(inSeries id: String) async -> AvailableLanguages? { nil }
|
||||||
|
|
||||||
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||||
///
|
///
|
||||||
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
||||||
@@ -249,3 +313,41 @@ public struct MediaFilterOption: Sendable, Equatable, Identifiable {
|
|||||||
self.title = title
|
self.title = title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Eine Sprache, wie sie in einem Menü steht.
|
||||||
|
public struct LanguageOption: Sendable, Equatable, Identifiable, Hashable {
|
||||||
|
public let code: String
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
public var id: String { code }
|
||||||
|
|
||||||
|
public init(code: String, name: String) {
|
||||||
|
self.code = code
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Sprachen ein Titel durchgehend führt.
|
||||||
|
///
|
||||||
|
/// **Durchgehend, nicht irgendwo.** Bei einer Serie ist das die
|
||||||
|
/// Schnittmenge über alle Folgen: eine Sprache anzubieten, die es nur in
|
||||||
|
/// Staffel 1 gibt, hiesse eine Einstellung für die Serie, die ab Staffel 2
|
||||||
|
/// stillschweigend etwas anderes tut.
|
||||||
|
public struct AvailableLanguages: Sendable, Equatable {
|
||||||
|
public let audio: [LanguageOption]
|
||||||
|
public let subtitles: [LanguageOption]
|
||||||
|
|
||||||
|
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
|
||||||
|
///
|
||||||
|
/// **Damit die Oberfläche es sagen kann.** Eine gekürzte Liste sieht
|
||||||
|
/// sonst aus wie die ganze Wahrheit, und wer eine Sprache vermisst, die
|
||||||
|
/// er in Staffel 1 gesehen hat, hält es für einen Fehler.
|
||||||
|
public let partial: Int
|
||||||
|
|
||||||
|
public init(audio: [LanguageOption], subtitles: [LanguageOption], partial: Int) {
|
||||||
|
self.audio = audio
|
||||||
|
self.subtitles = subtitles
|
||||||
|
self.partial = partial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
/// die Serie, und so heißt es jetzt auch.
|
/// die Serie, und so heißt es jetzt auch.
|
||||||
public let seriesThumb: String?
|
public let seriesThumb: String?
|
||||||
public let seriesArt: String?
|
public let seriesArt: String?
|
||||||
|
/// Das Bild, das jetzt im Aufmacher gilt.
|
||||||
|
///
|
||||||
|
/// **Vom Server gewählt, nicht hier.** Wer zu einem Titel mehrere
|
||||||
|
/// Aufnahmen ausgesucht hat, bekommt alle paar Stunden eine andere — und
|
||||||
|
/// zwar überall dieselbe. `nil` heisst: keine ausgesucht, dann gilt das
|
||||||
|
/// Hintergrundbild.
|
||||||
|
public let heroArt: String?
|
||||||
|
|
||||||
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
|
||||||
// Staffel.
|
// Staffel.
|
||||||
@@ -71,6 +78,26 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
/// eine Fähigkeit, die es nur auf einem gibt.
|
/// eine Fähigkeit, die es nur auf einem gibt.
|
||||||
public let isFavourite: Bool
|
public let isFavourite: Bool
|
||||||
|
|
||||||
|
/// Ob der Server den Titel als angefangen führt.
|
||||||
|
///
|
||||||
|
/// **Die Entscheidung gehört dem Server, nicht der Beschriftung.** „Hat
|
||||||
|
/// eine Stelle über null" und „ist angefangen" waren dasselbe, bis es eine
|
||||||
|
/// einstellbare Untergrenze gab. Seitdem heisst ein Titel, in den jemand
|
||||||
|
/// eine Sekunde hineingeschaut hat, nicht mehr „Weiterschauen" — er steht
|
||||||
|
/// ja auch nicht mehr in der Reihe.
|
||||||
|
///
|
||||||
|
/// `nil` heisst: der Server sagt nichts dazu (Jellyfin, oder ein älterer
|
||||||
|
/// Flaro). Dann gilt wie früher die blosse Stelle.
|
||||||
|
public let isInProgress: Bool?
|
||||||
|
|
||||||
|
/// Ab wann es den Titel gibt — `nil` heisst: jetzt.
|
||||||
|
///
|
||||||
|
/// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das
|
||||||
|
/// ist der Sinn des Planens: man soll sehen, dass er kommt. Ohne dieses
|
||||||
|
/// Feld bot die App „Abspielen" an und bekam vom Server eine Absage —
|
||||||
|
/// ein Knopf, der ein Versprechen gibt und es zurücknimmt.
|
||||||
|
public let availableFrom: Date?
|
||||||
|
|
||||||
// Zählwerte bei Serien und Staffeln.
|
// Zählwerte bei Serien und Staffeln.
|
||||||
public let episodeCount: Int?
|
public let episodeCount: Int?
|
||||||
public let playedEpisodeCount: Int?
|
public let playedEpisodeCount: Int?
|
||||||
@@ -106,6 +133,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
parentThumb: String? = nil,
|
parentThumb: String? = nil,
|
||||||
seriesThumb: String? = nil,
|
seriesThumb: String? = nil,
|
||||||
seriesArt: String? = nil,
|
seriesArt: String? = nil,
|
||||||
|
heroArt: String? = nil,
|
||||||
index: Int? = nil,
|
index: Int? = nil,
|
||||||
parentIndex: Int? = nil,
|
parentIndex: Int? = nil,
|
||||||
parentTitle: String? = nil,
|
parentTitle: String? = nil,
|
||||||
@@ -117,6 +145,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
lastPlayedAt: Date? = nil,
|
lastPlayedAt: Date? = nil,
|
||||||
addedAt: Date? = nil,
|
addedAt: Date? = nil,
|
||||||
isFavourite: Bool = false,
|
isFavourite: Bool = false,
|
||||||
|
isInProgress: Bool? = nil,
|
||||||
|
availableFrom: Date? = nil,
|
||||||
episodeCount: Int? = nil,
|
episodeCount: Int? = nil,
|
||||||
playedEpisodeCount: Int? = nil,
|
playedEpisodeCount: Int? = nil,
|
||||||
childCount: Int? = nil,
|
childCount: Int? = nil,
|
||||||
@@ -144,6 +174,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
self.parentThumb = parentThumb
|
self.parentThumb = parentThumb
|
||||||
self.seriesThumb = seriesThumb
|
self.seriesThumb = seriesThumb
|
||||||
self.seriesArt = seriesArt
|
self.seriesArt = seriesArt
|
||||||
|
self.heroArt = heroArt
|
||||||
self.index = index
|
self.index = index
|
||||||
self.parentIndex = parentIndex
|
self.parentIndex = parentIndex
|
||||||
self.parentTitle = parentTitle
|
self.parentTitle = parentTitle
|
||||||
@@ -155,6 +186,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
|
|||||||
self.lastPlayedAt = lastPlayedAt
|
self.lastPlayedAt = lastPlayedAt
|
||||||
self.addedAt = addedAt
|
self.addedAt = addedAt
|
||||||
self.isFavourite = isFavourite
|
self.isFavourite = isFavourite
|
||||||
|
self.isInProgress = isInProgress
|
||||||
|
self.availableFrom = availableFrom
|
||||||
self.episodeCount = episodeCount
|
self.episodeCount = episodeCount
|
||||||
self.playedEpisodeCount = playedEpisodeCount
|
self.playedEpisodeCount = playedEpisodeCount
|
||||||
self.childCount = childCount
|
self.childCount = childCount
|
||||||
@@ -225,6 +258,29 @@ extension MediaItem {
|
|||||||
|
|
||||||
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
public var isWatched: Bool { (playCount ?? 0) > 0 }
|
||||||
|
|
||||||
|
/// Ob die Beschriftung „Weiterschauen" heissen soll.
|
||||||
|
///
|
||||||
|
/// Eine Stelle, die beide Fälle kennt: was der Server sagt, und was ohne
|
||||||
|
/// seine Auskunft gilt. Stünde der Zweifel an jedem Knopf einzeln, hiesse
|
||||||
|
/// derselbe Titel auf der Startseite anders als auf seiner Seite.
|
||||||
|
public var isResumable: Bool { isInProgress ?? (watchProgress != nil) }
|
||||||
|
|
||||||
|
/// Der Balken unter der Kachel — nur, wenn der Titel wirklich angefangen ist.
|
||||||
|
///
|
||||||
|
/// **Sonst sagt die Kachel etwas anderes als der Knopf.** Ein Strich unter
|
||||||
|
/// dem Plakat heisst „du bist mittendrin". Für eine Sekunde, in die jemand
|
||||||
|
/// hineingeschaut hat, ist das keine Auskunft, sondern ein Fussel.
|
||||||
|
public var resumeProgress: Double? { isResumable ? watchProgress : nil }
|
||||||
|
|
||||||
|
/// Ob der Titel erst noch kommt.
|
||||||
|
///
|
||||||
|
/// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an
|
||||||
|
/// einem Bildschirm „Abspielen" und am nächsten der Termin.
|
||||||
|
public var isUpcoming: Bool {
|
||||||
|
guard let availableFrom else { return false }
|
||||||
|
return availableFrom > Date()
|
||||||
|
}
|
||||||
|
|
||||||
/// Ab wo fortgesetzt wird.
|
/// Ab wo fortgesetzt wird.
|
||||||
public var resumeTime: Duration {
|
public var resumeTime: Duration {
|
||||||
.milliseconds(resumeMilliseconds ?? 0)
|
.milliseconds(resumeMilliseconds ?? 0)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -112,10 +112,29 @@ public struct PlaybackTarget: Sendable, Equatable {
|
|||||||
/// ordnet es die Meldung keiner Wiedergabe zu.
|
/// ordnet es die Meldung keiner Wiedergabe zu.
|
||||||
public let versionID: String?
|
public let versionID: String?
|
||||||
|
|
||||||
public init(url: URL, sessionID: String? = nil, versionID: String? = nil) {
|
/// Ab welcher Stelle des Films dieser Strom beginnt.
|
||||||
|
///
|
||||||
|
/// **Null heisst: der Strom ist der ganze Film.** Dann springt der
|
||||||
|
/// Spieler selbst, und das ist der Normalfall — bei Jellyfin immer.
|
||||||
|
///
|
||||||
|
/// **Ein Wert darüber heisst: der Server hat schon gesprungen.** Flaros
|
||||||
|
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
|
||||||
|
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
|
||||||
|
/// null — ohne diesen Wert ginge jede angezeigte Zeit und jede gemeldete
|
||||||
|
/// Stelle um den Vorlauf daneben, und ein Sprung dorthin wäre ein Sprung
|
||||||
|
/// ins Leere.
|
||||||
|
public let startOffset: Duration
|
||||||
|
|
||||||
|
public init(
|
||||||
|
url: URL,
|
||||||
|
sessionID: String? = nil,
|
||||||
|
versionID: String? = nil,
|
||||||
|
startOffset: Duration = .zero
|
||||||
|
) {
|
||||||
self.url = url
|
self.url = url
|
||||||
self.sessionID = sessionID
|
self.sessionID = sessionID
|
||||||
self.versionID = versionID
|
self.versionID = versionID
|
||||||
|
self.startOffset = startOffset
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
|
||||||
|
///
|
||||||
|
/// Weder Jellyfin noch Flaro haben ein Verzeichnis wie plex.tv — die Adresse
|
||||||
|
/// kommt vom Nutzer. Und die tippt niemand vollständig: „192.168.1.50" ohne
|
||||||
|
/// Schema, ohne Port, oder „medien.example.com" ohne „https://". Dieser Typ
|
||||||
|
/// rät nicht **eine** Ergänzung, sondern nennt die plausiblen in der
|
||||||
|
/// Reihenfolge, in der sie es wahrscheinlich sind; der Aufrufer probiert sie
|
||||||
|
/// durch und nimmt die erste, die sich ausweist.
|
||||||
|
///
|
||||||
|
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
|
||||||
|
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
|
||||||
|
/// Domänenname fast immer.
|
||||||
|
///
|
||||||
|
/// **In der neutralen Schicht, weil beide Server dasselbe Problem haben.**
|
||||||
|
/// Nur der Standardport unterscheidet sich, und der ist ein Wert, kein
|
||||||
|
/// Gedanke. Diese Überlegungen zweimal hinzuschreiben hiesse, dass die eine
|
||||||
|
/// Fassung beim nächsten Sonderfall nachgezogen wird und die andere nicht.
|
||||||
|
public enum ServerAddress {
|
||||||
|
public static func candidates(for input: String, defaultPort: Int) -> [URL] {
|
||||||
|
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
while text.hasSuffix("/") { text.removeLast() }
|
||||||
|
guard !text.isEmpty else { return [] }
|
||||||
|
|
||||||
|
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
|
||||||
|
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
|
||||||
|
// ausdrücklich „https" getippt hat.
|
||||||
|
if text.contains("://") {
|
||||||
|
return URL(string: text).map { [$0] } ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
|
||||||
|
// Doppelpunkte sonst als Port gelesen würden.
|
||||||
|
if text.hasPrefix("[") {
|
||||||
|
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||||
|
}
|
||||||
|
|
||||||
|
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
|
||||||
|
let host = String(authority.split(separator: ":").first ?? "")
|
||||||
|
let hasExplicitPort = authority.contains(":")
|
||||||
|
&& Int(authority.split(separator: ":").last ?? "") != nil
|
||||||
|
|
||||||
|
if hasExplicitPort {
|
||||||
|
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
|
||||||
|
// eigener Port fast immer ein Server ohne Zertifikat ist.
|
||||||
|
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
|
||||||
|
// — alles Dinge, die es nur im eigenen Netz gibt.
|
||||||
|
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|
||||||
|
|| host.hasSuffix(".local")
|
||||||
|
|| !host.contains(".")
|
||||||
|
|
||||||
|
let mitPort = withDefaultPort(text, port: defaultPort)
|
||||||
|
let forms = isLocalLooking
|
||||||
|
? ["http://\(mitPort)", "http://\(text)", "https://\(text)"]
|
||||||
|
: ["https://\(text)", "http://\(text)", "http://\(mitPort)"]
|
||||||
|
return forms.compactMap(URL.init(string:))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
|
||||||
|
private static func withDefaultPort(_ text: String, port: Int) -> String {
|
||||||
|
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(port)" }
|
||||||
|
return "\(text[text.startIndex..<slash]):\(port)\(text[slash...])"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wie die Vorschaubilder eines Titels zusammengesetzt sind.
|
||||||
|
///
|
||||||
|
/// **Dieselbe Rechnung wie im Server und im Web.** Dort heisst der Typ
|
||||||
|
/// genauso; die Kachel zu einer Sekunde wird an drei Stellen ausgerechnet,
|
||||||
|
/// und wenn eine davon abweicht, sieht man es nicht als Fehler — man hält
|
||||||
|
/// die Vorschau für ein ungenaues Bild.
|
||||||
|
///
|
||||||
|
/// **Die Kachelmasse stehen nicht fest.** Skaliert wird auf eine feste
|
||||||
|
/// Breite, die Höhe folgt dem Seitenverhältnis des Films. Deshalb misst
|
||||||
|
/// der Server sie und teilt sie mit, statt dass sie hier stünden.
|
||||||
|
public struct Vorschauplan: Sendable, Equatable, Decodable {
|
||||||
|
public let intervall: Double
|
||||||
|
public let spalten: Int
|
||||||
|
public let zeilen: Int
|
||||||
|
public let kachelBreite: Double
|
||||||
|
public let kachelHoehe: Double
|
||||||
|
public let blaetter: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
intervall: Double, spalten: Int, zeilen: Int,
|
||||||
|
kachelBreite: Double, kachelHoehe: Double, blaetter: Int
|
||||||
|
) {
|
||||||
|
self.intervall = intervall
|
||||||
|
self.spalten = spalten
|
||||||
|
self.zeilen = zeilen
|
||||||
|
self.kachelBreite = kachelBreite
|
||||||
|
self.kachelHoehe = kachelHoehe
|
||||||
|
self.blaetter = blaetter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie eine Kachel adressiert wird: welches Blatt, und wo darin.
|
||||||
|
public struct Stelle: Sendable, Equatable {
|
||||||
|
public let blatt: Int
|
||||||
|
public let spalte: Int
|
||||||
|
public let zeile: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
public var proBlatt: Int { spalten * zeilen }
|
||||||
|
|
||||||
|
/// Welche Kachel die Stelle zeigt — oder nichts.
|
||||||
|
///
|
||||||
|
/// **Hinter dem letzten Bild bleibt das letzte stehen.** Das Raster
|
||||||
|
/// endet dort, wo ffmpeg aufgehört hat; eine Sekunde darüber hinaus —
|
||||||
|
/// bei ungenauer Laufzeit durchaus möglich — soll die Vorschau nicht
|
||||||
|
/// verschwinden.
|
||||||
|
public func stelle(bei sekunde: Double) -> Stelle? {
|
||||||
|
guard blaetter > 0, spalten > 0, zeilen > 0, intervall > 0 else { return nil }
|
||||||
|
|
||||||
|
let nummer = min(Int(max(0, sekunde) / intervall), proBlatt * blaetter - 1)
|
||||||
|
let rest = nummer % proBlatt
|
||||||
|
return Stelle(blatt: nummer / proBlatt, spalte: rest % spalten, zeile: rest / spalten)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import FlaroDesign
|
||||||
|
|
||||||
|
/// Ein Bild von der Platte muss ankommen.
|
||||||
|
///
|
||||||
|
/// **Der Fehler, für den diese Prüfung steht.** Der Lader verlangte von jeder
|
||||||
|
/// Antwort eine `HTTPURLResponse` — eine `file:`-Adresse antwortet aber mit
|
||||||
|
/// einer schlichten `URLResponse`, und die Prüfung gab darauf `nil` zurück.
|
||||||
|
/// Sichtbar wurde das erst, als die Downloads ihre Plakate mitbrachten: die
|
||||||
|
/// Dateien lagen da, und die Kacheln blieben trotzdem grau.
|
||||||
|
@Suite("Bilder von der Platte")
|
||||||
|
struct ArtworkLoaderTests {
|
||||||
|
/// Ein gültiges PNG, ein Bildpunkt gross.
|
||||||
|
private static let winzigesPNG = Data(base64Encoded: """
|
||||||
|
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmM\
|
||||||
|
IQAAAABJRU5ErkJggg==
|
||||||
|
""")!
|
||||||
|
|
||||||
|
private func ablegen(_ daten: Data, als name: String) throws -> URL {
|
||||||
|
let ziel = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "artwork-\(UUID().uuidString)-\(name)")
|
||||||
|
try daten.write(to: ziel)
|
||||||
|
return ziel
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine Datei wird gelesen, nicht abgefragt")
|
||||||
|
func dateiWirdGelesen() async throws {
|
||||||
|
let datei = try ablegen(Self.winzigesPNG, als: "plakat.bild")
|
||||||
|
defer { try? FileManager.default.removeItem(at: datei) }
|
||||||
|
|
||||||
|
let bild = await ArtworkLoader.shared.image(for: datei)
|
||||||
|
#expect(bild != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine Datei, die es nicht gibt, ergibt kein Bild")
|
||||||
|
func fehlendeDatei() async {
|
||||||
|
let nirgends = FileManager.default.temporaryDirectory
|
||||||
|
.appending(path: "gibt-es-nicht-\(UUID().uuidString).bild")
|
||||||
|
let bild = await ArtworkLoader.shared.image(for: nirgends)
|
||||||
|
#expect(bild == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Was keine Bilddaten sind, ergibt kein Bild")
|
||||||
|
func keinBild() async throws {
|
||||||
|
let datei = try ablegen(Data("kein Bild, nur Text".utf8), als: "text.bild")
|
||||||
|
defer { try? FileManager.default.removeItem(at: datei) }
|
||||||
|
|
||||||
|
let bild = await ArtworkLoader.shared.image(for: datei)
|
||||||
|
#expect(bild == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import MediaKit
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Adressen und Zugang — alles, was ohne Netz entsteht.
|
||||||
|
@Suite("Flaro: Adressen und Zugang")
|
||||||
|
struct AdressenTests {
|
||||||
|
private func backend(_ transport: Attrappe, cookieglas: Cookieglas? = nil) -> FlaroBackend {
|
||||||
|
FlaroBackend(
|
||||||
|
client: FlaroClient(
|
||||||
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||||
|
transport: transport, cookieJar: cookieglas),
|
||||||
|
account: FlaroAccount(
|
||||||
|
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||||
|
userID: "u1", userName: "guido", isAdministrator: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Kein Token in der Adresse — das ist der Unterschied zu Jellyfin.**
|
||||||
|
///
|
||||||
|
/// Dort steckt der Zugang als `?ApiKey=` in jeder Bildadresse, weil eine
|
||||||
|
/// Bildansicht keine Kopfzeilen schickt. Er steht damit in jedem
|
||||||
|
/// Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen Link.
|
||||||
|
/// Flaro geht über ein Cookie.
|
||||||
|
@Test("Bildadressen tragen keinen Zugang")
|
||||||
|
func bildAdresseOhneToken() {
|
||||||
|
let url = backend(Attrappe(rumpf: "{}")).rawImageURL(path: "abc123")
|
||||||
|
|
||||||
|
#expect(url?.path == "/api/v1/images/abc123")
|
||||||
|
#expect(url?.query == nil)
|
||||||
|
#expect(url?.absoluteString.contains("geheim") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein Zugang wird als Cookie abgelegt")
|
||||||
|
func tokenWirdZuCookie() async {
|
||||||
|
let glas = Cookieglas()
|
||||||
|
_ = FlaroClient(
|
||||||
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
||||||
|
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
|
||||||
|
|
||||||
|
let cookie = glas.cookies.first
|
||||||
|
#expect(cookie?.name == "flaro_session")
|
||||||
|
#expect(cookie?.value == "geheim-123")
|
||||||
|
#expect(cookie?.path == "/")
|
||||||
|
#expect(cookie?.domain.hasSuffix("nas") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Nach einem Neustart meldet sich niemand neu an.**
|
||||||
|
///
|
||||||
|
/// Die App hat dann nur das Token aus dem Schlüsselbund. Käme das Cookie
|
||||||
|
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
|
||||||
|
/// bliebe grau, obwohl der Zugang gilt.
|
||||||
|
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
|
||||||
|
func cookieAuchOhneAnmeldung() async throws {
|
||||||
|
let glas = Cookieglas()
|
||||||
|
let transport = Attrappe([
|
||||||
|
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
|
||||||
|
("/api/v1/system/info", 200, Proben.info),
|
||||||
|
])
|
||||||
|
|
||||||
|
_ = try await FlaroSignIn.restore(
|
||||||
|
baseURL: Proben.adresse, token: "geheim-123",
|
||||||
|
identity: Proben.kennung, transport: transport, cookieJar: glas)
|
||||||
|
|
||||||
|
#expect(glas.cookies.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
|
||||||
|
func downloadAdresse() {
|
||||||
|
let url = backend(Attrappe(rumpf: "{}")).downloadURL(
|
||||||
|
for: MediaFile(id: "datei-1", itemID: "film-1"))
|
||||||
|
|
||||||
|
#expect(url?.path == "/api/v1/files/datei-1/download")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Der Vertrag fragt nach einer Tonspur, Flaro braucht keine.**
|
||||||
|
///
|
||||||
|
/// `remuxDownloadURL` gibt es nur, weil Jellyfins Transcoder genau eine
|
||||||
|
/// Tonspur je Sitzung abbildet. Hier wäre eine Adresse mit Spurnummer
|
||||||
|
/// eine Lüge über das, was herauskommt — also gibt es keine, und die
|
||||||
|
/// Oberfläche erkennt an der Fähigkeit, dass sie nicht fragen muss.
|
||||||
|
@Test("Kein Umpacken mit Spurwahl — stattdessen die Fähigkeit")
|
||||||
|
func alleSpuren() {
|
||||||
|
let b = backend(Attrappe(rumpf: "{}"))
|
||||||
|
|
||||||
|
#expect(b.capabilities.contains(.allTracksDownload))
|
||||||
|
#expect(b.remuxDownloadURL(
|
||||||
|
itemID: "film-1", versionIndex: 0,
|
||||||
|
audioStreamIndex: 1, subtitleStreamIndex: nil) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zwei Server dürfen „Medien" heissen; derselbe darf umbenannt werden.
|
||||||
|
@Test("Die Kennung hängt an der Adresse, nicht am Namen")
|
||||||
|
func kennung() {
|
||||||
|
#expect(backend(Attrappe(rumpf: "{}")).identity == "flaro:http://nas:8080")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Anmelden schickt Gerätename und Kennung mit")
|
||||||
|
func anmeldung() async throws {
|
||||||
|
let transport = Attrappe([
|
||||||
|
("/api/v1/system/info", 200, Proben.info),
|
||||||
|
("/api/v1/auth/login", 200, Proben.anmeldung),
|
||||||
|
])
|
||||||
|
|
||||||
|
let (token, konto, _) = try await FlaroSignIn.signIn(
|
||||||
|
baseURL: Proben.adresse, userName: "guido", password: "geheim",
|
||||||
|
identity: Proben.kennung, transport: transport, cookieJar: Cookieglas())
|
||||||
|
|
||||||
|
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
|
||||||
|
|
||||||
|
#expect(token == "geheim-123")
|
||||||
|
#expect(konto.userName == "guido")
|
||||||
|
#expect(konto.isAdministrator)
|
||||||
|
#expect(konto.serverName == "Wohnzimmer")
|
||||||
|
#expect(rumpf["deviceName"] as? String == "Prüfgerät")
|
||||||
|
#expect(rumpf["deviceId"] as? String == "geraet-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Ein frischer Server ist kein falsches Passwort.**
|
||||||
|
@Test("Ein nicht eingerichteter Server sagt das")
|
||||||
|
func nochNichtEingerichtet() async throws {
|
||||||
|
let transport = Attrappe(rumpf: #"{"version":"0.1.0","serverName":null,"needsSetup":true}"#)
|
||||||
|
|
||||||
|
let gruss = try await FlaroSignIn.greet(
|
||||||
|
baseURL: Proben.adresse, identity: Proben.kennung, transport: transport)
|
||||||
|
|
||||||
|
#expect(gruss.needsSetup)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Flaro begründet seine Absagen, und der Satz zählt mehr als die Zahl.**
|
||||||
|
@Test("Eine Absage mit Begründung kommt als Begründung an")
|
||||||
|
func begruendeteAbsage() async {
|
||||||
|
let transport = Attrappe(rumpf: #"{"detail":"Für diese Bibliothek läuft bereits ein Scan."}"#, status: 409)
|
||||||
|
let b = backend(transport)
|
||||||
|
|
||||||
|
await #expect(throws: MediaBackendError.refused(
|
||||||
|
reason: "Für diese Bibliothek läuft bereits ein Scan.")) {
|
||||||
|
try await b.refreshLibrary(MediaLibrary(id: "b1", title: "Filme", kind: .movie))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein abgelaufener Zugang führt zur Abmeldung, nicht zu einer Meldung")
|
||||||
|
func abgelaufen() async {
|
||||||
|
let b = backend(Attrappe(rumpf: "", status: 401))
|
||||||
|
|
||||||
|
await #expect(throws: MediaBackendError.unauthorized) {
|
||||||
|
_ = try await b.libraries()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import Foundation
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Ein Transport, der vorbereitete Antworten gibt und mitschreibt.
|
||||||
|
///
|
||||||
|
/// Dieselbe Trennung wie bei Jellyfin: Prüfungen brauchen keinen Server,
|
||||||
|
/// sondern eine Antwort und die Frage, die gestellt wurde.
|
||||||
|
final class Attrappe: FlaroTransport, @unchecked Sendable {
|
||||||
|
/// Antworten je Pfad. Der erste Treffer gewinnt — so kann eine Prüfung
|
||||||
|
/// `/api/v1/movies` und `/api/v1/movies/{id}` getrennt bedienen.
|
||||||
|
private let antworten: [(pfad: String, status: Int, rumpf: String)]
|
||||||
|
private let sperre = NSLock()
|
||||||
|
private var protokoll: [URLRequest] = []
|
||||||
|
|
||||||
|
init(_ antworten: [(pfad: String, status: Int, rumpf: String)]) {
|
||||||
|
self.antworten = antworten
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(rumpf: String, status: Int = 200) {
|
||||||
|
self.init([("", status, rumpf)])
|
||||||
|
}
|
||||||
|
|
||||||
|
var anfragen: [URLRequest] { sperre.withLock { protokoll } }
|
||||||
|
var letzte: URLRequest? { anfragen.last }
|
||||||
|
|
||||||
|
func anfrage(auf pfad: String) -> URLRequest? {
|
||||||
|
anfragen.last { $0.url?.path == pfad }
|
||||||
|
}
|
||||||
|
|
||||||
|
func rumpf(von pfad: String) -> [String: Any]? {
|
||||||
|
guard let daten = anfrage(auf: pfad)?.httpBody else { return nil }
|
||||||
|
return try? JSONSerialization.jsonObject(with: daten) as? [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
sperre.withLock { protokoll.append(request) }
|
||||||
|
let pfad = request.url?.path ?? ""
|
||||||
|
let treffer = antworten.first { $0.pfad.isEmpty || pfad.hasSuffix($0.pfad) || pfad == $0.pfad }
|
||||||
|
?? ("", 404, "")
|
||||||
|
|
||||||
|
return (
|
||||||
|
Data(treffer.rumpf.utf8),
|
||||||
|
HTTPURLResponse(url: request.url!, statusCode: treffer.status,
|
||||||
|
httpVersion: nil, headerFields: nil)!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Cookieglas, das nur mitschreibt.
|
||||||
|
///
|
||||||
|
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
|
||||||
|
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
|
||||||
|
final class Cookieglas: FlaroCookieJar, @unchecked Sendable {
|
||||||
|
private let sperre = NSLock()
|
||||||
|
private var inhalt: [HTTPCookie] = []
|
||||||
|
|
||||||
|
var cookies: [HTTPCookie] { sperre.withLock { inhalt } }
|
||||||
|
|
||||||
|
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
|
||||||
|
|
||||||
|
func removeCookie(named name: String, host: String) {
|
||||||
|
sperre.withLock { inhalt.removeAll { $0.name == name } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Proben {
|
||||||
|
static let adresse = URL(string: "http://nas:8080")!
|
||||||
|
static let kennung = FlaroIdentity(deviceName: "Prüfgerät", deviceID: "geraet-1")
|
||||||
|
|
||||||
|
/// Ein Film mit zwei Tonspuren — der Fall, um den es geht.
|
||||||
|
static let film = """
|
||||||
|
{
|
||||||
|
"id": "film-1", "title": "Der Untergang", "year": 2004,
|
||||||
|
"overview": "Berlin, April 1945.",
|
||||||
|
"posterImage": "aaa", "backdropImage": "bbb",
|
||||||
|
"files": [{
|
||||||
|
"id": "datei-1", "fileName": "Der Untergang (2004).mkv",
|
||||||
|
"sizeBytes": 9000000000, "container": "matroska", "durationSeconds": 9360.0,
|
||||||
|
"streams": [
|
||||||
|
{"index":0,"kind":"Video","codec":"h264","language":null,"title":null,
|
||||||
|
"isDefault":true,"isForced":false,"channels":null,"width":1920,"height":1080},
|
||||||
|
{"index":1,"kind":"Audio","codec":"ac3","language":"deu","title":"Deutsch",
|
||||||
|
"isDefault":true,"isForced":false,"channels":6,"width":null,"height":null},
|
||||||
|
{"index":2,"kind":"Audio","codec":"ac3","language":"deu","title":"Audiodeskription",
|
||||||
|
"isDefault":false,"isForced":false,"channels":2,"width":null,"height":null},
|
||||||
|
{"index":3,"kind":"Subtitle","codec":"subrip","language":"deu","title":"Deutsch",
|
||||||
|
"isDefault":false,"isForced":false,"channels":null,"width":null,"height":null}
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
"state": {"positionSeconds":300.0,"runtimeSeconds":9360.0,"watched":false,
|
||||||
|
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
|
||||||
|
"lastPlayedAt":"2026-10-03T19:25:04.5209555+00:00"}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
static let info = """
|
||||||
|
{"version":"0.1.0","serverName":"Wohnzimmer","needsSetup":false,
|
||||||
|
"attributions":[{"text":"This product uses TMDB","logoUrl":"https://x/t.svg","url":"https://x"}]}
|
||||||
|
"""
|
||||||
|
|
||||||
|
static let anmeldung = """
|
||||||
|
{"token":"geheim-123","user":{"id":"u1","userName":"guido","isAdministrator":true}}
|
||||||
|
"""
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import MediaKit
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Vorspann und Abspann auf dem Weg vom Server in den Spieler.
|
||||||
|
///
|
||||||
|
/// **Der Anlass:** im Web erschien der Überspringen-Knopf, in der App nie.
|
||||||
|
/// Dazwischen liegt genau diese Übersetzung — und ohne Prüfung liesse sich
|
||||||
|
/// nicht sagen, ob sie der Grund ist oder etwas dahinter.
|
||||||
|
@Suite("Flaro: Marken")
|
||||||
|
struct MarkenTests {
|
||||||
|
private static let folge = """
|
||||||
|
{
|
||||||
|
"id": "folge-1", "season": 1, "episode": 6,
|
||||||
|
"title": "Bestimmung", "overview": null, "stillImage": null,
|
||||||
|
"files": [{
|
||||||
|
"id": "datei-1", "fileName": "F.mkv", "sizeBytes": 1,
|
||||||
|
"container": "mkv", "durationSeconds": 2700, "streams": []
|
||||||
|
}],
|
||||||
|
"state": null, "publishAt": null, "seriesId": "serie-1",
|
||||||
|
"markers": [
|
||||||
|
{ "kind": "intro", "startSeconds": 30.5, "endSeconds": 65 },
|
||||||
|
{ "kind": "credits", "startSeconds": 2500, "endSeconds": 2700 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Test("Eine Folge bringt Vorspann und Abspann mit")
|
||||||
|
func folgeMitMarken() throws {
|
||||||
|
let roh = try #require(
|
||||||
|
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(Self.folge.utf8)))
|
||||||
|
let titel = roh.neutral()
|
||||||
|
|
||||||
|
#expect(titel.markers.count == 2)
|
||||||
|
|
||||||
|
let intro = try #require(titel.markers.first { $0.kind == .intro })
|
||||||
|
#expect(intro.startMilliseconds == 30_500)
|
||||||
|
#expect(intro.endMilliseconds == 65_000)
|
||||||
|
#expect(intro.contains(45_000))
|
||||||
|
|
||||||
|
let abspann = try #require(titel.markers.first { $0.kind == .credits })
|
||||||
|
#expect(abspann.startMilliseconds == 2_500_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein älterer Server schickt das Feld gar nicht — das ist kein Fehler.
|
||||||
|
@Test("Ohne Marken bleibt die Liste leer")
|
||||||
|
func ohneMarken() throws {
|
||||||
|
let ohne = """
|
||||||
|
{
|
||||||
|
"id": "folge-1", "season": 1, "episode": 6,
|
||||||
|
"title": "Bestimmung", "overview": null, "stillImage": null,
|
||||||
|
"files": [], "state": null, "publishAt": null, "seriesId": "serie-1"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let roh = try #require(
|
||||||
|
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(ohne.utf8)))
|
||||||
|
|
||||||
|
#expect(roh.neutral().markers.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein unbekanntes Wort wirft die Marke nicht weg — sie zählt nur nicht.
|
||||||
|
@Test("Eine unbekannte Art wird nicht zur falschen")
|
||||||
|
func unbekannteArt() throws {
|
||||||
|
let fremd = Self.folge.replacingOccurrences(of: "\"intro\"", with: "\"werbung\"")
|
||||||
|
let roh = try #require(
|
||||||
|
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(fremd.utf8)))
|
||||||
|
let titel = roh.neutral()
|
||||||
|
|
||||||
|
#expect(titel.markers.count == 2)
|
||||||
|
#expect(titel.markers.first?.kind == .unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import MediaKit
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Die Folge nach dieser — über die Staffelgrenze hinweg.
|
||||||
|
///
|
||||||
|
/// **Der Anlass.** Im Web stand am Abspann „Nächste Folge", in der App nur
|
||||||
|
/// „Abspann überspringen". Der Server war es nicht: beide fragen denselben
|
||||||
|
/// Endpunkt, und er ordnet nach Staffel und dann nach Nummer, findet also
|
||||||
|
/// auch die erste Folge der nächsten Staffel.
|
||||||
|
///
|
||||||
|
/// **Es war der Dekoder.** `nachbar` baute sich einen frischen
|
||||||
|
/// `JSONDecoder()`, und der kennt das Datumsformat des Servers nicht.
|
||||||
|
/// Solange die Nachbarfolge weder `publishAt` noch `lastPlayedAt` hatte,
|
||||||
|
/// ging es gut — mit eingeschalteter Prüfung hat sie ein `publishAt`. Das
|
||||||
|
/// `try?` schluckte den Fehler, und von aussen sah es aus, als gäbe es
|
||||||
|
/// keine nächste Folge.
|
||||||
|
@Suite("Flaro: Nachbarfolge")
|
||||||
|
struct NachbarfolgeTests {
|
||||||
|
private func backend(_ transport: any FlaroTransport) -> FlaroBackend {
|
||||||
|
FlaroBackend(
|
||||||
|
client: FlaroClient(
|
||||||
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "t",
|
||||||
|
transport: transport, cookieJar: nil),
|
||||||
|
account: FlaroAccount(
|
||||||
|
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||||
|
userID: "u1", userName: "guido", isAdministrator: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func folge(
|
||||||
|
id: String, staffel: Int, nummer: Int,
|
||||||
|
publishAt: String = "null", zuletzt: String = "null"
|
||||||
|
) -> String {
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "\(id)", "season": \(staffel), "episode": \(nummer),
|
||||||
|
"title": "Neuanfang", "overview": null, "stillImage": null,
|
||||||
|
"files": [{
|
||||||
|
"id": "d-\(id)", "fileName": "F.mkv", "sizeBytes": 1,
|
||||||
|
"container": "mkv", "durationSeconds": 2700, "streams": []
|
||||||
|
}],
|
||||||
|
"state": { "positionSeconds": 0, "runtimeSeconds": 2700,
|
||||||
|
"watched": false, "playCount": 0, "favourite": false,
|
||||||
|
"rating": null, "dismissed": false,
|
||||||
|
"lastPlayedAt": \(zuletzt), "inProgress": false },
|
||||||
|
"publishAt": \(publishAt), "seriesId": "serie-1", "markers": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let folgeJetzt = MediaItem(
|
||||||
|
id: "s1e10", kind: .episode, title: "Finale", index: 10, parentIndex: 1)
|
||||||
|
|
||||||
|
/// Das Staffelfinale: die nächste Folge ist die erste der nächsten Staffel.
|
||||||
|
@Test("Über die Staffelgrenze hinweg")
|
||||||
|
func ueberDieStaffelgrenze() async throws {
|
||||||
|
let backend = backend(Attrappe(rumpf: Self.folge(id: "s2e1", staffel: 2, nummer: 1)))
|
||||||
|
|
||||||
|
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
|
||||||
|
|
||||||
|
#expect(naechste.id == "s2e1")
|
||||||
|
#expect(naechste.parentIndex == 2)
|
||||||
|
#expect(naechste.index == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Der Fehler selbst.** Mit `publishAt` und `lastPlayedAt` scheiterte
|
||||||
|
/// das Lesen stillschweigend, und der Knopf blieb weg. Mit dem frischen
|
||||||
|
/// `JSONDecoder()` von vorher ist diese Prüfung rot.
|
||||||
|
@Test("Auch mit Datumsangaben — daran scheiterte es")
|
||||||
|
func mitDatumsangaben() async throws {
|
||||||
|
let backend = backend(Attrappe(rumpf: Self.folge(
|
||||||
|
id: "s2e1", staffel: 2, nummer: 1,
|
||||||
|
publishAt: "\"2026-10-01T18:00:00Z\"",
|
||||||
|
zuletzt: "\"2026-10-11T21:14:03.482Z\"")))
|
||||||
|
|
||||||
|
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
|
||||||
|
|
||||||
|
#expect(naechste.id == "s2e1")
|
||||||
|
#expect(naechste.availableFrom != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach der letzten Folge kommt keine — 204, und das ist kein Fehler.
|
||||||
|
@Test("Hinter der letzten Folge kommt nichts")
|
||||||
|
func hinterDerLetzten() async throws {
|
||||||
|
let backend = backend(Attrappe(rumpf: "", status: 204))
|
||||||
|
|
||||||
|
#expect(try await backend.nextEpisode(after: Self.folgeJetzt) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Film hat keine Nachbarn — und fragt gar nicht erst.
|
||||||
|
@Test("Ein Film fragt nicht nach Nachbarn")
|
||||||
|
func filmFragtNicht() async throws {
|
||||||
|
let attrappe = Attrappe(rumpf: Self.folge(id: "x", staffel: 1, nummer: 1))
|
||||||
|
let film = MediaItem(id: "f1", kind: .movie, title: "Arrival")
|
||||||
|
|
||||||
|
#expect(try await backend(attrappe).nextEpisode(after: film) == nil)
|
||||||
|
#expect(attrappe.anfragen.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import MediaKit
|
||||||
|
@testable import FlaroServerKit
|
||||||
|
|
||||||
|
/// Aus Flaros Antworten werden neutrale Typen — ohne Verlust.
|
||||||
|
@Suite("Flaro: Übersetzung")
|
||||||
|
struct UebersetzungTests {
|
||||||
|
private func backend(_ transport: any FlaroTransport) -> FlaroBackend {
|
||||||
|
FlaroBackend(
|
||||||
|
client: FlaroClient(
|
||||||
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "t",
|
||||||
|
transport: transport, cookieJar: nil),
|
||||||
|
account: FlaroAccount(
|
||||||
|
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
||||||
|
userID: "u1", userName: "guido", isAdministrator: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Beide Tonspuren kommen an, mit Namen und Nummer.**
|
||||||
|
///
|
||||||
|
/// Das ist der ganze Anlass: eine Datei mit „Deutsch" und
|
||||||
|
/// „Audiodeskription", und die App muss beide sehen, um nicht blind die
|
||||||
|
/// falsche zu nehmen.
|
||||||
|
@Test("Ein Film behält alle Spuren")
|
||||||
|
func filmMitZweiTonspuren() async throws {
|
||||||
|
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
|
||||||
|
let titel = film.neutral()
|
||||||
|
let datei = try #require(titel.versions.first?.files.first)
|
||||||
|
|
||||||
|
#expect(titel.title == "Der Untergang")
|
||||||
|
#expect(titel.year == 2004)
|
||||||
|
#expect(datei.audioStreams.count == 2)
|
||||||
|
#expect(datei.audioStreams.map(\.title) == ["Deutsch", "Audiodeskription"])
|
||||||
|
// Die Nummer muss zurückrechenbar sein — daran hängt die Spurwahl.
|
||||||
|
#expect(datei.audioStreams.map(\.index) == [1, 2])
|
||||||
|
#expect(datei.subtitleStreams.count == 1)
|
||||||
|
#expect(datei.videoStream?.codec == "h264")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fünf Minuten von zwei Stunden sechsunddreissig — die Stelle, die
|
||||||
|
/// Jellyfin wegwirft.
|
||||||
|
@Test("Der Stand kommt als Fortschritt an")
|
||||||
|
func standWirdFortschritt() throws {
|
||||||
|
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
|
||||||
|
let titel = film.neutral()
|
||||||
|
|
||||||
|
#expect(titel.resumeMilliseconds == 300_000)
|
||||||
|
#expect(titel.duration == 9_360_000)
|
||||||
|
#expect(titel.isWatched == false)
|
||||||
|
let anteil = try #require(titel.watchProgress)
|
||||||
|
#expect(abs(anteil - 300.0 / 9360.0) < 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
|
||||||
|
///
|
||||||
|
/// **Der Standarddekoder scheitert daran**, und zwar stumm bis zur
|
||||||
|
/// Stelle, an der jemand das Datum liest. Hier stehen beide nebeneinander,
|
||||||
|
/// damit sichtbar bleibt, dass die eigene Strategie keine Verzierung ist.
|
||||||
|
@Test("Ein Datum mit Sekundenbruchteilen wird gelesen")
|
||||||
|
func datumMitBruch() async throws {
|
||||||
|
let roh = Data(Proben.film.utf8)
|
||||||
|
|
||||||
|
#expect((try? JSONDecoder().decode(Wire.Movie.self, from: roh)) == nil,
|
||||||
|
"2026-10-03T19:25:04.5209555+00:00 ist für .iso8601 kein Datum")
|
||||||
|
#expect((try? FlaroClient.decoder.decode(Wire.Movie.self, from: roh)) != nil)
|
||||||
|
|
||||||
|
let transport = Attrappe(rumpf: #"{"items":[\#(Proben.film)],"total":1}"#)
|
||||||
|
let seite = try await backend(transport).items(
|
||||||
|
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie),
|
||||||
|
query: MediaQuery(), offset: 0, limit: 50)
|
||||||
|
|
||||||
|
#expect(seite.items.first?.lastPlayedAt != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Der Server zählt, was offen ist; die App rechnet mit der Gegenzahl.
|
||||||
|
@Test("Offene Folgen werden zu gesehenen")
|
||||||
|
func serieZaehlt() throws {
|
||||||
|
let roh = """
|
||||||
|
{"id":"s1","title":"Dark","year":2017,"overview":null,
|
||||||
|
"posterImage":"p","backdropImage":"b",
|
||||||
|
"episodeCount":10,"seasons":[1,2],"unwatchedCount":3}
|
||||||
|
"""
|
||||||
|
let serie = try FlaroClient.decoder.decode(Wire.Series.self, from: Data(roh.utf8))
|
||||||
|
let titel = serie.neutral()
|
||||||
|
|
||||||
|
#expect(titel.kind == .show)
|
||||||
|
#expect(titel.episodeCount == 10)
|
||||||
|
#expect(titel.playedEpisodeCount == 7)
|
||||||
|
#expect(titel.childCount == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Oben die Serie, darunter die Folge.** Eine Kachel, die nur
|
||||||
|
/// „Lügen" sagt, hilft beim Wiedererkennen nicht.
|
||||||
|
@Test("Eine Folge aus Weiterschauen trägt ihre Serie mit")
|
||||||
|
func weiterschauenFolge() throws {
|
||||||
|
let roh = """
|
||||||
|
{"id":"f2","kind":"episode","title":"Dark","subTitle":"Lügen",
|
||||||
|
"season":1,"episode":2,"year":null,"posterImage":"p","backdropImage":"still",
|
||||||
|
"state":{"positionSeconds":400.0,"runtimeSeconds":2700.0,"watched":false,
|
||||||
|
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
|
||||||
|
"lastPlayedAt":null}}
|
||||||
|
"""
|
||||||
|
let eintrag = try FlaroClient.decoder.decode(Wire.Started.self, from: Data(roh.utf8))
|
||||||
|
let titel = eintrag.neutral()
|
||||||
|
|
||||||
|
#expect(titel.kind == .episode)
|
||||||
|
#expect(titel.title == "Lügen")
|
||||||
|
#expect(titel.seriesTitle == "Dark")
|
||||||
|
#expect(titel.parentIndex == 1)
|
||||||
|
#expect(titel.index == 2)
|
||||||
|
#expect(titel.resumeMilliseconds == 400_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mediatheken: Shows werden Serien, alles andere Filme")
|
||||||
|
func mediatheken() async throws {
|
||||||
|
let transport = Attrappe(rumpf: """
|
||||||
|
[{"id":"b1","name":"Filme","kind":"Movies","path":"/media/Filme"},
|
||||||
|
{"id":"b2","name":"Serien","kind":"Shows","path":"/media/Serien"}]
|
||||||
|
""")
|
||||||
|
|
||||||
|
let liste = try await backend(transport).libraries()
|
||||||
|
|
||||||
|
#expect(liste.map(\.kind) == [.movie, .show])
|
||||||
|
#expect(liste.map(\.title) == ["Filme", "Serien"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nach der letzten Folge kommt keine — 204, kein Fehler.
|
||||||
|
@Test("Hinter der letzten Folge steht nichts")
|
||||||
|
func keineNaechsteFolge() async throws {
|
||||||
|
let transport = Attrappe(rumpf: "", status: 204)
|
||||||
|
let folge = MediaItem(id: "f9", kind: .episode, title: "Letzte")
|
||||||
|
|
||||||
|
let naechste = try await backend(transport).nextEpisode(after: folge)
|
||||||
|
#expect(naechste == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Eine Fortschrittsmeldung geht in Sekunden hinaus")
|
||||||
|
func fortschrittMelden() async {
|
||||||
|
let transport = Attrappe(rumpf: "{}")
|
||||||
|
|
||||||
|
await backend(transport).reportProgress(PlaybackReport(
|
||||||
|
itemID: "film-1", state: .playing,
|
||||||
|
time: .seconds(300), duration: .seconds(9360)))
|
||||||
|
|
||||||
|
let rumpf = transport.rumpf(von: "/api/v1/items/film-1/progress")
|
||||||
|
#expect(transport.letzte?.httpMethod == "POST")
|
||||||
|
#expect(rumpf?["positionSeconds"] as? Double == 300)
|
||||||
|
#expect(rumpf?["runtimeSeconds"] as? Double == 9360)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Flaro hat einen eigenen Weg dafür — nur deshalb bleibt die Stelle.**
|
||||||
|
/// Bei Jellyfin wird hier das Benutzerdatenblatt überschrieben.
|
||||||
|
@Test("Weglegen ist nicht dasselbe wie gesehen")
|
||||||
|
func weglegen() async throws {
|
||||||
|
let transport = Attrappe(rumpf: "{}")
|
||||||
|
|
||||||
|
try await backend(transport).removeFromContinueWatching(
|
||||||
|
MediaItem(id: "film-1", title: "Der Untergang"))
|
||||||
|
|
||||||
|
#expect(transport.letzte?.url?.path == "/api/v1/items/film-1/dismiss")
|
||||||
|
#expect(transport.letzte?.httpMethod == "POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Gesehen und ungesehen gehen an dieselbe Stelle, mit anderem Wert")
|
||||||
|
func gesehenMelden() async throws {
|
||||||
|
let transport = Attrappe(rumpf: "{}")
|
||||||
|
let b = backend(transport)
|
||||||
|
|
||||||
|
try await b.markWatched(id: "film-1")
|
||||||
|
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == true)
|
||||||
|
|
||||||
|
try await b.markUnwatched(id: "film-1")
|
||||||
|
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == false)
|
||||||
|
#expect(transport.letzte?.httpMethod == "PUT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Abspielen -----------------------------------------------------
|
||||||
|
|
||||||
|
/// **Direkt spielbar: sofort los.**
|
||||||
|
@Test("Eine MP4 geht ohne Umweg hinaus")
|
||||||
|
func direkteWiedergabe() async throws {
|
||||||
|
let transport = Attrappe(rumpf: """
|
||||||
|
{"method":"directFile","reasons":[],"audioToEncode":[],"subtitlesDropped":[],
|
||||||
|
"packageState":"none","progress":0.0,"error":null}
|
||||||
|
""")
|
||||||
|
|
||||||
|
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
|
||||||
|
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
|
||||||
|
|
||||||
|
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
|
||||||
|
// Flaro legt für eine Wiedergabe nichts an, das aufzuräumen wäre.
|
||||||
|
#expect(ziel.sessionID == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Der Fehler, den erst der Simulator zeigte.**
|
||||||
|
///
|
||||||
|
/// Muss der Server umpacken, antwortet er auf den Download mit 202 und
|
||||||
|
/// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
|
||||||
|
/// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner:
|
||||||
|
/// „Stelle wird wiederhergestellt", 0:00, für immer.
|
||||||
|
@Test("Ein Umpacken wird abgewartet, nicht überholt")
|
||||||
|
func wartetAufsUmpacken() async throws {
|
||||||
|
let transport = Fortschrittsattrappe()
|
||||||
|
|
||||||
|
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
|
||||||
|
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
|
||||||
|
|
||||||
|
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
|
||||||
|
#expect(transport.abfragen >= 2, "es wurde nachgefragt, nicht einmal geschaut")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Absage des Servers ist eine Absage, kein Weiterwarten.
|
||||||
|
@Test("Ein gescheitertes Umpacken kommt als Begründung an")
|
||||||
|
func gescheitertesUmpacken() async {
|
||||||
|
let transport = Attrappe(rumpf: """
|
||||||
|
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
|
||||||
|
"packageState":"failed","progress":0.0,"error":"ffmpeg ist abgestürzt."}
|
||||||
|
""")
|
||||||
|
let b = backend(transport)
|
||||||
|
|
||||||
|
await #expect(throws: MediaBackendError.refused(reason: "ffmpeg ist abgestürzt.")) {
|
||||||
|
_ = try await b.preparePlayback(PlaybackIntent(
|
||||||
|
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flaro kennt noch keine Schlagwörter — eine leere Liste, kein Fehler.
|
||||||
|
@Test("Ohne Schlagwörter bleibt der Filter leer statt kaputt")
|
||||||
|
func keineSchlagwoerter() async throws {
|
||||||
|
let optionen = try await backend(Attrappe(rumpf: "{}")).filterOptions(
|
||||||
|
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie), field: .genre)
|
||||||
|
|
||||||
|
#expect(optionen.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Antwortet beim ersten Mal „läuft noch", danach „fertig".
|
||||||
|
///
|
||||||
|
/// Damit prüfbar ist, dass gewartet wird — eine Attrappe mit immer derselben
|
||||||
|
/// Antwort könnte das nicht von „einmal geschaut und losgelaufen" trennen.
|
||||||
|
final class Fortschrittsattrappe: FlaroTransport, @unchecked Sendable {
|
||||||
|
private let sperre = NSLock()
|
||||||
|
private var runde = 0
|
||||||
|
|
||||||
|
var abfragen: Int { sperre.withLock { runde } }
|
||||||
|
|
||||||
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||||
|
let pfad = request.url?.path ?? ""
|
||||||
|
let fertig: Bool = sperre.withLock {
|
||||||
|
if pfad.hasSuffix("/delivery") { runde += 1 }
|
||||||
|
return runde > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let rumpf = """
|
||||||
|
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
|
||||||
|
"packageState":"\(fertig ? "ready" : "running")","progress":\(fertig ? 1.0 : 0.4),"error":null}
|
||||||
|
"""
|
||||||
|
return (Data(rumpf.utf8),
|
||||||
|
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Erzeugt einen kleinen Bestand zum Ausprobieren: echte Dateinamen, winzige
|
||||||
|
# Dateien. Jede ist eine Sekunde Testbild mit Ton — damit der Scan, die
|
||||||
|
# Metadaten und das Umpacken etwas zu tun haben, ohne Gigabytes zu brauchen.
|
||||||
|
set -euo pipefail
|
||||||
|
ZIEL="${1:-$HOME/.flaro-medien}"
|
||||||
|
command -v ffmpeg >/dev/null || { echo "ffmpeg fehlt."; exit 1; }
|
||||||
|
|
||||||
|
mach() { # mach <pfad> <schachtel> [zweite-tonspur]
|
||||||
|
mkdir -p "$(dirname "$1")"
|
||||||
|
if [ "${3:-}" = "zwei" ]; then
|
||||||
|
ffmpeg -y -loglevel error \
|
||||||
|
-f lavfi -i "testsrc=duration=2:size=320x240:rate=12" \
|
||||||
|
-f lavfi -i "sine=frequency=440:duration=2" \
|
||||||
|
-f lavfi -i "sine=frequency=880:duration=2" \
|
||||||
|
-map 0:v -map 1:a -map 2:a \
|
||||||
|
-metadata:s:a:0 language=deu -metadata:s:a:0 title=Deutsch \
|
||||||
|
-metadata:s:a:1 language=deu -metadata:s:a:1 title=Audiodeskription \
|
||||||
|
-c:v libx264 -preset ultrafast -c:a aac "$1"
|
||||||
|
else
|
||||||
|
ffmpeg -y -loglevel error \
|
||||||
|
-f lavfi -i "testsrc=duration=2:size=320x240:rate=12" \
|
||||||
|
-f lavfi -i "sine=frequency=440:duration=2" \
|
||||||
|
-c:v libx264 -preset ultrafast -c:a aac "$1"
|
||||||
|
fi
|
||||||
|
echo " $(basename "$1")"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Filme:"
|
||||||
|
mach "$ZIEL/Filme/Der Untergang (2004)/Der Untergang (2004).mkv" mkv zwei
|
||||||
|
mach "$ZIEL/Filme/The Matrix (1999)/The Matrix (1999).mkv" mkv
|
||||||
|
mach "$ZIEL/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 (2017).mp4" mp4
|
||||||
|
mach "$ZIEL/Filme/Inception (2010)/Inception (2010).mkv" mkv
|
||||||
|
|
||||||
|
echo "Serien:"
|
||||||
|
mach "$ZIEL/Serien/Dark/Staffel 1/Dark - S01E01.mkv" mkv
|
||||||
|
mach "$ZIEL/Serien/Dark/Staffel 1/Dark - S01E02.mkv" mkv
|
||||||
|
mach "$ZIEL/Serien/Dark/Staffel 2/Dark - S02E01.mkv" mkv
|
||||||
|
mach "$ZIEL/Serien/Notruf Hafenkante/Staffel 1/Notruf Hafenkante - S01E01.mkv" mkv zwei
|
||||||
|
|
||||||
|
# **Eine zweite Filmbibliothek, und das ist Absicht.** Mit nur „Filme" und
|
||||||
|
# „Serien" lässt sich nicht prüfen, ob mehrere Mediatheken wirklich getrennt
|
||||||
|
# bleiben — zwei Bestände derselben Art sind der Fall, in dem es auffällt.
|
||||||
|
echo "Dokumentationen:"
|
||||||
|
mach "$ZIEL/Dokus/Nomaden der Lüfte (2001)/Nomaden der Lüfte (2001).mkv" mkv
|
||||||
|
mach "$ZIEL/Dokus/Unsere Erde (2007)/Unsere Erde (2007).mp4" mp4
|
||||||
|
|
||||||
|
echo "Konzerte:"
|
||||||
|
mach "$ZIEL/Konzerte/Stop Making Sense (1984)/Stop Making Sense (1984).mkv" mkv zwei
|
||||||
|
|
||||||
|
chmod -R a+rwX "$ZIEL"
|
||||||
|
echo
|
||||||
|
echo "Fertig: $ZIEL"
|
||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Baut das Flaro-Image für beide Architekturen und legt es in die Registry.
|
||||||
|
#
|
||||||
|
# **Der Weg von Hand** — für den Fall, dass kein Gitea-Runner läuft. Sonst
|
||||||
|
# macht das `.gitea/workflows/image.yml` bei jedem Push von selbst.
|
||||||
|
#
|
||||||
|
# Tools/image-veroeffentlichen.sh → :latest
|
||||||
|
# Tools/image-veroeffentlichen.sh 0.2.0 → :latest und :0.2.0
|
||||||
|
#
|
||||||
|
# Nur eine Architektur — deutlich schneller, weil die fremde sonst unter
|
||||||
|
# Emulation läuft:
|
||||||
|
#
|
||||||
|
# FLARO_PLATTFORMEN=linux/amd64 Tools/image-veroeffentlichen.sh
|
||||||
|
#
|
||||||
|
# Vorher einmalig anmelden — das Passwort tippst du selbst ein:
|
||||||
|
#
|
||||||
|
# docker login git.scarriffle.com
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="${FLARO_REGISTRY:-git.scarriffle.com}"
|
||||||
|
PFAD="${FLARO_IMAGE_PATH:-scarriffle/flaro}"
|
||||||
|
BILD="$REGISTRY/$PFAD"
|
||||||
|
FASSUNG="${1:-}"
|
||||||
|
PLATTFORMEN="${FLARO_PLATTFORMEN:-linux/amd64,linux/arm64}"
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
command -v docker >/dev/null || { echo "docker fehlt."; exit 1; }
|
||||||
|
|
||||||
|
# **Ohne Anmeldung bricht der Push erst nach dem ganzen Bau ab.** Zwei
|
||||||
|
# Minuten Arbeit für eine Fehlermeldung, die am Anfang gestanden hätte.
|
||||||
|
# Kein `docker info | grep -q`: `grep -q` beendet die Pipe beim ersten
|
||||||
|
# Treffer, Docker bekommt SIGPIPE, und mit `set -o pipefail` gilt das als
|
||||||
|
# Fehlschlag. Die Prüfung meldete dann „Docker läuft nicht", während es lief.
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
echo "Docker läuft nicht."; exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "$REGISTRY" ~/.docker/config.json 2>/dev/null; then
|
||||||
|
echo "Nicht an $REGISTRY angemeldet. Erst:"
|
||||||
|
echo
|
||||||
|
echo " docker login $REGISTRY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# **Vor dem Bauen, nicht danach.** Laufen App und Browser farblich
|
||||||
|
# auseinander, soll das auffallen, bevor ein Image daraus wird.
|
||||||
|
if command -v python3 >/dev/null && [ -f Tools/tokens-pruefen.py ]; then
|
||||||
|
python3 Tools/tokens-pruefen.py || {
|
||||||
|
echo
|
||||||
|
echo "Abgebrochen. Mit FLARO_DRIFT_EGAL=1 trotzdem bauen."
|
||||||
|
[ "${FLARO_DRIFT_EGAL:-}" = "1" ] || exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fragt die Registry, welcher Stand dort unter `latest` liegt.
|
||||||
|
#
|
||||||
|
# Anonym — das Paket ist öffentlich lesbar. Schlägt es fehl, ist die Antwort
|
||||||
|
# leer, und der Aufrufer entscheidet, was das bedeutet.
|
||||||
|
registry_digest() {
|
||||||
|
local token
|
||||||
|
token=$(curl -fsS --max-time 20 \
|
||||||
|
"https://$REGISTRY/v2/token?scope=repository:$PFAD:pull&service=container_registry" \
|
||||||
|
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("token",""))' 2>/dev/null) || return 1
|
||||||
|
[ -n "$token" ] || return 1
|
||||||
|
|
||||||
|
curl -fsS --max-time 20 -D- -o /dev/null \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Accept: application/vnd.oci.image.index.v1+json" \
|
||||||
|
"https://$REGISTRY/v2/$PFAD/manifests/latest" 2>/dev/null \
|
||||||
|
| awk 'BEGIN{IGNORECASE=1} /^docker-content-digest:/ {print $2}' | tr -d '\r'
|
||||||
|
}
|
||||||
|
|
||||||
|
MARKEN=(-t "$BILD:latest")
|
||||||
|
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
|
||||||
|
|
||||||
|
# **Eine Kennung je Bau.**
|
||||||
|
#
|
||||||
|
# Sechs Zeichen, jedes Mal neu — auch bei unverändertem Quelltext. Sie landet
|
||||||
|
# im Abbild und steht danach in der Oberfläche unter „Einstellungen" sowie
|
||||||
|
# unter `/health`. Damit ist die Frage „läuft auf dem NAS wirklich der Bau von
|
||||||
|
# eben?" in zwei Sekunden beantwortet statt in einem halben Tag.
|
||||||
|
#
|
||||||
|
# Ohne I, O, 0 und 1: diese Kennung wird vorgelesen und abgetippt.
|
||||||
|
#
|
||||||
|
# **Nicht `tr < /dev/urandom | head -c 6`.** `head` schliesst die Pipe nach
|
||||||
|
# sechs Zeichen, `tr` bekommt SIGPIPE, und mit `set -o pipefail` gilt das als
|
||||||
|
# Fehlschlag — das Skript brach mit 141 ab, bevor es irgendetwas gebaut hatte.
|
||||||
|
# Dieselbe Falle wie oben bei `grep -q`, und sie hat hier zweimal zugeschlagen.
|
||||||
|
BUILD=$(python3 -c "import secrets; print(''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(6)))")
|
||||||
|
echo "Build-Kennung dieses Laufs: $BUILD"
|
||||||
|
|
||||||
|
# **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein
|
||||||
|
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
|
||||||
|
docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|
||||||
|
|| docker buildx create --name flaro-bauer --use >/dev/null
|
||||||
|
|
||||||
|
# **Den Digest festhalten, bevor gebaut wird.** Nur so lässt sich hinterher
|
||||||
|
# sagen, ob wirklich etwas Neues angekommen ist.
|
||||||
|
vorher=$(registry_digest || true)
|
||||||
|
|
||||||
|
echo "Baue $BILD für $PLATTFORMEN."
|
||||||
|
if ! docker buildx build \
|
||||||
|
--builder flaro-bauer \
|
||||||
|
--platform "$PLATTFORMEN" \
|
||||||
|
-f server/Dockerfile \
|
||||||
|
--build-arg "FLARO_BUILD=$BUILD" \
|
||||||
|
"${MARKEN[@]}" \
|
||||||
|
--push . ; then
|
||||||
|
echo
|
||||||
|
echo "Der Bau oder das Hochladen ist gescheitert. In der Registry liegt weiter:"
|
||||||
|
echo " ${vorher:-nichts}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# **Nachsehen, statt zu glauben.**
|
||||||
|
#
|
||||||
|
# `docker buildx build --push` hat bei einem abgebrochenen Upload schon mit
|
||||||
|
# Rückgabewert 0 beendet, nachdem es „ERROR: failed to build" gedruckt hatte —
|
||||||
|
# ein TLS-Zeitlimit mitten im Hochladen. Das Skript meldete Erfolg, in der
|
||||||
|
# Registry lag das alte Image, und auf dem NAS kam eine Fassung an, die
|
||||||
|
# niemand gebaut hatte. Seitdem wird gefragt, nicht vertraut.
|
||||||
|
nachher=$(registry_digest || true)
|
||||||
|
|
||||||
|
if [ -z "$nachher" ]; then
|
||||||
|
echo
|
||||||
|
echo "Die Registry antwortet nicht auf $BILD:latest — hochgeladen wurde offenbar nichts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$vorher" ] && [ "$vorher" = "$nachher" ]; then
|
||||||
|
echo
|
||||||
|
echo "In der Registry liegt noch derselbe Stand wie vorher:"
|
||||||
|
echo " $nachher"
|
||||||
|
echo "Das Hochladen ist also nicht durchgekommen. Noch einmal versuchen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Fertig. In der Registry liegt jetzt:"
|
||||||
|
echo " $nachher"
|
||||||
|
echo
|
||||||
|
echo "Build-Kennung: $BUILD"
|
||||||
|
echo " Dieselbe muss nach dem Ziehen unter Einstellungen stehen."
|
||||||
|
echo " Steht dort eine andere, läuft auf dem NAS ein älteres Abbild."
|
||||||
|
echo
|
||||||
|
echo "Auf dem NAS:"
|
||||||
|
echo " image: $BILD:${FASSUNG:-latest}"
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prüft, ob App und Weboberfläche dieselben Farben und Formen benutzen.
|
||||||
|
|
||||||
|
**Warum es das braucht.** Die Werte stehen an mehreren Stellen von Hand
|
||||||
|
abgeschrieben — in Swift für die Apps, in CSS für den Browser. Sie sind
|
||||||
|
auseinandergelaufen, und zwar unsichtbar: `#1D1D21` gegen `#1D1D22`,
|
||||||
|
`#A0A0A8` gegen `#B4B4BE`, Radius 8 gegen 6. Einzeln merkt das niemand.
|
||||||
|
Zusammen sorgen sie dafür, dass App und Browser nicht wie dasselbe Programm
|
||||||
|
aussehen.
|
||||||
|
|
||||||
|
**Warum kein Generator.** Einer müsste Swift erzeugen, und erzeugter Swift
|
||||||
|
lässt sich schlechter lesen und kommentieren als geschriebener. Diese Prüfung
|
||||||
|
kostet weniger und fängt dasselbe ab: sie sagt, dass etwas auseinanderläuft,
|
||||||
|
und überlässt die Entscheidung dem Menschen.
|
||||||
|
|
||||||
|
Aufruf: python3 Tools/tokens-pruefen.py
|
||||||
|
Rückgabe: 0, wenn alles übereinstimmt; sonst 1.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WURZEL = Path(__file__).resolve().parent.parent
|
||||||
|
SWIFT_FARBEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/FlaroColor.swift"
|
||||||
|
SWIFT_AKZENT = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/AccentOption.swift"
|
||||||
|
SWIFT_FORMEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/FlaroFont.swift"
|
||||||
|
CSS = WURZEL / "web/app/src/style.css"
|
||||||
|
|
||||||
|
# Was womit zusammengehört. Links der Swift-Name, rechts das CSS-Token.
|
||||||
|
FARBEN = {
|
||||||
|
"canvas": "--canvas",
|
||||||
|
"surface": "--surface",
|
||||||
|
"elevated": "--elevated",
|
||||||
|
"placeholder": "--placeholder",
|
||||||
|
"textPrimary": "--text",
|
||||||
|
"textSecondary": "--text-dim",
|
||||||
|
"textMuted": "--text-muted",
|
||||||
|
"destructive": "--destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Radien: Swift-Name → CSS-Token. `sheet` und `panel` haben im Browser kein
|
||||||
|
# Gegenstück — dort gibt es keine Blätter und keine schwebende Seitenleiste.
|
||||||
|
RADIEN = {
|
||||||
|
"card": "--r-karte",
|
||||||
|
"control": "--r-flaeche",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hex_aus_swift(text: str, name: str) -> str | None:
|
||||||
|
"""Liest `static let name = RGB(hex: 0xAABBCC)` oder `RGB(0xAABBCC)`."""
|
||||||
|
m = re.search(rf"\b{name}\b[^\n=]*=\s*[^\n]*?0x([0-9A-Fa-f]{{6}})", text)
|
||||||
|
return "#" + m.group(1).upper() if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def ohne_tvos(text: str) -> str:
|
||||||
|
"""Entfernt die tvOS-Zweige.
|
||||||
|
|
||||||
|
**Sonst vergleicht man den falschen Wert.** `FlaroRadius.card` ist 8 —
|
||||||
|
und auf tvOS 12, weil aus drei Metern eine stärkere Rundung nötig ist.
|
||||||
|
Der Browser läuft nie auf einem Fernseher; massgeblich ist der andere Zweig.
|
||||||
|
"""
|
||||||
|
return re.sub(r"#if os\(tvOS\).*?(?:#else(.*?))?#endif", lambda m: m.group(1) or "",
|
||||||
|
text, flags=re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def zahl_aus_swift(text: str, name: str) -> float | None:
|
||||||
|
"""Liest den ersten Zahlenwert hinter `static let name`."""
|
||||||
|
m = re.search(rf"\b{name}\b\s*:\s*CGFloat\s*(?:\{{[^}}]*?return\s*)?([0-9]+(?:\.[0-9]+)?)", text, re.S)
|
||||||
|
if m:
|
||||||
|
return float(m.group(1))
|
||||||
|
m = re.search(rf"\b{name}\b[^\n=]*=\s*([0-9]+(?:\.[0-9]+)?)", text)
|
||||||
|
return float(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def token_aus_css(text: str, name: str) -> str | None:
|
||||||
|
m = re.search(rf"{re.escape(name)}\s*:\s*([^;]+);", text)
|
||||||
|
return m.group(1).strip() if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
fehlend = [p for p in (SWIFT_FARBEN, SWIFT_AKZENT, SWIFT_FORMEN, CSS) if not p.exists()]
|
||||||
|
if fehlend:
|
||||||
|
for p in fehlend:
|
||||||
|
print(f"Nicht gefunden: {p.relative_to(WURZEL)}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
farben_swift = SWIFT_FARBEN.read_text(encoding="utf-8")
|
||||||
|
akzent_swift = SWIFT_AKZENT.read_text(encoding="utf-8")
|
||||||
|
formen_swift = ohne_tvos(SWIFT_FORMEN.read_text(encoding="utf-8"))
|
||||||
|
css = CSS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
abweichungen: list[str] = []
|
||||||
|
geprueft = 0
|
||||||
|
|
||||||
|
for swift_name, css_name in FARBEN.items():
|
||||||
|
a = hex_aus_swift(farben_swift, swift_name)
|
||||||
|
b = token_aus_css(css, css_name)
|
||||||
|
if a is None or b is None:
|
||||||
|
abweichungen.append(f" {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
|
||||||
|
continue
|
||||||
|
geprueft += 1
|
||||||
|
if a.upper() != b.upper():
|
||||||
|
abweichungen.append(f" {swift_name}: Swift {a}, CSS {css_name} {b}")
|
||||||
|
|
||||||
|
# Die Vorgabefarbe der App gegen den Akzent im Browser.
|
||||||
|
vorgabe = re.search(r'identifier\s*==\s*"([a-z]+)"', akzent_swift)
|
||||||
|
if not vorgabe:
|
||||||
|
vorgabe = re.search(r'\.first\s*\{\s*\$0\.id\s*==\s*"([a-z]+)"', akzent_swift)
|
||||||
|
if vorgabe:
|
||||||
|
hex_vorgabe = hex_aus_swift(akzent_swift, vorgabe.group(1))
|
||||||
|
akzent_css = token_aus_css(css, "--accent")
|
||||||
|
if hex_vorgabe and akzent_css:
|
||||||
|
geprueft += 1
|
||||||
|
if hex_vorgabe.upper() != akzent_css.upper():
|
||||||
|
abweichungen.append(
|
||||||
|
f" Vorgabe-Akzent: Swift {vorgabe.group(1)} {hex_vorgabe}, CSS {akzent_css}")
|
||||||
|
|
||||||
|
for swift_name, css_name in RADIEN.items():
|
||||||
|
a = zahl_aus_swift(formen_swift, swift_name)
|
||||||
|
b = token_aus_css(css, css_name)
|
||||||
|
if a is None or b is None:
|
||||||
|
abweichungen.append(f" Radius {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
|
||||||
|
continue
|
||||||
|
geprueft += 1
|
||||||
|
b_zahl = float(re.sub(r"[^0-9.]", "", b) or 0)
|
||||||
|
if abs(a - b_zahl) > 0.01:
|
||||||
|
abweichungen.append(f" Radius {swift_name}: Swift {a:g}, CSS {css_name} {b}")
|
||||||
|
|
||||||
|
if abweichungen:
|
||||||
|
print(f"App und Weboberfläche laufen auseinander ({len(abweichungen)} Stelle(n)):")
|
||||||
|
print("\n".join(abweichungen))
|
||||||
|
print("\nFlaroDesign ist massgeblich — die CSS zieht nach.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"App und Weboberfläche stimmen überein ({geprueft} Werte geprüft).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Den Abspann im Bild finden — Entwurf
|
||||||
|
|
||||||
|
Der Tonabgleich findet Vorspann und Abspann, **weil sie sich wiederholen**:
|
||||||
|
dieselbe Musik in Folge 1 und Folge 2. Ein Film hat nichts, womit er sich
|
||||||
|
vergleichen liesse. Sein Abspann kommt genau einmal vor.
|
||||||
|
|
||||||
|
Was bleibt, ist das Bild. Dieser Entwurf sagt, was daran messbar ist, was es
|
||||||
|
kostet, und wo das Verfahren danebenliegt.
|
||||||
|
|
||||||
|
Kapitelmarken sind hier bewusst **nicht** der Weg: sie sind exakt, wo sie
|
||||||
|
stehen, aber bei einer gemischten Sammlung stehen sie selten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Das Signal, nachgemessen
|
||||||
|
|
||||||
|
Ein Abspann ist dunkel. Das ist keine Vermutung — nachgestellt mit einem
|
||||||
|
erzeugten Film (90 Sekunden Bild, danach 40 Sekunden weisse Schrift auf
|
||||||
|
schwarzem Grund), abgetastet mit zwei Bildern je Sekunde:
|
||||||
|
|
||||||
|
| Stelle | mittlere Helligkeit (`YAVG`, 0–255) |
|
||||||
|
|---|---|
|
||||||
|
| 0–89,5 s (Handlung) | **125** |
|
||||||
|
| ab 90,0 s (Abspann) | **18** |
|
||||||
|
|
||||||
|
Der Sprung liegt auf dem Bild genau, an dem der Abspann anfängt. Kein
|
||||||
|
Übergang, keine Grauzone — und das ist der Punkt: gesucht wird nicht „ein
|
||||||
|
dunkles Bild", sondern **die Kante**.
|
||||||
|
|
||||||
|
Dieselbe Richtung geht Jellyfins Intro Skipper. Dort heisst es
|
||||||
|
„Add support for using black frames to locate ending credits"; die Suche
|
||||||
|
läuft rückwärts vom Ende und hört auf, wenn der Bereich auf vier Sekunden
|
||||||
|
geschrumpft ist.
|
||||||
|
|
||||||
|
## 2 · Was es kostet
|
||||||
|
|
||||||
|
Das Rechnen ist umsonst, das **Dekodieren** ist der Preis. Gemessen an einer
|
||||||
|
erzeugten Datei, 1080p, h264, 180 Sekunden:
|
||||||
|
|
||||||
|
| Weg | Zeit |
|
||||||
|
|---|---|
|
||||||
|
| Jedes zweite Bild dekodieren (`fps=2`) | **2,89 s** |
|
||||||
|
| Nur Schlüsselbilder (`-skip_frame nokey`) | **0,24 s** |
|
||||||
|
|
||||||
|
Hochgerechnet auf die letzten 15 Minuten eines Films: der erste Weg kostet
|
||||||
|
rund anderthalb Minuten je Film, der zweite ein paar Sekunden. *Das ist eine
|
||||||
|
Hochrechnung, keine Messung an echtem Material.*
|
||||||
|
|
||||||
|
Es geht noch billiger, und so macht es Jellyfin: **nicht am Stück abtasten,
|
||||||
|
sondern zielen.** Binäre Suche mit Sonden von zwei Sekunden zwischen „sicher
|
||||||
|
Handlung" und „sicher Abspann" — zehn Sonden genügen für eine Genauigkeit von
|
||||||
|
vier Sekunden, und zehn mal zwei Sekunden Dekodierung sind nichts.
|
||||||
|
|
||||||
|
**Vorschlag: beides.** Erst eine grobe Reihe von Schlüsselbildern über die
|
||||||
|
letzten zwanzig Minuten (ein Durchlauf, wenige Sekunden) — die sagt, *ob* es
|
||||||
|
überhaupt eine dunkle Strecke am Ende gibt und ungefähr wo. Dann eine binäre
|
||||||
|
Suche mit kurzen Sonden auf die Kante. Der erste Schritt verhindert, dass die
|
||||||
|
binäre Suche in einem Film ohne dunklen Abspann wild herumspringt; der zweite
|
||||||
|
macht sie genau.
|
||||||
|
|
||||||
|
## 3 · Der Zuschnitt
|
||||||
|
|
||||||
|
Dieselbe Trennung wie beim Tonabgleich: **das Schwierige rein, das Dreckige
|
||||||
|
dünn.**
|
||||||
|
|
||||||
|
**`Bildabtaster` (Flaro.Media)** — der einzige Teil, der ffmpeg anfasst.
|
||||||
|
|
||||||
|
```
|
||||||
|
helligkeiten(pfad, ab, bis, schritt) -> [(sekunde, helligkeit)]
|
||||||
|
istDunkel(pfad, ab, dauer) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Beides über `signalstats` beziehungsweise `blackdetect`, als eigener Prozess
|
||||||
|
wie `FfprobeRunner`. Fehlt ffmpeg oder scheitert der Lauf: leere Antwort,
|
||||||
|
keine Marke, kein Abbruch.
|
||||||
|
|
||||||
|
**`Abspannkante` (Flaro.Core)** — reine Rechnung, ohne Datei:
|
||||||
|
|
||||||
|
```
|
||||||
|
Suchen(messpunkte, laufzeit, regeln) -> Kante?
|
||||||
|
```
|
||||||
|
|
||||||
|
Nimmt die Reihe aus Schritt eins und gibt zurück, wo die dunkle Strecke
|
||||||
|
anfängt — oder nichts. Hier liegen alle Schwellen, und hier liegen die
|
||||||
|
Prüfungen. Dieselbe Rolle wie `Tonabgleich`.
|
||||||
|
|
||||||
|
**`Markenerkennung`** bekommt einen zweiten Zweig: Serien über den Ton,
|
||||||
|
Filme über das Bild. Die Tabelle, der Vertrag, die Knöpfe in App und Web
|
||||||
|
stehen schon — ein Abspann ist ein Abspann, egal woher er kommt.
|
||||||
|
|
||||||
|
Dafür braucht `Marke` eine Spalte `MovieId` neben `EpisodeId` (heute nur
|
||||||
|
Folgen), und `MovieResponse` die Marken, wie `EpisodeResponse` sie schon hat.
|
||||||
|
|
||||||
|
## 4 · Die Schwellen
|
||||||
|
|
||||||
|
Alles hier ist eine Setzung und gehört an **eine** Stelle, damit man sie
|
||||||
|
gemeinsam verschieben kann:
|
||||||
|
|
||||||
|
| Grösse | Vorschlag | Warum |
|
||||||
|
|---|---|---|
|
||||||
|
| Helligkeit „dunkel" | `YAVG < 35` | Gemessen: Abspann 18, Handlung 125. Fünfunddreissig liegt weit von beiden. |
|
||||||
|
| Anteil dunkler Bilder in einer Sonde | 85 % | Eine Schrifttafel blitzt hell auf; eine einzelne helle Sonde darf die Strecke nicht brechen. |
|
||||||
|
| Kürzester Abspann | 45 s | Darunter lohnt kein Knopf, und zufällige Dunkelheit ist häufiger. |
|
||||||
|
| Längster Abspann | 12 min | Darüber ist es kein Abspann, sondern ein Fehlfund. |
|
||||||
|
| Suchbereich | letzte 20 min | Bei einem Dreistünder wäre „die letzten 10 %" zu viel, bei einem Kurzfilm zu wenig. |
|
||||||
|
| Genauigkeit | 4 s | Wie bei Jellyfin. Feiner zu suchen kostet Sonden und bringt niemandem etwas. |
|
||||||
|
|
||||||
|
## 5 · Wo es danebenliegt — und was dann passiert
|
||||||
|
|
||||||
|
**Das muss vorher dastehen, nicht hinterher.**
|
||||||
|
|
||||||
|
- **Abspann über laufendem Bild.** Marvel, Pixar, jede zweite Komödie: die
|
||||||
|
Namen laufen über eine Szene. Keine dunkle Strecke, kein Fund. → Kein
|
||||||
|
Knopf. Das ist die richtige Antwort; ein geratener Knopf schnitte Handlung
|
||||||
|
weg.
|
||||||
|
- **Dunkle Schlussszene.** Ein Film, der im Dunkeln endet, bekommt die Kante
|
||||||
|
zu früh. Dagegen hilft die Mindestlänge und die Obergrenze — und dass die
|
||||||
|
Kante die **früheste** dunkle Stelle einer durchgehenden Strecke bis zum
|
||||||
|
Ende sein muss, nicht irgendeine.
|
||||||
|
- **Schwarzblende mitten im Film.** Kommt vor, hält aber selten bis zum Ende
|
||||||
|
durch. Die Forderung „bis zum Dateiende dunkel" fängt das.
|
||||||
|
- **Szene nach dem Abspann.** Dann ist die Strecke unterbrochen, und der Fund
|
||||||
|
fällt heraus. Lieber keiner als einer, der über die Szene springt.
|
||||||
|
|
||||||
|
Grobe Erwartung, **ungemessen**: bei Serien greift das Verfahren oft, bei
|
||||||
|
Kinofilmen vielleicht bei der Hälfte. Mehr lässt sich erst sagen, wenn es an
|
||||||
|
echtem Material gelaufen ist — und dafür braucht es einen Weg, die Funde
|
||||||
|
nachzusehen.
|
||||||
|
|
||||||
|
## 6 · Nachsehen können
|
||||||
|
|
||||||
|
Ohne das wird aus dem Verfahren eine Blackbox, die manchmal recht hat.
|
||||||
|
Vorschlag: auf der Detailseite steht bei einem Film mit Marke, **wo** sie
|
||||||
|
liegt, mit einem Knopf „stimmt nicht" — der die Marke löscht und den Titel
|
||||||
|
für weitere Läufe sperrt. Drei Zeilen Oberfläche, und man hat eine Messung
|
||||||
|
statt eines Gefühls.
|
||||||
|
|
||||||
|
## 7 · Stufen
|
||||||
|
|
||||||
|
| | Schritt | Prüfbar an |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `Abspannkante` als reine Rechnung samt Prüfungen | erfundenen Messreihen |
|
||||||
|
| 2 | `Bildabtaster` über ffmpeg | der erzeugten Prüfdatei aus Abschnitt 1 |
|
||||||
|
| 3 | `Marke.MovieId`, Wanderung, Marken im Filmvertrag | Serverprüfungen |
|
||||||
|
| 4 | Filme im Lauf nach dem Scan | am Bestand, im Protokoll |
|
||||||
|
| 5 | „stimmt nicht" auf der Detailseite | von Hand |
|
||||||
|
|
||||||
|
## Prüfliste
|
||||||
|
|
||||||
|
- **V.1** Ein Film mit schwarzem Abspann bekommt eine Marke, und sie liegt
|
||||||
|
innerhalb von fünf Sekunden an der richtigen Stelle.
|
||||||
|
- **V.2** Ein Film mit Abspann über laufendem Bild bekommt **keine** Marke.
|
||||||
|
- **V.3** Ein Film, der im Dunkeln endet, ohne Abspann, bekommt keine Marke.
|
||||||
|
- **V.4** Der Lauf über hundert Filme belastet die Platte nicht länger als
|
||||||
|
der Scan selbst.
|
||||||
|
- **V.5** Der Überspringen-Knopf erscheint bei einem Film genauso wie bei
|
||||||
|
einer Folge.
|
||||||
|
- **V.6** Mit erkanntem Abspann gilt ein Film als gesehen, sobald er anfängt
|
||||||
|
— dieselbe Regel wie bei Folgen, sie steht schon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
- [intro-skipper auf GitHub](https://github.com/intro-skipper/intro-skipper)
|
||||||
|
- [Intro Skipper, README](https://cdn.jsdelivr.net/gh/intro-skipper/intro-skipper@10.11/README.md)
|
||||||
|
- [Forenbeitrag zu Abspann-Erkennung](https://forum.jellyfin.org/t-how-to-skip-ending-credits-with-confusedpolarbear-intro-skipper)
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# Flaro auf dem NAS einrichten
|
||||||
|
|
||||||
|
Für ein UGREEN-NAS mit der mitgelieferten Docker-App, und für jedes andere
|
||||||
|
System, auf dem Docker Compose läuft.
|
||||||
|
|
||||||
|
## Die kurze Antwort auf „ein Image oder mehrere?"
|
||||||
|
|
||||||
|
**Ein einziges.** Die Weboberfläche wird beim Bauen mit hineingelegt und vom
|
||||||
|
Server selbst ausgeliefert; es gibt keinen zweiten Container, keine Datenbank
|
||||||
|
daneben, keinen Proxy davor. Ein Container, drei Volumes, fertig.
|
||||||
|
|
||||||
|
```
|
||||||
|
flaro:<fassung> 231 MB
|
||||||
|
├── .NET-Laufzeit + Flaro
|
||||||
|
├── wwwroot/ die Weboberfläche
|
||||||
|
└── ffmpeg + VAAPI-Treiber
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 1: Die Architektur bestimmen
|
||||||
|
|
||||||
|
**Das ist der Schritt, den man am leichtesten überspringt und am teuersten
|
||||||
|
bezahlt.** Ein Image für `arm64` läuft auf einem Intel-NAS nicht, und die
|
||||||
|
Fehlermeldung (`exec format error`) sagt das nicht deutlich.
|
||||||
|
|
||||||
|
Auf dem NAS, über SSH oder die Konsole der Docker-App:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uname -m
|
||||||
|
```
|
||||||
|
|
||||||
|
| Ausgabe | Architektur | Was du bauen musst |
|
||||||
|
|---|---|---|
|
||||||
|
| `x86_64` | amd64 | `--platform linux/amd64` |
|
||||||
|
| `aarch64` | arm64 | `--platform linux/arm64` |
|
||||||
|
|
||||||
|
Die meisten UGREEN-Geräte mit Intel-Prozessor (N100, i5) melden `x86_64`.
|
||||||
|
Ein Mac baut ohne Angabe **arm64** — also gehört die Angabe dazu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 2: Das Image auf das NAS bringen
|
||||||
|
|
||||||
|
**Das NAS baut nicht, es lädt nur.** Genau wie bei Jellyfin oder jedem
|
||||||
|
anderen Container: dort baut jemand anderes und veröffentlicht, du ziehst
|
||||||
|
das Ergebnis. Bei Flaro ist dieser Jemand dein Arbeitsrechner — er ist für
|
||||||
|
die Apps ohnehin nötig, weil Xcode nirgendwo sonst läuft.
|
||||||
|
|
||||||
|
Drei Wege. Der erste ist der vorgesehene; der zweite braucht keine Registry.
|
||||||
|
|
||||||
|
### Weg A — über die Registry deines Gitea (empfohlen)
|
||||||
|
|
||||||
|
Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit.
|
||||||
|
Einmal einrichten, danach ist jedes Update ein `docker compose pull`.
|
||||||
|
|
||||||
|
#### A1 · Anmelden, einmalig
|
||||||
|
|
||||||
|
Nicht mit dem Kontopasswort, sondern mit einem Zugriffstoken: *Gitea →
|
||||||
|
Einstellungen → Anwendungen → Zugriffstoken*, Berechtigungen
|
||||||
|
`write:package` und `read:package`. Ein Token lässt sich einzeln widerrufen,
|
||||||
|
und bei eingeschalteter Zwei-Faktor-Anmeldung funktioniert das Passwort hier
|
||||||
|
gar nicht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.scarriffle.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Benutzername ist dein Gitea-Name, als Passwort das Token.
|
||||||
|
|
||||||
|
#### A2 · Bauen und hochladen
|
||||||
|
|
||||||
|
Ein Befehl; er baut amd64 **und** arm64 und lädt beides als ein Manifest
|
||||||
|
hoch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Tools/image-veroeffentlichen.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit einer Fassungsnummer, wenn du sie festnageln willst:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Tools/image-veroeffentlichen.sh 0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur die Architektur des NAS — deutlich schneller, weil die fremde sonst
|
||||||
|
unter Emulation gebaut wird:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLARO_PLATTFORMEN=linux/amd64 Tools/image-veroeffentlichen.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A3 · Das Paket öffentlich stellen
|
||||||
|
|
||||||
|
Sonst muss sich auch das NAS anmelden. In Gitea unter
|
||||||
|
*dein Profil → Pakete → flaro → Einstellungen* auf öffentlich stellen. Der
|
||||||
|
Quelltext ist ohnehin offen; das Image enthält nichts, was er nicht auch
|
||||||
|
enthält.
|
||||||
|
|
||||||
|
#### A4 · Die Registry im NAS eintragen
|
||||||
|
|
||||||
|
In der Docker-App des UGREEN: **Repository → Zahnrad → Quellname des
|
||||||
|
Images → Hinzufügen**. Dort stehen schon DockerHub und `ghcr.io`; daneben
|
||||||
|
kommt:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Alias | `gitea` |
|
||||||
|
| Link | `https://git.scarriffle.com/` |
|
||||||
|
|
||||||
|
**Bestätigen** — danach findet die Suche in der Docker-App auch
|
||||||
|
`scarriffle/flaro`. Ist das Paket privat, trägst du unter
|
||||||
|
*Registierungseinstellungen* Benutzername und dasselbe Zugriffstoken ein.
|
||||||
|
|
||||||
|
#### A5 · Das Image herunterladen, bevor du das Projekt anlegst
|
||||||
|
|
||||||
|
**Die Docker-App zieht Images nicht selbst.** Steht in einer Compose-Datei
|
||||||
|
ein Image, das lokal fehlt, bricht sie das Anlegen mit „Bitte überprüfen Sie
|
||||||
|
die Konfigurationsinformationen" ab — ohne zu sagen, woran es liegt. Also
|
||||||
|
vorher **Image → Herunterladen**, Registry `gitea`, Quellname
|
||||||
|
`scarriffle/flaro:latest`.
|
||||||
|
|
||||||
|
In der Compose-Datei steht dann:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image: git.scarriffle.com/scarriffle/flaro:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Weg B — als Datei hochladen
|
||||||
|
|
||||||
|
Ohne Registry, dafür bei jedem Update von Hand.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-f server/Dockerfile -t flaro:latest --load .
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker save flaro:latest | gzip > flaro.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ergibt rund **230 MB** — gemessen, nicht geschätzt. Das `gzip` bringt
|
||||||
|
dabei fast nichts: die Ebenen eines Docker-Images sind bereits gepackt.
|
||||||
|
Die Datei über die Dateiverwaltung auf das NAS legen, dann dort:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker load < /volume1/docker/flaro.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
In der Compose-Datei steht dann schlicht `image: flaro:latest`.
|
||||||
|
|
||||||
|
### Weg C — Gitea baut selbst
|
||||||
|
|
||||||
|
`.gitea/workflows/image.yml` liegt im Projekt und kann denselben Bau auf
|
||||||
|
einem Act-Runner ausführen. **Gitea verteilt Arbeit nur, es baut nicht
|
||||||
|
selbst** — ohne registrierten Runner passiert nichts.
|
||||||
|
|
||||||
|
Der Runner müsste auf dem NAS laufen, und daran scheitert es dort: er
|
||||||
|
braucht Zugriff auf Docker, denn sein Zweck ist das Bauen von Images. Die
|
||||||
|
Docker-App des UGREEN lehnt den Einhängepunkt `/var/run/docker.sock` ab —
|
||||||
|
geprüft, indem die Compose-Datei Zeile für Zeile aufgebaut wurde: ohne diese
|
||||||
|
eine Zeile wird das Projekt angelegt, mit ihr nicht. Übrig bleiben
|
||||||
|
`privileged: true` mit einem Docker-in-Docker-Image oder der Weg über SSH an
|
||||||
|
der Oberfläche vorbei. Beide geben dem Runner faktisch Root auf dem NAS.
|
||||||
|
|
||||||
|
Deshalb löst der Workflow **nicht** mehr bei jedem Push aus — sonst sammeln
|
||||||
|
sich Läufe an, die nie jemand abarbeitet. Er steht auf `workflow_dispatch`
|
||||||
|
und lässt sich in Gitea von Hand starten, sobald einmal ein Runner da ist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 3: Die Compose-Datei
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
flaro:
|
||||||
|
image: git.scarriffle.com/scarriffle/flaro:latest
|
||||||
|
container_name: flaro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "8096:8080"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Zurich
|
||||||
|
# Schreibt Plakate und NFO neben die Mediendateien. Ohne das bleibt
|
||||||
|
# der Medienordner unangetastet.
|
||||||
|
- FLARO_WRITE_SIDECARS=true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Datenbank und Konfiguration. **Das ist das Volume, das du sichern
|
||||||
|
# musst** — darin stehen Benutzer, Zuordnungen und Sehfortschritt.
|
||||||
|
- /volume1/docker/flaro/config:/config
|
||||||
|
# Heruntergeladene Plakate. Verlierbar: wird neu geholt.
|
||||||
|
- /volume1/docker/flaro/cache:/cache
|
||||||
|
# Umgepackte Dateien. Ebenfalls verlierbar, kann aber gross werden.
|
||||||
|
- /volume1/docker/flaro/transcode:/transcode
|
||||||
|
|
||||||
|
# Die Medien. Links der Pfad auf dem NAS, rechts der im Container —
|
||||||
|
# und **rechts** ist der, den du in Flaro einträgst.
|
||||||
|
- /volume1/Medien/Filme:/media/Filme
|
||||||
|
- /volume1/Medien/Serien:/media/Serien
|
||||||
|
|
||||||
|
# Hardware-Beschleunigung. Weglassen, wenn das NAS keine hat.
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rechte
|
||||||
|
|
||||||
|
Der Container läuft als Benutzer `1000:1000` — der erste Benutzer auf fast
|
||||||
|
jedem Linux und auf den üblichen NAS-Geräten. Gehören deine Ordner jemand
|
||||||
|
anderem, setz es ausdrücklich:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
user: "1001:100"
|
||||||
|
```
|
||||||
|
|
||||||
|
Welche Kennung gilt, sagt auf dem NAS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Der Port
|
||||||
|
|
||||||
|
`8096` ist frei gewählt. Läuft dort schon Jellyfin, nimm etwas anderes —
|
||||||
|
links die Zahl, unter der du Flaro erreichst, rechts bleibt `8080`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schritt 4: Starten und einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann im Browser `http://<nas>:8096` öffnen. Es erscheint der
|
||||||
|
Einrichtungsassistent:
|
||||||
|
|
||||||
|
1. **Administratorkonto** anlegen — Benutzername und Passwort, mindestens
|
||||||
|
acht Zeichen.
|
||||||
|
2. Unter **Verwaltung → Mediatheken** eine Mediathek hinzufügen. Beim Ordner
|
||||||
|
gehört der Pfad **im Container** hinein, also `/media/Filme`, nicht
|
||||||
|
`/volume1/Medien/Filme`.
|
||||||
|
3. **Scannen.** Der Scan liest die Dateien, danach holt Flaro die Metadaten.
|
||||||
|
4. Unter **Verwaltung → Server → Metadaten** die Sprache einstellen, etwa
|
||||||
|
`de-DE`. Danach einmal **Metadaten auffrischen** — vorhandene Titel
|
||||||
|
ändern sich sonst nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sichern
|
||||||
|
|
||||||
|
Was unersetzlich ist, liegt in **einem** Verzeichnis:
|
||||||
|
|
||||||
|
```
|
||||||
|
/volume1/docker/flaro/config/
|
||||||
|
```
|
||||||
|
|
||||||
|
Darin: die SQLite-Datenbank mit Benutzern, Zuordnungen, Sehfortschritt und
|
||||||
|
Einstellungen. Eine Kopie dieser Datei ist die ganze Sicherung — kein
|
||||||
|
Datenbank-Dump, kein Export.
|
||||||
|
|
||||||
|
`cache` und `transcode` dürfen weg; beides baut sich neu auf.
|
||||||
|
|
||||||
|
**Die Mediendateien fasst Flaro nicht an.** Es liest sie, und es legt —
|
||||||
|
sofern `FLARO_WRITE_SIDECARS=true` gesetzt ist — Plakate und NFO-Dateien
|
||||||
|
daneben. Umbenannt oder gelöscht wird nie etwas; es gibt im ganzen Programm
|
||||||
|
keine Stelle, die das könnte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aktualisieren
|
||||||
|
|
||||||
|
**Zwei Schritte, zwei Maschinen.** Erst baut der Arbeitsrechner die neue
|
||||||
|
Fassung und legt sie in die Registry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Tools/image-veroeffentlichen.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann holt das NAS sie. In der Docker-App genügt das Aktualisieren des
|
||||||
|
Projekts; über die Kommandozeile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrationen der Datenbank laufen beim Start von selbst. Ein Rückschritt auf
|
||||||
|
eine ältere Fassung ist **nicht** vorgesehen — vor einem Sprung lohnt eine
|
||||||
|
Kopie von `config/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wenn etwas nicht geht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f flaro
|
||||||
|
```
|
||||||
|
|
||||||
|
| Zeichen | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| `exec format error` | falsche Architektur — siehe Schritt 1 |
|
||||||
|
| Mediathek bleibt leer | der Pfad in Flaro ist der des NAS statt der im Container |
|
||||||
|
| „Zugriff verweigert" im Protokoll | Rechte, siehe `user:` oben |
|
||||||
|
| Keine Plakate, Titel wie Dateinamen | kein TMDB-Zugang oder kein Netz |
|
||||||
|
| Hardware-Beschleunigung greift nicht | `/dev/dri` nicht durchgereicht |
|
||||||
|
|
||||||
|
Ein Gesundheitsendpunkt ohne Anmeldung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://<nas>:8096/health
|
||||||
|
```
|
||||||
@@ -57,6 +57,8 @@ targets:
|
|||||||
# zweiter dazu, steht er daneben.
|
# zweiter dazu, steht er daneben.
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
product: JellyfinKit
|
product: JellyfinKit
|
||||||
|
- package: FlaroKit
|
||||||
|
product: FlaroServerKit
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
product: FlaroDesign
|
product: FlaroDesign
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
@@ -188,6 +190,8 @@ targets:
|
|||||||
product: MediaKit
|
product: MediaKit
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
product: JellyfinKit
|
product: JellyfinKit
|
||||||
|
- package: FlaroKit
|
||||||
|
product: FlaroServerKit
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
product: FlaroDesign
|
product: FlaroDesign
|
||||||
- package: FlaroKit
|
- package: FlaroKit
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<key id="15706be2-d1ec-4fa7-918b-920e87c4acce" version="1">
|
||||||
|
<creationDate>2026-10-07T19:35:00.68989Z</creationDate>
|
||||||
|
<activationDate>2026-10-07T19:35:00.68989Z</activationDate>
|
||||||
|
<expirationDate>2027-01-05T19:35:00.68989Z</expirationDate>
|
||||||
|
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=10.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
|
||||||
|
<descriptor>
|
||||||
|
<encryption algorithm="AES_256_CBC" />
|
||||||
|
<validation algorithm="HMACSHA256" />
|
||||||
|
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
|
||||||
|
<!-- Warning: the key below is in an unencrypted form. -->
|
||||||
|
<value>oyFqIl0Q27Th3X+CX07TX95s7kMGEQ2OkjOUJEJVkgK7LeTccP4tp3FeJnMzLSuu7rsKVIJa4xINONp+0AOAWg==</value>
|
||||||
|
</masterKey>
|
||||||
|
</descriptor>
|
||||||
|
</descriptor>
|
||||||
|
</key>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
21:35:00 INFO Server gestartet — 1.0.0 · DEV
|
||||||
|
21:55:29 INFO Server fährt herunter.
|
||||||
|
22:00:35 INFO Server gestartet — 1.0.0 · DEV
|
||||||
|
22:11:06 INFO Server fährt herunter.
|
||||||
|
22:14:53 INFO Server gestartet — 1.0.0 · DEV
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
06:38:47 INFO Server fährt herunter.
|
||||||
|
06:44:02 INFO Server gestartet — 1.0.0 · DEV
|
||||||
|
06:57:23 INFO Server fährt herunter.
|
||||||
|
06:59:45 INFO Server gestartet — 1.0.0 · DEV
|
||||||
|
17:38:34 INFO Server fährt herunter.
|
||||||
|
17:57:11 INFO Server gestartet — 1.0.0 · DEV
|
||||||
|
19:05:40 INFO Server fährt herunter.
|
||||||
|
19:08:22 INFO Server gestartet — 1.0.0 · DEV
|
||||||
|
21:03:32 INFO Server fährt herunter.
|
||||||
|
21:08:04 INFO Server gestartet — 1.0.0 · DEV
|
||||||
|
21:10:04 INFO Wartung — Sicherung: flaro-2026-10-08-1910.db, 0 MB
|
||||||
|
21:10:04 INFO Wartung — Optimieren: nichts zusammenzupacken
|
||||||
|
21:10:04 INFO Wartung — Bilder: kein Bildspeicher da
|
||||||
|
21:45:40 INFO Server fährt herunter.
|
||||||
|
21:45:44 INFO Server gestartet — 1.0.0 · DEV
|
||||||
|
22:06:03 INFO Server fährt herunter.
|
||||||
|
22:12:27 INFO Server gestartet — 1.0.0 · DEV
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
21:17:00 INFO Wartung — Sicherung: flaro-2026-10-11-1917.db, 0 MB
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
21:26:01 INFO Wartung — Sicherung: flaro-2026-10-14-1926.db, 0 MB
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
21:10:21 INFO Wartung — Optimieren: nichts zusammenzupacken
|
||||||
|
21:10:21 INFO Wartung — Bilder: kein Bildspeicher da
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +0,0 @@
|
|||||||
**/bin/
|
|
||||||
**/obj/
|
|
||||||
**/*.user
|
|
||||||
data/
|
|
||||||
cache/
|
|
||||||
transcode/
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Eine kaputte Protokollvorlage ist kein Schönheitsfehler.
|
||||||
|
|
||||||
|
`log.LogInformation("… {Folgen} … {Folgen}.", a, b, c, d, e)` wirft zur
|
||||||
|
Laufzeit — jeder Platzhalter verbraucht ein Argument, auch ein
|
||||||
|
wiederholter. Geschieht das in einem Hintergrundlauf, reisst die
|
||||||
|
Ausnahme den ganzen Lauf mit, und alles, was er bis dahin gefunden
|
||||||
|
hatte, ist verloren. Genau so ist es der Markenerkennung ergangen.
|
||||||
|
|
||||||
|
CA2017 hatte das längst gemeldet — als Warnung unter hundert anderen
|
||||||
|
Zeilen, und damit übersehen. Als Fehler ist es nicht zu übersehen.
|
||||||
|
|
||||||
|
Nur diese eine Regel, nicht alle Warnungen: ein pauschales
|
||||||
|
`TreatWarningsAsErrors` wäre eine andere Entscheidung und gehört nicht
|
||||||
|
nebenbei getroffen.
|
||||||
|
-->
|
||||||
|
<PropertyGroup>
|
||||||
|
<WarningsAsErrors>$(WarningsAsErrors);CA2017</WarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user