Compare commits
199
Commits
4b9a1e7b67
..
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
|
||||||
+10
-3
@@ -9,8 +9,15 @@ xcuserdata/
|
|||||||
# Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine
|
# Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine
|
||||||
# Konflikte in der pbxproj gibt. Nach dem Klonen: xcodegen generate
|
# Konflikte in der pbxproj gibt. Nach dem Klonen: xcodegen generate
|
||||||
#
|
#
|
||||||
# Halo.xcworkspace daneben ist dagegen von Hand geschrieben und gehört dazu:
|
# Flaro.xcworkspace daneben ist dagegen von Hand geschrieben und gehört dazu:
|
||||||
# er verweist nur auf das Projekt und überlebt jedes Neuerzeugen. Warum das
|
# er verweist nur auf das Projekt und überlebt jedes Neuerzeugen. Warum das
|
||||||
# nötig ist, steht in Halo.xcworkspace/README.md.
|
# nötig ist, steht in Flaro.xcworkspace/README.md.
|
||||||
Halo.xcodeproj/
|
Flaro.xcodeproj/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# .NET
|
||||||
|
server/**/bin/
|
||||||
|
server/**/obj/
|
||||||
|
server/**/data/
|
||||||
|
server/**/cache/
|
||||||
|
server/**/transcode/
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import ActivityKit
|
import ActivityKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
|
|
||||||
/// Der Download-Fortschritt im Sperrbildschirm und in der Dynamic Island.
|
/// Der Download-Fortschritt im Sperrbildschirm und in der Dynamic Island.
|
||||||
struct DownloadLiveActivity: Widget {
|
struct DownloadLiveActivity: Widget {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Halo Downloads</string>
|
<string>Flaro Downloads</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Diesen Arbeitsbereich öffnen, nicht das Projekt
|
# Diesen Arbeitsbereich öffnen, nicht das Projekt
|
||||||
|
|
||||||
`Halo.xcodeproj` wird von XcodeGen aus `project.yml` erzeugt — und dabei
|
`Flaro.xcodeproj` wird von XcodeGen aus `project.yml` erzeugt — und dabei
|
||||||
**ersetzt**, nicht bearbeitet: jede Datei darin bekommt einen neuen Inode,
|
**ersetzt**, nicht bearbeitet: jede Datei darin bekommt einen neuen Inode,
|
||||||
auch die, deren Inhalt gleich bleibt. Hat Xcode den Arbeitsbereich **im**
|
auch die, deren Inhalt gleich bleibt. Hat Xcode den Arbeitsbereich **im**
|
||||||
Projekt offen (`Halo.xcodeproj/project.xcworkspace`), meldet es danach
|
Projekt offen (`Flaro.xcodeproj/project.xcworkspace`), meldet es danach
|
||||||
|
|
||||||
> The workspace file … has disappeared. Do you want to re-save the container,
|
> The workspace file … has disappeared. Do you want to re-save the container,
|
||||||
> or close it?
|
> or close it?
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Workspace version = "1.0">
|
<Workspace version = "1.0">
|
||||||
<FileRef location = "group:Halo.xcodeproj"></FileRef>
|
<FileRef location = "group:Flaro.xcodeproj"></FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
@@ -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 |
@@ -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
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,21 +9,21 @@ import Foundation
|
|||||||
///
|
///
|
||||||
/// Der Name steht noch nicht fest. Damit ein späterer Wechsel nicht zur Suche
|
/// Der Name steht noch nicht fest. Damit ein späterer Wechsel nicht zur Suche
|
||||||
/// quer durch den Quelltext wird, greift die App nirgends auf ein hart
|
/// quer durch den Quelltext wird, greift die App nirgends auf ein hart
|
||||||
/// geschriebenes „Halo" zu, sondern immer hierauf. Zu ändern sind dann genau
|
/// geschriebenes „Flaro" zu, sondern immer hierauf. Zu ändern sind dann genau
|
||||||
/// zwei Dinge: diese Datei und `project.yml` (Produktname, Bundle-Kennung,
|
/// zwei Dinge: diese Datei und `project.yml` (Produktname, Bundle-Kennung,
|
||||||
/// Anzeigename).
|
/// Anzeigename).
|
||||||
///
|
///
|
||||||
/// Die Typen aus HaloKit behalten ihre Namen — sie sind Bauteile, keine Marke.
|
/// Die Typen aus FlaroKit behalten ihre Namen — sie sind Bauteile, keine Marke.
|
||||||
enum Branding {
|
enum Branding {
|
||||||
/// Wie die App sich selbst nennt.
|
/// Wie die App sich selbst nennt.
|
||||||
static let appName = "Halo"
|
static let appName = "Flaro"
|
||||||
|
|
||||||
/// Unter welchem Namen die Tokens im Schlüsselbund liegen.
|
/// Unter welchem Namen die Tokens im Schlüsselbund liegen.
|
||||||
///
|
///
|
||||||
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
|
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
|
||||||
/// Umbenennung der App soll niemanden abmelden.
|
/// Umbenennung der App soll niemanden abmelden.
|
||||||
static var keychainService: String {
|
static var keychainService: String {
|
||||||
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.halo"
|
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flaro"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wann dieser Build entstanden ist.
|
/// Wann dieser Build entstanden ist.
|
||||||
@@ -3,14 +3,15 @@ import SwiftUI
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct HaloApp: App {
|
struct FlaroApp: App {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von
|
/// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von
|
||||||
/// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen
|
/// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen
|
||||||
@@ -23,8 +24,8 @@ struct HaloApp: App {
|
|||||||
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
|
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
|
||||||
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
|
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
|
||||||
/// Downloads zurücksetzen.
|
/// Downloads zurücksetzen.
|
||||||
@State private var theme = HaloTheme()
|
@State private var theme = FlaroTheme()
|
||||||
@State private var session: HaloSession
|
@State private var session: FlaroSession
|
||||||
@State private var playback = PlaybackCoordinator()
|
@State private var playback = PlaybackCoordinator()
|
||||||
@State private var playbackSettings = PlaybackSettings()
|
@State private var playbackSettings = PlaybackSettings()
|
||||||
@State private var languages = LanguagePreferences()
|
@State private var languages = LanguagePreferences()
|
||||||
@@ -35,16 +36,20 @@ struct HaloApp: 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
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
_session = State(initialValue: HaloSession(
|
_session = State(initialValue: FlaroSession(
|
||||||
jellyfinIdentity: JellyfinIdentity.current(
|
jellyfinIdentity: JellyfinIdentity.current(
|
||||||
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)
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -74,8 +79,8 @@ struct HaloApp: App {
|
|||||||
// Notizzettelgröße, und darin fällt der Aufmacher in sich
|
// Notizzettelgröße, und darin fällt der Aufmacher in sich
|
||||||
// zusammen, weil seine Abstände dehnbar sind.
|
// zusammen, weil seine Abstände dehnbar sind.
|
||||||
.frame(
|
.frame(
|
||||||
minWidth: HaloPlatform.isMac ? 900 : nil,
|
minWidth: FlaroPlatform.isMac ? 900 : nil,
|
||||||
minHeight: HaloPlatform.isMac ? 620 : nil
|
minHeight: FlaroPlatform.isMac ? 620 : nil
|
||||||
)
|
)
|
||||||
// `defaultSize` gibt es hier nicht; die Größe hängt an der
|
// `defaultSize` gibt es hier nicht; die Größe hängt an der
|
||||||
// Szene.
|
// Szene.
|
||||||
@@ -90,6 +95,7 @@ struct HaloApp: 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 HaloApp: 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)
|
||||||
@@ -133,7 +141,7 @@ struct HaloApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
extension HaloApp {
|
extension FlaroApp {
|
||||||
/// Gibt dem Fenster eine vernünftige Mindestgröße.
|
/// Gibt dem Fenster eine vernünftige Mindestgröße.
|
||||||
///
|
///
|
||||||
/// Auf dem Mac öffnet das System sonst ein Fenster von der Größe eines
|
/// Auf dem Mac öffnet das System sonst ein Fenster von der Größe eines
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Halo</string>
|
<string>Flaro</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Halo benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen und Medien direkt von ihm zu laden.</string>
|
<string>Flaro benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen und Medien direkt von ihm zu laden.</string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
@@ -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",
|
||||||
@@ -5647,13 +5866,13 @@
|
|||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "Die Obergrenze, nicht das Ziel. Im eigenen Netz überträgt der Server das Original; unterwegs beginnt Halo kleiner und tastet sich hinauf, solange die Verbindung es trägt — höchstens bis zu dieser Stufe. Auflösung und Bitrate gehen dabei zusammen."
|
"value": "Die Obergrenze, nicht das Ziel. Im eigenen Netz überträgt der Server das Original; unterwegs beginnt Flaro kleiner und tastet sich hinauf, solange die Verbindung es trägt — höchstens bis zu dieser Stufe. Auflösung und Bitrate gehen dabei zusammen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"en": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"state": "translated",
|
||||||
"value": "An upper limit, not a target. On your own network the server sends the original; away from home Halo starts lower and works its way up as long as the connection holds — never above this step. Resolution and bitrate move together."
|
"value": "An upper limit, not a target. On your own network the server sends the original; away from home Flaro starts lower and works its way up as long as the connection holds — never above this step. Resolution and bitrate move together."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
|
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Übersetzt Titel vom Server in Download-Aufträge.
|
/// Übersetzt Titel vom Server in Download-Aufträge.
|
||||||
@@ -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
|
||||||
@@ -15,7 +16,7 @@ import Observation
|
|||||||
/// Umschalten ja, nebeneinander nein.
|
/// Umschalten ja, nebeneinander nein.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class HaloSession {
|
final class FlaroSession {
|
||||||
enum Phase: Equatable {
|
enum Phase: Equatable {
|
||||||
/// Beim Start: liegt ein Token im Schlüsselbund?
|
/// Beim Start: liegt ein Token im Schlüsselbund?
|
||||||
case launching
|
case launching
|
||||||
@@ -31,12 +32,41 @@ final class HaloSession {
|
|||||||
// 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 {
|
||||||
static let jellyfinAddress = "halo.jellyfin.address"
|
/// Welche Art zuletzt angemeldet war.
|
||||||
static let jellyfinUserID = "halo.jellyfin.userID"
|
///
|
||||||
static let jellyfinUserName = "halo.jellyfin.userName"
|
/// Nicht `flaro.backend.kind`: so hiess der Schlüssel zu Plex-Zeiten,
|
||||||
static let jellyfinServerName = "halo.jellyfin.serverName"
|
/// und der steht in der Aufräumliste weiter unten. Denselben Namen
|
||||||
static let jellyfinIsAdministrator = "halo.jellyfin.isAdministrator"
|
/// 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 jellyfinUserID = "flaro.jellyfin.userID"
|
||||||
|
static let jellyfinUserName = "flaro.jellyfin.userName"
|
||||||
|
static let jellyfinServerName = "flaro.jellyfin.serverName"
|
||||||
|
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 HaloSession {
|
|||||||
/// 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.
|
||||||
///
|
///
|
||||||
@@ -55,14 +86,14 @@ final class HaloSession {
|
|||||||
/// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
|
/// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
|
||||||
/// verschwinden.
|
/// verschwinden.
|
||||||
private enum Altlast {
|
private enum Altlast {
|
||||||
static let merker = "halo.migration.plexPurged"
|
static let merker = "flaro.migration.plexPurged"
|
||||||
static let tokenKonten = ["plex.account", "plex.server"]
|
static let tokenKonten = ["plex.account", "plex.server"]
|
||||||
static let schlüssel = [
|
static let schlüssel = [
|
||||||
"halo.backend.kind",
|
"flaro.backend.kind",
|
||||||
"halo.server.clientIdentifier",
|
"flaro.server.clientIdentifier",
|
||||||
"halo.server.connectionURI",
|
"flaro.server.connectionURI",
|
||||||
"halo.server.name",
|
"flaro.server.name",
|
||||||
"halo.server.connectionIsLocal",
|
"flaro.server.connectionIsLocal",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +121,26 @@ final class HaloSession {
|
|||||||
/// 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?
|
||||||
|
|
||||||
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
/// Das angemeldete Flaro-Konto, solange Flaro aktiv ist.
|
||||||
var accountName: String? { jellyfinAccount?.userName }
|
private(set) var flaroAccount: FlaroAccount?
|
||||||
|
|
||||||
/// Die Mediatheken, die Halo darstellen kann — Musik und Fotos
|
/// Mit welcher Art Server gerade gesprochen wird.
|
||||||
|
private(set) var activeKind: ServerKind?
|
||||||
|
|
||||||
|
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
|
||||||
|
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
|
||||||
/// erscheinen gar nicht erst.
|
/// erscheinen gar nicht erst.
|
||||||
private(set) var libraries: [MediaLibrary] = []
|
private(set) var libraries: [MediaLibrary] = []
|
||||||
|
|
||||||
@@ -101,7 +148,12 @@ final class HaloSession {
|
|||||||
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 HaloSession {
|
|||||||
|
|
||||||
/// 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 HaloSession {
|
|||||||
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 HaloSession {
|
|||||||
/// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,14 +301,14 @@ final class HaloSession {
|
|||||||
/// Meldet sich an der Attrappe aus `Tools/JellyfinStub` an.
|
/// Meldet sich an der Attrappe aus `Tools/JellyfinStub` an.
|
||||||
///
|
///
|
||||||
/// Nur in der Entwicklung und nur auf ausdrückliches Kommando:
|
/// Nur in der Entwicklung und nur auf ausdrückliches Kommando:
|
||||||
/// `-HaloStubServer http://127.0.0.1:8096` als Startargument. Der Weg
|
/// `-FlaroStubServer http://127.0.0.1:8096` als Startargument. Der Weg
|
||||||
/// führt bewusst **am Schlüsselbund vorbei** und schreibt nichts fort —
|
/// führt bewusst **am Schlüsselbund vorbei** und schreibt nichts fort —
|
||||||
/// so lässt sich die Oberfläche ansehen, prüfen und fotografieren, ohne
|
/// so lässt sich die Oberfläche ansehen, prüfen und fotografieren, ohne
|
||||||
/// dass je ein echter Server, ein echtes Konto oder ein echter Token im
|
/// dass je ein echter Server, ein echtes Konto oder ein echter Token im
|
||||||
/// Spiel wäre. Genau daran hat es bisher gefehlt: Aussagen über das
|
/// Spiel wäre. Genau daran hat es bisher gefehlt: Aussagen über das
|
||||||
/// Aussehen waren Vermutungen, weil niemand hinsehen konnte.
|
/// Aussehen waren Vermutungen, weil niemand hinsehen konnte.
|
||||||
private func adoptStubServerIfRequested() async -> Bool {
|
private func adoptStubServerIfRequested() async -> Bool {
|
||||||
guard let raw = defaults.string(forKey: "HaloStubServer"),
|
guard let raw = defaults.string(forKey: "FlaroStubServer"),
|
||||||
let baseURL = URL(string: raw)
|
let baseURL = URL(string: raw)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
@@ -276,14 +344,14 @@ final class HaloSession {
|
|||||||
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 HaloSession {
|
|||||||
|
|
||||||
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 HaloSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 HaloSession {
|
|||||||
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 HaloSession {
|
|||||||
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Woher der Aufmacher seine Bilder nimmt.
|
/// Woher der Aufmacher seine Bilder nimmt.
|
||||||
@@ -64,8 +64,8 @@ enum HeroArtwork {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
|
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
|
||||||
if isPortrait(placement, compact: compact) { return HaloAspectRatio.poster }
|
if isPortrait(placement, compact: compact) { return FlaroAspectRatio.poster }
|
||||||
return compact ? HaloAspectRatio.heroCompact : HaloAspectRatio.hero
|
return compact ? FlaroAspectRatio.heroCompact : FlaroAspectRatio.hero
|
||||||
}
|
}
|
||||||
|
|
||||||
static func backdropURL(
|
static func backdropURL(
|
||||||
@@ -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))
|
||||||
@@ -12,15 +12,15 @@ import MediaKit
|
|||||||
@Observable
|
@Observable
|
||||||
final class HomeLayoutSettings {
|
final class HomeLayoutSettings {
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let hidden = "halo.home.hiddenHubs"
|
static let hidden = "flaro.home.hiddenHubs"
|
||||||
static let order = "halo.home.hubOrder"
|
static let order = "flaro.home.hubOrder"
|
||||||
static let heroCount = "halo.home.heroCount"
|
static let heroCount = "flaro.home.heroCount"
|
||||||
static let heroSource = "halo.home.heroSource"
|
static let heroSource = "flaro.home.heroSource"
|
||||||
static let heroLibraries = "halo.home.heroLibraries"
|
static let heroLibraries = "flaro.home.heroLibraries"
|
||||||
static let combinesContinueRows = "halo.home.combinesContinueRows"
|
static let combinesContinueRows = "flaro.home.combinesContinueRows"
|
||||||
static let hidesWatchedInLatest = "halo.home.hidesWatchedInLatest"
|
static let hidesWatchedInLatest = "flaro.home.hidesWatchedInLatest"
|
||||||
static let cardScale = "halo.home.cardScale"
|
static let cardScale = "flaro.home.cardScale"
|
||||||
static let heroHeight = "halo.home.heroHeight"
|
static let heroHeight = "flaro.home.heroHeight"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Woraus sich der Aufmacher speist.
|
/// Woraus sich der Aufmacher speist.
|
||||||
@@ -5,14 +5,14 @@ import Observation
|
|||||||
|
|
||||||
/// Führt durch die Anmeldung an einem Jellyfin-Server.
|
/// Führt durch die Anmeldung an einem Jellyfin-Server.
|
||||||
///
|
///
|
||||||
/// Eigenes Modell statt eines weiteren Falls in `HaloSession.Phase`: die
|
/// Eigenes Modell statt eines weiteren Falls in `FlaroSession.Phase`: die
|
||||||
/// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse
|
/// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse
|
||||||
/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort
|
/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort
|
||||||
/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am
|
/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am
|
||||||
/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin.
|
/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin.
|
||||||
///
|
///
|
||||||
/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits
|
/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits
|
||||||
/// angemeldeten Client bestätigt; das Passwort erreicht Halo dabei nie —
|
/// angemeldeten Client bestätigt; das Passwort erreicht Flaro dabei nie —
|
||||||
/// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt.
|
/// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt.
|
||||||
/// Benutzername und Passwort sind der Rückfall für Server, auf denen Quick
|
/// Benutzername und Passwort sind der Rückfall für Server, auf denen Quick
|
||||||
/// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer
|
/// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand
|
/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand
|
||||||
/// gesagt hat.
|
/// gesagt hat.
|
||||||
@@ -28,13 +28,14 @@ final class LanguagePreferences {
|
|||||||
private enum Key {
|
private enum Key {
|
||||||
/// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der
|
/// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der
|
||||||
/// Besitzer gewechselt, nicht der Wert.
|
/// Besitzer gewechselt, nicht der Wert.
|
||||||
static let audio = "halo.playback.audioLanguage"
|
static let audio = "flaro.playback.audioLanguage"
|
||||||
static let subtitles = "halo.playback.subtitleLanguage"
|
static let subtitles = "flaro.playback.subtitleLanguage"
|
||||||
static let fallbacks = "halo.languages.fallbacks"
|
static let fallbacks = "flaro.languages.fallbacks"
|
||||||
static let avoidsAudioDescription = "halo.languages.avoidsAudioDescription"
|
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
|
||||||
static let perLibrary = "halo.languages.library"
|
static let perLibrary = "flaro.languages.library"
|
||||||
static let perSeries = "halo.languages.series"
|
static let perSeries = "flaro.languages.series"
|
||||||
static let rememberedTracks = "halo.languages.seriesTrack"
|
static let perSeriesSubtitles = "flaro.languages.seriesSubtitles"
|
||||||
|
static let rememberedTracks = "flaro.languages.seriesTrack"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Sprachen, die zur Auswahl stehen.
|
/// Die Sprachen, die zur Auswahl stehen.
|
||||||
@@ -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]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@ import MediaKit
|
|||||||
/// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild.
|
/// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild.
|
||||||
///
|
///
|
||||||
/// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den
|
/// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den
|
||||||
/// übrigen Modellen und nicht in HaloDesign, das `MediaItem` gar nicht kennt.
|
/// übrigen Modellen und nicht in FlaroDesign, das `MediaItem` gar nicht kennt.
|
||||||
extension MediaItem {
|
extension MediaItem {
|
||||||
/// Der Titel auf einer Kachel in einer Reihe.
|
/// Der Titel auf einer Kachel in einer Reihe.
|
||||||
///
|
///
|
||||||
+2
-2
@@ -13,8 +13,8 @@ import MediaKit
|
|||||||
@Observable
|
@Observable
|
||||||
final class NavigationLayoutSettings {
|
final class NavigationLayoutSettings {
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let hidden = "halo.navigation.hidden"
|
static let hidden = "flaro.navigation.hidden"
|
||||||
static let order = "halo.navigation.order"
|
static let order = "flaro.navigation.order"
|
||||||
}
|
}
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Hält fest, was beim letzten Abspielversuch passiert ist.
|
/// Hält fest, was beim letzten Abspielversuch passiert ist.
|
||||||
///
|
///
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Was der Nutzer über die Wiedergabe eingestellt hat.
|
/// Was der Nutzer über die Wiedergabe eingestellt hat.
|
||||||
///
|
///
|
||||||
@@ -28,19 +28,20 @@ final class PlaybackSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let wifiQuality = "halo.playback.quality.wifi"
|
static let wifiQuality = "flaro.playback.quality.wifi"
|
||||||
static let cellularQuality = "halo.playback.quality.cellular"
|
static let cellularQuality = "flaro.playback.quality.cellular"
|
||||||
static let autoSkipIntro = "halo.playback.autoSkipIntro"
|
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
|
||||||
static let autoSkipCredits = "halo.playback.autoSkipCredits"
|
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
|
||||||
static let autoPlayNext = "halo.playback.autoPlayNext"
|
static let skipExtraScenes = "flaro.playback.skipExtraScenes"
|
||||||
static let locksLandscape = "halo.playback.locksLandscape"
|
static let autoPlayNext = "flaro.playback.autoPlayNext"
|
||||||
static let rewindSeconds = "halo.playback.rewindSeconds"
|
static let locksLandscape = "flaro.playback.locksLandscape"
|
||||||
static let forwardSeconds = "halo.playback.forwardSeconds"
|
static let rewindSeconds = "flaro.playback.rewindSeconds"
|
||||||
static let defaultRate = "halo.playback.defaultRate"
|
static let forwardSeconds = "flaro.playback.forwardSeconds"
|
||||||
static let remembersQuality = "halo.playback.remembersQuality"
|
static let defaultRate = "flaro.playback.defaultRate"
|
||||||
static let rememberedQuality = "halo.playback.rememberedQuality"
|
static let remembersQuality = "flaro.playback.remembersQuality"
|
||||||
static let showsBitrate = "halo.playback.showsBitrate"
|
static let rememberedQuality = "flaro.playback.rememberedQuality"
|
||||||
static let lastPlaybackStruggled = "halo.playback.struggled"
|
static let showsBitrate = "flaro.playback.showsBitrate"
|
||||||
|
static let lastPlaybackStruggled = "flaro.playback.struggled"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Sprünge, die es zur Auswahl gibt.
|
/// Die Sprünge, die es zur Auswahl gibt.
|
||||||
@@ -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
|
||||||
@@ -15,7 +15,7 @@ import Observation
|
|||||||
final class SearchFeed {
|
final class SearchFeed {
|
||||||
/// Wie viele zuletzt gesuchte Begriffe gemerkt werden.
|
/// Wie viele zuletzt gesuchte Begriffe gemerkt werden.
|
||||||
private static let recentLimit = 8
|
private static let recentLimit = 8
|
||||||
private static let recentKey = "halo.search.recent"
|
private static let recentKey = "flaro.search.recent"
|
||||||
|
|
||||||
private(set) var hubs: [MediaRowGroup] = []
|
private(set) var hubs: [MediaRowGroup] = []
|
||||||
private(set) var isSearching = false
|
private(set) var isSearching = false
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg.
|
/// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg.
|
||||||
///
|
///
|
||||||
@@ -14,8 +14,8 @@ import HaloPlayback
|
|||||||
@Observable
|
@Observable
|
||||||
final class SubtitleAppearance {
|
final class SubtitleAppearance {
|
||||||
private enum Key {
|
private enum Key {
|
||||||
static let size = "halo.subtitles.size"
|
static let size = "flaro.subtitles.size"
|
||||||
static let palette = "halo.subtitles.palette"
|
static let palette = "flaro.subtitles.palette"
|
||||||
}
|
}
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe
|
/// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// **Die ganze Datei nur für den Fernseher.**
|
// **Die ganze Datei nur für den Fernseher.**
|
||||||
//
|
//
|
||||||
// Sie liegt im geteilten Ordner, weil `Halo/Session/` von beiden Zielen
|
// Sie liegt im geteilten Ordner, weil `Flaro/Session/` von beiden Zielen
|
||||||
// übersetzt wird — und `TVServices` gibt es auf iOS nicht. Ein `#if` um den
|
// übersetzt wird — und `TVServices` gibt es auf iOS nicht. Ein `#if` um den
|
||||||
// einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft
|
// einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft
|
||||||
// ihn trotzdem und bricht mit „Unable to resolve module dependency:
|
// ihn trotzdem und bricht mit „Unable to resolve module dependency:
|
||||||
@@ -21,7 +21,7 @@ import TVServices
|
|||||||
@MainActor
|
@MainActor
|
||||||
enum TopShelfWriter {
|
enum TopShelfWriter {
|
||||||
/// Die App-Gruppe, die App und Erweiterung teilen.
|
/// Die App-Gruppe, die App und Erweiterung teilen.
|
||||||
static let group = "group.com.scarriffleservices.halo"
|
static let group = "group.com.scarriffleservices.flaro"
|
||||||
|
|
||||||
/// Die Kante, in der das Regal Plakate zeigt.
|
/// Die Kante, in der das Regal Plakate zeigt.
|
||||||
///
|
///
|
||||||
@@ -61,7 +61,7 @@ enum TopShelfWriter {
|
|||||||
width: posterWidth,
|
width: posterWidth,
|
||||||
height: posterHeight
|
height: posterHeight
|
||||||
),
|
),
|
||||||
progress: item.watchProgress
|
progress: item.resumeProgress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
+2
-2
@@ -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: {
|
||||||
@@ -99,5 +99,5 @@ extension EnvironmentValues {
|
|||||||
///
|
///
|
||||||
/// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es
|
/// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es
|
||||||
/// auch nichts neu zu laden.
|
/// auch nichts neu zu laden.
|
||||||
@Entry var haloItemChanged: () async -> Void = {}
|
@Entry var flaroItemChanged: () async -> Void = {}
|
||||||
}
|
}
|
||||||
+55
-32
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
|
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
|
||||||
@@ -11,28 +11,32 @@ import MediaKit
|
|||||||
/// Zwei Schritte für eine Absicht.
|
/// Zwei Schritte für eine Absicht.
|
||||||
///
|
///
|
||||||
/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt
|
/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt
|
||||||
/// in `HaloDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es
|
/// in `FlaroDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es
|
||||||
/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der
|
/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der
|
||||||
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
|
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
|
||||||
/// 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(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(QuickPlay.self) private var quickPlay
|
@Environment(QuickPlay.self) private var quickPlay
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
@State private var isPointerInside = false
|
@State private var isPointerInside = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@@ -48,16 +52,20 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
|
|
||||||
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
|
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
|
||||||
/// hat. Siehe `ItemActionEntries`.
|
/// hat. Siehe `ItemActionEntries`.
|
||||||
@Environment(\.haloItemChanged) private var itemChanged
|
@Environment(\.flaroItemChanged) private var itemChanged
|
||||||
/// Der Weg auf die Detailseite — siehe `MediaStack`.
|
/// Der Weg auf die Detailseite — siehe `MediaStack`.
|
||||||
@Environment(\.haloPush) private var push
|
@Environment(\.flaroPush) private var push
|
||||||
|
|
||||||
private var isMac: Bool { HaloPlatform.isMac }
|
private var isMac: Bool { FlaroPlatform.isMac }
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -236,13 +245,27 @@ struct QuickPlayTile<Card: View>: View {
|
|||||||
.shadow(color: .black.opacity(0.35), radius: 3, y: 1)
|
.shadow(color: .black.opacity(0.35), radius: 3, y: 1)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
.opacity(isPointerInside ? 1 : 0)
|
.opacity(isPointerInside ? 1 : 0)
|
||||||
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
|
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
|
||||||
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
|
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
|
||||||
.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,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Das Aktionsmenü auf einer Detailseite.
|
/// Das Aktionsmenü auf einer Detailseite.
|
||||||
@@ -17,7 +17,7 @@ struct ItemActionsMenu: View {
|
|||||||
/// Detailseite neu geladen werden sollte.
|
/// Detailseite neu geladen werden sollte.
|
||||||
let onChange: () async -> Void
|
let onChange: () async -> Void
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@State private var isWorking = false
|
@State private var isWorking = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Die Detailseite eines Titels.
|
||||||
|
///
|
||||||
|
/// Holt die Metadaten frisch, statt den Eintrag aus der Liste weiterzureichen:
|
||||||
|
/// Einträge in Reihen und Rastern kommen ohne Dateiliste, ohne Besetzung und
|
||||||
|
/// ohne Marker. Für das Abspielen braucht es aber genau diese Angaben.
|
||||||
|
struct MediaDetailView: View {
|
||||||
|
let route: MediaRoute
|
||||||
|
|
||||||
|
@Environment(FlaroSession.self) private var session
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@State private var state: LoadState<MediaItem> = .loading
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch state {
|
||||||
|
case .loading:
|
||||||
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
case .failed(let message):
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||||
|
} description: {
|
||||||
|
Text(message)
|
||||||
|
} actions: {
|
||||||
|
Button("action.retry") { Task { await load() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
case .loaded(let item):
|
||||||
|
switch item.kind {
|
||||||
|
case .show, .season:
|
||||||
|
SeriesDetailView(item: item)
|
||||||
|
default:
|
||||||
|
PlayableDetailView(item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.flaroCanvas)
|
||||||
|
.navigationTitle(state.value?.title ?? route.title)
|
||||||
|
.flaroInlineTitle()
|
||||||
|
.toolbar {
|
||||||
|
if let item = state.value {
|
||||||
|
ToolbarItem(placement: .flaroTrailing) {
|
||||||
|
ItemActionsMenu(item: item) { 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 {
|
||||||
|
guard let server = session.backend else {
|
||||||
|
state = vomGeraet.map { .loaded($0) }
|
||||||
|
?? .failed(message: String(localized: "error.serverUnreachable"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = await LoadState.load {
|
||||||
|
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,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
|
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
|
||||||
@@ -15,7 +15,7 @@ struct ParentLinks: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
if !destinations.isEmpty {
|
if !destinations.isEmpty {
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
ForEach(destinations, id: \.route.ratingKey) { destination in
|
ForEach(destinations, id: \.route.ratingKey) { destination in
|
||||||
NavigationLink(value: destination.route) {
|
NavigationLink(value: destination.route) {
|
||||||
Label {
|
Label {
|
||||||
@@ -24,16 +24,16 @@ struct ParentLinks: View {
|
|||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: destination.symbol)
|
Image(systemName: destination.symbol)
|
||||||
}
|
}
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.background(Color.haloElevated, in: .capsule)
|
.background(Color.flaroElevated, in: .capsule)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
}
|
}
|
||||||
+136
-59
@@ -1,15 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
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.
|
||||||
struct PlayableDetailView: View {
|
struct PlayableDetailView: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
@State private var selectedVersion = 0
|
@State private var selectedVersion = 0
|
||||||
@@ -17,7 +18,7 @@ struct PlayableDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.section) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
header
|
header
|
||||||
|
|
||||||
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
|
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
|
||||||
@@ -29,14 +30,14 @@ struct PlayableDetailView: View {
|
|||||||
|
|
||||||
if item.hasMultipleVersions {
|
if item.hasMultipleVersions {
|
||||||
VersionPicker(media: item.versions, selection: $selectedVersion)
|
VersionPicker(media: item.versions, selection: $selectedVersion)
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let summary = item.summary, !summary.isEmpty {
|
if let summary = item.summary, !summary.isEmpty {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !item.genres.isEmpty {
|
if !item.genres.isEmpty {
|
||||||
@@ -53,13 +54,13 @@ struct PlayableDetailView: View {
|
|||||||
part: part,
|
part: part,
|
||||||
isExpanded: $showsTechnicalDetails
|
isExpanded: $showsTechnicalDetails
|
||||||
)
|
)
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, HaloSpacing.section)
|
.padding(.bottom, FlaroSpacing.section)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentMedia: MediaVersion? {
|
private var currentMedia: MediaVersion? {
|
||||||
@@ -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(.haloCardTitle)
|
|
||||||
.padding(.horizontal, HaloSpacing.loose)
|
|
||||||
.padding(.vertical, HaloSpacing.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(.haloCardTitle)
|
systemImage: "play.fill"
|
||||||
.padding(HaloSpacing.snug)
|
)
|
||||||
|
.font(.flaroCardTitle)
|
||||||
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(Color.haloTextSecondary)
|
.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,10 +287,10 @@ struct VersionPicker: View {
|
|||||||
@Binding var selection: Int
|
@Binding var selection: Int
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||||
Text("detail.versions")
|
Text("detail.versions")
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
Picker("detail.versions", selection: $selection) {
|
Picker("detail.versions", selection: $selection) {
|
||||||
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
|
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
|
||||||
@@ -232,17 +309,17 @@ struct TagStrip: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
ForEach(tags, id: \.self) { tag in
|
ForEach(tags, id: \.self) { tag in
|
||||||
Text(tag)
|
Text(tag)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.vertical, HaloSpacing.tight)
|
.padding(.vertical, FlaroSpacing.tight)
|
||||||
.background(Color.haloElevated, in: .capsule)
|
.background(Color.flaroElevated, in: .capsule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
}
|
}
|
||||||
@@ -251,20 +328,20 @@ struct TagStrip: View {
|
|||||||
/// Die Besetzung mit Porträts.
|
/// Die Besetzung mit Porträts.
|
||||||
struct CastRow: View {
|
struct CastRow: View {
|
||||||
let cast: [MediaPerson]
|
let cast: [MediaPerson]
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
Text("detail.cast")
|
Text("detail.cast")
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
HStack(alignment: .top, spacing: HaloSpacing.regular) {
|
HStack(alignment: .top, spacing: FlaroSpacing.regular) {
|
||||||
ForEach(cast.prefix(20)) { role in
|
ForEach(cast.prefix(20)) { role in
|
||||||
VStack(spacing: HaloSpacing.snug) {
|
VStack(spacing: FlaroSpacing.snug) {
|
||||||
HaloArtwork(
|
FlaroArtwork(
|
||||||
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
|
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
fallbackSymbol: "person"
|
fallbackSymbol: "person"
|
||||||
@@ -273,15 +350,15 @@ struct CastRow: View {
|
|||||||
.clipShape(.circle)
|
.clipShape(.circle)
|
||||||
|
|
||||||
Text(role.name)
|
Text(role.name)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
if let character = role.role {
|
if let character = role.role {
|
||||||
Text(character)
|
Text(character)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,7 +366,7 @@ struct CastRow: View {
|
|||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
}
|
}
|
||||||
+165
-51
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Detailseite einer Serie oder Staffel.
|
/// Die Detailseite einer Serie oder Staffel.
|
||||||
@@ -10,10 +10,10 @@ import MediaKit
|
|||||||
struct SeriesDetailView: View {
|
struct SeriesDetailView: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
@State private var children: LoadState<[MediaItem]> = .loading
|
@State private var children: LoadState<[MediaItem]> = .loading
|
||||||
|
|
||||||
@@ -33,18 +33,29 @@ 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: HaloSpacing.section) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
header
|
header
|
||||||
|
|
||||||
ParentLinks(item: item)
|
ParentLinks(item: item)
|
||||||
|
|
||||||
if let summary = item.summary, !summary.isEmpty {
|
if let summary = item.summary, !summary.isEmpty {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !item.genres.isEmpty { TagStrip(tags: item.genres) }
|
if !item.genres.isEmpty { TagStrip(tags: item.genres) }
|
||||||
@@ -54,21 +65,45 @@ 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",
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
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)
|
||||||
|
.task(id: item.id) {
|
||||||
|
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
childList
|
childList
|
||||||
|
|
||||||
if !item.cast.isEmpty { CastRow(cast: item.cast) }
|
if !item.cast.isEmpty { CastRow(cast: item.cast) }
|
||||||
}
|
}
|
||||||
.padding(.bottom, HaloSpacing.section)
|
.padding(.bottom, FlaroSpacing.section)
|
||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) { selectionBar }
|
.safeAreaInset(edge: .bottom, spacing: 0) { selectionBar }
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
Text("downloads.quality.title"),
|
Text("downloads.quality.title"),
|
||||||
@@ -163,40 +198,99 @@ struct SeriesDetailView: View {
|
|||||||
private var childList: some View {
|
private var childList: some View {
|
||||||
switch children {
|
switch children {
|
||||||
case .loading:
|
case .loading:
|
||||||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, HaloSpacing.section)
|
ProgressView().frame(maxWidth: .infinity).padding(.vertical, FlaroSpacing.section)
|
||||||
|
|
||||||
case .failed(let message):
|
case .failed(let message):
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
case .loaded(let entries):
|
case .loaded(let entries):
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
let nummern = staffelnummern(in: entries)
|
||||||
|
let sichtbar = sichtbareFolgen(in: entries, staffeln: nummern)
|
||||||
|
|
||||||
|
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(.haloRowHeader)
|
staffelwahl(nummern)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
} 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, HaloSpacing.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.haloSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
@@ -207,16 +301,16 @@ struct SeriesDetailView: View {
|
|||||||
Button {
|
Button {
|
||||||
if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) }
|
if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) }
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
ChildRow(item: entry)
|
ChildRow(item: entry)
|
||||||
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
|
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(gewaehlt ? accent : Color.haloTextMuted)
|
.foregroundStyle(gewaehlt ? accent : Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
|
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
|
||||||
} else {
|
} else {
|
||||||
// **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im
|
// **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im
|
||||||
@@ -228,7 +322,7 @@ struct SeriesDetailView: View {
|
|||||||
// Als Geschwister in einer Reihe statt als Überzug: dann teilt das
|
// Als Geschwister in einer Reihe statt als Überzug: dann teilt das
|
||||||
// Layout den Platz auf, und der Titel kann gar nicht erst unter
|
// Layout den Platz auf, und der Titel kann gar nicht erst unter
|
||||||
// dem Knopf verschwinden.
|
// dem Knopf verschwinden.
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
|
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
|
||||||
ChildRow(item: entry)
|
ChildRow(item: entry)
|
||||||
}
|
}
|
||||||
@@ -241,7 +335,7 @@ struct SeriesDetailView: View {
|
|||||||
DownloadButton(item: entry, loadsDetails: true)
|
DownloadButton(item: entry, loadsDetails: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +343,7 @@ struct SeriesDetailView: View {
|
|||||||
private func selectionControls(for entries: [MediaItem]) -> some View {
|
private func selectionControls(for entries: [MediaItem]) -> some View {
|
||||||
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
|
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
|
||||||
|
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
if isSelecting {
|
if isSelecting {
|
||||||
Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") {
|
Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") {
|
||||||
selection = selection.count == folgen.count ? [] : Set(folgen)
|
selection = selection.count == folgen.count ? [] : Set(folgen)
|
||||||
@@ -258,12 +352,12 @@ struct SeriesDetailView: View {
|
|||||||
isSelecting = false
|
isSelecting = false
|
||||||
selection = []
|
selection = []
|
||||||
}
|
}
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
} else {
|
} else {
|
||||||
Button("detail.select") { isSelecting = true }
|
Button("detail.select") { isSelecting = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
}
|
}
|
||||||
@@ -288,14 +382,14 @@ struct SeriesDetailView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.padding(.horizontal, HaloSpacing.loose)
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.disabled(isWorking)
|
.disabled(isWorking)
|
||||||
.padding(.vertical, HaloSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(.bar)
|
.background(.bar)
|
||||||
}
|
}
|
||||||
@@ -376,22 +470,42 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Staffel- oder Folgenzeile.
|
/// Eine Staffel- oder Folgenzeile.
|
||||||
private struct ChildRow: View {
|
private struct ChildRow: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
HaloArtwork(
|
FlaroArtwork(
|
||||||
url: session.backend?.artworkURL(
|
url: session.backend?.artworkURL(
|
||||||
path: item.thumb,
|
path: item.thumb,
|
||||||
width: item.kind == .season ? 120 : 240,
|
width: item.kind == .season ? 120 : 240,
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,14 +525,14 @@ private struct ChildRow: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(rowTitle)
|
Text(rowTitle)
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
|
|
||||||
if let subtitle {
|
if let subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,14 +541,14 @@ private struct ChildRow: View {
|
|||||||
|
|
||||||
if item.isWatched {
|
if item.isWatched {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.accessibilityLabel(Text("item.watched"))
|
.accessibilityLabel(Text("item.watched"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Das waagerechte Polster liegt jetzt eine Ebene höher, bei der
|
// Das waagerechte Polster liegt jetzt eine Ebene höher, bei der
|
||||||
// Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am
|
// Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am
|
||||||
// Bildschirmrand.
|
// Bildschirmrand.
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
+18
-18
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die aufklappbaren technischen Angaben zu einer Datei.
|
/// Die aufklappbaren technischen Angaben zu einer Datei.
|
||||||
///
|
///
|
||||||
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Halo sie
|
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Flaro sie
|
||||||
/// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die
|
/// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die
|
||||||
/// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding
|
/// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding
|
||||||
/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird.
|
/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird.
|
||||||
@@ -34,13 +34,13 @@ struct TechnicalDetails: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
DisclosureGroup(isExpanded: $isExpanded) {
|
DisclosureGroup(isExpanded: $isExpanded) {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||||
row("technical.playbackMethod", playbackMethodLabel)
|
row("technical.playbackMethod", playbackMethodLabel)
|
||||||
if let reason = rationaleLabel {
|
if let reason = rationaleLabel {
|
||||||
row("technical.reason", reason)
|
row("technical.reason", reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
|
Divider().overlay(Color.flaroSeparator).padding(.vertical, FlaroSpacing.tight)
|
||||||
|
|
||||||
if let container = part.container { row("technical.container", container.uppercased()) }
|
if let container = part.container { row("technical.container", container.uppercased()) }
|
||||||
if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
|
if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
|
||||||
@@ -55,7 +55,7 @@ struct TechnicalDetails: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !part.audioStreams.isEmpty {
|
if !part.audioStreams.isEmpty {
|
||||||
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
|
Divider().overlay(Color.flaroSeparator).padding(.vertical, FlaroSpacing.tight)
|
||||||
ForEach(part.audioStreams) { stream in
|
ForEach(part.audioStreams) { stream in
|
||||||
row("technical.audio", trackLabel(stream))
|
row("technical.audio", trackLabel(stream))
|
||||||
}
|
}
|
||||||
@@ -68,36 +68,36 @@ struct TechnicalDetails: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let file = part.file {
|
if let file = part.file {
|
||||||
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight)
|
Divider().overlay(Color.flaroSeparator).padding(.vertical, FlaroSpacing.tight)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("technical.file")
|
Text("technical.file")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
Text(file)
|
Text(file)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, HaloSpacing.snug)
|
.padding(.top, FlaroSpacing.snug)
|
||||||
} label: {
|
} label: {
|
||||||
Text("technical.title")
|
Text("technical.title")
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
}
|
}
|
||||||
.tint(Color.haloTextSecondary)
|
.tint(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
|
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+10
-10
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Der Download-Knopf auf einer Detailseite.
|
/// Der Download-Knopf auf einer Detailseite.
|
||||||
@@ -25,10 +25,10 @@ struct DownloadButton: View {
|
|||||||
/// nicht drückt.
|
/// nicht drückt.
|
||||||
var loadsDetails: Bool = false
|
var loadsDetails: Bool = false
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
@State private var refusal: DownloadRefusal?
|
@State private var refusal: DownloadRefusal?
|
||||||
@State private var showsQualityChoice = false
|
@State private var showsQualityChoice = false
|
||||||
@@ -64,18 +64,18 @@ struct DownloadButton: View {
|
|||||||
} else {
|
} else {
|
||||||
Label(label, systemImage: symbol)
|
Label(label, systemImage: symbol)
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
|
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der
|
||||||
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
|
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während
|
||||||
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
|
// des Wartens die Breite wechselt, sieht aus wie ein Fehler.
|
||||||
.frame(width: 22, height: 22)
|
.frame(width: 22, height: 22)
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.disabled(isLoadingDetails)
|
.disabled(isLoadingDetails)
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(existing?.state == .completed ? accent : Color.haloTextSecondary)
|
.tint(existing?.state == .completed ? accent : Color.flaroTextSecondary)
|
||||||
.accessibilityLabel(Text(label))
|
.accessibilityLabel(Text(label))
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
Text("downloads.quality.title"),
|
Text("downloads.quality.title"),
|
||||||
@@ -247,10 +247,10 @@ extension DownloadQuality {
|
|||||||
struct DownloadAllButton: View {
|
struct DownloadAllButton: View {
|
||||||
let container: MediaItem
|
let container: MediaItem
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
@State private var isWorking = false
|
@State private var isWorking = false
|
||||||
@State private var summary: String?
|
@State private var summary: String?
|
||||||
@@ -269,7 +269,7 @@ struct DownloadAllButton: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(Color.haloTextSecondary)
|
.tint(Color.flaroTextSecondary)
|
||||||
.disabled(isWorking)
|
.disabled(isWorking)
|
||||||
.alert(
|
.alert(
|
||||||
Text("downloads.batch.title"),
|
Text("downloads.batch.title"),
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen.
|
/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen.
|
||||||
+40
-40
@@ -1,14 +1,14 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Was offline ist und was gerade geladen wird.
|
/// Was offline ist und was gerade geladen wird.
|
||||||
struct DownloadsView: View {
|
struct DownloadsView: View {
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
|
|
||||||
@@ -24,16 +24,16 @@ struct DownloadsView: View {
|
|||||||
list
|
list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("sidebar.downloads"))
|
.navigationTitle(Text("sidebar.downloads"))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
|
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
|
||||||
// mehr, und dies ist der Weg dorthin.
|
// mehr, und dies ist der Weg dorthin.
|
||||||
if sizeClass == .compact {
|
if sizeClass == .compact {
|
||||||
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
|
ToolbarItem(placement: .flaroTrailing) { AccountToolbarButton() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
// Keine Rückfrage beim Entfernen.
|
// Keine Rückfrage beim Entfernen.
|
||||||
//
|
//
|
||||||
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
|
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
|
||||||
@@ -88,8 +88,8 @@ struct DownloadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var active: [DownloadItem] {
|
private var active: [DownloadItem] {
|
||||||
@@ -125,7 +125,7 @@ struct DownloadsView: View {
|
|||||||
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
|
/// Die Kopfzeile mit Speicherbelegung und Schaltern.
|
||||||
private struct StorageSection: View {
|
private struct StorageSection: View {
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var downloads = downloads
|
@Bindable var downloads = downloads
|
||||||
@@ -133,10 +133,10 @@ private struct StorageSection: View {
|
|||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
Text("downloads.used")
|
Text("downloads.used")
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
|
Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let limit = downloads.storageLimitBytes, limit > 0 {
|
if let limit = downloads.storageLimitBytes, limit > 0 {
|
||||||
@@ -157,20 +157,20 @@ private struct DownloadRow: View {
|
|||||||
let onRemove: () -> Void
|
let onRemove: () -> Void
|
||||||
|
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let subtitle = item.subtitle {
|
if let subtitle = item.subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,19 +190,19 @@ private struct DownloadRow: View {
|
|||||||
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
|
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
|
||||||
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
|
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
|
||||||
)
|
)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let message = item.failureMessage {
|
if let message = item.failureMessage {
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloDestructive)
|
.foregroundStyle(Color.flaroDestructive)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, HaloSpacing.tight)
|
.padding(.vertical, FlaroSpacing.tight)
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
.swipeActions(edge: .trailing) {
|
.swipeActions(edge: .trailing) {
|
||||||
Button("action.delete", role: .destructive, action: onRemove)
|
Button("action.delete", role: .destructive, action: onRemove)
|
||||||
}
|
}
|
||||||
@@ -240,18 +240,18 @@ private struct DownloadRow: View {
|
|||||||
|
|
||||||
case .completed:
|
case .completed:
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.accessibilityLabel(Text("downloads.available"))
|
.accessibilityLabel(Text("downloads.available"))
|
||||||
|
|
||||||
case .queued, .waiting:
|
case .queued, .waiting:
|
||||||
Image(systemName: "clock")
|
Image(systemName: "clock")
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.accessibilityLabel(Text("downloads.queued"))
|
.accessibilityLabel(Text("downloads.queued"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var progress: some View {
|
private var progress: some View {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.tight) {
|
||||||
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
|
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
|
||||||
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
|
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender
|
||||||
// Download.
|
// Download.
|
||||||
@@ -273,17 +273,17 @@ private struct DownloadRow: View {
|
|||||||
// gemeinsames Mass für beide Zahlen (siehe `unit`), und die
|
// gemeinsames Mass für beide Zahlen (siehe `unit`), und die
|
||||||
// Prozente am rechten Rand — die wachsen nach innen und schieben
|
// Prozente am rechten Rand — die wachsen nach innen und schieben
|
||||||
// nichts.
|
// nichts.
|
||||||
HStack(alignment: .firstTextBaseline, spacing: HaloSpacing.snug) {
|
HStack(alignment: .firstTextBaseline, spacing: FlaroSpacing.snug) {
|
||||||
Text(statusLine)
|
Text(statusLine)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: HaloSpacing.snug)
|
Spacer(minLength: FlaroSpacing.snug)
|
||||||
if let percentage {
|
if let percentage {
|
||||||
Text(percentage)
|
Text(percentage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,28 +387,28 @@ private struct DownloadRow: View {
|
|||||||
private struct HoldNotice: View {
|
private struct HoldNotice: View {
|
||||||
let hold: TransferHold
|
let hold: TransferHold
|
||||||
|
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: HaloSpacing.regular) {
|
HStack(alignment: .top, spacing: FlaroSpacing.regular) {
|
||||||
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
|
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
|
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
Text(hold == .offline
|
Text(hold == .offline
|
||||||
? "downloads.hold.offline.description"
|
? "downloads.hold.offline.description"
|
||||||
: "downloads.hold.wifi.description")
|
: "downloads.hold.wifi.description")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, HaloSpacing.tight)
|
.padding(.vertical, FlaroSpacing.tight)
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
|
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
|
||||||
@@ -65,8 +65,8 @@ struct HeroCarousel: View {
|
|||||||
@State private var isHoveringDots = false
|
@State private var isHoveringDots = false
|
||||||
|
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
@Environment(TopChrome.self) private var chrome
|
@Environment(TopChrome.self) private var chrome
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
@@ -118,7 +118,7 @@ struct HeroCarousel: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: HaloSpacing.regular) {
|
VStack(spacing: FlaroSpacing.regular) {
|
||||||
heroSection
|
heroSection
|
||||||
// Die Höhe: fast das ganze Fenster.
|
// Die Höhe: fast das ganze Fenster.
|
||||||
//
|
//
|
||||||
@@ -189,7 +189,7 @@ struct HeroCarousel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isMac: Bool { HaloPlatform.isMac }
|
private var isMac: Bool { FlaroPlatform.isMac }
|
||||||
|
|
||||||
/// Die beiden Pfeile links und rechts.
|
/// Die beiden Pfeile links und rechts.
|
||||||
///
|
///
|
||||||
@@ -203,7 +203,7 @@ struct HeroCarousel: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
arrow(symbol: "chevron.right", direction: 1)
|
arrow(symbol: "chevron.right", direction: 1)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.opacity(isPointerInside ? 1 : 0)
|
.opacity(isPointerInside ? 1 : 0)
|
||||||
.animation(.easeOut(duration: 0.2), value: isPointerInside)
|
.animation(.easeOut(duration: 0.2), value: isPointerInside)
|
||||||
.onContinuousHover { phase in
|
.onContinuousHover { phase in
|
||||||
@@ -219,7 +219,7 @@ struct HeroCarousel: View {
|
|||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 18, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(HaloSpacing.regular)
|
.padding(FlaroSpacing.regular)
|
||||||
.background(.black.opacity(0.45), in: .circle)
|
.background(.black.opacity(0.45), in: .circle)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -237,10 +237,10 @@ struct HeroCarousel: View {
|
|||||||
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
|
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
|
||||||
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
|
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
|
||||||
private var indicator: some View {
|
private var indicator: some View {
|
||||||
HStack(spacing: isEnlarged ? HaloSpacing.regular : HaloSpacing.snug) {
|
HStack(spacing: isEnlarged ? FlaroSpacing.regular : FlaroSpacing.snug) {
|
||||||
ForEach(items.indices, id: \.self) { position in
|
ForEach(items.indices, id: \.self) { position in
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(position == currentIndex ? accent : Color.haloTextMuted.opacity(0.4))
|
.fill(position == currentIndex ? accent : Color.flaroTextMuted.opacity(0.4))
|
||||||
.frame(
|
.frame(
|
||||||
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
|
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
|
||||||
height: isEnlarged ? 12 : 6
|
height: isEnlarged ? 12 : 6
|
||||||
@@ -255,7 +255,7 @@ struct HeroCarousel: View {
|
|||||||
}
|
}
|
||||||
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
|
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
|
||||||
// beim Annähern etwas bewirken, nicht erst beim Treffen.
|
// beim Annähern etwas bewirken, nicht erst beim Treffen.
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.onContinuousHover { phase in
|
.onContinuousHover { phase in
|
||||||
guard isMac else { return }
|
guard isMac else { return }
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Der Aufmacher oben auf der Startseite.
|
/// Der Aufmacher oben auf der Startseite.
|
||||||
@@ -12,10 +12,10 @@ struct HeroSection: View {
|
|||||||
/// Wie weit es dabei hereinrutscht.
|
/// Wie weit es dabei hereinrutscht.
|
||||||
var incomingOffset: CGFloat = 0
|
var incomingOffset: CGFloat = 0
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(QuickPlay.self) private var quickPlay
|
@Environment(QuickPlay.self) private var quickPlay
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -47,37 +47,44 @@ 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(.haloCardTitle)
|
// schickte der direkte Weg deren eigene Kennung an den
|
||||||
.padding(.horizontal, HaloSpacing.loose)
|
// Player, und der scheiterte mit „keine abspielbare
|
||||||
.padding(.vertical, HaloSpacing.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")
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.padding(.horizontal, HaloSpacing.loose)
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(Color.haloTextSecondary)
|
.tint(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// „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) }
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||||
|
///
|
||||||
|
/// **Nur noch Darstellung.** Was auf der Startseite steht — laden, ordnen,
|
||||||
|
/// den Aufmacher auslosen — liegt in `HomeFeed`, weil der Fernseher dieselben
|
||||||
|
/// Reihen zeigt und die Logik sonst zweimal dastünde. Kopiert wäre dabei
|
||||||
|
/// ausgerechnet der Teil gewesen, an dem schon mehrere Fehler steckten.
|
||||||
|
struct HomeView: View {
|
||||||
|
@Environment(FlaroSession.self) private var session
|
||||||
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(Fortschrittspost.self) private var post
|
||||||
|
@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 {
|
||||||
|
Group {
|
||||||
|
switch feed.state {
|
||||||
|
case .loading:
|
||||||
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
case .failed(let message) where heruntergeladen.isEmpty:
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||||
|
} description: {
|
||||||
|
Text(message)
|
||||||
|
} actions: {
|
||||||
|
Button("action.retry") { Task { await feed.load(from: session.backend) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Mit Downloads ist „kein Server" kein Fehlerbildschirm.**
|
||||||
|
// Was auf dem Gerät liegt, lässt sich auch ohne ihn ansehen —
|
||||||
|
// genau dafür wurde es heruntergeladen. Ein Fehlerbild davor zu
|
||||||
|
// stellen hiesse, dem Nutzer etwas wegzunehmen, das er hat.
|
||||||
|
case .failed, .loaded:
|
||||||
|
let hubs = feed.rows(for: layout, downloaded: heruntergeladen)
|
||||||
|
if hubs.isEmpty {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("home.empty.title", systemImage: "film.stack")
|
||||||
|
} description: {
|
||||||
|
Text("home.empty.description")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content(hubs: hubs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.flaroCanvas)
|
||||||
|
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
|
||||||
|
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
|
||||||
|
// ohnehin mehr.
|
||||||
|
.navigationTitle("")
|
||||||
|
.flaroInlineTitle()
|
||||||
|
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
|
||||||
|
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
|
||||||
|
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
|
||||||
|
// Aufmacher.
|
||||||
|
.flaroClearToolbarBackground()
|
||||||
|
// Gar keine Navigationsleiste auf der Startseite.
|
||||||
|
//
|
||||||
|
// Sie war leer — kein Titel, keine Knöpfe — und belegte trotzdem ihre
|
||||||
|
// Höhe. Der Aufmacher fing dadurch tiefer an als die Seitenleiste
|
||||||
|
// daneben, und genau diese Kante sah man. Auf den geschobenen
|
||||||
|
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.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) }
|
||||||
|
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
|
||||||
|
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
|
||||||
|
.environment(\.flaroItemChanged, { await feed.load(from: session.backend) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func content(hubs: [MediaRowGroup]) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
|
if ohneServer {
|
||||||
|
offlinehinweis
|
||||||
|
} else {
|
||||||
|
let featured = feed.heroItems(for: layout)
|
||||||
|
if !featured.isEmpty {
|
||||||
|
HeroCarousel(items: featured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(hubs) { hub in
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.scrollIndicators(.never)
|
||||||
|
.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 HaloDesign
|
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(HaloSession.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.
|
||||||
@@ -23,19 +28,19 @@ struct MediaRow: View {
|
|||||||
private var posterWidth: CGFloat { 116 * layout.cardScale }
|
private var posterWidth: CGFloat { 116 * layout.cardScale }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
Text(row.title)
|
Text(row.title)
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
LazyHStack(spacing: HaloSpacing.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, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
|
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
|
||||||
// bleibt an beliebiger Stelle stehen.
|
// bleibt an beliebiger Stelle stehen.
|
||||||
.scrollTargetLayout()
|
.scrollTargetLayout()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+13
-13
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln.
|
/// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln.
|
||||||
@@ -20,7 +20,7 @@ import MediaKit
|
|||||||
/// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst
|
/// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst
|
||||||
/// gezeichnete Leiste kennt diese Grenze nicht.
|
/// gezeichnete Leiste kennt diese Grenze nicht.
|
||||||
struct LibraryBrowser: View {
|
struct LibraryBrowser: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
/// Was gerade gezeigt wird.
|
/// Was gerade gezeigt wird.
|
||||||
///
|
///
|
||||||
@@ -36,7 +36,7 @@ struct LibraryBrowser: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
inhalt
|
inhalt
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
// **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste
|
// **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste
|
||||||
// über der Leiste, statt dahinter zu verschwinden — und das gilt
|
// über der Leiste, statt dahinter zu verschwinden — und das gilt
|
||||||
// auch für die Ladeanzeige und den Fuss „alles geladen".
|
// auch für die Ladeanzeige und den Fuss „alles geladen".
|
||||||
@@ -86,11 +86,11 @@ struct LibraryBrowser: View {
|
|||||||
}
|
}
|
||||||
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
|
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
|
||||||
// kein nachgebautes Aussehen.
|
// kein nachgebautes Aussehen.
|
||||||
.background { HaloSidebar.surface(in: .capsule) }
|
.background { FlaroSidebar.surface(in: .capsule) }
|
||||||
.clipShape(.capsule)
|
.clipShape(.capsule)
|
||||||
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.bottom, HaloSpacing.snug)
|
.padding(.bottom, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var reihe: some View {
|
private var reihe: some View {
|
||||||
@@ -99,8 +99,8 @@ struct LibraryBrowser: View {
|
|||||||
pille(eintrag)
|
pille(eintrag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.snug)
|
.padding(.horizontal, FlaroSpacing.snug)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pille(_ eintrag: Eintrag) -> some View {
|
private func pille(_ eintrag: Eintrag) -> some View {
|
||||||
@@ -116,12 +116,12 @@ struct LibraryBrowser: View {
|
|||||||
}
|
}
|
||||||
// Kleiner als in der Leiste auf dem iPad: dort stehen drei
|
// Kleiner als in der Leiste auf dem iPad: dort stehen drei
|
||||||
// Einträge, hier bis zu fünf — und auf einem Telefon.
|
// Einträge, hier bis zu fünf — und auf einem Telefon.
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(gewaehlt ? Color.haloTextPrimary : Color.haloTextSecondary)
|
.foregroundStyle(gewaehlt ? Color.flaroTextPrimary : Color.flaroTextSecondary)
|
||||||
.padding(.horizontal, HaloSpacing.snug)
|
.padding(.horizontal, FlaroSpacing.snug)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.background {
|
.background {
|
||||||
if gewaehlt { Capsule().fill(Color.haloElevated) }
|
if gewaehlt { Capsule().fill(Color.flaroElevated) }
|
||||||
}
|
}
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
+22
-9
@@ -1,10 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
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*
|
||||||
@@ -14,8 +17,8 @@ struct LibraryFilterSheet: View {
|
|||||||
let library: MediaLibrary
|
let library: MediaLibrary
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@State private var genres: [MediaFilterOption] = []
|
@State private var genres: [MediaFilterOption] = []
|
||||||
|
|
||||||
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
|
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
|
||||||
@@ -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.
|
||||||
@@ -91,17 +103,18 @@ struct LibraryFilterSheet: View {
|
|||||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.haloForm()
|
.flaroForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("filter.title"))
|
.navigationTitle(Text("filter.title"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("action.cancel") { dismiss() }
|
Button("action.cancel") { dismiss() }
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.apply") {
|
Button("action.apply") {
|
||||||
|
nurHeruntergeladene = entwurfHeruntergeladene
|
||||||
query = draft
|
query = draft
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
@@ -109,7 +122,7 @@ struct LibraryFilterSheet: View {
|
|||||||
}
|
}
|
||||||
.task { await loadGenres() }
|
.task { await loadGenres() }
|
||||||
}
|
}
|
||||||
.haloResizableSheet()
|
.flaroResizableSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadGenres() async {
|
private func loadGenres() async {
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Eine Mediathek zum Durchblättern.
|
/// Eine Mediathek zum Durchblättern.
|
||||||
struct LibraryView: View {
|
struct LibraryView: View {
|
||||||
let library: MediaLibrary
|
let library: MediaLibrary
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
/// 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
|
||||||
@AppStorage("halo.library.layout") private var layout: LibraryLayout = .grid
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
@State private var items: [MediaItem] = []
|
@State private var items: [MediaItem] = []
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,12 +70,16 @@ struct LibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(library.title)
|
.navigationTitle(library.title)
|
||||||
.haloInlineTitle()
|
.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,9 +93,14 @@ 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(\.haloItemChanged, { await reload() })
|
.environment(\.flaroItemChanged, { await reload() })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -97,16 +117,20 @@ struct LibraryView: View {
|
|||||||
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
|
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
|
||||||
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
|
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
|
||||||
// ohne dass die Größenklasse abgefragt werden muss.
|
// ohne dass die Größenklasse abgefragt werden muss.
|
||||||
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: HaloSpacing.regular)],
|
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: FlaroSpacing.regular)],
|
||||||
spacing: HaloSpacing.loose
|
spacing: FlaroSpacing.loose
|
||||||
) {
|
) {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
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) }
|
||||||
@@ -119,12 +143,12 @@ struct LibraryView: View {
|
|||||||
PlaceholderCard(index: position)
|
PlaceholderCard(index: position)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.screenMargin)
|
.padding(FlaroSpacing.screenMargin)
|
||||||
|
|
||||||
moreButton.padding(.bottom, HaloSpacing.section)
|
moreButton.padding(.bottom, FlaroSpacing.section)
|
||||||
footerCount.padding(.bottom, HaloSpacing.section)
|
footerCount.padding(.bottom, FlaroSpacing.section)
|
||||||
}
|
}
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var list: some View {
|
private var list: some View {
|
||||||
@@ -137,19 +161,19 @@ struct LibraryView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.task { await loadMoreIfNeeded(reaching: item) }
|
.task { await loadMoreIfNeeded(reaching: item) }
|
||||||
|
|
||||||
Divider().overlay(Color.haloSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pendingCount > 0 {
|
if pendingCount > 0 {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.padding(.vertical, HaloSpacing.section)
|
.padding(.vertical, FlaroSpacing.section)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
moreButton.padding(.vertical, HaloSpacing.section)
|
moreButton.padding(.vertical, FlaroSpacing.section)
|
||||||
footerCount.padding(.bottom, HaloSpacing.section)
|
footerCount.padding(.bottom, FlaroSpacing.section)
|
||||||
}
|
}
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
|
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
|
||||||
@@ -173,8 +197,8 @@ struct LibraryView: View {
|
|||||||
private var footerCount: some View {
|
private var footerCount: some View {
|
||||||
if let page, !page.hasMore, !items.isEmpty {
|
if let page, !page.hasMore, !items.isEmpty {
|
||||||
Text("library.allLoaded \(items.count)")
|
Text("library.allLoaded \(items.count)")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,10 +207,10 @@ struct LibraryView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var moreButton: some View {
|
private var moreButton: some View {
|
||||||
if loadMoreFailed, let page, page.hasMore {
|
if loadMoreFailed, let page, page.hasMore {
|
||||||
VStack(spacing: HaloSpacing.snug) {
|
VStack(spacing: FlaroSpacing.snug) {
|
||||||
Text("library.loadMoreFailed")
|
Text("library.loadMoreFailed")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
Button("library.loadMore") {
|
Button("library.loadMore") {
|
||||||
Task { await loadMore() }
|
Task { await loadMore() }
|
||||||
}
|
}
|
||||||
@@ -206,7 +230,7 @@ struct LibraryView: View {
|
|||||||
// steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste,
|
// steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste,
|
||||||
// und ein zweiter Weg dorthin wäre nur Verdopplung.
|
// und ein zweiter Weg dorthin wäre nur Verdopplung.
|
||||||
if sizeClass == .compact {
|
if sizeClass == .compact {
|
||||||
ToolbarItem(placement: .haloTrailing) {
|
ToolbarItem(placement: .flaroTrailing) {
|
||||||
NavigationLink(value: SearchRoute()) {
|
NavigationLink(value: SearchRoute()) {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
}
|
}
|
||||||
@@ -218,7 +242,7 @@ struct LibraryView: View {
|
|||||||
// oben, und selbst dort wo es das gibt, ist es in einem Raster kaum
|
// oben, und selbst dort wo es das gibt, ist es in einem Raster kaum
|
||||||
// zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen
|
// zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen
|
||||||
// Knopf und keine Geste.
|
// Knopf und keine Geste.
|
||||||
ToolbarItem(placement: .haloTrailing) {
|
ToolbarItem(placement: .flaroTrailing) {
|
||||||
Button {
|
Button {
|
||||||
Task { await reload() }
|
Task { await reload() }
|
||||||
} label: {
|
} label: {
|
||||||
@@ -228,18 +252,18 @@ struct LibraryView: View {
|
|||||||
.disabled(isLoadingMore)
|
.disabled(isLoadingMore)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .haloTrailing) {
|
ToolbarItem(placement: .flaroTrailing) {
|
||||||
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")
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("library.filters"))
|
.accessibilityLabel(Text("library.filters"))
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .haloTrailing) {
|
ToolbarItem(placement: .flaroTrailing) {
|
||||||
Button {
|
Button {
|
||||||
layout = layout == .grid ? .list : .grid
|
layout = layout == .grid ? .list : .grid
|
||||||
} label: {
|
} label: {
|
||||||
@@ -255,7 +279,7 @@ struct LibraryView: View {
|
|||||||
// Reihe etwas steht, kann nur der entscheiden, der die Reihe
|
// Reihe etwas steht, kann nur der entscheiden, der die Reihe
|
||||||
// schreibt.
|
// schreibt.
|
||||||
if sizeClass == .compact {
|
if sizeClass == .compact {
|
||||||
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() }
|
ToolbarItem(placement: .flaroTrailing) { AccountToolbarButton() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -380,12 +433,13 @@ enum LibraryLayout: String, CaseIterable {
|
|||||||
/// Eine Zeile in der Listenansicht.
|
/// Eine Zeile in der Listenansicht.
|
||||||
private struct LibraryListRow: View {
|
private struct LibraryListRow: View {
|
||||||
let item: MediaItem
|
let item: MediaItem
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
HaloArtwork(
|
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)
|
||||||
@@ -393,13 +447,13 @@ private struct LibraryListRow: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let subtitle = item.cardSubtitle {
|
if let subtitle = item.cardSubtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,12 +461,12 @@ private struct LibraryListRow: View {
|
|||||||
|
|
||||||
if item.isWatched {
|
if item.isWatched {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.accessibilityLabel(Text("item.watched"))
|
.accessibilityLabel(Text("item.watched"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
}
|
}
|
||||||
+12
-12
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Welche Sender überhaupt angezeigt werden.
|
/// Welche Sender überhaupt angezeigt werden.
|
||||||
@@ -12,8 +12,8 @@ struct ChannelSelectionSheet: View {
|
|||||||
let channels: [LiveChannel]
|
let channels: [LiveChannel]
|
||||||
@Binding var selection: Set<String>
|
@Binding var selection: Set<String>
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -28,9 +28,9 @@ struct ChannelSelectionSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(Text("liveTV.selection"))
|
.navigationTitle(Text("liveTV.selection"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .haloLeading) {
|
ToolbarItem(placement: .flaroLeading) {
|
||||||
// „Alle" statt „keine": eine leere Auswahl bedeutet
|
// „Alle" statt „keine": eine leere Auswahl bedeutet
|
||||||
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
|
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
|
||||||
// wäre verwirrend.
|
// wäre verwirrend.
|
||||||
@@ -57,10 +57,10 @@ struct ChannelSelectionSheet: View {
|
|||||||
selection.insert(channel.id)
|
selection.insert(channel.id)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
HaloArtwork(
|
FlaroArtwork(
|
||||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||||
aspectRatio: HaloAspectRatio.channelLogo,
|
aspectRatio: FlaroAspectRatio.channelLogo,
|
||||||
fallbackSymbol: "tv",
|
fallbackSymbol: "tv",
|
||||||
contentMode: .fit
|
contentMode: .fit
|
||||||
)
|
)
|
||||||
@@ -69,17 +69,17 @@ struct ChannelSelectionSheet: View {
|
|||||||
if let number = channel.number {
|
if let number = channel.number {
|
||||||
Text(number)
|
Text(number)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(channel.title)
|
Text(channel.title)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
|
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
|
||||||
.foregroundStyle(isShown(channel) ? accent : Color.haloTextMuted)
|
.foregroundStyle(isShown(channel) ? accent : Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Live-TV mit Programmführer.
|
/// Live-TV mit Programmführer.
|
||||||
struct LiveTVView: View {
|
struct LiveTVView: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@@ -74,7 +74,7 @@ struct LiveTVView: View {
|
|||||||
} description: {
|
} description: {
|
||||||
Text("liveTV.noChannels.description")
|
Text("liveTV.noChannels.description")
|
||||||
} actions: {
|
} actions: {
|
||||||
VStack(spacing: HaloSpacing.regular) {
|
VStack(spacing: FlaroSpacing.regular) {
|
||||||
Button("action.retry") { Task { await load() } }
|
Button("action.retry") { Task { await load() } }
|
||||||
searchTrace
|
searchTrace
|
||||||
}
|
}
|
||||||
@@ -107,12 +107,12 @@ struct LiveTVView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("sidebar.liveTV"))
|
.navigationTitle(Text("sidebar.liveTV"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if case .available = availability, !channels.isEmpty {
|
if case .available = availability, !channels.isEmpty {
|
||||||
ToolbarItem(placement: .haloTrailing) {
|
ToolbarItem(placement: .flaroTrailing) {
|
||||||
Button {
|
Button {
|
||||||
showsSelection = true
|
showsSelection = true
|
||||||
} label: {
|
} label: {
|
||||||
@@ -120,7 +120,7 @@ struct LiveTVView: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel(Text("liveTV.selection"))
|
.accessibilityLabel(Text("liveTV.selection"))
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .haloTrailing) {
|
ToolbarItem(placement: .flaroTrailing) {
|
||||||
Picker("liveTV.mode", selection: $mode) {
|
Picker("liveTV.mode", selection: $mode) {
|
||||||
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
|
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
|
||||||
Image(systemName: "list.bullet").tag(LiveTVMode.channels)
|
Image(systemName: "list.bullet").tag(LiveTVMode.channels)
|
||||||
@@ -169,11 +169,11 @@ struct LiveTVView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var searchTrace: some View {
|
private var searchTrace: some View {
|
||||||
if !trace.isEmpty {
|
if !trace.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||||
if let channelSource {
|
if let channelSource {
|
||||||
Text(channelSource)
|
Text(channelSource)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -181,7 +181,7 @@ struct LiveTVView: View {
|
|||||||
ForEach(trace) { probe in
|
ForEach(trace) { probe in
|
||||||
Text("\(probe.path) → \(probe.result)")
|
Text("\(probe.path) → \(probe.result)")
|
||||||
.font(.system(size: 10, design: .monospaced))
|
.font(.system(size: 10, design: .monospaced))
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
@@ -191,18 +191,18 @@ struct LiveTVView: View {
|
|||||||
.frame(maxHeight: 200)
|
.frame(maxHeight: 200)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
HaloClipboard.copy(
|
FlaroClipboard.copy(
|
||||||
trace
|
trace
|
||||||
.map { "\($0.path) → \($0.result)" }
|
.map { "\($0.path) → \($0.result)" }
|
||||||
.joined(separator: "\n")
|
.joined(separator: "\n")
|
||||||
)
|
)
|
||||||
} label: {
|
} label: {
|
||||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,11 +216,11 @@ struct LiveTVView: View {
|
|||||||
isTuning: isTuning == channel.id,
|
isTuning: isTuning == channel.id,
|
||||||
onTune: { Task { await tune(channel) } }
|
onTune: { Task { await tune(channel) } }
|
||||||
)
|
)
|
||||||
Divider().overlay(Color.haloSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
@@ -329,43 +329,43 @@ private struct ChannelRow: View {
|
|||||||
let isTuning: Bool
|
let isTuning: Bool
|
||||||
let onTune: () -> Void
|
let onTune: () -> Void
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
private var current: LiveProgram? { programs.first { $0.isRunning() } }
|
private var current: LiveProgram? { programs.first { $0.isRunning() } }
|
||||||
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
|
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onTune) {
|
Button(action: onTune) {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
|
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
|
||||||
// fast nie quadratisch, und formatfüllend fehlten links und
|
// fast nie quadratisch, und formatfüllend fehlten links und
|
||||||
// rechts genau die Teile, an denen man den Sender erkennt.
|
// rechts genau die Teile, an denen man den Sender erkennt.
|
||||||
HaloArtwork(
|
FlaroArtwork(
|
||||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||||
aspectRatio: HaloAspectRatio.channelLogo,
|
aspectRatio: FlaroAspectRatio.channelLogo,
|
||||||
fallbackSymbol: "tv",
|
fallbackSymbol: "tv",
|
||||||
contentMode: .fit
|
contentMode: .fit
|
||||||
)
|
)
|
||||||
.frame(width: 56, height: 34)
|
.frame(width: 56, height: 34)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
if let number = channel.number {
|
if let number = channel.number {
|
||||||
Text(number)
|
Text(number)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
Text(channel.title)
|
Text(channel.title)
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let current {
|
if let current {
|
||||||
Text(current.title)
|
Text(current.title)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
// Der Balken zeigt, wie weit die Sendung ist — beim
|
// Der Balken zeigt, wie weit die Sendung ist — beim
|
||||||
// Umschalten die nützlichste Angabe überhaupt.
|
// Umschalten die nützlichste Angabe überhaupt.
|
||||||
@@ -373,14 +373,14 @@ private struct ChannelRow: View {
|
|||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
} else {
|
} else {
|
||||||
Text("liveTV.noProgram")
|
Text("liveTV.noProgram")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let next {
|
if let next {
|
||||||
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
|
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,8 +395,8 @@ private struct ChannelRow: View {
|
|||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||||||
.padding(.vertical, HaloSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -427,43 +427,43 @@ private struct TuneFailureSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
Text(failure.channel)
|
Text(failure.channel)
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
Text(failure.reason)
|
Text(failure.reason)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
|
|
||||||
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
|
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
|
||||||
Text(line)
|
Text(line)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
HaloClipboard.copy(failure.trace.joined(separator: "\n"))
|
FlaroClipboard.copy(failure.trace.joined(separator: "\n"))
|
||||||
} label: {
|
} label: {
|
||||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.screenMargin)
|
.padding(FlaroSpacing.screenMargin)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("liveTV.tuneFailed"))
|
.navigationTitle(Text("liveTV.tuneFailed"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.close") { dismiss() }
|
Button("action.close") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.haloResizableSheet()
|
.flaroResizableSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
|
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
|
||||||
@@ -34,7 +34,7 @@ struct ProgramGuide: View {
|
|||||||
var onRecord: ((LiveProgram) -> Void)?
|
var onRecord: ((LiveProgram) -> Void)?
|
||||||
var onRecordSeries: ((LiveProgram) -> Void)?
|
var onRecordSeries: ((LiveProgram) -> Void)?
|
||||||
|
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
@State private var scrollX: CGFloat = 0
|
@State private var scrollX: CGFloat = 0
|
||||||
@State private var selection: GuideSelection?
|
@State private var selection: GuideSelection?
|
||||||
@@ -50,7 +50,7 @@ struct ProgramGuide: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
axis
|
axis
|
||||||
Divider().overlay(Color.haloSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
|
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
@@ -65,7 +65,7 @@ struct ProgramGuide: View {
|
|||||||
}
|
}
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.sheet(item: $selection) { selection in
|
.sheet(item: $selection) { selection in
|
||||||
ProgramDetailSheet(
|
ProgramDetailSheet(
|
||||||
channel: selection.channel,
|
channel: selection.channel,
|
||||||
@@ -104,12 +104,12 @@ struct ProgramGuide: View {
|
|||||||
private var axis: some View {
|
private var axis: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("liveTV.channelCount \(channels.count)")
|
Text("liveTV.channelCount \(channels.count)")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
// Der Abstand gehört in die Spaltenbreite hinein, nicht
|
// Der Abstand gehört in die Spaltenbreite hinein, nicht
|
||||||
// dazu: außen addiert er sich, und die ganze Zeitachse steht
|
// dazu: außen addiert er sich, und die ganze Zeitachse steht
|
||||||
// acht Punkte neben dem Programm darunter.
|
// acht Punkte neben dem Programm darunter.
|
||||||
.padding(.leading, HaloSpacing.snug)
|
.padding(.leading, FlaroSpacing.snug)
|
||||||
.frame(width: channelWidth, alignment: .leading)
|
.frame(width: channelWidth, alignment: .leading)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ struct ProgramGuide: View {
|
|||||||
ForEach(layout.ticks, id: \.self) { tick in
|
ForEach(layout.ticks, id: \.self) { tick in
|
||||||
Text(tick.formatted(date: .omitted, time: .shortened))
|
Text(tick.formatted(date: .omitted, time: .shortened))
|
||||||
.font(.system(.caption2, design: .rounded))
|
.font(.system(.caption2, design: .rounded))
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.frame(width: layout.tickWidth, alignment: .leading)
|
.frame(width: layout.tickWidth, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,7 @@ struct ProgramGuide: View {
|
|||||||
.clipped()
|
.clipped()
|
||||||
}
|
}
|
||||||
.frame(height: axisHeight)
|
.frame(height: axisHeight)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Was auf diesem Sender gerade läuft.
|
/// Was auf diesem Sender gerade läuft.
|
||||||
@@ -147,20 +147,20 @@ struct ProgramGuide: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ForEach(channels) { channel in
|
ForEach(channels) { channel in
|
||||||
Button { onTune(channel, running(on: channel)) } label: {
|
Button { onTune(channel, running(on: channel)) } label: {
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
HaloArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
|
FlaroArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
.clipShape(.rect(cornerRadius: 4))
|
.clipShape(.rect(cornerRadius: 4))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(channel.title)
|
Text(channel.title)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
if let number = channel.number {
|
if let number = channel.number {
|
||||||
Text(number)
|
Text(number)
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ struct ProgramGuide: View {
|
|||||||
ProgressView().controlSize(.mini)
|
ProgressView().controlSize(.mini)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.snug)
|
.padding(.horizontal, FlaroSpacing.snug)
|
||||||
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
|
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
|
||||||
.overlay(alignment: .bottom) { rowSeparator }
|
.overlay(alignment: .bottom) { rowSeparator }
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
@@ -179,7 +179,7 @@ struct ProgramGuide: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: channelWidth)
|
.frame(width: channelWidth)
|
||||||
.background(Color.haloElevated.opacity(0.4))
|
.background(Color.flaroElevated.opacity(0.4))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var lanes: some View {
|
private var lanes: some View {
|
||||||
@@ -217,9 +217,9 @@ struct ProgramGuide: View {
|
|||||||
return ZStack(alignment: .topLeading) {
|
return ZStack(alignment: .topLeading) {
|
||||||
if entries.isEmpty {
|
if entries.isEmpty {
|
||||||
Text("liveTV.noProgram")
|
Text("liveTV.noProgram")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.frame(height: rowHeight, alignment: .leading)
|
.frame(height: rowHeight, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ struct ProgramGuide: View {
|
|||||||
/// Zeile, die man antippt, gehört dann zum Nachbarsender.
|
/// Zeile, die man antippt, gehört dann zum Nachbarsender.
|
||||||
private var rowSeparator: some View {
|
private var rowSeparator: some View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.haloSeparator)
|
.fill(Color.flaroSeparator)
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,21 +250,21 @@ struct ProgramGuide: View {
|
|||||||
return Button { selection = GuideSelection(channel: channel, program: program) } label: {
|
return Button { selection = GuideSelection(channel: channel, program: program) } label: {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(program.title)
|
Text(program.title)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
|
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
|
||||||
.font(.system(.caption2, design: .rounded))
|
.font(.system(.caption2, design: .rounded))
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.snug)
|
.padding(.horizontal, FlaroSpacing.snug)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
|
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
|
||||||
.background(
|
.background(
|
||||||
running ? accent.opacity(0.28) : Color.haloElevated,
|
running ? accent.opacity(0.28) : Color.flaroElevated,
|
||||||
in: .rect(cornerRadius: HaloRadius.control)
|
in: .rect(cornerRadius: FlaroRadius.control)
|
||||||
)
|
)
|
||||||
.overlay(alignment: .bottomLeading) {
|
.overlay(alignment: .bottomLeading) {
|
||||||
// Der Balken zeigt, wie weit die laufende Sendung ist — beim
|
// Der Balken zeigt, wie weit die laufende Sendung ist — beim
|
||||||
@@ -299,45 +299,45 @@ private struct ProgramDetailSheet: View {
|
|||||||
let onRecord: (() -> Void)?
|
let onRecord: (() -> Void)?
|
||||||
let onRecordSeries: (() -> Void)?
|
let onRecordSeries: (() -> Void)?
|
||||||
|
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
HaloArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
|
FlaroArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 44, height: 44)
|
||||||
.clipShape(.rect(cornerRadius: 6))
|
.clipShape(.rect(cornerRadius: 6))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(channel.title)
|
Text(channel.title)
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
|
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(program.title)
|
Text(program.title)
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
if let subtitle = program.subtitle {
|
if let subtitle = program.subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isRunning {
|
if isRunning {
|
||||||
Button { onTune(); dismiss() } label: {
|
Button { onTune(); dismiss() } label: {
|
||||||
Label("liveTV.tune", systemImage: "play.fill")
|
Label("liveTV.tune", systemImage: "play.fill")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
@@ -348,15 +348,15 @@ private struct ProgramDetailSheet: View {
|
|||||||
// ohne Aufnahmen fehlt hier schlicht alles.
|
// ohne Aufnahmen fehlt hier schlicht alles.
|
||||||
if program.seriesTimerID != nil {
|
if program.seriesTimerID != nil {
|
||||||
Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle")
|
Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
} else if onRecord != nil || onRecordSeries != nil {
|
} else if onRecord != nil || onRecordSeries != nil {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
if let onRecord {
|
if let onRecord {
|
||||||
Button { onRecord(); dismiss() } label: {
|
Button { onRecord(); dismiss() } label: {
|
||||||
Label("liveTV.record", systemImage: "record.circle")
|
Label("liveTV.record", systemImage: "record.circle")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@ private struct ProgramDetailSheet: View {
|
|||||||
Button { onRecordSeries(); dismiss() } label: {
|
Button { onRecordSeries(); dismiss() } label: {
|
||||||
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
|
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
}
|
}
|
||||||
@@ -373,20 +373,20 @@ private struct ProgramDetailSheet: View {
|
|||||||
|
|
||||||
if let summary = program.summary, !summary.isEmpty {
|
if let summary = program.summary, !summary.isEmpty {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.screenMargin)
|
.padding(FlaroSpacing.screenMargin)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.close") { dismiss() }
|
Button("action.close") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.haloResizableSheet()
|
.flaroResizableSheet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Aufnahmen und was noch vorgemerkt ist.
|
/// Aufnahmen und was noch vorgemerkt ist.
|
||||||
@@ -12,7 +12,7 @@ import MediaKit
|
|||||||
/// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine
|
/// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine
|
||||||
/// stellt, stellt meist gleich die andere mit.
|
/// stellt, stellt meist gleich die andere mit.
|
||||||
struct RecordingsView: View {
|
struct RecordingsView: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
@State private var recordings: [LiveRecording] = []
|
@State private var recordings: [LiveRecording] = []
|
||||||
@State private var timers: [LiveTimer] = []
|
@State private var timers: [LiveTimer] = []
|
||||||
@@ -47,7 +47,7 @@ struct RecordingsView: View {
|
|||||||
list
|
list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
.refreshable { await load() }
|
.refreshable { await load() }
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
@@ -90,7 +90,7 @@ struct RecordingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
} header: {
|
} header: {
|
||||||
Text("liveTV.recordings")
|
Text("liveTV.recordings")
|
||||||
}
|
}
|
||||||
@@ -118,23 +118,23 @@ struct RecordingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
} header: {
|
} header: {
|
||||||
Text("liveTV.scheduled")
|
Text("liveTV.scheduled")
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Zeilen
|
// MARK: - Zeilen
|
||||||
|
|
||||||
private func row(_ recording: LiveRecording) -> some View {
|
private func row(_ recording: LiveRecording) -> some View {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
HaloArtwork(
|
FlaroArtwork(
|
||||||
url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120),
|
url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120),
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
fallbackSymbol: "recordingtape",
|
fallbackSymbol: "recordingtape",
|
||||||
@@ -145,23 +145,23 @@ struct RecordingsView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(recording.title)
|
Text(recording.title)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
HStack(spacing: HaloSpacing.tight) {
|
HStack(spacing: FlaroSpacing.tight) {
|
||||||
if recording.isInProgress {
|
if recording.isInProgress {
|
||||||
// Eine laufende Aufnahme ist keine fertige: sie wächst
|
// Eine laufende Aufnahme ist keine fertige: sie wächst
|
||||||
// noch, und wer sie jetzt öffnet, sieht ein Ende, das
|
// noch, und wer sie jetzt öffnet, sieht ein Ende, das
|
||||||
// nicht das Ende ist.
|
// nicht das Ende ist.
|
||||||
Label("liveTV.recording.inProgress", systemImage: "record.circle")
|
Label("liveTV.recording.inProgress", systemImage: "record.circle")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloDestructive)
|
.foregroundStyle(Color.flaroDestructive)
|
||||||
}
|
}
|
||||||
if let subtitle = subtitle(for: recording) {
|
if let subtitle = subtitle(for: recording) {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,29 +175,29 @@ struct RecordingsView: View {
|
|||||||
private func timerRow(_ timer: LiveTimer) -> some View {
|
private func timerRow(_ timer: LiveTimer) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(timer.title)
|
Text(timer.title)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
|
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
|
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
Image(systemName: "rectangle.stack.badge.record")
|
Image(systemName: "rectangle.stack.badge.record")
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(timer.name)
|
Text(timer.name)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
|
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ extension LoadState {
|
|||||||
|
|
||||||
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
|
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
|
||||||
///
|
///
|
||||||
/// **Eine** Stelle. Es gab davon zwei — hier und in `HaloSession` —, und sie
|
/// **Eine** Stelle. Es gab davon zwei — hier und in `FlaroSession` —, und sie
|
||||||
/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
|
/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
|
||||||
/// Bildschirm eine andere Erklärung.
|
/// Bildschirm eine andere Erklärung.
|
||||||
enum ErrorText {
|
enum ErrorText {
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
|
||||||
|
/// Das eigene Bild auf einer Scheibe — oder der erste Buchstabe.
|
||||||
|
///
|
||||||
|
/// **Der Buchstabe ist keine Notlösung.** Jellyfin gibt kein Profilbild
|
||||||
|
/// heraus, und auch an einem Flaro-Konto muss keines hängen. Ein Buchstabe
|
||||||
|
/// ist ehrlicher als eine graue Silhouette, die bei jedem gleich aussieht —
|
||||||
|
/// und man erkennt an ihm wenigstens, mit welchem Konto man angemeldet ist.
|
||||||
|
///
|
||||||
|
/// Steht ein Bild im Konto, tritt es an seine Stelle. Lädt es nicht, kommt
|
||||||
|
/// der Buchstabe zurück: eine leere Scheibe sagt weniger als ein P.
|
||||||
|
///
|
||||||
|
/// 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 {
|
||||||
|
let name: String
|
||||||
|
var diameter: CGFloat = 26
|
||||||
|
/// Das Bild des Kontos, falls eines hinterlegt ist.
|
||||||
|
var image: URL?
|
||||||
|
|
||||||
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
|
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())
|
||||||
|
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
|
||||||
|
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
|
||||||
|
// Kleinschrift auf einer grossen Scheibe sieht aus wie ein
|
||||||
|
// Fehler.
|
||||||
|
.font(.system(size: diameter * 0.46, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.flaroCanvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
/// Öffnet die Einstellungen.
|
||||||
|
///
|
||||||
|
/// **Über die Umgebung, weil der Knopf an mehreren Stellen steht.** Das
|
||||||
|
/// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs.
|
||||||
|
/// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst
|
||||||
|
/// als Argument durchgereicht bekommen.
|
||||||
|
@Entry var flaroOpenSettings: () -> Void = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Monogramm als Knopf für eine Werkzeugleiste.
|
||||||
|
///
|
||||||
|
/// **Als Ansicht und nicht als Modifikator.** Ein Modifikator hängt seine
|
||||||
|
/// Einträge aussen an, und SwiftUI reiht aussen zuerst — das Monogramm stand
|
||||||
|
/// damit **vor** Suche, Filter und Ansicht statt dahinter. Wo in der Reihe es
|
||||||
|
/// steht, kann nur der entscheiden, der die Reihe schreibt.
|
||||||
|
struct AccountToolbarButton: View {
|
||||||
|
@Environment(FlaroSession.self) private var session
|
||||||
|
@Environment(\.flaroOpenSettings) private var openSettings
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: openSettings) {
|
||||||
|
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
|
||||||
|
image: session.accountAvatarURL)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(Text("sidebar.settings"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Wer angemeldet ist — unten in der Seitenleiste.
|
/// Wer angemeldet ist — unten in der Seitenleiste.
|
||||||
///
|
///
|
||||||
@@ -22,7 +22,7 @@ import HaloDesign
|
|||||||
struct AccountBar: View {
|
struct AccountBar: View {
|
||||||
@Binding var selection: SidebarDestination
|
@Binding var selection: SidebarDestination
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@State private var showsSignOutConfirmation = false
|
@State private var showsSignOutConfirmation = false
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ struct AccountBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 240)
|
.frame(minWidth: 240)
|
||||||
.background(Color.haloSurface)
|
.background(Color.flaroSurface)
|
||||||
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
|
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
|
||||||
// steht trotzdem da, damit aus dem Popover dort kein
|
// steht trotzdem da, damit aus dem Popover dort kein
|
||||||
// bildschirmfüllendes Sheet wird.
|
// bildschirmfüllendes Sheet wird.
|
||||||
@@ -77,19 +77,19 @@ struct AccountBar: View {
|
|||||||
Button {
|
Button {
|
||||||
isExpanded.toggle()
|
isExpanded.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: HaloSpacing.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)
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
if let subtitle {
|
if let subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,10 +102,10 @@ struct AccountBar: View {
|
|||||||
// Zeigt nach oben: dorthin geht die Karte auf.
|
// Zeigt nach oben: dorthin geht die Karte auf.
|
||||||
Image(systemName: "chevron.up")
|
Image(systemName: "chevron.up")
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -121,18 +121,18 @@ struct AccountBar: View {
|
|||||||
) -> some View {
|
) -> some View {
|
||||||
Button(action: perform) {
|
Button(action: perform) {
|
||||||
Label(title, systemImage: symbol)
|
Label(title, systemImage: symbol)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(isDestructive ? Color.haloDestructive : Color.haloTextPrimary)
|
.foregroundStyle(isDestructive ? Color.flaroDestructive : Color.flaroTextPrimary)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.vertical, HaloSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var separator: some View {
|
private var separator: some View {
|
||||||
Divider().overlay(Color.haloSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
+69
-69
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Wo die Navigation sitzt.
|
/// Wo die Navigation sitzt.
|
||||||
@@ -49,7 +49,7 @@ struct NavigationGroup: Identifiable {
|
|||||||
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
|
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
|
||||||
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
|
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
|
||||||
/// „umrandet" und „aus Glas".
|
/// „umrandet" und „aus Glas".
|
||||||
struct HaloSidebar: View {
|
struct FlaroSidebar: View {
|
||||||
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
|
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
|
||||||
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
|
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
|
||||||
let groups: [NavigationGroup]
|
let groups: [NavigationGroup]
|
||||||
@@ -57,7 +57,7 @@ struct HaloSidebar: View {
|
|||||||
@Binding var placement: NavigationPlacement
|
@Binding var placement: NavigationPlacement
|
||||||
let layout: NavigationLayoutSettings
|
let layout: NavigationLayoutSettings
|
||||||
|
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
|
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
|
||||||
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
|
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
|
||||||
@State private var collapsed: Set<String> = []
|
@State private var collapsed: Set<String> = []
|
||||||
@@ -80,7 +80,7 @@ struct HaloSidebar: View {
|
|||||||
static func surface(in shape: some InsettableShape) -> some View {
|
static func surface(in shape: some InsettableShape) -> some View {
|
||||||
shape
|
shape
|
||||||
.fill(.ultraThinMaterial)
|
.fill(.ultraThinMaterial)
|
||||||
.overlay { shape.fill(Color.haloCanvas.opacity(0.55)) }
|
.overlay { shape.fill(Color.flaroCanvas.opacity(0.55)) }
|
||||||
.overlay {
|
.overlay {
|
||||||
shape.strokeBorder(
|
shape.strokeBorder(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
@@ -134,10 +134,10 @@ struct HaloSidebar: View {
|
|||||||
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
|
// Ziehen zum Umsortieren mit, und die scrollt von sich aus.
|
||||||
if isEditing {
|
if isEditing {
|
||||||
Text("sidebar.editHint")
|
Text("sidebar.editHint")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.bottom, HaloSpacing.snug)
|
.padding(.bottom, FlaroSpacing.snug)
|
||||||
|
|
||||||
List {
|
List {
|
||||||
ForEach(groups) { group in
|
ForEach(groups) { group in
|
||||||
@@ -149,8 +149,8 @@ struct HaloSidebar: View {
|
|||||||
} header: {
|
} header: {
|
||||||
if let title = group.title {
|
if let title = group.title {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -162,13 +162,13 @@ struct HaloSidebar: View {
|
|||||||
.environment(\.editMode, .constant(.active))
|
.environment(\.editMode, .constant(.active))
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
ForEach(groups) { group in
|
ForEach(groups) { group in
|
||||||
section(visible(group))
|
section(visible(group))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.snug)
|
.padding(.horizontal, FlaroSpacing.snug)
|
||||||
.padding(.bottom, HaloSpacing.regular)
|
.padding(.bottom, FlaroSpacing.regular)
|
||||||
}
|
}
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
@@ -176,14 +176,14 @@ struct HaloSidebar: View {
|
|||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
Divider().overlay(Color.haloSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
AccountBar(selection: $selection)
|
AccountBar(selection: $selection)
|
||||||
.padding(.horizontal, HaloSpacing.snug)
|
.padding(.horizontal, FlaroSpacing.snug)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.frame(width: Self.width)
|
.frame(width: Self.width)
|
||||||
.background { Self.surface(in: .rect(cornerRadius: HaloRadius.panel, style: .continuous)) }
|
.background { Self.surface(in: .rect(cornerRadius: FlaroRadius.panel, style: .continuous)) }
|
||||||
.clipShape(.rect(cornerRadius: HaloRadius.panel, style: .continuous))
|
.clipShape(.rect(cornerRadius: FlaroRadius.panel, style: .continuous))
|
||||||
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
|
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
|
||||||
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
|
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
|
||||||
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
|
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
|
||||||
@@ -194,18 +194,18 @@ struct HaloSidebar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
|
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
|
||||||
} label: {
|
} label: {
|
||||||
Text(isEditing ? "action.done" : "sidebar.edit")
|
Text(isEditing ? "action.done" : "sidebar.edit")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.padding(.horizontal, HaloSpacing.snug)
|
.padding(.horizontal, FlaroSpacing.snug)
|
||||||
.padding(.vertical, HaloSpacing.tight)
|
.padding(.vertical, FlaroSpacing.tight)
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(isEditing ? accent : Color.haloTextSecondary)
|
.foregroundStyle(isEditing ? accent : Color.flaroTextSecondary)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
@@ -214,21 +214,21 @@ struct HaloSidebar: View {
|
|||||||
placement = .top
|
placement = .top
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "rectangle.topthird.inset.filled")
|
Image(systemName: "rectangle.topthird.inset.filled")
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.padding(HaloSpacing.tight)
|
.padding(FlaroSpacing.tight)
|
||||||
// Ohne eigene Klickfläche reicht das Ziel nur so weit
|
// Ohne eigene Klickfläche reicht das Ziel nur so weit
|
||||||
// wie das Zeichen selbst — auf dem Mac trifft man das
|
// wie das Zeichen selbst — auf dem Mac trifft man das
|
||||||
// mit dem Zeiger kaum.
|
// mit dem Zeiger kaum.
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.accessibilityLabel(Text("sidebar.moveToTop"))
|
.accessibilityLabel(Text("sidebar.moveToTop"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.top, HaloSpacing.regular)
|
.padding(.top, FlaroSpacing.regular)
|
||||||
.padding(.bottom, HaloSpacing.snug)
|
.padding(.bottom, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
|
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
|
||||||
@@ -271,16 +271,16 @@ struct HaloSidebar: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
|
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.top, HaloSpacing.snug)
|
.padding(.top, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -301,12 +301,12 @@ struct HaloSidebar: View {
|
|||||||
private func editRow(_ entry: NavigationEntry) -> some View {
|
private func editRow(_ entry: NavigationEntry) -> some View {
|
||||||
let isHidden = layout.isHidden(entry.arrangementID)
|
let isHidden = layout.isHidden(entry.arrangementID)
|
||||||
|
|
||||||
return HStack(spacing: HaloSpacing.snug) {
|
return HStack(spacing: FlaroSpacing.snug) {
|
||||||
Button {
|
Button {
|
||||||
layout.setHidden(!isHidden, for: entry.arrangementID)
|
layout.setHidden(!isHidden, for: entry.arrangementID)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
|
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
|
||||||
.foregroundStyle(isHidden ? Color.haloTextMuted : accent)
|
.foregroundStyle(isHidden ? Color.flaroTextMuted : accent)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -318,8 +318,8 @@ struct HaloSidebar: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(isHidden ? Color.haloTextMuted : Color.haloTextPrimary)
|
.foregroundStyle(isHidden ? Color.flaroTextMuted : Color.flaroTextPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ entry: NavigationEntry) -> some View {
|
private func row(_ entry: NavigationEntry) -> some View {
|
||||||
@@ -328,23 +328,23 @@ struct HaloSidebar: View {
|
|||||||
return Button {
|
return Button {
|
||||||
selection = entry.destination
|
selection = entry.destination
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
Image(systemName: entry.symbol)
|
Image(systemName: entry.symbol)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.frame(width: 22, alignment: .center)
|
.frame(width: 22, alignment: .center)
|
||||||
entry.title
|
entry.title
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
// Ein langer Mediathekenname darf die Leiste nicht
|
// Ein langer Mediathekenname darf die Leiste nicht
|
||||||
// breiter machen; sie hat eine feste Breite.
|
// breiter machen; sie hat eine feste Breite.
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
.foregroundStyle(isSelected ? accent : Color.flaroTextPrimary)
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.background {
|
.background {
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Capsule().fill(Color.haloElevated)
|
Capsule().fill(Color.flaroElevated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
@@ -360,29 +360,29 @@ struct HaloSidebar: View {
|
|||||||
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
|
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
|
||||||
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
|
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
|
||||||
/// hinauswächst, wäre schlechter als ein Menü.
|
/// hinauswächst, wäre schlechter als ein Menü.
|
||||||
struct HaloTopBar: View {
|
struct FlaroTopBar: View {
|
||||||
let groups: [NavigationGroup]
|
let groups: [NavigationGroup]
|
||||||
@Binding var selection: SidebarDestination
|
@Binding var selection: SidebarDestination
|
||||||
@Binding var placement: NavigationPlacement
|
@Binding var placement: NavigationPlacement
|
||||||
|
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: HaloSpacing.tight) {
|
HStack(spacing: FlaroSpacing.tight) {
|
||||||
Button {
|
Button {
|
||||||
placement = .sidebar
|
placement = .sidebar
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "sidebar.leading")
|
Image(systemName: "sidebar.leading")
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.padding(.horizontal, HaloSpacing.regular)
|
.padding(.horizontal, FlaroSpacing.regular)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
|
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
|
||||||
// das Zeichen; auf dem Mac war der Umschalter damit
|
// das Zeichen; auf dem Mac war der Umschalter damit
|
||||||
// praktisch nicht zu treffen.
|
// praktisch nicht zu treffen.
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.accessibilityLabel(Text("sidebar.moveToSide"))
|
.accessibilityLabel(Text("sidebar.moveToSide"))
|
||||||
|
|
||||||
ForEach(groups) { group in
|
ForEach(groups) { group in
|
||||||
@@ -395,12 +395,12 @@ struct HaloTopBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.snug)
|
.padding(.horizontal, FlaroSpacing.snug)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.background { HaloSidebar.surface(in: .capsule) }
|
.background { FlaroSidebar.surface(in: .capsule) }
|
||||||
.clipShape(.capsule)
|
.clipShape(.capsule)
|
||||||
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||||
.padding(.top, HaloSpacing.snug)
|
.padding(.top, FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pill(_ entry: NavigationEntry) -> some View {
|
private func pill(_ entry: NavigationEntry) -> some View {
|
||||||
@@ -410,13 +410,13 @@ struct HaloTopBar: View {
|
|||||||
selection = entry.destination
|
selection = entry.destination
|
||||||
} label: {
|
} label: {
|
||||||
entry.title
|
entry.title
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary)
|
.foregroundStyle(isSelected ? accent : Color.flaroTextPrimary)
|
||||||
.padding(.horizontal, HaloSpacing.loose)
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.background {
|
.background {
|
||||||
if isSelected { Capsule().fill(Color.haloElevated) }
|
if isSelected { Capsule().fill(Color.flaroElevated) }
|
||||||
}
|
}
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
@@ -433,21 +433,21 @@ struct HaloTopBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: HaloSpacing.tight) {
|
HStack(spacing: FlaroSpacing.tight) {
|
||||||
if let title = group.title {
|
if let title = group.title {
|
||||||
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
|
// Ohne das wurde aus „Mediatheken" ein „Mediathe…".
|
||||||
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
|
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
|
||||||
// Grund, an einem Wort zu sparen.
|
// Grund, an einem Wort zu sparen.
|
||||||
Text(title).fixedSize()
|
Text(title).fixedSize()
|
||||||
}
|
}
|
||||||
Image(systemName: "chevron.down").font(.haloCaption)
|
Image(systemName: "chevron.down").font(.flaroCaption)
|
||||||
}
|
}
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(containsSelection ? accent : Color.haloTextPrimary)
|
.foregroundStyle(containsSelection ? accent : Color.flaroTextPrimary)
|
||||||
.padding(.horizontal, HaloSpacing.loose)
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.background {
|
.background {
|
||||||
if containsSelection { Capsule().fill(Color.haloElevated) }
|
if containsSelection { Capsule().fill(Color.flaroElevated) }
|
||||||
}
|
}
|
||||||
.contentShape(Capsule())
|
.contentShape(Capsule())
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ import MediaKit
|
|||||||
struct MediaStack<Content: View>: View {
|
struct MediaStack<Content: View>: View {
|
||||||
@ViewBuilder let content: () -> Content
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@State private var path = NavigationPath()
|
@State private var path = NavigationPath()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -31,7 +31,7 @@ struct MediaStack<Content: View>: View {
|
|||||||
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
|
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
|
||||||
.navigationDestination(for: SearchRoute.self) { _ in SearchView() }
|
.navigationDestination(for: SearchRoute.self) { _ in SearchView() }
|
||||||
}
|
}
|
||||||
.environment(\.haloPush) { ziel in path.append(ziel) }
|
.environment(\.flaroPush) { ziel in path.append(ziel) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,5 +45,5 @@ extension EnvironmentValues {
|
|||||||
///
|
///
|
||||||
/// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts
|
/// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts
|
||||||
/// zu schieben, und ein Absturz wäre die schlechtere Antwort.
|
/// zu schieben, und ein Absturz wäre die schlechtere Antwort.
|
||||||
@Entry var haloPush: (any Hashable) -> Void = { _ in }
|
@Entry var flaroPush: (any Hashable) -> Void = { _ in }
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Die Wurzelansicht.
|
/// Die Wurzelansicht.
|
||||||
///
|
///
|
||||||
@@ -10,8 +10,8 @@ import HaloPlayback
|
|||||||
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
|
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
|
||||||
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
|
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(HaloTheme.self) private var theme
|
@Environment(FlaroTheme.self) private var theme
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(LanguagePreferences.self) private var languages
|
@Environment(LanguagePreferences.self) private var languages
|
||||||
@@ -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.
|
||||||
@@ -35,13 +36,13 @@ struct RootView: View {
|
|||||||
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
|
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
|
||||||
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
|
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
|
||||||
/// zum Aufbewahren.
|
/// zum Aufbewahren.
|
||||||
@AppStorage("halo.navigation.customization")
|
@AppStorage("flaro.navigation.customization")
|
||||||
private var customization = TabViewCustomization()
|
private var customization = TabViewCustomization()
|
||||||
|
|
||||||
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
|
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
|
||||||
///
|
///
|
||||||
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
|
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
|
||||||
@AppStorage("halo.navigation.placement")
|
@AppStorage("flaro.navigation.placement")
|
||||||
private var storedPlacement: NavigationPlacement = .sidebar
|
private var storedPlacement: NavigationPlacement = .sidebar
|
||||||
|
|
||||||
/// Dieselbe Wahl, aber als Zustand dieser Ansicht.
|
/// Dieselbe Wahl, aber als Zustand dieser Ansicht.
|
||||||
@@ -85,7 +86,7 @@ struct RootView: View {
|
|||||||
case .launching:
|
case .launching:
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
|
|
||||||
case .signedOut:
|
case .signedOut:
|
||||||
SignInView()
|
SignInView()
|
||||||
@@ -104,7 +105,7 @@ struct RootView: View {
|
|||||||
library
|
library
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.environment(topChrome)
|
.environment(topChrome)
|
||||||
// **Aus dem Fenster, nicht aus dem Layout.**
|
// **Aus dem Fenster, nicht aus dem Layout.**
|
||||||
//
|
//
|
||||||
@@ -121,7 +122,7 @@ struct RootView: View {
|
|||||||
// Durchgang.
|
// Durchgang.
|
||||||
//
|
//
|
||||||
// **Nicht** die Ursache des Players, der oben und unten hinausging:
|
// **Nicht** die Ursache des Players, der oben und unten hinausging:
|
||||||
// das war die Seitenleiste selbst, siehe `HaloSidebar`. Diese Zeile
|
// das war die Seitenleiste selbst, siehe `FlaroSidebar`. Diese Zeile
|
||||||
// spart eine Layoutrunde, mehr nicht.
|
// spart eine Layoutrunde, mehr nicht.
|
||||||
.onGeometryChange(for: CGSize.self) { proxy in
|
.onGeometryChange(for: CGSize.self) { proxy in
|
||||||
proxy.size
|
proxy.size
|
||||||
@@ -129,7 +130,7 @@ struct RootView: View {
|
|||||||
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
|
.onAppear { statusBarHeight = ScreenSafeArea.current.top }
|
||||||
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen
|
||||||
// zuverlässig neu aus, ein Scene-Body nicht.
|
// zuverlässig neu aus, ein Scene-Body nicht.
|
||||||
.haloAccent(theme)
|
.flaroAccent(theme)
|
||||||
.tint(theme.accentColor)
|
.tint(theme.accentColor)
|
||||||
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
|
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
|
||||||
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
|
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
|
||||||
@@ -174,7 +175,7 @@ struct RootView: View {
|
|||||||
// ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer
|
// ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer
|
||||||
// Stunde wieder stand.
|
// Stunde wieder stand.
|
||||||
//
|
//
|
||||||
// Hier und nicht im Koordinator: `HaloDownloads` hängt bewusst nicht
|
// Hier und nicht im Koordinator: `FlaroDownloads` hängt bewusst nicht
|
||||||
// an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht
|
// an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht
|
||||||
// (siehe `TransferHold`). Diese Ansicht kennt beide.
|
// (siehe `TransferHold`). Diese Ansicht kennt beide.
|
||||||
.onChange(of: network.isReachable) { _, erreichbar in
|
.onChange(of: network.isReachable) { _, erreichbar in
|
||||||
@@ -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,
|
||||||
@@ -289,7 +291,7 @@ struct RootView: View {
|
|||||||
.overlay(alignment: .topTrailing) { profileButton }
|
.overlay(alignment: .topTrailing) { profileButton }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.customizationID("halo.tab.home")
|
.customizationID("flaro.tab.home")
|
||||||
|
|
||||||
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
|
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
|
||||||
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
|
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
|
||||||
@@ -307,12 +309,12 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
sidebarLabel(Text(library.title), symbol: library.kind.symbol)
|
sidebarLabel(Text(library.title), symbol: library.kind.symbol)
|
||||||
}
|
}
|
||||||
.customizationID("halo.tab.library.\(library.id)")
|
.customizationID("flaro.tab.library.\(library.id)")
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("sidebar.libraries")
|
Text("sidebar.libraries")
|
||||||
}
|
}
|
||||||
.customizationID("halo.tab.libraries")
|
.customizationID("flaro.tab.libraries")
|
||||||
|
|
||||||
if session.supportsLiveTV {
|
if session.supportsLiveTV {
|
||||||
Tab(value: SidebarDestination.liveTV) {
|
Tab(value: SidebarDestination.liveTV) {
|
||||||
@@ -320,7 +322,7 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
|
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
|
||||||
}
|
}
|
||||||
.customizationID("halo.tab.liveTV")
|
.customizationID("flaro.tab.liveTV")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Tab(value: SidebarDestination.libraryHub) {
|
Tab(value: SidebarDestination.libraryHub) {
|
||||||
@@ -328,7 +330,7 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
tabLabel("sidebar.library", symbol: "books.vertical")
|
tabLabel("sidebar.library", symbol: "books.vertical")
|
||||||
}
|
}
|
||||||
.customizationID("halo.tab.libraryHub")
|
.customizationID("flaro.tab.libraryHub")
|
||||||
}
|
}
|
||||||
|
|
||||||
Tab(value: SidebarDestination.downloads) {
|
Tab(value: SidebarDestination.downloads) {
|
||||||
@@ -336,7 +338,7 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
|
tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
|
||||||
}
|
}
|
||||||
.customizationID("halo.tab.downloads")
|
.customizationID("flaro.tab.downloads")
|
||||||
|
|
||||||
// **Nicht auf dem iPhone.** Dort führt der Weg über das
|
// **Nicht auf dem iPhone.** Dort führt der Weg über das
|
||||||
// Monogramm oben rechts — siehe `profileButton`. Einstellungen
|
// Monogramm oben rechts — siehe `profileButton`. Einstellungen
|
||||||
@@ -349,7 +351,7 @@ struct RootView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
tabLabel("sidebar.settings", symbol: "gearshape")
|
tabLabel("sidebar.settings", symbol: "gearshape")
|
||||||
}
|
}
|
||||||
.customizationID("halo.tab.settings")
|
.customizationID("flaro.tab.settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Suche nur dort, wo Platz für sie ist.
|
// Die Suche nur dort, wo Platz für sie ist.
|
||||||
@@ -362,7 +364,7 @@ struct RootView: View {
|
|||||||
Tab(value: SidebarDestination.search, role: .search) {
|
Tab(value: SidebarDestination.search, role: .search) {
|
||||||
mediaStack { SearchView() }
|
mediaStack { SearchView() }
|
||||||
}
|
}
|
||||||
.customizationID("halo.tab.search")
|
.customizationID("flaro.tab.search")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.sidebarAdaptable)
|
.tabViewStyle(.sidebarAdaptable)
|
||||||
@@ -389,7 +391,7 @@ struct RootView: View {
|
|||||||
if sizeClass == .regular {
|
if sizeClass == .regular {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
if placement == .sidebar {
|
if placement == .sidebar {
|
||||||
HaloSidebar(
|
FlaroSidebar(
|
||||||
groups: navigationGroups,
|
groups: navigationGroups,
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
placement: $placement.animation(Self.placementAnimation),
|
placement: $placement.animation(Self.placementAnimation),
|
||||||
@@ -433,7 +435,7 @@ struct RootView: View {
|
|||||||
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
|
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if placement == .top {
|
if placement == .top {
|
||||||
HaloTopBar(
|
FlaroTopBar(
|
||||||
groups: navigationGroups.map(visible),
|
groups: navigationGroups.map(visible),
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
placement: $placement.animation(Self.placementAnimation)
|
placement: $placement.animation(Self.placementAnimation)
|
||||||
@@ -473,7 +475,7 @@ struct RootView: View {
|
|||||||
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
|
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
|
||||||
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
|
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
|
||||||
// statt in einem Bereich, den man erst wieder verlassen muss.
|
// statt in einem Bereich, den man erst wieder verlassen muss.
|
||||||
.environment(\.haloOpenSettings, { showsSettings = true })
|
.environment(\.flaroOpenSettings, { showsSettings = true })
|
||||||
.sheet(isPresented: $showsSettings) {
|
.sheet(isPresented: $showsSettings) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
@@ -481,7 +483,7 @@ struct RootView: View {
|
|||||||
// jeder weiss das, und wer in einem Unterbildschirm steht,
|
// jeder weiss das, und wer in einem Unterbildschirm steht,
|
||||||
// zieht damit gleich alles weg. Ein Knopf sagt es.
|
// zieht damit gleich alles weg. Ein Knopf sagt es.
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .haloTrailing) {
|
ToolbarItem(placement: .flaroTrailing) {
|
||||||
Button("action.close") { showsSettings = false }
|
Button("action.close") { showsSettings = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,17 +516,18 @@ 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,
|
||||||
.padding(HaloSpacing.snug)
|
image: session.accountAvatarURL)
|
||||||
|
.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)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.trailing, HaloSpacing.regular)
|
.padding(.trailing, FlaroSpacing.regular)
|
||||||
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
|
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
|
||||||
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
|
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
|
||||||
// Knopf um ihre Höhe nach unten, mitten ins Bild.
|
// Knopf um ihre Höhe nach unten, mitten ins Bild.
|
||||||
.padding(.top, HaloSpacing.snug)
|
.padding(.top, FlaroSpacing.snug)
|
||||||
.opacity(topChrome.isRevealed ? 1 : 0)
|
.opacity(topChrome.isRevealed ? 1 : 0)
|
||||||
.allowsHitTesting(topChrome.isRevealed)
|
.allowsHitTesting(topChrome.isRevealed)
|
||||||
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
|
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
|
||||||
@@ -543,7 +546,7 @@ struct RootView: View {
|
|||||||
/// schwebt nichts, worunter man greifen müsste.
|
/// schwebt nichts, worunter man greifen müsste.
|
||||||
private var topInset: CGFloat {
|
private var topInset: CGFloat {
|
||||||
guard sizeClass == .regular else { return 0 }
|
guard sizeClass == .regular else { return 0 }
|
||||||
return statusBarHeight + (placement == .sidebar ? HaloSidebar.margin : Self.topBarHeight)
|
return statusBarHeight + (placement == .sidebar ? FlaroSidebar.margin : Self.topBarHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var navigationGroups: [NavigationGroup] {
|
private var navigationGroups: [NavigationGroup] {
|
||||||
@@ -40,7 +40,7 @@ extension View {
|
|||||||
/// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht
|
/// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht
|
||||||
/// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite
|
/// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite
|
||||||
/// weit unten steht.
|
/// weit unten steht.
|
||||||
func haloRevealsTopChrome() -> some View {
|
func flaroRevealsTopChrome() -> some View {
|
||||||
modifier(RevealsTopChrome())
|
modifier(RevealsTopChrome())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Ein noch nicht gebauter Bereich.
|
/// Ein noch nicht gebauter Bereich.
|
||||||
///
|
///
|
||||||
@@ -15,9 +15,9 @@ struct PlaceholderScreen: View {
|
|||||||
} description: {
|
} description: {
|
||||||
Text("placeholder.description")
|
Text("placeholder.description")
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(destination.title)
|
.navigationTitle(destination.title)
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Die schwebende Videokachel.
|
/// Die schwebende Videokachel.
|
||||||
///
|
///
|
||||||
@@ -51,7 +51,7 @@ struct MiniPlayer: View {
|
|||||||
// ist als das Fenster, ließe sich nicht mehr wegziehen.
|
// ist als das Fenster, ließe sich nicht mehr wegziehen.
|
||||||
let upper = max(160, min(bounds.width * 0.75, 560))
|
let upper = max(160, min(bounds.width * 0.75, 560))
|
||||||
let width = min(max(chosen * zoom, 140), upper)
|
let width = min(max(chosen * zoom, 140), upper)
|
||||||
return CGSize(width: width, height: (width / HaloAspectRatio.landscape).rounded())
|
return CGSize(width: width, height: (width / FlaroAspectRatio.landscape).rounded())
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -105,10 +105,10 @@ struct MiniPlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
.clipShape(.rect(cornerRadius: FlaroRadius.card))
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: HaloRadius.card)
|
RoundedRectangle(cornerRadius: FlaroRadius.card)
|
||||||
.strokeBorder(Color.haloSeparator, lineWidth: 0.5)
|
.strokeBorder(Color.flaroSeparator, lineWidth: 0.5)
|
||||||
}
|
}
|
||||||
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
|
.shadow(color: .black.opacity(0.5), radius: 12, y: 4)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
@@ -158,9 +158,9 @@ struct MiniPlayer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.tight)
|
.padding(FlaroSpacing.tight)
|
||||||
// Über dem Fortschrittsbalken, nicht darauf.
|
// Über dem Fortschrittsbalken, nicht darauf.
|
||||||
.padding(.bottom, HaloSpacing.tight)
|
.padding(.bottom, FlaroSpacing.tight)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
|
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
|
||||||
+96
-2
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
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,7 +35,12 @@ 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
|
||||||
|
|
||||||
@Environment(\.haloAccent) private var accent
|
/// 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
|
||||||
|
|
||||||
private static let trackHeight: CGFloat = 5
|
private static let trackHeight: CGFloat = 5
|
||||||
private static let thumbSize: CGFloat = 13
|
private static let thumbSize: CGFloat = 13
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import AVKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MediaKit
|
import MediaKit
|
||||||
import Observation
|
import Observation
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Führt einen Abspielwunsch aus.
|
/// Führt einen Abspielwunsch aus.
|
||||||
///
|
///
|
||||||
@@ -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
|
||||||
@@ -169,10 +200,10 @@ final class PlayerModel {
|
|||||||
|
|
||||||
/// Wo das Bild aufhört.
|
/// Wo das Bild aufhört.
|
||||||
///
|
///
|
||||||
/// Liegt bei `HaloPlayback`, weil beide Engines es brauchen. Der Name
|
/// Liegt bei `FlaroPlayback`, weil beide Engines es brauchen. Der Name
|
||||||
/// hier bleibt, damit die Ansicht weiter `PlayerModel.VideoFit` schreiben
|
/// hier bleibt, damit die Ansicht weiter `PlayerModel.VideoFit` schreiben
|
||||||
/// kann.
|
/// kann.
|
||||||
typealias VideoFit = HaloPlayback.VideoFit
|
typealias VideoFit = FlaroPlayback.VideoFit
|
||||||
|
|
||||||
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
|
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
|
||||||
///
|
///
|
||||||
@@ -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? {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Der Vollbildplayer.
|
/// Der Vollbildplayer.
|
||||||
struct PlayerView: View {
|
struct PlayerView: View {
|
||||||
@@ -13,14 +13,14 @@ struct PlayerView: View {
|
|||||||
/// Nutzers statt eine Nebenwirkung der Navigation.
|
/// Nutzers statt eine Nebenwirkung der Navigation.
|
||||||
let model: PlayerModel
|
let model: PlayerModel
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@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(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
/// Woran sich Quer- und Hochformat unterscheiden lassen.
|
/// Woran sich Quer- und Hochformat unterscheiden lassen.
|
||||||
///
|
///
|
||||||
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
|
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
|
||||||
@@ -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?
|
||||||
@@ -72,7 +81,7 @@ struct PlayerView: View {
|
|||||||
/// Knopf.
|
/// Knopf.
|
||||||
@State private var isFullScreen = false
|
@State private var isFullScreen = false
|
||||||
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
|
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
|
||||||
private var isMac: Bool { HaloPlatform.isMac }
|
private var isMac: Bool { FlaroPlatform.isMac }
|
||||||
/// Ob gedreht und mit zwei Fingern gezoomt werden kann.
|
/// Ob gedreht und mit zwei Fingern gezoomt werden kann.
|
||||||
///
|
///
|
||||||
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
|
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
|
||||||
@@ -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)
|
||||||
@@ -200,7 +230,7 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
.opacity(zeigt ? 1 : 0)
|
.opacity(zeigt ? 1 : 0)
|
||||||
.allowsHitTesting(zeigt)
|
.allowsHitTesting(zeigt)
|
||||||
.padding(HaloSpacing.loose)
|
.padding(FlaroSpacing.loose)
|
||||||
// Siehe `controls(for:)`: die Kopfzeile lag sonst hinter
|
// Siehe `controls(for:)`: die Kopfzeile lag sonst hinter
|
||||||
// der Dynamic Island, und der Zurück-Pfeil war tot.
|
// der Dynamic Island, und der Zurück-Pfeil war tot.
|
||||||
.padding(.top, safeArea.top)
|
.padding(.top, safeArea.top)
|
||||||
@@ -217,7 +247,7 @@ struct PlayerView: View {
|
|||||||
// ein zweiter Klick auf dieselbe Stelle legte das Fenster
|
// ein zweiter Klick auf dieselbe Stelle legte das Fenster
|
||||||
// ins Dock, weil das der Doppelklick auf eine
|
// ins Dock, weil das der Doppelklick auf eine
|
||||||
// Fensterleiste tut.
|
// Fensterleiste tut.
|
||||||
.padding(.top, HaloPlatform.isMac ? Self.titleBarHeight : 0)
|
.padding(.top, FlaroPlatform.isMac ? Self.titleBarHeight : 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,12 +364,23 @@ struct PlayerView: View {
|
|||||||
// kein Absturz, aber die falsche Farbe.
|
// kein Absturz, aber die falsche Farbe.
|
||||||
.sheet(item: $trackPicker) { kind in
|
.sheet(item: $trackPicker) { kind in
|
||||||
TrackPickerSheet(model: model, kind: kind)
|
TrackPickerSheet(model: model, kind: kind)
|
||||||
.environment(\.haloAccent, accent)
|
.environment(\.flaroAccent, accent)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showsQualityChoice) {
|
.sheet(isPresented: $showsQualityChoice) {
|
||||||
QualitySheet(model: model)
|
QualitySheet(model: model)
|
||||||
.environment(settings)
|
.environment(settings)
|
||||||
.environment(\.haloAccent, 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
|
||||||
@@ -371,7 +412,7 @@ struct PlayerView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
scrubber(for: model)
|
scrubber(for: model)
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.loose)
|
.padding(FlaroSpacing.loose)
|
||||||
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
|
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
|
||||||
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
|
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
|
||||||
// soll. Ersetzte eines das andere, klebte der Schieberegler im
|
// soll. Ersetzte eines das andere, klebte der Schieberegler im
|
||||||
@@ -395,7 +436,7 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func topBar(for model: PlayerModel) -> some View {
|
private func topBar(for model: PlayerModel) -> some View {
|
||||||
HStack(alignment: .top, spacing: HaloSpacing.regular) {
|
HStack(alignment: .top, spacing: FlaroSpacing.regular) {
|
||||||
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
|
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
|
||||||
// Zweck der Übung: weiterschauen, während man stöbert.
|
// Zweck der Übung: weiterschauen, während man stöbert.
|
||||||
Button {
|
Button {
|
||||||
@@ -403,7 +444,7 @@ struct PlayerView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.minimize"))
|
.accessibilityLabel(Text("player.minimize"))
|
||||||
|
|
||||||
@@ -414,17 +455,17 @@ struct PlayerView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.stop"))
|
.accessibilityLabel(Text("player.stop"))
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(model.title)
|
Text(model.title)
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let subtitle = model.subtitle {
|
if let subtitle = model.subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
@@ -447,7 +488,7 @@ struct PlayerView: View {
|
|||||||
? "arrow.down.right.and.arrow.up.left"
|
? "arrow.down.right.and.arrow.up.left"
|
||||||
: "arrow.up.left.and.arrow.down.right")
|
: "arrow.up.left.and.arrow.down.right")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text(isFullScreen ? "player.exitFullScreen" : "player.fullScreen"))
|
.accessibilityLabel(Text(isFullScreen ? "player.exitFullScreen" : "player.fullScreen"))
|
||||||
.keyboardShortcut("f", modifiers: [.command, .control])
|
.keyboardShortcut("f", modifiers: [.command, .control])
|
||||||
@@ -465,7 +506,7 @@ struct PlayerView: View {
|
|||||||
? "arrow.down.right.and.arrow.up.left"
|
? "arrow.down.right.and.arrow.up.left"
|
||||||
: "arrow.up.left.and.arrow.down.right")
|
: "arrow.up.left.and.arrow.down.right")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo"))
|
.accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo"))
|
||||||
}
|
}
|
||||||
@@ -488,7 +529,7 @@ struct PlayerView: View {
|
|||||||
// Zwei Pfeile im Kreis sagen, was passiert: es dreht.
|
// Zwei Pfeile im Kreis sagen, was passiert: es dreht.
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.rotate"))
|
.accessibilityLabel(Text("player.rotate"))
|
||||||
}
|
}
|
||||||
@@ -524,7 +565,7 @@ struct PlayerView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: model.isPictureInPictureActive ? "pip.exit" : "pip.enter")
|
Image(systemName: model.isPictureInPictureActive ? "pip.exit" : "pip.enter")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text("player.pictureInPicture"))
|
.accessibilityLabel(Text("player.pictureInPicture"))
|
||||||
}
|
}
|
||||||
@@ -532,7 +573,7 @@ struct PlayerView: View {
|
|||||||
if model.engine?.supportsAirPlay == true {
|
if model.engine?.supportsAirPlay == true {
|
||||||
AirPlayButton(tint: accent)
|
AirPlayButton(tint: accent)
|
||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
.accessibilityLabel(Text("player.airPlay"))
|
.accessibilityLabel(Text("player.airPlay"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +595,7 @@ struct PlayerView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text(label))
|
.accessibilityLabel(Text(label))
|
||||||
}
|
}
|
||||||
@@ -578,15 +619,15 @@ struct PlayerView: View {
|
|||||||
// sagen nicht, was eingestellt ist; „1080p" schon — und genau
|
// sagen nicht, was eingestellt ist; „1080p" schon — und genau
|
||||||
// das ist die Frage, wegen der man hier hintippt.
|
// das ist die Frage, wegen der man hier hintippt.
|
||||||
Text(model.qualityLabel)
|
Text(model.qualityLabel)
|
||||||
.font(.haloCaption.weight(.semibold))
|
.font(.flaroCaption.weight(.semibold))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.padding(.horizontal, HaloSpacing.snug)
|
.padding(.horizontal, FlaroSpacing.snug)
|
||||||
.padding(.vertical, HaloSpacing.tight)
|
.padding(.vertical, FlaroSpacing.tight)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: HaloRadius.card)
|
RoundedRectangle(cornerRadius: FlaroRadius.card)
|
||||||
.strokeBorder(.white.opacity(0.5), lineWidth: 1)
|
.strokeBorder(.white.opacity(0.5), lineWidth: 1)
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.snug)
|
.padding(FlaroSpacing.snug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(model.isChangingQuality)
|
.disabled(model.isChangingQuality)
|
||||||
@@ -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: HaloSpacing.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)
|
||||||
}
|
}
|
||||||
@@ -652,7 +720,7 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scrubber(for model: PlayerModel) -> some View {
|
private func scrubber(for model: PlayerModel) -> some View {
|
||||||
VStack(spacing: HaloSpacing.tight) {
|
VStack(spacing: FlaroSpacing.tight) {
|
||||||
PlaybackScrubber(
|
PlaybackScrubber(
|
||||||
position: model.currentTime,
|
position: model.currentTime,
|
||||||
duration: model.duration ?? .seconds(1),
|
duration: model.duration ?? .seconds(1),
|
||||||
@@ -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,47 +753,87 @@ 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)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("player.nextEpisode", systemImage: "forward.end.fill")
|
Label("player.nextEpisode", systemImage: "forward.end.fill")
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.padding(.horizontal, HaloSpacing.loose)
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
.padding(.vertical, HaloSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
}
|
}
|
||||||
.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: {
|
||||||
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
|
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.padding(.horizontal, HaloSpacing.loose)
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
.padding(.vertical, HaloSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.white)
|
.tint(.white)
|
||||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, HaloSpacing.section)
|
.padding(.horizontal, FlaroSpacing.section)
|
||||||
.padding(.leading, safeArea.leading)
|
.padding(.leading, safeArea.leading)
|
||||||
.padding(.trailing, safeArea.trailing)
|
.padding(.trailing, safeArea.trailing)
|
||||||
// Ü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 : HaloSpacing.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.
|
||||||
@@ -731,10 +841,10 @@ struct PlayerView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
Color.black.ignoresSafeArea()
|
Color.black.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: HaloSpacing.regular) {
|
VStack(spacing: FlaroSpacing.regular) {
|
||||||
ProgressView().tint(.white)
|
ProgressView().tint(.white)
|
||||||
Text("player.restoring")
|
Text("player.restoring")
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
// Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass
|
// Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass
|
||||||
// niemand denkt, er müsse sie wiederfinden.
|
// niemand denkt, er müsse sie wiederfinden.
|
||||||
@@ -747,33 +857,33 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func preparing(for model: PlayerModel) -> some View {
|
private func preparing(for model: PlayerModel) -> some View {
|
||||||
VStack(spacing: HaloSpacing.loose) {
|
VStack(spacing: FlaroSpacing.loose) {
|
||||||
ProgressView().tint(.white)
|
ProgressView().tint(.white)
|
||||||
Text(model.title)
|
Text(model.title)
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Text("player.preparing")
|
Text("player.preparing")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.section)
|
.padding(FlaroSpacing.section)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func failure(message: String) -> some View {
|
private func failure(message: String) -> some View {
|
||||||
VStack(spacing: HaloSpacing.loose) {
|
VStack(spacing: FlaroSpacing.loose) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
Button("action.close") { playback.close() }
|
Button("action.close") { playback.close() }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.section)
|
.padding(FlaroSpacing.section)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Die beiden Flächen zum Springen.
|
/// Die beiden Flächen zum Springen.
|
||||||
@@ -821,7 +931,7 @@ struct PlayerView: View {
|
|||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
.font(.system(size: 44))
|
.font(.system(size: 44))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(HaloSpacing.section)
|
.padding(FlaroSpacing.section)
|
||||||
.background(.black.opacity(0.35), in: .circle)
|
.background(.black.opacity(0.35), in: .circle)
|
||||||
.transition(.opacity.combined(with: .scale))
|
.transition(.opacity.combined(with: .scale))
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
|
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
|
||||||
///
|
///
|
||||||
@@ -11,7 +11,7 @@ import HaloPlayback
|
|||||||
struct QualitySheet: View {
|
struct QualitySheet: View {
|
||||||
let model: PlayerModel
|
let model: PlayerModel
|
||||||
|
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
/// **Als Fragezeichen**, nicht als Behauptung.
|
/// **Als Fragezeichen**, nicht als Behauptung.
|
||||||
///
|
///
|
||||||
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
|
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
|
||||||
@@ -62,14 +62,14 @@ struct QualitySheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(Text("player.quality"))
|
.navigationTitle(Text("player.quality"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.done") { dismiss() }
|
Button("action.done") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.haloResizableSheet()
|
.flaroResizableSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Was gerade läuft — gemessen, nicht gewünscht.
|
/// Was gerade läuft — gemessen, nicht gewünscht.
|
||||||
@@ -158,11 +158,11 @@ struct QualitySheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(label(for: option))
|
Text(label(for: option))
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
if let detail {
|
if let detail {
|
||||||
Text(detail)
|
Text(detail)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if option == model.quality {
|
if option == model.quality {
|
||||||
+9
-9
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
|
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
|
||||||
@@ -27,7 +27,7 @@ struct TrackPickerSheet: View {
|
|||||||
let kind: Kind
|
let kind: Kind
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -68,7 +68,7 @@ struct TrackPickerSheet: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward")
|
Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward")
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
} header: {
|
} header: {
|
||||||
Text("player.rememberedTrack")
|
Text("player.rememberedTrack")
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -121,23 +121,23 @@ struct TrackPickerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text(kind.title))
|
.navigationTitle(Text(kind.title))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("action.close") { dismiss() }
|
Button("action.close") { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.haloMediumSheet()
|
.flaroMediumSheet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||||
@@ -146,7 +146,7 @@ struct TrackPickerSheet: View {
|
|||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import AVKit
|
import AVKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Sucht über alle Mediatheken des Servers.
|
/// Sucht über alle Mediatheken des Servers.
|
||||||
@@ -8,7 +8,7 @@ import MediaKit
|
|||||||
/// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server
|
/// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server
|
||||||
/// stellt. Siehe dort, warum das kein Beiwerk ist.
|
/// stellt. Siehe dort, warum das kein Beiwerk ist.
|
||||||
struct SearchView: View {
|
struct SearchView: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
|
|
||||||
@State private var text = ""
|
@State private var text = ""
|
||||||
@State private var feed = SearchFeed()
|
@State private var feed = SearchFeed()
|
||||||
@@ -23,9 +23,9 @@ struct SearchView: View {
|
|||||||
results
|
results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("search.title"))
|
.navigationTitle(Text("search.title"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.searchable(text: $text)
|
.searchable(text: $text)
|
||||||
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
|
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
|
||||||
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
|
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
|
||||||
@@ -35,12 +35,12 @@ struct SearchView: View {
|
|||||||
// in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus
|
// in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus
|
||||||
// dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah
|
// dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah
|
||||||
// aus, als wäre nichts passiert.
|
// aus, als wäre nichts passiert.
|
||||||
.environment(\.haloItemChanged, { await feed.search(text, from: session.backend) })
|
.environment(\.flaroItemChanged, { await feed.search(text, from: session.backend) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private var results: some View {
|
private var results: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) {
|
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
ForEach(feed.hubs) { hub in
|
ForEach(feed.hubs) { hub in
|
||||||
// **Antippen führt auf die Seite, nicht in den Film.** Die
|
// **Antippen führt auf die Seite, nicht in den Film.** Die
|
||||||
// Suche benutzt dieselbe Reihe wie die Startseite — und
|
// Suche benutzt dieselbe Reihe wie die Startseite — und
|
||||||
@@ -48,12 +48,12 @@ 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, HaloSpacing.loose)
|
.padding(.vertical, FlaroSpacing.loose)
|
||||||
}
|
}
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -72,7 +72,7 @@ struct SearchView: View {
|
|||||||
text = entry
|
text = entry
|
||||||
} label: {
|
} label: {
|
||||||
Label(entry, systemImage: "clock.arrow.circlepath")
|
Label(entry, systemImage: "clock.arrow.circlepath")
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -81,12 +81,12 @@ struct SearchView: View {
|
|||||||
Button("search.forgetRecent", role: .destructive) {
|
Button("search.forgetRecent", role: .destructive) {
|
||||||
feed.forgetRecent()
|
feed.forgetRecent()
|
||||||
}
|
}
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
}
|
}
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+28
-28
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Die Farbeinstellungen.
|
/// Die Farbeinstellungen.
|
||||||
///
|
///
|
||||||
@@ -8,22 +8,22 @@ import HaloDesign
|
|||||||
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
|
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
|
||||||
/// stillschweigend zu verändern.
|
/// stillschweigend zu verändern.
|
||||||
struct AppearanceView: View {
|
struct AppearanceView: View {
|
||||||
@Environment(HaloTheme.self) private var theme
|
@Environment(FlaroTheme.self) private var theme
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
@State private var customHex: String = ""
|
@State private var customHex: String = ""
|
||||||
@State private var showsCustomField = false
|
@State private var showsCustomField = false
|
||||||
|
|
||||||
private let columns = [GridItem(.adaptive(minimum: 64), spacing: HaloSpacing.regular)]
|
private let columns = [GridItem(.adaptive(minimum: 64), spacing: FlaroSpacing.regular)]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
LazyVGrid(columns: columns, spacing: HaloSpacing.regular) {
|
LazyVGrid(columns: columns, spacing: FlaroSpacing.regular) {
|
||||||
ForEach(AccentOption.palette) { option in
|
ForEach(AccentOption.palette) { option in
|
||||||
swatch(option)
|
swatch(option)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
} header: {
|
} header: {
|
||||||
Text("appearance.accent")
|
Text("appearance.accent")
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -38,7 +38,7 @@ struct AppearanceView: View {
|
|||||||
|
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0))))
|
Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0))))
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
} label: {
|
} label: {
|
||||||
Text("appearance.cardScale")
|
Text("appearance.cardScale")
|
||||||
@@ -52,7 +52,7 @@ struct AppearanceView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("appearance.cardScaleExplanation")
|
Text("appearance.cardScaleExplanation")
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
|
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
|
||||||
@@ -60,7 +60,7 @@ struct AppearanceView: View {
|
|||||||
if showsCustomField {
|
if showsCustomField {
|
||||||
HStack {
|
HStack {
|
||||||
TextField("appearance.hexPlaceholder", text: $customHex)
|
TextField("appearance.hexPlaceholder", text: $customHex)
|
||||||
.haloPlainEntry()
|
.flaroPlainEntry()
|
||||||
.onSubmit(applyCustomColor)
|
.onSubmit(applyCustomColor)
|
||||||
|
|
||||||
Button("action.apply", action: applyCustomColor)
|
Button("action.apply", action: applyCustomColor)
|
||||||
@@ -69,8 +69,8 @@ struct AppearanceView: View {
|
|||||||
|
|
||||||
if !customHex.isEmpty, RGB(string: customHex) == nil {
|
if !customHex.isEmpty, RGB(string: customHex) == nil {
|
||||||
Text("appearance.invalidHex")
|
Text("appearance.invalidHex")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloDestructive)
|
.foregroundStyle(Color.flaroDestructive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +80,11 @@ struct AppearanceView: View {
|
|||||||
// als im Farbwähler, und niemand wüsste warum.
|
// als im Farbwähler, und niemand wüsste warum.
|
||||||
Label {
|
Label {
|
||||||
Text("appearance.adjusted \(theme.accent.hexString)")
|
Text("appearance.adjusted \(theme.accent.hexString)")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: "info.circle")
|
Image(systemName: "info.circle")
|
||||||
}
|
}
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,11 +92,11 @@ struct AppearanceView: View {
|
|||||||
PreviewRow()
|
PreviewRow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.haloForm()
|
.flaroForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("appearance.title"))
|
.navigationTitle(Text("appearance.title"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if theme.selection.id == AccentOption.customIdentifier {
|
if theme.selection.id == AccentOption.customIdentifier {
|
||||||
customHex = theme.selection.color.hexString
|
customHex = theme.selection.color.hexString
|
||||||
@@ -119,13 +119,13 @@ struct AppearanceView: View {
|
|||||||
// Dunkel auf der Farbe, nicht weiß: die Palette
|
// Dunkel auf der Farbe, nicht weiß: die Palette
|
||||||
// enthält helle Töne wie Amber, auf denen Weiß
|
// enthält helle Töne wie Amber, auf denen Weiß
|
||||||
// verschwinden würde.
|
// verschwinden würde.
|
||||||
.foregroundStyle(Color.haloCanvas)
|
.foregroundStyle(Color.flaroCanvas)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
Circle()
|
Circle()
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
theme.selection == option ? Color.haloTextPrimary : .clear,
|
theme.selection == option ? Color.flaroTextPrimary : .clear,
|
||||||
lineWidth: 2
|
lineWidth: 2
|
||||||
)
|
)
|
||||||
.padding(-4)
|
.padding(-4)
|
||||||
@@ -144,27 +144,27 @@ struct AppearanceView: View {
|
|||||||
|
|
||||||
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
|
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
|
||||||
private struct PreviewRow: View {
|
private struct PreviewRow: View {
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
Label("hero.play", systemImage: "play.fill")
|
Label("hero.play", systemImage: "play.fill")
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.padding(.horizontal, HaloSpacing.loose)
|
.padding(.horizontal, FlaroSpacing.loose)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.background(accent, in: .rect(cornerRadius: HaloRadius.control))
|
.background(accent, in: .rect(cornerRadius: FlaroRadius.control))
|
||||||
.foregroundStyle(Color.haloCanvas)
|
.foregroundStyle(Color.flaroCanvas)
|
||||||
|
|
||||||
Label("sidebar.downloads", systemImage: "arrow.down.circle")
|
Label("sidebar.downloads", systemImage: "arrow.down.circle")
|
||||||
.font(.haloCardTitle)
|
.font(.flaroCardTitle)
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
WatchProgressBar(fraction: 0.45)
|
WatchProgressBar(fraction: 0.45)
|
||||||
}
|
}
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
import MediaKit
|
||||||
|
|
||||||
|
/// „Welche Sprache gilt hier?" — an mehreren Orten dieselbe Frage.
|
||||||
|
///
|
||||||
|
/// **Warum ein gemeinsamer Typ.** Die Frage steht auf der Serienseite, auf
|
||||||
|
/// der Filmseite und bei der Mediathek, und die Antwort bedeutet jedes Mal
|
||||||
|
/// dasselbe: diese Ebene setzt sich gegen die darüber durch, oder sie tut es
|
||||||
|
/// nicht. Mehrere Auswahlknöpfe mit demselben Inhalt liefen unweigerlich
|
||||||
|
/// 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
|
||||||
|
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
||||||
|
/// kann. Ein Menü sieht überall gleich aus.
|
||||||
|
struct AudioLanguagePicker: View {
|
||||||
|
/// Für welche Ebene gewählt wird.
|
||||||
|
enum Scope {
|
||||||
|
/// Eine Serie oder ein Film — beides eine Kennung, beides ein Titel.
|
||||||
|
case series(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 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(\.flaroAccent) private var accent
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch form {
|
||||||
|
case .zeile:
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Das Menü selbst — zweimal dieselben Einträge, zwei Aufhänger.
|
||||||
|
private func menü(@ViewBuilder label: () -> some View) -> some View {
|
||||||
|
Menu { auswahlEintraege } label: { label() }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
||||||
|
// durchreichen.
|
||||||
|
Button {
|
||||||
|
set(nil)
|
||||||
|
} label: {
|
||||||
|
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// **„Keine" nur beim Untertitel.** Beim Ton gibt es sie
|
||||||
|
// nicht: ein Film ohne Tonspur ist kein Wunsch, sondern ein
|
||||||
|
// Fehler.
|
||||||
|
if kind == .subtitles {
|
||||||
|
Button {
|
||||||
|
set(LanguagePreferences.noSubtitles)
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
"languages.none",
|
||||||
|
systemImage: current == LanguagePreferences.noSubtitles
|
||||||
|
? "checkmark" : ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
ForEach(options, id: \.code) { option in
|
||||||
|
Button {
|
||||||
|
set(option.code)
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
option.name,
|
||||||
|
systemImage: current == option.code ? "checkmark" : ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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? {
|
||||||
|
switch (scope, kind) {
|
||||||
|
case (.series(let id), .audio): languages.audioLanguage(forSeries: 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 {
|
||||||
|
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?) {
|
||||||
|
switch (scope, kind) {
|
||||||
|
case (.series(let id), .audio): languages.setAudioLanguage(code, forSeries: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func name(of code: String) -> String {
|
||||||
|
Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-16
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Was beim letzten Abspielversuch passiert ist.
|
/// Was beim letzten Abspielversuch passiert ist.
|
||||||
///
|
///
|
||||||
@@ -7,8 +7,8 @@ import HaloDesign
|
|||||||
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
|
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
|
||||||
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
|
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
|
||||||
struct DiagnosticsView: View {
|
struct DiagnosticsView: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -17,14 +17,14 @@ struct DiagnosticsView: View {
|
|||||||
LabeledContent("settings.serverName", value: session.serverName)
|
LabeledContent("settings.serverName", value: session.serverName)
|
||||||
if let address = session.serverAddress {
|
if let address = session.serverAddress {
|
||||||
LabeledContent("settings.address", value: address)
|
LabeledContent("settings.address", value: address)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
}
|
}
|
||||||
if let beschreibung = session.connectionDescription {
|
if let beschreibung = session.connectionDescription {
|
||||||
LabeledContent("settings.connection", value: beschreibung)
|
LabeledContent("settings.connection", value: beschreibung)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("diagnostics.noServer").foregroundStyle(Color.haloTextMuted)
|
Text("diagnostics.noServer").foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,11 +36,11 @@ struct DiagnosticsView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("diagnostics.source")
|
Text("diagnostics.source")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
Text(attempt.sourceDescription)
|
Text(attempt.sourceDescription)
|
||||||
.font(.system(.caption2, design: .monospaced))
|
.font(.system(.caption2, design: .monospaced))
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +48,13 @@ struct DiagnosticsView: View {
|
|||||||
ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in
|
ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in
|
||||||
Text(step)
|
Text(step)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
HaloClipboard.copy(report(for: attempt))
|
FlaroClipboard.copy(report(for: attempt))
|
||||||
} label: {
|
} label: {
|
||||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ struct DiagnosticsView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Section("diagnostics.lastAttempt") {
|
Section("diagnostics.lastAttempt") {
|
||||||
Text("diagnostics.nothingYet").foregroundStyle(Color.haloTextMuted)
|
Text("diagnostics.nothingYet").foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +79,11 @@ struct DiagnosticsView: View {
|
|||||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.haloForm()
|
.flaroForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("diagnostics.title"))
|
.navigationTitle(Text("diagnostics.title"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Der Bericht als Text zum Weitergeben.
|
/// Der Bericht als Text zum Weitergeben.
|
||||||
@@ -92,7 +92,7 @@ struct DiagnosticsView: View {
|
|||||||
/// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches.
|
/// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches.
|
||||||
private func report(for attempt: PlaybackDiagnostics.Attempt) -> String {
|
private func report(for attempt: PlaybackDiagnostics.Attempt) -> String {
|
||||||
"""
|
"""
|
||||||
Halo \(Branding.version) · Build \(Branding.buildStamp)
|
Flaro \(Branding.version) · Build \(Branding.buildStamp)
|
||||||
Verbindung: \(attempt.connection)
|
Verbindung: \(attempt.connection)
|
||||||
Titel: \(attempt.title)
|
Titel: \(attempt.title)
|
||||||
Weg: \(attempt.method)
|
Weg: \(attempt.method)
|
||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloDownloads
|
import FlaroDownloads
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Was beim Herunterladen gilt.
|
/// Was beim Herunterladen gilt.
|
||||||
@@ -11,7 +11,7 @@ import MediaKit
|
|||||||
/// rutschen.
|
/// rutschen.
|
||||||
struct DownloadSettingsView: View {
|
struct DownloadSettingsView: View {
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var downloads = downloads
|
@Bindable var downloads = downloads
|
||||||
@@ -73,11 +73,11 @@ struct DownloadSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.haloForm()
|
.flaroForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("sidebar.downloads"))
|
.navigationTitle(Text("sidebar.downloads"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var storageLimitBinding: Binding<Int64> {
|
private var storageLimitBinding: Binding<Int64> {
|
||||||
+17
-17
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
||||||
struct HomeLayoutView: View {
|
struct HomeLayoutView: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@Environment(HomeLayoutSettings.self) private var layout
|
@Environment(HomeLayoutSettings.self) private var layout
|
||||||
|
|
||||||
@State private var rows: [HomeRow] = []
|
@State private var rows: [HomeRow] = []
|
||||||
@@ -21,9 +21,9 @@ struct HomeLayoutView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
list
|
list
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("homeLayout.title"))
|
.navigationTitle(Text("homeLayout.title"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ struct HomeLayoutView: View {
|
|||||||
// gerade so weit hereinlugt, wie man es mag.
|
// gerade so weit hereinlugt, wie man es mag.
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
|
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
} label: {
|
} label: {
|
||||||
Text("home.heroHeight")
|
Text("home.heroHeight")
|
||||||
@@ -64,7 +64,7 @@ struct HomeLayoutView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("home.heroExplanation")
|
Text("home.heroExplanation")
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
|
|
||||||
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
|
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
|
||||||
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
|
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
|
||||||
@@ -84,7 +84,7 @@ struct HomeLayoutView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(library.title)
|
Text(library.title)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
}
|
}
|
||||||
// Damit der ganze Streifen trifft und nicht nur
|
// Damit der ganze Streifen trifft und nicht nur
|
||||||
// die Schrift — eine Zeile, bei der nur ein Wort
|
// die Schrift — eine Zeile, bei der nur ein Wort
|
||||||
@@ -99,7 +99,7 @@ struct HomeLayoutView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("home.heroLibraries")
|
Text("home.heroLibraries")
|
||||||
} footer: {
|
} footer: {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||||
Text("home.heroLibrariesExplanation")
|
Text("home.heroLibrariesExplanation")
|
||||||
// Lieber eine Erklärung als ein Aufmacher, der
|
// Lieber eine Erklärung als ein Aufmacher, der
|
||||||
// heimlich etwas anderes zeigt: trifft die Auswahl
|
// heimlich etwas anderes zeigt: trifft die Auswahl
|
||||||
@@ -107,18 +107,18 @@ struct HomeLayoutView: View {
|
|||||||
// dann muss dastehen, warum.
|
// dann muss dastehen, warum.
|
||||||
if greiftInsLeere {
|
if greiftInsLeere {
|
||||||
Text("home.heroLibrariesNoMatch")
|
Text("home.heroLibrariesNoMatch")
|
||||||
.foregroundStyle(Color.haloDestructive)
|
.foregroundStyle(Color.flaroDestructive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
||||||
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -126,8 +126,8 @@ struct HomeLayoutView: View {
|
|||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
} else if rows.isEmpty {
|
} else if rows.isEmpty {
|
||||||
Text("homeLayout.empty")
|
Text("homeLayout.empty")
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
@@ -140,9 +140,9 @@ struct HomeLayoutView: View {
|
|||||||
}
|
}
|
||||||
)) {
|
)) {
|
||||||
Text(row.title)
|
Text(row.title)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
}
|
}
|
||||||
.onMove { indices, destination in
|
.onMove { indices, destination in
|
||||||
rows.move(fromOffsets: indices, toOffset: destination)
|
rows.move(fromOffsets: indices, toOffset: destination)
|
||||||
@@ -162,7 +162,7 @@ struct HomeLayoutView: View {
|
|||||||
if !rows.isEmpty {
|
if !rows.isEmpty {
|
||||||
Spacer()
|
Spacer()
|
||||||
EditButton()
|
EditButton()
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.textCase(nil)
|
.textCase(nil)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
+10
-10
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Welche Sprachen der Player von sich aus wählen soll.
|
/// Welche Sprachen der Player von sich aus wählen soll.
|
||||||
///
|
///
|
||||||
@@ -9,7 +9,7 @@ import HaloDesign
|
|||||||
/// Vorliebe, die niemand äußern kann, ist keine.
|
/// Vorliebe, die niemand äußern kann, ist keine.
|
||||||
struct LanguagesView: View {
|
struct LanguagesView: View {
|
||||||
@Environment(LanguagePreferences.self) private var settings
|
@Environment(LanguagePreferences.self) private var settings
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
@State private var isAdding = false
|
@State private var isAdding = false
|
||||||
|
|
||||||
@@ -61,11 +61,11 @@ struct LanguagesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.haloForm()
|
.flaroForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("languages.title"))
|
.navigationTitle(Text("languages.title"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
// Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht
|
// Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht
|
||||||
// das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form`
|
// das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form`
|
||||||
// genügt, sobald die Liste bearbeitbar ist.
|
// genügt, sobald die Liste bearbeitbar ist.
|
||||||
@@ -86,19 +86,19 @@ struct LanguagesView: View {
|
|||||||
Section {
|
Section {
|
||||||
if settings.fallbacks.isEmpty {
|
if settings.fallbacks.isEmpty {
|
||||||
Text("languages.fallbackEmpty")
|
Text("languages.fallbackEmpty")
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in
|
ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
// Die Nummer, weil die Reihenfolge der ganze Zweck
|
// Die Nummer, weil die Reihenfolge der ganze Zweck
|
||||||
// ist — eine Liste ohne sichtbaren Rang liest sich
|
// ist — eine Liste ohne sichtbaren Rang liest sich
|
||||||
// wie eine Menge.
|
// wie eine Menge.
|
||||||
Text("\(position + 1)")
|
Text("\(position + 1)")
|
||||||
.font(.haloCaption.monospacedDigit())
|
.font(.flaroCaption.monospacedDigit())
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
.frame(minWidth: 18, alignment: .trailing)
|
.frame(minWidth: 18, alignment: .trailing)
|
||||||
Text(name(of: code))
|
Text(name(of: code))
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onMove { von, nach in
|
.onMove { von, nach in
|
||||||
+10
-9
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Alles, was die Wiedergabe betrifft.
|
/// Alles, was die Wiedergabe betrifft.
|
||||||
///
|
///
|
||||||
@@ -9,7 +9,7 @@ import HaloPlayback
|
|||||||
/// einmal durchgeht und dann in Ruhe lässt.
|
/// einmal durchgeht und dann in Ruhe lässt.
|
||||||
struct PlaybackSettingsView: View {
|
struct PlaybackSettingsView: View {
|
||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var settings = settings
|
@Bindable var settings = settings
|
||||||
@@ -36,7 +36,7 @@ struct PlaybackSettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("settings.quality")
|
Text("settings.quality")
|
||||||
} footer: {
|
} footer: {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||||||
Text("settings.qualityExplanation")
|
Text("settings.qualityExplanation")
|
||||||
|
|
||||||
// **Nur mit Beleg.** Eine Warnung, die immer dasteht,
|
// **Nur mit Beleg.** Eine Warnung, die immer dasteht,
|
||||||
@@ -55,8 +55,8 @@ struct PlaybackSettingsView: View {
|
|||||||
} icon: {
|
} icon: {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
}
|
}
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,17 +102,18 @@ 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.haloForm()
|
.flaroForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("settings.playback"))
|
.navigationTitle(Text("settings.playback"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rateLabel(_ rate: Double) -> String {
|
private func rateLabel(_ rate: Double) -> String {
|
||||||
+11
-11
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
|
||||||
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
||||||
struct ServerSettingsView: View {
|
struct ServerSettingsView: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -19,7 +19,7 @@ struct ServerSettingsView: View {
|
|||||||
}
|
}
|
||||||
if let address = session.serverAddress {
|
if let address = session.serverAddress {
|
||||||
LabeledContent("settings.address", value: address)
|
LabeledContent("settings.address", value: address)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
if let accountName = session.accountName {
|
if let accountName = session.accountName {
|
||||||
@@ -44,11 +44,11 @@ struct ServerSettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.haloForm()
|
.flaroForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("settings.server"))
|
.navigationTitle(Text("settings.server"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
|
/// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
|
||||||
@@ -64,20 +64,20 @@ struct ServerSettingsView: View {
|
|||||||
struct LibraryRow: View {
|
struct LibraryRow: View {
|
||||||
let library: MediaLibrary
|
let library: MediaLibrary
|
||||||
|
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@State private var phase: Phase = .idle
|
@State private var phase: Phase = .idle
|
||||||
|
|
||||||
private enum Phase { case idle, running, done }
|
private enum Phase { case idle, running, done }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
|
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
|
||||||
// „Filme" stand sonst als „Filme — Filme" da.
|
// „Filme" stand sonst als „Filme — Filme" da.
|
||||||
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
|
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
|
||||||
Text(library.kind.label)
|
Text(library.kind.label)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.backend?.capabilities.contains(.libraryScan) == true {
|
if session.backend?.capabilities.contains(.libraryScan) == true {
|
||||||
+8
-8
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Das farbige Symbol vor einer Einstellungszeile.
|
/// Das farbige Symbol vor einer Einstellungszeile.
|
||||||
///
|
///
|
||||||
@@ -35,7 +35,7 @@ struct SettingsHeader: View {
|
|||||||
let explanation: LocalizedStringKey
|
let explanation: LocalizedStringKey
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
Image(systemName: symbol)
|
Image(systemName: symbol)
|
||||||
.font(.title2.weight(.semibold))
|
.font(.title2.weight(.semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
@@ -43,16 +43,16 @@ struct SettingsHeader: View {
|
|||||||
.background(colour.gradient, in: .rect(cornerRadius: 13))
|
.background(colour.gradient, in: .rect(cornerRadius: 13))
|
||||||
|
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.haloTitle)
|
.font(.flaroTitle)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
Text(explanation)
|
Text(explanation)
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.vertical, HaloSpacing.snug)
|
.padding(.vertical, FlaroSpacing.snug)
|
||||||
.listRowBackground(Color.haloSurface)
|
.listRowBackground(Color.flaroSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
|
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
|
||||||
///
|
///
|
||||||
@@ -10,8 +10,8 @@ import HaloDesign
|
|||||||
///
|
///
|
||||||
/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört.
|
/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
@State private var showsSignOutConfirmation = false
|
@State private var showsSignOutConfirmation = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -23,7 +23,7 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
Text(session.serverName)
|
Text(session.serverName)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
} label: {
|
} label: {
|
||||||
row(
|
row(
|
||||||
"settings.server",
|
"settings.server",
|
||||||
@@ -83,12 +83,12 @@ struct SettingsView: View {
|
|||||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.haloForm()
|
.flaroForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.haloRevealsTopChrome()
|
.flaroRevealsTopChrome()
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("sidebar.settings"))
|
.navigationTitle(Text("sidebar.settings"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
Text("settings.signOutConfirm"),
|
Text("settings.signOutConfirm"),
|
||||||
isPresented: $showsSignOutConfirmation,
|
isPresented: $showsSignOutConfirmation,
|
||||||
@@ -115,7 +115,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View {
|
private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View {
|
||||||
HStack(spacing: HaloSpacing.regular) {
|
HStack(spacing: FlaroSpacing.regular) {
|
||||||
SettingsIcon(symbol: symbol, colour: colour)
|
SettingsIcon(symbol: symbol, colour: colour)
|
||||||
Text(title)
|
Text(title)
|
||||||
}
|
}
|
||||||
+8
-8
@@ -1,11 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
|
|
||||||
/// Wie Untertitel aussehen sollen.
|
/// Wie Untertitel aussehen sollen.
|
||||||
struct SubtitleAppearanceView: View {
|
struct SubtitleAppearanceView: View {
|
||||||
@Environment(SubtitleAppearance.self) private var appearance
|
@Environment(SubtitleAppearance.self) private var appearance
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var appearance = appearance
|
@Bindable var appearance = appearance
|
||||||
@@ -51,11 +51,11 @@ struct SubtitleAppearanceView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
.haloForm()
|
.flaroForm()
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("subtitleStyle.title"))
|
.navigationTitle(Text("subtitleStyle.title"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Beispielsatz über grauer Fläche.
|
/// Ein Beispielsatz über grauer Fläche.
|
||||||
@@ -84,10 +84,10 @@ struct SubtitleAppearanceView: View {
|
|||||||
radius: 2,
|
radius: 2,
|
||||||
y: 1
|
y: 1
|
||||||
)
|
)
|
||||||
.padding(HaloSpacing.regular)
|
.padding(FlaroSpacing.regular)
|
||||||
}
|
}
|
||||||
.frame(height: 96)
|
.frame(height: 96)
|
||||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
.clipShape(.rect(cornerRadius: FlaroRadius.card))
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
+7
-7
@@ -1,21 +1,21 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
|
|
||||||
/// Während ein Weg zum Server gesucht wird.
|
/// Während ein Weg zum Server gesucht wird.
|
||||||
struct ConnectingView: View {
|
struct ConnectingView: View {
|
||||||
let serverName: String
|
let serverName: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: HaloSpacing.loose) {
|
VStack(spacing: FlaroSpacing.loose) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Text(serverName.isEmpty
|
Text(serverName.isEmpty
|
||||||
? String(localized: "connecting.searching")
|
? String(localized: "connecting.searching")
|
||||||
: String(localized: "connecting.to \(serverName)"))
|
: String(localized: "connecting.to \(serverName)"))
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +33,8 @@ struct ConnectionFailureView: View {
|
|||||||
} actions: {
|
} actions: {
|
||||||
Button("action.retry", action: onRetry).buttonStyle(.borderedProminent)
|
Button("action.retry", action: onRetry).buttonStyle(.borderedProminent)
|
||||||
Button("action.signOut", action: onSignOut)
|
Button("action.signOut", action: onSignOut)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-62
@@ -1,5 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
|
|
||||||
/// Die Anmeldung an einem Jellyfin-Server.
|
/// Die Anmeldung an einem Jellyfin-Server.
|
||||||
@@ -9,9 +9,9 @@ import JellyfinKit
|
|||||||
/// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort
|
/// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort
|
||||||
/// durch diese App läuft.
|
/// durch diese App läuft.
|
||||||
struct JellyfinSignInView: View {
|
struct JellyfinSignInView: View {
|
||||||
@Environment(HaloSession.self) private var session
|
@Environment(FlaroSession.self) private var session
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.haloAccent) private var accent
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
|
||||||
@State private var model: JellyfinSignInModel?
|
@State private var model: JellyfinSignInModel?
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@@ -23,16 +23,16 @@ struct JellyfinSignInView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
if let model {
|
if let model {
|
||||||
content(for: model)
|
content(for: model)
|
||||||
.padding(.horizontal, HaloSpacing.section)
|
.padding(.horizontal, FlaroSpacing.section)
|
||||||
.padding(.vertical, HaloSpacing.section)
|
.padding(.vertical, FlaroSpacing.section)
|
||||||
.frame(maxWidth: 420)
|
.frame(maxWidth: 420)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.background(Color.haloCanvas)
|
.background(Color.flaroCanvas)
|
||||||
.navigationTitle(Text("signIn.jellyfin.title"))
|
.navigationTitle(Text("signIn.jellyfin.title"))
|
||||||
.haloInlineTitle()
|
.flaroInlineTitle()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("action.cancel") {
|
Button("action.cancel") {
|
||||||
@@ -66,7 +66,7 @@ struct JellyfinSignInView: View {
|
|||||||
private func content(for model: JellyfinSignInModel) -> some View {
|
private func content(for model: JellyfinSignInModel) -> some View {
|
||||||
@Bindable var model = model
|
@Bindable var model = model
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.section) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||||||
switch model.step {
|
switch model.step {
|
||||||
case .address:
|
case .address:
|
||||||
addressStep(model: model)
|
addressStep(model: model)
|
||||||
@@ -82,8 +82,8 @@ struct JellyfinSignInView: View {
|
|||||||
|
|
||||||
if let message = model.errorMessage {
|
if let message = model.errorMessage {
|
||||||
Label(message, systemImage: "exclamationmark.triangle")
|
Label(message, systemImage: "exclamationmark.triangle")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloDestructive)
|
.foregroundStyle(Color.flaroDestructive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,39 +94,39 @@ struct JellyfinSignInView: View {
|
|||||||
private func addressStep(model: JellyfinSignInModel) -> some View {
|
private func addressStep(model: JellyfinSignInModel) -> some View {
|
||||||
@Bindable var model = model
|
@Bindable var model = model
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
Text("signIn.jellyfin.addressPrompt")
|
Text("signIn.jellyfin.addressPrompt")
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
|
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.textContentType(.URL)
|
.textContentType(.URL)
|
||||||
.haloPlainEntry(isAddress: true)
|
.flaroPlainEntry(isAddress: true)
|
||||||
.submitLabel(.go)
|
.submitLabel(.go)
|
||||||
.focused($focusedField, equals: .address)
|
.focused($focusedField, equals: .address)
|
||||||
.onSubmit { Task { await model.findServer() } }
|
.onSubmit { Task { await model.findServer() } }
|
||||||
// Hier statt beim Öffnen des Sheets: so bekommt das Feld den
|
// Hier statt beim Öffnen des Sheets: so bekommt das Feld den
|
||||||
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
|
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
|
||||||
.task { focusedField = .address }
|
.task { focusedField = .address }
|
||||||
.padding(HaloSpacing.regular)
|
.padding(FlaroSpacing.regular)
|
||||||
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
|
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
|
||||||
// niemandem etwas, „192.168.1.50" jedem.
|
// niemandem etwas, „192.168.1.50" jedem.
|
||||||
Text("signIn.jellyfin.addressHint")
|
Text("signIn.jellyfin.addressHint")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
focusedField = nil
|
focusedField = nil
|
||||||
Task { await model.findServer() }
|
Task { await model.findServer() }
|
||||||
} label: {
|
} label: {
|
||||||
Text("signIn.jellyfin.connect")
|
Text("signIn.jellyfin.connect")
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, HaloSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
@@ -138,40 +138,40 @@ struct JellyfinSignInView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View {
|
private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||||||
foundServerHeader(model: model)
|
foundServerHeader(model: model)
|
||||||
|
|
||||||
Text("signIn.jellyfin.quickConnectHint")
|
Text("signIn.jellyfin.quickConnectHint")
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
|
|
||||||
Text(code)
|
Text(code)
|
||||||
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
|
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
|
||||||
.tracking(8)
|
.tracking(8)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.padding(.vertical, HaloSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||||
|
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
Text("signIn.jellyfin.waitingForApproval")
|
Text("signIn.jellyfin.waitingForApproval")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("signIn.jellyfin.privacy")
|
Text("signIn.jellyfin.privacy")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
Divider().overlay(Color.haloSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
|
|
||||||
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
|
Button("signIn.jellyfin.usePassword") { model.useCredentials() }
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,12 +181,12 @@ struct JellyfinSignInView: View {
|
|||||||
private func credentialsStep(model: JellyfinSignInModel) -> some View {
|
private func credentialsStep(model: JellyfinSignInModel) -> some View {
|
||||||
@Bindable var model = model
|
@Bindable var model = model
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.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)
|
||||||
.haloPlainEntry()
|
.flaroPlainEntry()
|
||||||
.submitLabel(.next)
|
.submitLabel(.next)
|
||||||
.focused($focusedField, equals: .userName)
|
.focused($focusedField, equals: .userName)
|
||||||
.onSubmit { focusedField = .password }
|
.onSubmit { focusedField = .password }
|
||||||
@@ -200,10 +200,10 @@ struct JellyfinSignInView: View {
|
|||||||
focusedField = .password
|
focusedField = .password
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.regular)
|
.padding(FlaroSpacing.regular)
|
||||||
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.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)
|
||||||
@@ -214,17 +214,17 @@ struct JellyfinSignInView: View {
|
|||||||
focusedField = .userName
|
focusedField = .userName
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
.padding(HaloSpacing.regular)
|
.padding(FlaroSpacing.regular)
|
||||||
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control))
|
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
focusedField = nil
|
focusedField = nil
|
||||||
Task { await model.signInWithPassword() }
|
Task { await model.signInWithPassword() }
|
||||||
} label: {
|
} label: {
|
||||||
Text("signIn.jellyfin.submit")
|
Text("signIn.jellyfin.submit")
|
||||||
.font(.haloRowHeader)
|
.font(.flaroRowHeader)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, HaloSpacing.regular)
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(accent)
|
.tint(accent)
|
||||||
@@ -234,46 +234,46 @@ struct JellyfinSignInView: View {
|
|||||||
// verworfen. Gespeichert wird nur der Token, und der im
|
// verworfen. Gespeichert wird nur der Token, und der im
|
||||||
// Schlüsselbund.
|
// Schlüsselbund.
|
||||||
Text("signIn.jellyfin.passwordNote")
|
Text("signIn.jellyfin.passwordNote")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
|
||||||
Divider().overlay(Color.haloSeparator)
|
Divider().overlay(Color.flaroSeparator)
|
||||||
|
|
||||||
if model.offersQuickConnect {
|
if model.offersQuickConnect {
|
||||||
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
|
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
}
|
}
|
||||||
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
Button("signIn.jellyfin.otherAddress") { model.startOver() }
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bausteine
|
// MARK: - Bausteine
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func foundServerHeader(model: JellyfinSignInModel) -> some View {
|
private func foundServerHeader(model: JellyfinSignInModel) -> some View {
|
||||||
VStack(alignment: .leading, spacing: HaloSpacing.tight) {
|
VStack(alignment: .leading, spacing: FlaroSpacing.tight) {
|
||||||
Text("signIn.jellyfin.foundServer")
|
Text("signIn.jellyfin.foundServer")
|
||||||
.font(.haloCaption)
|
.font(.flaroCaption)
|
||||||
.foregroundStyle(Color.haloTextMuted)
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
Text(model.serverName ?? model.address)
|
Text(model.serverName ?? model.address)
|
||||||
.font(.haloTitle)
|
.font(.flaroTitle)
|
||||||
.foregroundStyle(Color.haloTextPrimary)
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func waiting(text: LocalizedStringKey) -> some View {
|
private func waiting(text: LocalizedStringKey) -> some View {
|
||||||
HStack(spacing: HaloSpacing.snug) {
|
HStack(spacing: FlaroSpacing.snug) {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.haloBody)
|
.font(.flaroBody)
|
||||||
.foregroundStyle(Color.haloTextSecondary)
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.vertical, HaloSpacing.section)
|
.padding(.vertical, FlaroSpacing.section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import FlaroDesign
|
||||||
|
|
||||||
|
/// Der Anmeldebildschirm.
|
||||||
|
///
|
||||||
|
/// **Wieder zwei Knöpfe, und diesmal zu zwei Servern.** Hier standen schon
|
||||||
|
/// einmal zwei — Plex oben, Jellyfin darunter —, und nach dem Entfernen von
|
||||||
|
/// Plex blieb einer übrig. Jetzt gibt es Flaro, und die Frage stellt sich
|
||||||
|
/// neu: welcher steht oben?
|
||||||
|
///
|
||||||
|
/// **Flaro.** Nicht aus Eitelkeit, sondern weil die App dort mehr kann: der
|
||||||
|
/// Download bringt alle Tonspuren mit, der Fortschritt wird ab der ersten
|
||||||
|
/// 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 {
|
||||||
|
@Environment(\.flaroAccent) private var accent
|
||||||
|
@State private var showsFlaroSignIn = false
|
||||||
|
@State private var showsJellyfinSignIn = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: FlaroSpacing.section) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: FlaroSpacing.regular) {
|
||||||
|
Image(systemName: "play.rectangle.on.rectangle")
|
||||||
|
.font(.system(size: 52))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
|
||||||
|
Text(Branding.appName)
|
||||||
|
.font(.flaroHero)
|
||||||
|
.foregroundStyle(Color.flaroTextPrimary)
|
||||||
|
|
||||||
|
Text("signIn.tagline")
|
||||||
|
.font(.flaroBody)
|
||||||
|
.foregroundStyle(Color.flaroTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
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 {
|
||||||
|
showsJellyfinSignIn = true
|
||||||
|
} label: {
|
||||||
|
Text("signIn.withJellyfin")
|
||||||
|
.font(.flaroRowHeader)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, FlaroSpacing.regular)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Text("signIn.privacy")
|
||||||
|
.font(.flaroCaption)
|
||||||
|
.foregroundStyle(Color.flaroTextMuted)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer().frame(height: FlaroSpacing.section)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, FlaroSpacing.section)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.flaroCanvas)
|
||||||
|
.sheet(isPresented: $showsFlaroSignIn) {
|
||||||
|
FlaroSignInView()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showsJellyfinSignIn) {
|
||||||
|
JellyfinSignInView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.scarriffleservices.halo</string>
|
<string>group.com.scarriffleservices.flaro</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import HaloDesign
|
import FlaroDesign
|
||||||
import HaloPlayback
|
import FlaroPlayback
|
||||||
import MediaKit
|
import MediaKit
|
||||||
|
import FlaroServerKit
|
||||||
import JellyfinKit
|
import JellyfinKit
|
||||||
|
|
||||||
/// Die App auf dem Fernseher.
|
/// Die App auf dem Fernseher.
|
||||||
///
|
///
|
||||||
/// **Eine eigene Wurzel statt eines Zweigs in `HaloApp`.** Der Unterschied
|
/// **Eine eigene Wurzel statt eines Zweigs in `FlaroApp`.** Der Unterschied
|
||||||
/// zwischen Telefon und Fernseher ist keine Feinheit der Darstellung, sondern
|
/// zwischen Telefon und Fernseher ist keine Feinheit der Darstellung, sondern
|
||||||
/// die Bedienart: kein Finger, keine Grössenklasse, keine Werkzeugleiste, kein
|
/// die Bedienart: kein Finger, keine Grössenklasse, keine Werkzeugleiste, kein
|
||||||
/// Ziehen zum Aktualisieren, keine halbhohen Sheets — und dafür ein Fokus, den
|
/// Ziehen zum Aktualisieren, keine halbhohen Sheets — und dafür ein Fokus, den
|
||||||
@@ -14,7 +15,7 @@ import JellyfinKit
|
|||||||
/// wäre kein Zweig, sondern ein zweiter Rumpf in derselben Datei, und der
|
/// wäre kein Zweig, sondern ein zweiter Rumpf in derselben Datei, und der
|
||||||
/// nächste, der die iOS-Seite ändert, bräche die andere, ohne es zu merken.
|
/// nächste, der die iOS-Seite ändert, bräche die andere, ohne es zu merken.
|
||||||
///
|
///
|
||||||
/// **Die Modelle dagegen werden geteilt**, nicht kopiert: `Halo/Session/`
|
/// **Die Modelle dagegen werden geteilt**, nicht kopiert: `Flaro/Session/`
|
||||||
/// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft
|
/// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft
|
||||||
/// zwischen Modell und Ansicht, nicht zwischen den Plattformen.
|
/// zwischen Modell und Ansicht, nicht zwischen den Plattformen.
|
||||||
///
|
///
|
||||||
@@ -22,9 +23,9 @@ import JellyfinKit
|
|||||||
/// iOS-App trägt ausschliesslich Downloads, und die gibt es hier nicht — damit
|
/// iOS-App trägt ausschliesslich Downloads, und die gibt es hier nicht — damit
|
||||||
/// fällt beides weg.
|
/// fällt beides weg.
|
||||||
@main
|
@main
|
||||||
struct HaloTVApp: App {
|
struct FlaroTVApp: App {
|
||||||
@State private var session: HaloSession
|
@State private var session: FlaroSession
|
||||||
@State private var theme = HaloTheme()
|
@State private var theme = FlaroTheme()
|
||||||
@State private var playback = PlaybackCoordinator()
|
@State private var playback = PlaybackCoordinator()
|
||||||
@State private var playbackSettings = PlaybackSettings()
|
@State private var playbackSettings = PlaybackSettings()
|
||||||
@State private var languages = LanguagePreferences()
|
@State private var languages = LanguagePreferences()
|
||||||
@@ -40,11 +41,12 @@ struct HaloTVApp: App {
|
|||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
_session = State(initialValue: HaloSession(
|
_session = State(initialValue: FlaroSession(
|
||||||
jellyfinIdentity: JellyfinIdentity.current(
|
jellyfinIdentity: JellyfinIdentity.current(
|
||||||
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)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,7 @@ struct HaloTVApp: App {
|
|||||||
.environment(homeLayout)
|
.environment(homeLayout)
|
||||||
.environment(diagnostics)
|
.environment(diagnostics)
|
||||||
.environment(network)
|
.environment(network)
|
||||||
.haloAccent(theme)
|
.flaroAccent(theme)
|
||||||
.tint(theme.accentColor)
|
.tint(theme.accentColor)
|
||||||
// Ein Fernseher ist immer dunkel. Der Schalter in der
|
// Ein Fernseher ist immer dunkel. Der Schalter in der
|
||||||
// Info.plist gilt fürs System, diese Zeile für die Vorschauen.
|
// Info.plist gilt fürs System, diese Zeile für die Vorschauen.
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Halo</string>
|
<string>Flaro</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -22,10 +22,10 @@
|
|||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLName</key>
|
<key>CFBundleURLName</key>
|
||||||
<string>com.scarriffleservices.halo.topshelf</string>
|
<string>com.scarriffleservices.flaro.topshelf</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>halo</string>
|
<string>flaro</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Halo benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen.</string>
|
<string>Flaro benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen.</string>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<string>Dark</string>
|
<string>Dark</string>
|
||||||
</dict>
|
</dict>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user