From 979ed71a1f147a2fb0fe669ee00eea64119ac764 Mon Sep 17 00:00:00 2001 From: Philippe Date: Sat, 24 Oct 2026 15:16:23 +0200 Subject: [PATCH 1/2] Server tasks, preview check and MP4 video previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Server tasks starts Stash's scan, generate, auto tag and clean, shows the job queue with progress and a stop button, and checks a random sample of scenes and markers for their WebP and MP4 previews. When the animated previews are missing it explains why and generates them. The lists' previews can now play Stash's MP4 preview instead of the WebP (Preview format), with a hint about the extra data, battery and memory. Refs #43, replaces #44. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- docs/BACKLOG.md | 5 +- lib/core/api/documents/tasks.graphql | 35 + lib/core/api/documents/tasks.graphql.dart | 1716 +++++++++++++++++ lib/data/models/server_task.dart | 101 + lib/data/repositories/stash_repository.dart | 85 + lib/features/demo/demo_library.dart | 5 + .../server_tasks/server_tasks_page.dart | 364 ++++ lib/features/settings/settings_page.dart | 43 + lib/l10n/app_de.arb | 50 +- lib/l10n/app_en.arb | 60 +- lib/l10n/gen/app_localizations.dart | 288 +++ lib/l10n/gen/app_localizations_de.dart | 153 ++ lib/l10n/gen/app_localizations_en.dart | 153 ++ lib/l10n/labels.dart | 36 + lib/widgets/animated_previews.dart | 26 + lib/widgets/auto_preview.dart | 25 +- lib/widgets/preview_video.dart | 53 + lib/widgets/scene_card.dart | 1 + test/features/demo_test.dart | 16 + test/features/server_tasks_test.dart | 101 + test/features/settings_sheet_test.dart | 7 +- 22 files changed, 3311 insertions(+), 16 deletions(-) create mode 100644 lib/core/api/documents/tasks.graphql create mode 100644 lib/core/api/documents/tasks.graphql.dart create mode 100644 lib/data/models/server_task.dart create mode 100644 lib/features/server_tasks/server_tasks_page.dart create mode 100644 lib/widgets/preview_video.dart create mode 100644 test/features/server_tasks_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index d460584..deef404 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,9 @@ lib/ **Security.** `features/security/`. `AppLockGate` in `MaterialApp.builder` shows `LockScreen` while `appLockedProvider` is true, and a `PrivacyCover` while the app is inactive (with the lock on or "hide in app switcher"). Without a delay the lock engages as the app is hidden, not on return. `LockScreen` asks for biometrics only once the app is resumed (iOS cancels a prompt while still inactive), again after the app was in the background but not after its own prompt. `confirmPin`/`showCreatePin` push full-screen routes on the root navigator, so they cover the app above the settings sheet too. `AppLockSettings.hidesApp` (lock on or "hide in app switcher"; the switch is fixed on with the lock) drives `SecureWindow`: Android's FLAG_SECURE, and on iOS `PrivacyCoverController` (AppDelegate.swift), a native cover added on `UIScene.willDeactivateNotification`, because the app is in the background before Flutter draws another frame (locking the phone showed the old content on return). Whether to cover it reads from UserDefaults itself (`flutter.lock_enabled`, `flutter.lock_hide_in_switcher`): a channel message sent at startup can arrive before the handler exists. Dart removes it (`SecureWindow.uncover`) once a frame was drawn after resuming; a 2 s fallback removes it otherwise. The PIN is stored only as a salted SHA-256 hash, as `:` under `lock_pin` in the `SecretStore` (older versions' SharedPreferences values are moved there). `PinThrottle` blocks the PIN pad for a growing time after 5 wrong PINs (stored, so restarts don't reset it); `LockScreen` then shows `PinBlockedView`. While locked, `AppLockGate` excludes focus and semantics, and `lockBackGuard` (registered in `main()` before `runApp`) turns back into leaving the app. The main player keeps playing (background playback, PiP); the shorts stop. Native channels: `stash/privacy` (Android `FLAG_SECURE`) and `stash/appicon` (Android activity-aliases / iOS alternate icons). On Android the launcher entries are the `activity-alias`es in `src/main/AndroidManifest.xml`. MainActivity keeps a MAIN/LAUNCHER filter there only because `flutter run` looks for it; the debug/profile/release manifests remove that filter again (`tools:node="remove"`). Android uses `FlutterFragmentActivity` and an AppCompat launch theme for `local_auth`. -**Animated previews.** Stash's looping WebP previews, switched separately (`widgets/animated_previews.dart`, on by default; `previewsPlay` also checks reduced motion): `markerPreviewsProvider` for marker tiles (`Marker.previewUrl`), `feedPreviewsProvider` for the first fully shown video in `SceneFeedView`: `AutoPreviewScope` around the list picks it `feedPreviewDelayProvider` (1.5 s by default) after scrolling stopped, and the `AutoPreview` in `SceneThumbnail` plays `Scene.webpUrl` over the still. Scrolling stops it. Thumbnails outside a scope (the player's "Up next") never play. +**Animated previews.** Stash's looping WebP previews, switched separately (`widgets/animated_previews.dart`, on by default; `previewsPlay` also checks reduced motion): `markerPreviewsProvider` for marker tiles (`Marker.previewUrl`), `feedPreviewsProvider` for the first fully shown video in `SceneFeedView`: `AutoPreviewScope` around the list picks it `feedPreviewDelayProvider` (1.5 s by default) after scrolling stopped, and the `AutoPreview` in `SceneThumbnail` plays `Scene.webpUrl` over the still. Scrolling stops it. Thumbnails outside a scope (the player's "Up next") never play. `previewSourceProvider` switches the lists' previews to Stash's MP4 preview (`Scene.previewUrl`), played by `PreviewVideo` in a media_kit player of its own (one at a time; the settings warn about data, battery and memory); markers always play the WebP. + +**Server tasks.** `features/server_tasks/` (Settings → Server tasks, pushed inside the settings sheet; its own `ScaffoldMessenger` so snack bars show above the sheet). Starts Stash's scan, generate, auto tag and clean (`StashRepository.scanLibrary/generate/autoTag/clean`; `ScanOption`/`GenerateOption` are the input keys), shows `jobQueueProvider` (polls `jobQueue` every 2 s while watched; `stopJob`) and `previewCheckProvider`: `checkPreviews` fetches one byte of the WebP/MP4 preview URLs of a few random scenes and markers (Stash answers 404 for missing files) and runs again when a job finishes. The demo accepts the mutations and has an empty queue. **Haptics.** Use `Haptics.selection/light/medium/heavy/error` (`core/config/haptics.dart`), never `HapticFeedback` directly: they follow `hapticLevelProvider` (off; light = only `medium`/`heavy`/`error`, the noticeable ones; normal = also the small `selection`/`light` taps), which `StashApp` watches to apply the stored level. Wrap the `onChanged` of switches, checkboxes and filter chips in `withHaptic(...)`. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index d96d20e..360e0fa 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -45,7 +45,7 @@ Eine wartbare Basis, damit Features unabhängig voneinander wachsen können. | 3.1 | Als Nutzer möchte ich auf der Startseite einen endlosen Feed von Szenen mit großen 16:9-Vorschaubildern sehen. | Karten mit Thumbnail, Dauer-Badge, Auflösungs-Badge, Kanal-Avatar, Titel (2 Zeilen) und Metazeile. Die Kopfzeile verschwindet beim Herunterscrollen und kommt beim Hochscrollen wieder; sie liegt über der Liste, damit ihre Knöpfe immer reagieren. | MVP | ✅ | | 3.2 | Als Nutzer möchte ich den Feed über Filter-Chips umschalten („Neu hinzugefügt“, „Neueste“, „Zufällig“, „Top bewertet“, „Meistgesehen“). | Chip-Leiste wie bei YouTube. Der Wechsel lädt den Feed neu. „Zufällig“ ist über alle Seiten stabil (Seed). | MVP | ✅ | | 3.3 | Als Nutzer möchte ich per Pull-to-Refresh aktualisieren – auch wenn nur wenige Einträge da sind. | `AlwaysScrollableScrollPhysics`. Der bekannte Bug „Refresh geht nicht bei kurzem Inhalt“ ist behoben. | MVP | ✅ | -| 3.4 | Als Nutzer möchte ich beim Gedrückthalten bzw. Scrollen eine animierte Vorschau (Stash-`preview`) sehen. | Autoplay des Preview-MP4 (stumm) für die sichtbare Karte. | Next | ⬜ | +| 3.4 | Als Nutzer möchte ich beim Gedrückthalten bzw. Scrollen eine animierte Vorschau (Stash-`preview`) sehen. | Autoplay des Preview-MP4 (stumm) für die sichtbare Karte. Umgesetzt als Vorschau-Format „Video (MP4)“ der Listenvorschauen (13.16), mit Hinweis auf den höheren Daten-, Akku- und Speicherbedarf; es spielt immer nur eine Vorschau in einem eigenen Player. | Next | ✅ | | 3.5 | Als Nutzer möchte ich auf der Startseite Abschnitte sehen („Weiterschauen“, „Neu von Favoriten“). | Horizontale Reihen oberhalb des Feeds. „Weiterschauen“: Szenen mit `resume_time > 0`, zuletzt gesehen zuerst, lädt nach dem ersten gespeicherten Fortschritt neu. „Neu von Favoriten“: `performer_favorite`. Leere Reihen werden ausgeblendet. | Next | ✅ | | 3.6 | Als Nutzer möchte ich über das „⋮“-Menü einer Karte Aktionen ausführen (Zur Playlist, Zum Studio, Teilen). | Bottom Sheet mit Aktionen. | Later | 🟡 (zu Studio/Performer) | @@ -169,8 +169,9 @@ Eine wartbare Basis, damit Features unabhängig voneinander wachsen können. | 13.13 | Als Nutzer möchte ich nach dem ersten Login das Design wählen. | Einmaliger Dialog „Wähle dein Design“ nach dem Hinzufügen des ersten Servers: Hell/System/Dunkel und Akzentfarbe, sofort sichtbar; bleibt bis zum Anzeigen gespeichert (auch über einen Neustart). Bestehende Nutzer und weitere Server sehen ihn nicht. Dieselben Bedienelemente wie in den Einstellungen (`appearance_picker.dart`). | Next | ✅ | | 13.14 | Als Nutzer möchte ich spürbares haptisches Feedback und dessen Stärke wählen. | Einstellung „Haptisches Feedback“: Aus, Wenig (nur die spürbaren Rückmeldungen: Favorit, O-Zähler, Langdruck-Menü, Aktualisieren, Entsperren, falsche PIN), Normal (zusätzlich Tabs, Sortier- und Filter-Chips, Schalter, Play/Pause, Wischen in den Shorts, Später ansehen, Theme/Farbe). | Next | ✅ | | 13.15 | Als Nutzer möchte ich durch erneutes Tippen auf den aktiven Tab nach oben scrollen. | Aus einer Unterseite geht es zurück zur Startseite des Tabs, dort scrollen die Listen sanft nach oben (wie YouTube). | Next | ✅ | -| 13.16 | Als Nutzer möchte ich in Listen eine bewegte Vorschau sehen. | Ruht das Scrollen eine Weile (Standard 1,5 s, einstellbar 0,5–3 s), spielt das oberste vollständig sichtbare Video seine WebP-Vorschau (Stash `paths.webp`) über dem Standbild in Dauerschleife, wie YouTube; Scrollen stoppt sie. Gilt für alle Szenenlisten (Start, Bibliothek, Suche, Verlauf, Kanäle, Gruppen). Einstellung „Vorschauen in Listen abspielen“, bei „Bewegung reduzieren“ aus. | Next | ✅ | +| 13.16 | Als Nutzer möchte ich in Listen eine bewegte Vorschau sehen. | Ruht das Scrollen eine Weile (Standard 1,5 s, einstellbar 0,5–3 s), spielt das oberste vollständig sichtbare Video seine WebP-Vorschau (Stash `paths.webp`) über dem Standbild in Dauerschleife, wie YouTube; Scrollen stoppt sie. Gilt für alle Szenenlisten (Start, Bibliothek, Suche, Verlauf, Kanäle, Gruppen). Einstellung „Vorschauen in Listen abspielen“, bei „Bewegung reduzieren“ aus. Vorschau-Format wählbar: animiertes Bild (WebP, Standard) oder Video (MP4, 3.4). | Next | ✅ | | 13.17 | Als Nutzer möchte ich die App-Version und die Open-Source-Lizenzen sehen. | Einstellungen → „Über“: Version mit Build-Nummer (`package_info_plus`) und „Open-Source-Lizenzen“ (Flutters Lizenzseite mit allen Paketen, dazu StashTubes eigene AGPL-3.0 und die nativen Bibliotheken des Videoplayers aus media_kits libmpv-Build: mpv, FFmpeg, libass, FreeType, FriBidi, HarfBuzz, dav1d, Mbed TLS, libxml2, unter iOS zusätzlich libpng, uchardet, Protocol Buffers; Lizenztexte in `assets/licenses/`). | Next | ✅ | +| 13.18 | Als Nutzer möchte ich Stashs Server-Aufgaben aus der App starten und sehen, ob die Vorschauen erzeugt sind. | Einstellungen → „Server-Aufgaben“ (auch „Vorschauen auf dem Server prüfen“ bei den Vorschau-Einstellungen): Bibliothek scannen (mit Auswahl, was für neue Dateien erzeugt wird), Generieren (Cover, Video- und animierte Vorschauen, Sprites, Marker-Vorschauen und -Standbilder, pHashes; nur Fehlendes), Automatisch taggen, Aufräumen (mit Bestätigung). Laufende Aufgaben aus Stashs Warteschlange mit Fortschritt, alle 2 s aktualisiert, abbrechbar. Vorschau-Check: Stichprobe zufälliger Szenen und Marker, ob WebP- und MP4-Vorschauen vorhanden sind; fehlen die WebP-Vorschauen, ein Hinweis und „Animierte Vorschauen erzeugen“. Nach einer beendeten Aufgabe prüft er erneut. | Next | ✅ | ## Epic 14 – Plattform & Release diff --git a/lib/core/api/documents/tasks.graphql b/lib/core/api/documents/tasks.graphql new file mode 100644 index 0000000..edb30ee --- /dev/null +++ b/lib/core/api/documents/tasks.graphql @@ -0,0 +1,35 @@ +# Server tasks (Settings → Server tasks): start Stash's jobs and follow the +# job queue. The mutations return the new job's id. + +fragment JobFields on Job { + id + status + description + subTasks + progress + error +} + +query JobQueue { + jobQueue { ...JobFields } +} + +mutation StopJob($job_id: ID!) { + stopJob(job_id: $job_id) +} + +mutation MetadataScan($input: ScanMetadataInput!) { + metadataScan(input: $input) +} + +mutation MetadataGenerate($input: GenerateMetadataInput!) { + metadataGenerate(input: $input) +} + +mutation MetadataAutoTag($input: AutoTagMetadataInput!) { + metadataAutoTag(input: $input) +} + +mutation MetadataClean($input: CleanMetadataInput!) { + metadataClean(input: $input) +} diff --git a/lib/core/api/documents/tasks.graphql.dart b/lib/core/api/documents/tasks.graphql.dart new file mode 100644 index 0000000..9293243 --- /dev/null +++ b/lib/core/api/documents/tasks.graphql.dart @@ -0,0 +1,1716 @@ +import '../stash_schema.graphql.dart'; + +import 'dart:async'; + +import 'package:gql/ast.dart'; +import 'package:graphql/client.dart' as graphql; + +class Fragment$JobFields { + Fragment$JobFields({ + required this.id, + required this.status, + required this.description, + this.subTasks, + this.progress, + this.error, + this.$__typename = 'Job', + }); + + factory Fragment$JobFields.fromJson(Map json) { + final l$id = json['id']; + final l$status = json['status']; + final l$description = json['description']; + final l$subTasks = json['subTasks']; + final l$progress = json['progress']; + final l$error = json['error']; + final l$$__typename = json['__typename']; + return Fragment$JobFields( + id: (l$id as String), + status: fromJson$Enum$JobStatus((l$status as String)), + description: (l$description as String), + subTasks: (l$subTasks as List?) + ?.map((e) => (e as String)) + .toList(), + progress: (l$progress as num?)?.toDouble(), + error: (l$error as String?), + $__typename: (l$$__typename as String), + ); + } + + final String id; + + final Enum$JobStatus status; + + final String description; + + final List? subTasks; + + final double? progress; + + final String? error; + + final String $__typename; + + Map toJson() { + final _resultData = {}; + final l$id = id; + _resultData['id'] = l$id; + final l$status = status; + _resultData['status'] = toJson$Enum$JobStatus(l$status); + final l$description = description; + _resultData['description'] = l$description; + final l$subTasks = subTasks; + _resultData['subTasks'] = l$subTasks?.map((e) => e).toList(); + final l$progress = progress; + _resultData['progress'] = l$progress; + final l$error = error; + _resultData['error'] = l$error; + final l$$__typename = $__typename; + _resultData['__typename'] = l$$__typename; + return _resultData; + } + + @override + int get hashCode { + final l$id = id; + final l$status = status; + final l$description = description; + final l$subTasks = subTasks; + final l$progress = progress; + final l$error = error; + final l$$__typename = $__typename; + return Object.hashAll([ + l$id, + l$status, + l$description, + l$subTasks == null ? null : Object.hashAll(l$subTasks.map((v) => v)), + l$progress, + l$error, + l$$__typename, + ]); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Fragment$JobFields || runtimeType != other.runtimeType) { + return false; + } + final l$id = id; + final lOther$id = other.id; + if (l$id != lOther$id) { + return false; + } + final l$status = status; + final lOther$status = other.status; + if (l$status != lOther$status) { + return false; + } + final l$description = description; + final lOther$description = other.description; + if (l$description != lOther$description) { + return false; + } + final l$subTasks = subTasks; + final lOther$subTasks = other.subTasks; + if (l$subTasks != null && lOther$subTasks != null) { + if (l$subTasks.length != lOther$subTasks.length) { + return false; + } + for (int i = 0; i < l$subTasks.length; i++) { + final l$subTasks$entry = l$subTasks[i]; + final lOther$subTasks$entry = lOther$subTasks[i]; + if (l$subTasks$entry != lOther$subTasks$entry) { + return false; + } + } + } else if (l$subTasks != lOther$subTasks) { + return false; + } + final l$progress = progress; + final lOther$progress = other.progress; + if (l$progress != lOther$progress) { + return false; + } + final l$error = error; + final lOther$error = other.error; + if (l$error != lOther$error) { + return false; + } + final l$$__typename = $__typename; + final lOther$$__typename = other.$__typename; + if (l$$__typename != lOther$$__typename) { + return false; + } + return true; + } +} + +const fragmentDefinitionJobFields = FragmentDefinitionNode( + name: NameNode(value: 'JobFields'), + typeCondition: TypeConditionNode( + on: NamedTypeNode(name: NameNode(value: 'Job'), isNonNull: false), + ), + directives: [], + selectionSet: SelectionSetNode( + selections: [ + FieldNode( + name: NameNode(value: 'id'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: 'status'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: 'description'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: 'subTasks'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: 'progress'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: 'error'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: '__typename'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + ], + ), +); +const documentNodeFragmentJobFields = DocumentNode( + definitions: [fragmentDefinitionJobFields], +); + +extension ClientExtension$Fragment$JobFields on graphql.GraphQLClient { + void writeFragment$JobFields({ + required Fragment$JobFields data, + required Map idFields, + bool broadcast = true, + }) => this.writeFragment( + graphql.FragmentRequest( + idFields: idFields, + fragment: const graphql.Fragment( + fragmentName: 'JobFields', + document: documentNodeFragmentJobFields, + ), + ), + data: data.toJson(), + broadcast: broadcast, + ); + + Fragment$JobFields? readFragment$JobFields({ + required Map idFields, + bool optimistic = true, + }) { + final result = this.readFragment( + graphql.FragmentRequest( + idFields: idFields, + fragment: const graphql.Fragment( + fragmentName: 'JobFields', + document: documentNodeFragmentJobFields, + ), + ), + optimistic: optimistic, + ); + return result == null ? null : Fragment$JobFields.fromJson(result); + } +} + +class Query$JobQueue { + Query$JobQueue({this.jobQueue, this.$__typename = 'Query'}); + + factory Query$JobQueue.fromJson(Map json) { + final l$jobQueue = json['jobQueue']; + final l$$__typename = json['__typename']; + return Query$JobQueue( + jobQueue: (l$jobQueue as List?) + ?.map((e) => Fragment$JobFields.fromJson((e as Map))) + .toList(), + $__typename: (l$$__typename as String), + ); + } + + final List? jobQueue; + + final String $__typename; + + Map toJson() { + final _resultData = {}; + final l$jobQueue = jobQueue; + _resultData['jobQueue'] = l$jobQueue?.map((e) => e.toJson()).toList(); + final l$$__typename = $__typename; + _resultData['__typename'] = l$$__typename; + return _resultData; + } + + @override + int get hashCode { + final l$jobQueue = jobQueue; + final l$$__typename = $__typename; + return Object.hashAll([ + l$jobQueue == null ? null : Object.hashAll(l$jobQueue.map((v) => v)), + l$$__typename, + ]); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Query$JobQueue || runtimeType != other.runtimeType) { + return false; + } + final l$jobQueue = jobQueue; + final lOther$jobQueue = other.jobQueue; + if (l$jobQueue != null && lOther$jobQueue != null) { + if (l$jobQueue.length != lOther$jobQueue.length) { + return false; + } + for (int i = 0; i < l$jobQueue.length; i++) { + final l$jobQueue$entry = l$jobQueue[i]; + final lOther$jobQueue$entry = lOther$jobQueue[i]; + if (l$jobQueue$entry != lOther$jobQueue$entry) { + return false; + } + } + } else if (l$jobQueue != lOther$jobQueue) { + return false; + } + final l$$__typename = $__typename; + final lOther$$__typename = other.$__typename; + if (l$$__typename != lOther$$__typename) { + return false; + } + return true; + } +} + +const documentNodeQueryJobQueue = DocumentNode( + definitions: [ + OperationDefinitionNode( + type: OperationType.query, + name: NameNode(value: 'JobQueue'), + variableDefinitions: [], + directives: [], + selectionSet: SelectionSetNode( + selections: [ + FieldNode( + name: NameNode(value: 'jobQueue'), + alias: null, + arguments: [], + directives: [], + selectionSet: SelectionSetNode( + selections: [ + FragmentSpreadNode( + name: NameNode(value: 'JobFields'), + directives: [], + ), + FieldNode( + name: NameNode(value: '__typename'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + ], + ), + ), + FieldNode( + name: NameNode(value: '__typename'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + ], + ), + ), + fragmentDefinitionJobFields, + ], +); +Query$JobQueue _parserFn$Query$JobQueue(Map data) => + Query$JobQueue.fromJson(data); +typedef OnQueryComplete$Query$JobQueue = FutureOr Function( + Map?, + Query$JobQueue?, +); + +class Options$Query$JobQueue extends graphql.QueryOptions { + Options$Query$JobQueue({ + String? operationName, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Query$JobQueue? typedOptimisticResult, + Duration? pollInterval, + graphql.Context? context, + OnQueryComplete$Query$JobQueue? onComplete, + graphql.OnQueryError? onError, + }) : onCompleteWithParsed = onComplete, + super( + operationName: operationName ?? 'JobQueue', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + pollInterval: pollInterval, + context: context, + onComplete: onComplete == null + ? null + : (data) => onComplete( + data, + data == null ? null : _parserFn$Query$JobQueue(data), + ), + onError: onError, + document: documentNodeQueryJobQueue, + parserFn: _parserFn$Query$JobQueue, + ); + + final OnQueryComplete$Query$JobQueue? onCompleteWithParsed; + + @override + List get properties => [ + ...super.onComplete == null + ? super.properties + : super.properties.where((property) => property != onComplete), + onCompleteWithParsed, + ]; +} + +class WatchOptions$Query$JobQueue + extends graphql.WatchQueryOptions { + WatchOptions$Query$JobQueue({ + String? operationName, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Query$JobQueue? typedOptimisticResult, + graphql.Context? context, + Duration? pollInterval, + bool? eagerlyFetchResults, + bool carryForwardDataOnException = true, + bool fetchResults = false, + }) : super( + operationName: operationName ?? 'JobQueue', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + document: documentNodeQueryJobQueue, + pollInterval: pollInterval, + eagerlyFetchResults: eagerlyFetchResults, + carryForwardDataOnException: carryForwardDataOnException, + fetchResults: fetchResults, + parserFn: _parserFn$Query$JobQueue, + ); +} + +class FetchMoreOptions$Query$JobQueue extends graphql.FetchMoreOptions { + FetchMoreOptions$Query$JobQueue({required graphql.UpdateQuery updateQuery}) + : super(updateQuery: updateQuery, document: documentNodeQueryJobQueue); +} + +extension ClientExtension$Query$JobQueue on graphql.GraphQLClient { + Future> query$JobQueue([ + Options$Query$JobQueue? options, + ]) async => await this.query(options ?? Options$Query$JobQueue()); + + graphql.ObservableQuery watchQuery$JobQueue([ + WatchOptions$Query$JobQueue? options, + ]) => this.watchQuery(options ?? WatchOptions$Query$JobQueue()); + + void writeQuery$JobQueue({ + required Query$JobQueue data, + bool broadcast = true, + }) => this.writeQuery( + graphql.Request( + operation: graphql.Operation(document: documentNodeQueryJobQueue), + ), + data: data.toJson(), + broadcast: broadcast, + ); + + Query$JobQueue? readQuery$JobQueue({bool optimistic = true}) { + final result = this.readQuery( + graphql.Request( + operation: graphql.Operation(document: documentNodeQueryJobQueue), + ), + optimistic: optimistic, + ); + return result == null ? null : Query$JobQueue.fromJson(result); + } +} + +class Variables$Mutation$StopJob { + factory Variables$Mutation$StopJob({required String job_id}) => + Variables$Mutation$StopJob._({r'job_id': job_id}); + + Variables$Mutation$StopJob._(this._$data); + + factory Variables$Mutation$StopJob.fromJson(Map data) { + final result$data = {}; + final l$job_id = data['job_id']; + result$data['job_id'] = (l$job_id as String); + return Variables$Mutation$StopJob._(result$data); + } + + Map _$data; + + String get job_id => (_$data['job_id'] as String); + + Map toJson() { + final result$data = {}; + final l$job_id = job_id; + result$data['job_id'] = l$job_id; + return result$data; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Variables$Mutation$StopJob || + runtimeType != other.runtimeType) { + return false; + } + final l$job_id = job_id; + final lOther$job_id = other.job_id; + if (l$job_id != lOther$job_id) { + return false; + } + return true; + } + + @override + int get hashCode { + final l$job_id = job_id; + return Object.hashAll([l$job_id]); + } +} + +class Mutation$StopJob { + Mutation$StopJob({required this.stopJob, this.$__typename = 'Mutation'}); + + factory Mutation$StopJob.fromJson(Map json) { + final l$stopJob = json['stopJob']; + final l$$__typename = json['__typename']; + return Mutation$StopJob( + stopJob: (l$stopJob as bool), + $__typename: (l$$__typename as String), + ); + } + + final bool stopJob; + + final String $__typename; + + Map toJson() { + final _resultData = {}; + final l$stopJob = stopJob; + _resultData['stopJob'] = l$stopJob; + final l$$__typename = $__typename; + _resultData['__typename'] = l$$__typename; + return _resultData; + } + + @override + int get hashCode { + final l$stopJob = stopJob; + final l$$__typename = $__typename; + return Object.hashAll([l$stopJob, l$$__typename]); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Mutation$StopJob || runtimeType != other.runtimeType) { + return false; + } + final l$stopJob = stopJob; + final lOther$stopJob = other.stopJob; + if (l$stopJob != lOther$stopJob) { + return false; + } + final l$$__typename = $__typename; + final lOther$$__typename = other.$__typename; + if (l$$__typename != lOther$$__typename) { + return false; + } + return true; + } +} + +const documentNodeMutationStopJob = DocumentNode( + definitions: [ + OperationDefinitionNode( + type: OperationType.mutation, + name: NameNode(value: 'StopJob'), + variableDefinitions: [ + VariableDefinitionNode( + variable: VariableNode(name: NameNode(value: 'job_id')), + type: NamedTypeNode(name: NameNode(value: 'ID'), isNonNull: true), + defaultValue: DefaultValueNode(value: null), + directives: [], + ), + ], + directives: [], + selectionSet: SelectionSetNode( + selections: [ + FieldNode( + name: NameNode(value: 'stopJob'), + alias: null, + arguments: [ + ArgumentNode( + name: NameNode(value: 'job_id'), + value: VariableNode(name: NameNode(value: 'job_id')), + ), + ], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: '__typename'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + ], + ), + ), + ], +); +Mutation$StopJob _parserFn$Mutation$StopJob(Map data) => + Mutation$StopJob.fromJson(data); +typedef OnMutationCompleted$Mutation$StopJob = FutureOr Function( + Map?, + Mutation$StopJob?, +); + +class Options$Mutation$StopJob + extends graphql.MutationOptions { + Options$Mutation$StopJob({ + String? operationName, + required Variables$Mutation$StopJob variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$StopJob? typedOptimisticResult, + graphql.Context? context, + OnMutationCompleted$Mutation$StopJob? onCompleted, + graphql.OnMutationUpdate? update, + graphql.OnError? onError, + }) : onCompletedWithParsed = onCompleted, + super( + variables: variables.toJson(), + operationName: operationName ?? 'StopJob', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + onCompleted: onCompleted == null + ? null + : (data) => onCompleted( + data, + data == null ? null : _parserFn$Mutation$StopJob(data), + ), + update: update, + onError: onError, + document: documentNodeMutationStopJob, + parserFn: _parserFn$Mutation$StopJob, + ); + + final OnMutationCompleted$Mutation$StopJob? onCompletedWithParsed; + + @override + List get properties => [ + ...super.onCompleted == null + ? super.properties + : super.properties.where((property) => property != onCompleted), + onCompletedWithParsed, + ]; +} + +class WatchOptions$Mutation$StopJob + extends graphql.WatchQueryOptions { + WatchOptions$Mutation$StopJob({ + String? operationName, + required Variables$Mutation$StopJob variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$StopJob? typedOptimisticResult, + graphql.Context? context, + Duration? pollInterval, + bool? eagerlyFetchResults, + bool carryForwardDataOnException = true, + bool fetchResults = false, + }) : super( + variables: variables.toJson(), + operationName: operationName ?? 'StopJob', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + document: documentNodeMutationStopJob, + pollInterval: pollInterval, + eagerlyFetchResults: eagerlyFetchResults, + carryForwardDataOnException: carryForwardDataOnException, + fetchResults: fetchResults, + parserFn: _parserFn$Mutation$StopJob, + ); +} + +extension ClientExtension$Mutation$StopJob on graphql.GraphQLClient { + Future> mutate$StopJob( + Options$Mutation$StopJob options, + ) async => await this.mutate(options); + + graphql.ObservableQuery watchMutation$StopJob( + WatchOptions$Mutation$StopJob options, + ) => this.watchMutation(options); +} + +class Variables$Mutation$MetadataScan { + factory Variables$Mutation$MetadataScan({ + required Input$ScanMetadataInput input, + }) => Variables$Mutation$MetadataScan._({r'input': input}); + + Variables$Mutation$MetadataScan._(this._$data); + + factory Variables$Mutation$MetadataScan.fromJson(Map data) { + final result$data = {}; + final l$input = data['input']; + result$data['input'] = Input$ScanMetadataInput.fromJson( + (l$input as Map), + ); + return Variables$Mutation$MetadataScan._(result$data); + } + + Map _$data; + + Input$ScanMetadataInput get input => + (_$data['input'] as Input$ScanMetadataInput); + + Map toJson() { + final result$data = {}; + final l$input = input; + result$data['input'] = l$input.toJson(); + return result$data; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Variables$Mutation$MetadataScan || + runtimeType != other.runtimeType) { + return false; + } + final l$input = input; + final lOther$input = other.input; + if (l$input != lOther$input) { + return false; + } + return true; + } + + @override + int get hashCode { + final l$input = input; + return Object.hashAll([l$input]); + } +} + +class Mutation$MetadataScan { + Mutation$MetadataScan({ + required this.metadataScan, + this.$__typename = 'Mutation', + }); + + factory Mutation$MetadataScan.fromJson(Map json) { + final l$metadataScan = json['metadataScan']; + final l$$__typename = json['__typename']; + return Mutation$MetadataScan( + metadataScan: (l$metadataScan as String), + $__typename: (l$$__typename as String), + ); + } + + final String metadataScan; + + final String $__typename; + + Map toJson() { + final _resultData = {}; + final l$metadataScan = metadataScan; + _resultData['metadataScan'] = l$metadataScan; + final l$$__typename = $__typename; + _resultData['__typename'] = l$$__typename; + return _resultData; + } + + @override + int get hashCode { + final l$metadataScan = metadataScan; + final l$$__typename = $__typename; + return Object.hashAll([l$metadataScan, l$$__typename]); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Mutation$MetadataScan || runtimeType != other.runtimeType) { + return false; + } + final l$metadataScan = metadataScan; + final lOther$metadataScan = other.metadataScan; + if (l$metadataScan != lOther$metadataScan) { + return false; + } + final l$$__typename = $__typename; + final lOther$$__typename = other.$__typename; + if (l$$__typename != lOther$$__typename) { + return false; + } + return true; + } +} + +const documentNodeMutationMetadataScan = DocumentNode( + definitions: [ + OperationDefinitionNode( + type: OperationType.mutation, + name: NameNode(value: 'MetadataScan'), + variableDefinitions: [ + VariableDefinitionNode( + variable: VariableNode(name: NameNode(value: 'input')), + type: NamedTypeNode( + name: NameNode(value: 'ScanMetadataInput'), + isNonNull: true, + ), + defaultValue: DefaultValueNode(value: null), + directives: [], + ), + ], + directives: [], + selectionSet: SelectionSetNode( + selections: [ + FieldNode( + name: NameNode(value: 'metadataScan'), + alias: null, + arguments: [ + ArgumentNode( + name: NameNode(value: 'input'), + value: VariableNode(name: NameNode(value: 'input')), + ), + ], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: '__typename'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + ], + ), + ), + ], +); +Mutation$MetadataScan _parserFn$Mutation$MetadataScan( + Map data, +) => Mutation$MetadataScan.fromJson(data); +typedef OnMutationCompleted$Mutation$MetadataScan = FutureOr Function( + Map?, + Mutation$MetadataScan?, +); + +class Options$Mutation$MetadataScan + extends graphql.MutationOptions { + Options$Mutation$MetadataScan({ + String? operationName, + required Variables$Mutation$MetadataScan variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$MetadataScan? typedOptimisticResult, + graphql.Context? context, + OnMutationCompleted$Mutation$MetadataScan? onCompleted, + graphql.OnMutationUpdate? update, + graphql.OnError? onError, + }) : onCompletedWithParsed = onCompleted, + super( + variables: variables.toJson(), + operationName: operationName ?? 'MetadataScan', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + onCompleted: onCompleted == null + ? null + : (data) => onCompleted( + data, + data == null ? null : _parserFn$Mutation$MetadataScan(data), + ), + update: update, + onError: onError, + document: documentNodeMutationMetadataScan, + parserFn: _parserFn$Mutation$MetadataScan, + ); + + final OnMutationCompleted$Mutation$MetadataScan? onCompletedWithParsed; + + @override + List get properties => [ + ...super.onCompleted == null + ? super.properties + : super.properties.where((property) => property != onCompleted), + onCompletedWithParsed, + ]; +} + +class WatchOptions$Mutation$MetadataScan + extends graphql.WatchQueryOptions { + WatchOptions$Mutation$MetadataScan({ + String? operationName, + required Variables$Mutation$MetadataScan variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$MetadataScan? typedOptimisticResult, + graphql.Context? context, + Duration? pollInterval, + bool? eagerlyFetchResults, + bool carryForwardDataOnException = true, + bool fetchResults = false, + }) : super( + variables: variables.toJson(), + operationName: operationName ?? 'MetadataScan', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + document: documentNodeMutationMetadataScan, + pollInterval: pollInterval, + eagerlyFetchResults: eagerlyFetchResults, + carryForwardDataOnException: carryForwardDataOnException, + fetchResults: fetchResults, + parserFn: _parserFn$Mutation$MetadataScan, + ); +} + +extension ClientExtension$Mutation$MetadataScan on graphql.GraphQLClient { + Future> mutate$MetadataScan( + Options$Mutation$MetadataScan options, + ) async => await this.mutate(options); + + graphql.ObservableQuery watchMutation$MetadataScan( + WatchOptions$Mutation$MetadataScan options, + ) => this.watchMutation(options); +} + +class Variables$Mutation$MetadataGenerate { + factory Variables$Mutation$MetadataGenerate({ + required Input$GenerateMetadataInput input, + }) => Variables$Mutation$MetadataGenerate._({r'input': input}); + + Variables$Mutation$MetadataGenerate._(this._$data); + + factory Variables$Mutation$MetadataGenerate.fromJson( + Map data, + ) { + final result$data = {}; + final l$input = data['input']; + result$data['input'] = Input$GenerateMetadataInput.fromJson( + (l$input as Map), + ); + return Variables$Mutation$MetadataGenerate._(result$data); + } + + Map _$data; + + Input$GenerateMetadataInput get input => + (_$data['input'] as Input$GenerateMetadataInput); + + Map toJson() { + final result$data = {}; + final l$input = input; + result$data['input'] = l$input.toJson(); + return result$data; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Variables$Mutation$MetadataGenerate || + runtimeType != other.runtimeType) { + return false; + } + final l$input = input; + final lOther$input = other.input; + if (l$input != lOther$input) { + return false; + } + return true; + } + + @override + int get hashCode { + final l$input = input; + return Object.hashAll([l$input]); + } +} + +class Mutation$MetadataGenerate { + Mutation$MetadataGenerate({ + required this.metadataGenerate, + this.$__typename = 'Mutation', + }); + + factory Mutation$MetadataGenerate.fromJson(Map json) { + final l$metadataGenerate = json['metadataGenerate']; + final l$$__typename = json['__typename']; + return Mutation$MetadataGenerate( + metadataGenerate: (l$metadataGenerate as String), + $__typename: (l$$__typename as String), + ); + } + + final String metadataGenerate; + + final String $__typename; + + Map toJson() { + final _resultData = {}; + final l$metadataGenerate = metadataGenerate; + _resultData['metadataGenerate'] = l$metadataGenerate; + final l$$__typename = $__typename; + _resultData['__typename'] = l$$__typename; + return _resultData; + } + + @override + int get hashCode { + final l$metadataGenerate = metadataGenerate; + final l$$__typename = $__typename; + return Object.hashAll([l$metadataGenerate, l$$__typename]); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Mutation$MetadataGenerate || + runtimeType != other.runtimeType) { + return false; + } + final l$metadataGenerate = metadataGenerate; + final lOther$metadataGenerate = other.metadataGenerate; + if (l$metadataGenerate != lOther$metadataGenerate) { + return false; + } + final l$$__typename = $__typename; + final lOther$$__typename = other.$__typename; + if (l$$__typename != lOther$$__typename) { + return false; + } + return true; + } +} + +const documentNodeMutationMetadataGenerate = DocumentNode( + definitions: [ + OperationDefinitionNode( + type: OperationType.mutation, + name: NameNode(value: 'MetadataGenerate'), + variableDefinitions: [ + VariableDefinitionNode( + variable: VariableNode(name: NameNode(value: 'input')), + type: NamedTypeNode( + name: NameNode(value: 'GenerateMetadataInput'), + isNonNull: true, + ), + defaultValue: DefaultValueNode(value: null), + directives: [], + ), + ], + directives: [], + selectionSet: SelectionSetNode( + selections: [ + FieldNode( + name: NameNode(value: 'metadataGenerate'), + alias: null, + arguments: [ + ArgumentNode( + name: NameNode(value: 'input'), + value: VariableNode(name: NameNode(value: 'input')), + ), + ], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: '__typename'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + ], + ), + ), + ], +); +Mutation$MetadataGenerate _parserFn$Mutation$MetadataGenerate( + Map data, +) => Mutation$MetadataGenerate.fromJson(data); +typedef OnMutationCompleted$Mutation$MetadataGenerate = FutureOr Function( + Map?, + Mutation$MetadataGenerate?, +); + +class Options$Mutation$MetadataGenerate + extends graphql.MutationOptions { + Options$Mutation$MetadataGenerate({ + String? operationName, + required Variables$Mutation$MetadataGenerate variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$MetadataGenerate? typedOptimisticResult, + graphql.Context? context, + OnMutationCompleted$Mutation$MetadataGenerate? onCompleted, + graphql.OnMutationUpdate? update, + graphql.OnError? onError, + }) : onCompletedWithParsed = onCompleted, + super( + variables: variables.toJson(), + operationName: operationName ?? 'MetadataGenerate', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + onCompleted: onCompleted == null + ? null + : (data) => onCompleted( + data, + data == null + ? null + : _parserFn$Mutation$MetadataGenerate(data), + ), + update: update, + onError: onError, + document: documentNodeMutationMetadataGenerate, + parserFn: _parserFn$Mutation$MetadataGenerate, + ); + + final OnMutationCompleted$Mutation$MetadataGenerate? onCompletedWithParsed; + + @override + List get properties => [ + ...super.onCompleted == null + ? super.properties + : super.properties.where((property) => property != onCompleted), + onCompletedWithParsed, + ]; +} + +class WatchOptions$Mutation$MetadataGenerate + extends graphql.WatchQueryOptions { + WatchOptions$Mutation$MetadataGenerate({ + String? operationName, + required Variables$Mutation$MetadataGenerate variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$MetadataGenerate? typedOptimisticResult, + graphql.Context? context, + Duration? pollInterval, + bool? eagerlyFetchResults, + bool carryForwardDataOnException = true, + bool fetchResults = false, + }) : super( + variables: variables.toJson(), + operationName: operationName ?? 'MetadataGenerate', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + document: documentNodeMutationMetadataGenerate, + pollInterval: pollInterval, + eagerlyFetchResults: eagerlyFetchResults, + carryForwardDataOnException: carryForwardDataOnException, + fetchResults: fetchResults, + parserFn: _parserFn$Mutation$MetadataGenerate, + ); +} + +extension ClientExtension$Mutation$MetadataGenerate on graphql.GraphQLClient { + Future> + mutate$MetadataGenerate(Options$Mutation$MetadataGenerate options) async => + await this.mutate(options); + + graphql.ObservableQuery + watchMutation$MetadataGenerate( + WatchOptions$Mutation$MetadataGenerate options, + ) => this.watchMutation(options); +} + +class Variables$Mutation$MetadataAutoTag { + factory Variables$Mutation$MetadataAutoTag({ + required Input$AutoTagMetadataInput input, + }) => Variables$Mutation$MetadataAutoTag._({r'input': input}); + + Variables$Mutation$MetadataAutoTag._(this._$data); + + factory Variables$Mutation$MetadataAutoTag.fromJson( + Map data, + ) { + final result$data = {}; + final l$input = data['input']; + result$data['input'] = Input$AutoTagMetadataInput.fromJson( + (l$input as Map), + ); + return Variables$Mutation$MetadataAutoTag._(result$data); + } + + Map _$data; + + Input$AutoTagMetadataInput get input => + (_$data['input'] as Input$AutoTagMetadataInput); + + Map toJson() { + final result$data = {}; + final l$input = input; + result$data['input'] = l$input.toJson(); + return result$data; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Variables$Mutation$MetadataAutoTag || + runtimeType != other.runtimeType) { + return false; + } + final l$input = input; + final lOther$input = other.input; + if (l$input != lOther$input) { + return false; + } + return true; + } + + @override + int get hashCode { + final l$input = input; + return Object.hashAll([l$input]); + } +} + +class Mutation$MetadataAutoTag { + Mutation$MetadataAutoTag({ + required this.metadataAutoTag, + this.$__typename = 'Mutation', + }); + + factory Mutation$MetadataAutoTag.fromJson(Map json) { + final l$metadataAutoTag = json['metadataAutoTag']; + final l$$__typename = json['__typename']; + return Mutation$MetadataAutoTag( + metadataAutoTag: (l$metadataAutoTag as String), + $__typename: (l$$__typename as String), + ); + } + + final String metadataAutoTag; + + final String $__typename; + + Map toJson() { + final _resultData = {}; + final l$metadataAutoTag = metadataAutoTag; + _resultData['metadataAutoTag'] = l$metadataAutoTag; + final l$$__typename = $__typename; + _resultData['__typename'] = l$$__typename; + return _resultData; + } + + @override + int get hashCode { + final l$metadataAutoTag = metadataAutoTag; + final l$$__typename = $__typename; + return Object.hashAll([l$metadataAutoTag, l$$__typename]); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Mutation$MetadataAutoTag || + runtimeType != other.runtimeType) { + return false; + } + final l$metadataAutoTag = metadataAutoTag; + final lOther$metadataAutoTag = other.metadataAutoTag; + if (l$metadataAutoTag != lOther$metadataAutoTag) { + return false; + } + final l$$__typename = $__typename; + final lOther$$__typename = other.$__typename; + if (l$$__typename != lOther$$__typename) { + return false; + } + return true; + } +} + +const documentNodeMutationMetadataAutoTag = DocumentNode( + definitions: [ + OperationDefinitionNode( + type: OperationType.mutation, + name: NameNode(value: 'MetadataAutoTag'), + variableDefinitions: [ + VariableDefinitionNode( + variable: VariableNode(name: NameNode(value: 'input')), + type: NamedTypeNode( + name: NameNode(value: 'AutoTagMetadataInput'), + isNonNull: true, + ), + defaultValue: DefaultValueNode(value: null), + directives: [], + ), + ], + directives: [], + selectionSet: SelectionSetNode( + selections: [ + FieldNode( + name: NameNode(value: 'metadataAutoTag'), + alias: null, + arguments: [ + ArgumentNode( + name: NameNode(value: 'input'), + value: VariableNode(name: NameNode(value: 'input')), + ), + ], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: '__typename'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + ], + ), + ), + ], +); +Mutation$MetadataAutoTag _parserFn$Mutation$MetadataAutoTag( + Map data, +) => Mutation$MetadataAutoTag.fromJson(data); +typedef OnMutationCompleted$Mutation$MetadataAutoTag = FutureOr Function( + Map?, + Mutation$MetadataAutoTag?, +); + +class Options$Mutation$MetadataAutoTag + extends graphql.MutationOptions { + Options$Mutation$MetadataAutoTag({ + String? operationName, + required Variables$Mutation$MetadataAutoTag variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$MetadataAutoTag? typedOptimisticResult, + graphql.Context? context, + OnMutationCompleted$Mutation$MetadataAutoTag? onCompleted, + graphql.OnMutationUpdate? update, + graphql.OnError? onError, + }) : onCompletedWithParsed = onCompleted, + super( + variables: variables.toJson(), + operationName: operationName ?? 'MetadataAutoTag', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + onCompleted: onCompleted == null + ? null + : (data) => onCompleted( + data, + data == null ? null : _parserFn$Mutation$MetadataAutoTag(data), + ), + update: update, + onError: onError, + document: documentNodeMutationMetadataAutoTag, + parserFn: _parserFn$Mutation$MetadataAutoTag, + ); + + final OnMutationCompleted$Mutation$MetadataAutoTag? onCompletedWithParsed; + + @override + List get properties => [ + ...super.onCompleted == null + ? super.properties + : super.properties.where((property) => property != onCompleted), + onCompletedWithParsed, + ]; +} + +class WatchOptions$Mutation$MetadataAutoTag + extends graphql.WatchQueryOptions { + WatchOptions$Mutation$MetadataAutoTag({ + String? operationName, + required Variables$Mutation$MetadataAutoTag variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$MetadataAutoTag? typedOptimisticResult, + graphql.Context? context, + Duration? pollInterval, + bool? eagerlyFetchResults, + bool carryForwardDataOnException = true, + bool fetchResults = false, + }) : super( + variables: variables.toJson(), + operationName: operationName ?? 'MetadataAutoTag', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + document: documentNodeMutationMetadataAutoTag, + pollInterval: pollInterval, + eagerlyFetchResults: eagerlyFetchResults, + carryForwardDataOnException: carryForwardDataOnException, + fetchResults: fetchResults, + parserFn: _parserFn$Mutation$MetadataAutoTag, + ); +} + +extension ClientExtension$Mutation$MetadataAutoTag on graphql.GraphQLClient { + Future> mutate$MetadataAutoTag( + Options$Mutation$MetadataAutoTag options, + ) async => await this.mutate(options); + + graphql.ObservableQuery + watchMutation$MetadataAutoTag( + WatchOptions$Mutation$MetadataAutoTag options, + ) => this.watchMutation(options); +} + +class Variables$Mutation$MetadataClean { + factory Variables$Mutation$MetadataClean({ + required Input$CleanMetadataInput input, + }) => Variables$Mutation$MetadataClean._({r'input': input}); + + Variables$Mutation$MetadataClean._(this._$data); + + factory Variables$Mutation$MetadataClean.fromJson(Map data) { + final result$data = {}; + final l$input = data['input']; + result$data['input'] = Input$CleanMetadataInput.fromJson( + (l$input as Map), + ); + return Variables$Mutation$MetadataClean._(result$data); + } + + Map _$data; + + Input$CleanMetadataInput get input => + (_$data['input'] as Input$CleanMetadataInput); + + Map toJson() { + final result$data = {}; + final l$input = input; + result$data['input'] = l$input.toJson(); + return result$data; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Variables$Mutation$MetadataClean || + runtimeType != other.runtimeType) { + return false; + } + final l$input = input; + final lOther$input = other.input; + if (l$input != lOther$input) { + return false; + } + return true; + } + + @override + int get hashCode { + final l$input = input; + return Object.hashAll([l$input]); + } +} + +class Mutation$MetadataClean { + Mutation$MetadataClean({ + required this.metadataClean, + this.$__typename = 'Mutation', + }); + + factory Mutation$MetadataClean.fromJson(Map json) { + final l$metadataClean = json['metadataClean']; + final l$$__typename = json['__typename']; + return Mutation$MetadataClean( + metadataClean: (l$metadataClean as String), + $__typename: (l$$__typename as String), + ); + } + + final String metadataClean; + + final String $__typename; + + Map toJson() { + final _resultData = {}; + final l$metadataClean = metadataClean; + _resultData['metadataClean'] = l$metadataClean; + final l$$__typename = $__typename; + _resultData['__typename'] = l$$__typename; + return _resultData; + } + + @override + int get hashCode { + final l$metadataClean = metadataClean; + final l$$__typename = $__typename; + return Object.hashAll([l$metadataClean, l$$__typename]); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is! Mutation$MetadataClean || runtimeType != other.runtimeType) { + return false; + } + final l$metadataClean = metadataClean; + final lOther$metadataClean = other.metadataClean; + if (l$metadataClean != lOther$metadataClean) { + return false; + } + final l$$__typename = $__typename; + final lOther$$__typename = other.$__typename; + if (l$$__typename != lOther$$__typename) { + return false; + } + return true; + } +} + +const documentNodeMutationMetadataClean = DocumentNode( + definitions: [ + OperationDefinitionNode( + type: OperationType.mutation, + name: NameNode(value: 'MetadataClean'), + variableDefinitions: [ + VariableDefinitionNode( + variable: VariableNode(name: NameNode(value: 'input')), + type: NamedTypeNode( + name: NameNode(value: 'CleanMetadataInput'), + isNonNull: true, + ), + defaultValue: DefaultValueNode(value: null), + directives: [], + ), + ], + directives: [], + selectionSet: SelectionSetNode( + selections: [ + FieldNode( + name: NameNode(value: 'metadataClean'), + alias: null, + arguments: [ + ArgumentNode( + name: NameNode(value: 'input'), + value: VariableNode(name: NameNode(value: 'input')), + ), + ], + directives: [], + selectionSet: null, + ), + FieldNode( + name: NameNode(value: '__typename'), + alias: null, + arguments: [], + directives: [], + selectionSet: null, + ), + ], + ), + ), + ], +); +Mutation$MetadataClean _parserFn$Mutation$MetadataClean( + Map data, +) => Mutation$MetadataClean.fromJson(data); +typedef OnMutationCompleted$Mutation$MetadataClean = FutureOr Function( + Map?, + Mutation$MetadataClean?, +); + +class Options$Mutation$MetadataClean + extends graphql.MutationOptions { + Options$Mutation$MetadataClean({ + String? operationName, + required Variables$Mutation$MetadataClean variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$MetadataClean? typedOptimisticResult, + graphql.Context? context, + OnMutationCompleted$Mutation$MetadataClean? onCompleted, + graphql.OnMutationUpdate? update, + graphql.OnError? onError, + }) : onCompletedWithParsed = onCompleted, + super( + variables: variables.toJson(), + operationName: operationName ?? 'MetadataClean', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + onCompleted: onCompleted == null + ? null + : (data) => onCompleted( + data, + data == null ? null : _parserFn$Mutation$MetadataClean(data), + ), + update: update, + onError: onError, + document: documentNodeMutationMetadataClean, + parserFn: _parserFn$Mutation$MetadataClean, + ); + + final OnMutationCompleted$Mutation$MetadataClean? onCompletedWithParsed; + + @override + List get properties => [ + ...super.onCompleted == null + ? super.properties + : super.properties.where((property) => property != onCompleted), + onCompletedWithParsed, + ]; +} + +class WatchOptions$Mutation$MetadataClean + extends graphql.WatchQueryOptions { + WatchOptions$Mutation$MetadataClean({ + String? operationName, + required Variables$Mutation$MetadataClean variables, + graphql.FetchPolicy? fetchPolicy, + graphql.ErrorPolicy? errorPolicy, + graphql.CacheRereadPolicy? cacheRereadPolicy, + Object? optimisticResult, + Mutation$MetadataClean? typedOptimisticResult, + graphql.Context? context, + Duration? pollInterval, + bool? eagerlyFetchResults, + bool carryForwardDataOnException = true, + bool fetchResults = false, + }) : super( + variables: variables.toJson(), + operationName: operationName ?? 'MetadataClean', + fetchPolicy: fetchPolicy, + errorPolicy: errorPolicy, + cacheRereadPolicy: cacheRereadPolicy, + optimisticResult: optimisticResult ?? typedOptimisticResult?.toJson(), + context: context, + document: documentNodeMutationMetadataClean, + pollInterval: pollInterval, + eagerlyFetchResults: eagerlyFetchResults, + carryForwardDataOnException: carryForwardDataOnException, + fetchResults: fetchResults, + parserFn: _parserFn$Mutation$MetadataClean, + ); +} + +extension ClientExtension$Mutation$MetadataClean on graphql.GraphQLClient { + Future> mutate$MetadataClean( + Options$Mutation$MetadataClean options, + ) async => await this.mutate(options); + + graphql.ObservableQuery watchMutation$MetadataClean( + WatchOptions$Mutation$MetadataClean options, + ) => this.watchMutation(options); +} diff --git a/lib/data/models/server_task.dart b/lib/data/models/server_task.dart new file mode 100644 index 0000000..2e1ff5b --- /dev/null +++ b/lib/data/models/server_task.dart @@ -0,0 +1,101 @@ +import '../../core/api/documents/tasks.graphql.dart'; +import '../../core/api/stash_schema.graphql.dart'; + +enum JobState { ready, running, finished, stopping, cancelled, failed } + +/// A job in Stash's task queue (scan, generate, ...). +class ServerJob { + const ServerJob({ + required this.id, + required this.state, + required this.description, + this.subTasks = const [], + this.progress, + this.error, + }); + + final String id; + final JobState state; + + /// Stash's own text, e.g. "Scanning...". + final String description; + + /// What the job is working on right now (a file, a step). + final List subTasks; + + /// 0–1; null while Stash can't tell yet. + final double? progress; + final String? error; + + bool get isActive => state == JobState.ready || state == JobState.running; + + factory ServerJob.fromFields(Fragment$JobFields j) => ServerJob( + id: j.id, + state: switch (j.status) { + Enum$JobStatus.READY => JobState.ready, + Enum$JobStatus.RUNNING || Enum$JobStatus.$unknown => JobState.running, + Enum$JobStatus.FINISHED => JobState.finished, + Enum$JobStatus.STOPPING => JobState.stopping, + Enum$JobStatus.CANCELLED => JobState.cancelled, + Enum$JobStatus.FAILED => JobState.failed, + }, + description: j.description, + subTasks: j.subTasks ?? const [], + progress: j.progress, + error: j.error, + ); +} + +/// What a scan generates for new files besides reading them in; the values +/// are the keys of Stash's `ScanMetadataInput`. +enum ScanOption { + rescan('rescan'), + covers('scanGenerateCovers'), + previews('scanGeneratePreviews'), + imagePreviews('scanGenerateImagePreviews'), + sprites('scanGenerateSprites'), + phashes('scanGeneratePhashes'); + + const ScanOption(this.key); + + final String key; + + static const standard = {covers, previews, imagePreviews, sprites, phashes}; +} + +/// What the generate task creates; the values are the keys of Stash's +/// `GenerateMetadataInput`. +enum GenerateOption { + covers('covers'), + previews('previews'), + imagePreviews('imagePreviews'), + sprites('sprites'), + markers('markers'), + markerImagePreviews('markerImagePreviews'), + markerScreenshots('markerScreenshots'), + phashes('phashes'); + + const GenerateOption(this.key); + + final String key; + + /// The animated (WebP) previews the lists and markers play. + static const animatedPreviews = {imagePreviews, markerImagePreviews}; + + static const standard = {covers, previews, imagePreviews, sprites, markers, markerImagePreviews, phashes}; +} + +/// Whether the server has generated the previews the app plays, judged +/// from a random sample. +class PreviewCheck { + const PreviewCheck({this.scenes = 0, this.animated = 0, this.videos = 0, this.markers = 0, this.markerAnimated = 0}); + + /// How many scenes were checked, and how many of them had an animated + /// (WebP) and a video (MP4) preview. + final int scenes; + final int animated; + final int videos; + + final int markers; + final int markerAnimated; +} diff --git a/lib/data/repositories/stash_repository.dart b/lib/data/repositories/stash_repository.dart index ff848b1..c398d69 100644 --- a/lib/data/repositories/stash_repository.dart +++ b/lib/data/repositories/stash_repository.dart @@ -11,6 +11,7 @@ import '../../core/api/documents/scenes.graphql.dart'; import '../../core/api/documents/studios.graphql.dart'; import '../../core/api/documents/system.graphql.dart'; import '../../core/api/documents/tags.graphql.dart'; +import '../../core/api/documents/tasks.graphql.dart'; import '../../core/api/stash_schema.graphql.dart'; import '../../core/config/server_config.dart'; import '../../features/player/playback_tracker.dart'; @@ -26,6 +27,7 @@ import '../models/scene.dart'; import '../models/saved_filter.dart'; import '../models/scene_details.dart'; import '../models/scrub_thumbnails.dart'; +import '../models/server_task.dart'; import '../models/stats.dart'; import '../models/studio.dart'; import '../models/tag.dart'; @@ -522,6 +524,89 @@ class StashRepository implements PlaybackActivityApi { ), ); + Future> jobQueue() async => [ + for (final job in (await _query(Options$Query$JobQueue())).jobQueue ?? const []) + ServerJob.fromFields(job), + ]; + + Future stopJob(String id) => + _mutate(Options$Mutation$StopJob(variables: Variables$Mutation$StopJob(job_id: id))); + + /// Scans all library paths; returns the job id. + Future scanLibrary(Set options) async => (await _mutate( + Options$Mutation$MetadataScan( + variables: Variables$Mutation$MetadataScan( + input: _input( + {for (final o in ScanOption.values) o.key: options.contains(o)}, + Input$ScanMetadataInput.fromJson, + (i) => i.toJson(), + )!, + ), + ), + )).metadataScan; + + /// Generates what's missing (no overwrite) for the whole library. + Future generate(Set options) async => (await _mutate( + Options$Mutation$MetadataGenerate( + variables: Variables$Mutation$MetadataGenerate( + input: _input( + {for (final o in GenerateOption.values) o.key: options.contains(o), 'overwrite': false}, + Input$GenerateMetadataInput.fromJson, + (i) => i.toJson(), + )!, + ), + ), + )).metadataGenerate; + + /// Tags all files with every performer, studio and tag their paths name. + Future autoTag() async => (await _mutate( + Options$Mutation$MetadataAutoTag( + variables: Variables$Mutation$MetadataAutoTag( + input: Input$AutoTagMetadataInput(performers: const ['*'], studios: const ['*'], tags: const ['*']), + ), + ), + )).metadataAutoTag; + + /// Removes the entries of files that are gone from disk. + Future clean() async => (await _mutate( + Options$Mutation$MetadataClean( + variables: Variables$Mutation$MetadataClean(input: Input$CleanMetadataInput(dryRun: false)), + ), + )).metadataClean; + + /// Looks whether a few random scenes and markers have their previews + /// generated: Stash names the preview URLs for every scene and answers + /// 404 for missing files. + Future checkPreviews({int sample = 8}) async { + final scenes = (await findScenes(SceneQuery(sort: SceneSort.random), perPage: sample)).items; + final markers = (await findMarkers(MarkerQuery(sort: MarkerSort.random), perPage: sample ~/ 2)).items; + Future count(Iterable urls) async => + (await Future.wait(urls.map(_exists))).where((found) => found).length; + final (animated, videos, markerAnimated) = await ( + count(scenes.map((s) => s.webpUrl)), + count(scenes.map((s) => s.previewUrl)), + count(markers.map((m) => m.previewUrl)), + ).wait; + return PreviewCheck( + scenes: scenes.length, + animated: animated, + videos: videos, + markers: markers.length, + markerAnimated: markerAnimated, + ); + } + + /// Whether the server has the file at [url]; asks for one byte only. + Future _exists(String? url) async { + if (url == null) return false; + try { + final response = await _http.get(Uri.parse(url), headers: {..._authHeaders, 'Range': 'bytes=0-0'}); + return response.statusCode == 200 || response.statusCode == 206; + } catch (_) { + return false; + } + } + static Input$FindFilterType _findFilter({ String? search, required int page, diff --git a/lib/features/demo/demo_library.dart b/lib/features/demo/demo_library.dart index 624bfdb..2f3222a 100644 --- a/lib/features/demo/demo_library.dart +++ b/lib/features/demo/demo_library.dart @@ -402,6 +402,7 @@ class DemoLibrary { Map _queryRoot() => { '__typename': 'Query', 'systemStatus': {'__typename': 'SystemStatus', 'status': 'OK'}, + 'jobQueue': const [], 'version': {'__typename': 'Version', 'version': 'demo'}, 'findScenes': (Map args) { final ids = (args['ids'] as List?)?.map((e) => '$e').toSet(); @@ -742,6 +743,10 @@ class DemoLibrary { Map _mutationRoot() => { '__typename': 'Mutation', + // Server tasks: accepted, but there is nothing to scan or generate. + for (final task in ['metadataScan', 'metadataGenerate', 'metadataAutoTag', 'metadataClean']) + task: (Map _) => '${_nextId++}', + 'stopJob': (Map _) => true, 'sceneSaveActivity': (Map args) { final scene = _scene(args['id']); if (scene == null) return false; diff --git a/lib/features/server_tasks/server_tasks_page.dart b/lib/features/server_tasks/server_tasks_page.dart new file mode 100644 index 0000000..d1e0c7b --- /dev/null +++ b/lib/features/server_tasks/server_tasks_page.dart @@ -0,0 +1,364 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/config/haptics.dart'; +import '../../data/models/server_task.dart'; +import '../../data/repositories/stash_repository.dart'; +import '../../l10n/l10n.dart'; + +/// Stash's task queue, polled while someone watches it. +class JobQueueNotifier extends AsyncNotifier> { + static const interval = Duration(seconds: 2); + + @override + Future> build() { + final timer = Timer.periodic(interval, (_) => refresh()); + ref.onDispose(timer.cancel); + return ref.watch(stashRepositoryProvider).jobQueue(); + } + + Future refresh() async { + try { + final jobs = await ref.read(stashRepositoryProvider).jobQueue(); + if (ref.mounted) state = AsyncData(jobs); + } catch (e, stack) { + // A failed poll keeps the last list; the next one tries again. + if (ref.mounted && !state.hasValue) state = AsyncError(e, stack); + } + } +} + +final jobQueueProvider = AsyncNotifierProvider.autoDispose>(JobQueueNotifier.new); + +/// Whether the server has the previews the lists play (a random sample). +final previewCheckProvider = FutureProvider.autoDispose( + (ref) => ref.watch(stashRepositoryProvider).checkPreviews(), +); + +/// Settings → Server tasks: Stash's scan, generate, auto tag and clean, the +/// running jobs, and whether the previews exist. Inside the settings sheet. +class ServerTasksPage extends StatelessWidget { + const ServerTasksPage({super.key}); + + // Its own messenger: the app's would show the snack bars under the sheet. + @override + Widget build(BuildContext context) => const ScaffoldMessenger(child: _ServerTasksView()); +} + +class _ServerTasksView extends ConsumerWidget { + const _ServerTasksView(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = context.l10n; + // A finished job may have created the previews. + ref.listen(jobQueueProvider, (previous, next) { + final before = previous?.value?.where((j) => j.isActive).length ?? 0; + final now = next.value?.where((j) => j.isActive).length ?? 0; + if (now < before) ref.invalidate(previewCheckProvider); + }); + return Scaffold( + appBar: AppBar(title: Text(l.serverTasks)), + body: ListView( + children: [ + _SectionTitle( + l.sectionPreviewCheck, + trailing: IconButton( + tooltip: l.checkAgain, + icon: const Icon(Icons.refresh), + onPressed: () => ref.invalidate(previewCheckProvider), + ), + ), + const _PreviewCheckTiles(), + _SectionTitle(l.sectionTasks), + ListTile( + leading: const Icon(Icons.manage_search), + title: Text(l.taskScan), + subtitle: Text(l.taskScanSubtitle), + onTap: () async { + final options = await _pickOptions( + context, + title: l.taskScan, + heading: l.taskScanGenerates, + values: ScanOption.values, + initial: ScanOption.standard, + label: (o) => o.label(l), + ); + if (options != null && context.mounted) { + await _start(context, ref, (repo) => repo.scanLibrary(options)); + } + }, + ), + ListTile( + leading: const Icon(Icons.auto_awesome_motion_outlined), + title: Text(l.taskGenerate), + subtitle: Text(l.taskGenerateSubtitle), + onTap: () async { + final options = await _pickOptions( + context, + title: l.taskGenerate, + values: GenerateOption.values, + initial: GenerateOption.standard, + label: (o) => o.label(l), + ); + if (options != null && context.mounted) { + await _start(context, ref, (repo) => repo.generate(options)); + } + }, + ), + ListTile( + leading: const Icon(Icons.sell_outlined), + title: Text(l.taskAutoTag), + subtitle: Text(l.taskAutoTagSubtitle), + onTap: () async { + if (await _confirm(context, l.taskAutoTag, l.taskAutoTagSubtitle) && context.mounted) { + await _start(context, ref, (repo) => repo.autoTag()); + } + }, + ), + ListTile( + leading: const Icon(Icons.cleaning_services_outlined), + title: Text(l.taskClean), + subtitle: Text(l.taskCleanSubtitle), + onTap: () async { + if (await _confirm(context, l.taskClean, l.taskCleanConfirm) && context.mounted) { + await _start(context, ref, (repo) => repo.clean()); + } + }, + ), + _SectionTitle(l.sectionJobs), + const _JobList(), + const SizedBox(height: 16), + ], + ), + ); + } +} + +/// Starts a job and reports it; the queue shows its progress. +Future _start(BuildContext context, WidgetRef ref, Future Function(StashRepository repo) task) async { + final messenger = ScaffoldMessenger.of(context); + final l = context.l10n; + try { + await task(ref.read(stashRepositoryProvider)); + Haptics.medium(); + messenger.showSnackBar(SnackBar(content: Text(l.taskStarted))); + await ref.read(jobQueueProvider.notifier).refresh(); + } catch (e) { + Haptics.error(); + messenger.showSnackBar(SnackBar(content: Text(errorText(l, e)))); + } +} + +Future _confirm(BuildContext context, String title, String body) async => + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(body), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.cancel)), + FilledButton(onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.startTask)), + ], + ), + ) ?? + false; + +/// A sheet of check boxes for a task's options; null when dismissed. +Future?> _pickOptions( + BuildContext context, { + required String title, + String? heading, + required List values, + required Set initial, + required String Function(T) label, +}) => showModalBottomSheet>( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) { + final selected = {...initial}; + return StatefulBuilder( + builder: (context, setState) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text(title, style: Theme.of(context).textTheme.titleLarge), + ), + if (heading != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Text(heading, style: Theme.of(context).textTheme.bodyMedium), + ), + for (final value in values) + CheckboxListTile( + title: Text(label(value)), + value: selected.contains(value), + onChanged: withHaptic( + (on) => setState(() => on == true ? selected.add(value) : selected.remove(value)), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton( + onPressed: () => Navigator.pop(context, selected), + child: Text(context.l10n.startTask), + ), + ), + ], + ), + ), + ), + ); + }, +); + +class _PreviewCheckTiles extends ConsumerWidget { + const _PreviewCheckTiles(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = context.l10n; + return switch (ref.watch(previewCheckProvider)) { + AsyncData(:final value) when value.scenes == 0 => ListTile(title: Text(l.previewCheckNothing)), + AsyncData(:final value) => Column( + children: [ + _CheckTile(title: l.previewCheckAnimated, found: value.animated, checked: value.scenes), + _CheckTile(title: l.previewCheckVideo, found: value.videos, checked: value.scenes), + if (value.markers > 0) + _CheckTile(title: l.previewCheckMarkers, found: value.markerAnimated, checked: value.markers), + if (value.animated < value.scenes || value.markerAnimated < value.markers) ...[ + ListTile(leading: const Icon(Icons.info_outline), subtitle: Text(l.previewCheckMissingHint)), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: FilledButton.tonalIcon( + icon: const Icon(Icons.auto_awesome_motion_outlined), + label: Text(l.generateAnimatedPreviews), + onPressed: () => _start(context, ref, (repo) => repo.generate(GenerateOption.animatedPreviews)), + ), + ), + ], + ], + ), + AsyncError(:final error) => ListTile( + leading: const Icon(Icons.error_outline), + title: Text(errorText(l, error)), + trailing: TextButton(onPressed: () => ref.invalidate(previewCheckProvider), child: Text(l.retry)), + ), + _ => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + }; + } +} + +class _CheckTile extends StatelessWidget { + const _CheckTile({required this.title, required this.found, required this.checked}); + + final String title; + final int found; + final int checked; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return ListTile( + leading: found == checked + ? Icon(Icons.check_circle_outline, color: scheme.primary) + : Icon(found == 0 ? Icons.cancel_outlined : Icons.warning_amber_outlined, color: scheme.error), + title: Text(title), + subtitle: Text(context.l10n.previewCheckFound(found, checked)), + ); + } +} + +class _JobList extends ConsumerWidget { + const _JobList(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = context.l10n; + return switch (ref.watch(jobQueueProvider)) { + AsyncData(:final value) when value.isEmpty => ListTile(title: Text(l.noJobs)), + AsyncData(:final value) => Column(children: [for (final job in value) _JobTile(job)]), + AsyncError(:final error) => ListTile(leading: const Icon(Icons.error_outline), title: Text(errorText(l, error))), + _ => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + }; + } +} + +class _JobTile extends ConsumerWidget { + const _JobTile(this.job); + + final ServerJob job; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l = context.l10n; + final progress = job.progress; + final detail = job.error ?? job.subTasks.firstOrNull; + return ListTile( + title: Text(job.description), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text([job.state.label(l), if (progress != null) '${(progress * 100).round()} %'].join(' · ')), + if (detail != null) Text(detail, maxLines: 1, overflow: TextOverflow.ellipsis), + if (job.isActive) + Padding( + padding: const EdgeInsets.only(top: 6), + child: LinearProgressIndicator(value: progress), + ), + ], + ), + trailing: job.isActive + ? IconButton( + tooltip: l.stopJob, + icon: const Icon(Icons.stop_circle_outlined), + onPressed: () async { + Haptics.light(); + try { + await ref.read(stashRepositoryProvider).stopJob(job.id); + } catch (_) { + // The queue shows whether it stopped. + } + await ref.read(jobQueueProvider.notifier).refresh(); + }, + ) + : null, + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text, {this.trailing}); + + final String text; + final Widget? trailing; + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.fromLTRB(16, 20, trailing == null ? 16 : 4, 4), + child: Row( + children: [ + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.labelLarge?.copyWith(color: Theme.of(context).colorScheme.primary), + ), + ), + ?trailing, + ], + ), + ); +} diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index ba4ac2c..3a8319a 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -14,6 +14,7 @@ import '../player/player_providers.dart'; import '../security/app_lock.dart'; import '../auth/server_switcher.dart'; import '../security/app_icon.dart'; +import '../server_tasks/server_tasks_page.dart'; import '../security/app_lock_gate.dart'; import '../shell/nav_bar_config.dart'; import 'about.dart'; @@ -90,6 +91,13 @@ class SettingsPage extends ConsumerWidget { trailing: const Icon(Icons.edit_outlined), onTap: server == null ? null : () => openServerEditor(context, server), ), + ListTile( + leading: const Icon(Icons.task_alt), + title: Text(l.serverTasks), + subtitle: Text(l.serverTasksSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openServerTasks(context), + ), _SectionTitle(l.sectionAppearance), const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -166,6 +174,30 @@ class SettingsPage extends ConsumerWidget { value: ref.watch(feedPreviewsProvider), onChanged: withHaptic((v) => ref.read(feedPreviewsProvider.notifier).set(v)), ), + ListTile( + leading: const Icon(Icons.movie_filter_outlined), + title: Text(l.previewSource), + enabled: ref.watch(feedPreviewsProvider), + trailing: DropdownButton( + value: ref.watch(previewSourceProvider), + underline: const SizedBox.shrink(), + items: [ + DropdownMenuItem(value: PreviewSource.animatedImage, child: Text(l.previewSourceAnimated)), + DropdownMenuItem(value: PreviewSource.video, child: Text(l.previewSourceVideo)), + ], + onChanged: ref.watch(feedPreviewsProvider) + ? (source) { + if (source != null) ref.read(previewSourceProvider.notifier).set(source); + } + : null, + ), + ), + // MP4 previews need a native player per preview (one at a time). + if (ref.watch(previewSourceProvider) == PreviewSource.video && ref.watch(feedPreviewsProvider)) + ListTile( + leading: Icon(Icons.info_outline, color: theme.colorScheme.tertiary), + subtitle: Text(l.previewSourceVideoHint), + ), ListTile( leading: const Icon(Icons.timer_outlined), title: Text(l.feedPreviewDelay), @@ -191,6 +223,13 @@ class SettingsPage extends ConsumerWidget { value: ref.watch(markerPreviewsProvider), onChanged: withHaptic((v) => ref.read(markerPreviewsProvider.notifier).set(v)), ), + ListTile( + leading: const Icon(Icons.fact_check_outlined), + title: Text(l.previewsOnServer), + subtitle: Text(l.previewsOnServerSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openServerTasks(context), + ), ListTile( leading: const Icon(Icons.account_circle_outlined), title: Text(l.cardChannel), @@ -246,6 +285,10 @@ class SettingsPage extends ConsumerWidget { } } +/// Inside the settings sheet. +void _openServerTasks(BuildContext context) => + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const ServerTasksPage())); + /// Picture-in-picture when leaving the app (Android) and background /// playback (4.12). class _PlaybackModeTiles extends ConsumerWidget { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 95d260f..de92e88 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -429,5 +429,53 @@ "insecureConnectionTitle": "Unverschlüsselte Verbindung", "insecureConnectionBody": "{host} liegt außerhalb deines Heimnetzes und wird über unverschlüsseltes HTTP erreicht. Dein API-Key oder Passwort und alles, was du ansiehst, können unterwegs mitgelesen werden. Nutze https:// oder ein VPN.", "connectAnyway": "Trotzdem verbinden", - "castApiKeyHint": "Beim Streamen erhält das Cast-Gerät die Stream-Adresse samt deinem API-Key. Andere Geräte in diesem WLAN können sie unter Umständen auslesen." + "castApiKeyHint": "Beim Streamen erhält das Cast-Gerät die Stream-Adresse samt deinem API-Key. Andere Geräte in diesem WLAN können sie unter Umständen auslesen.", + "serverTasks": "Server-Aufgaben", + "serverTasksSubtitle": "Scannen, Generieren, Vorschauen prüfen", + "sectionPreviewCheck": "Vorschauen auf dem Server", + "previewCheckAnimated": "Animierte Vorschauen (WebP)", + "previewCheckVideo": "Video-Vorschauen (MP4)", + "previewCheckMarkers": "Marker-Vorschauen (WebP)", + "previewCheckFound": "Bei {found} von {checked} Stichproben vorhanden", + "previewCheckNothing": "Keine Szenen zum Prüfen", + "previewCheckMissingHint": "Listen und Marker spielen die animierten Vorschauen, die Stash standardmäßig nicht erstellt. Erzeuge sie hier oder stelle in den Einstellungen das Vorschau-Format auf Video um.", + "generateAnimatedPreviews": "Animierte Vorschauen erzeugen", + "checkAgain": "Erneut prüfen", + "sectionTasks": "Aufgaben", + "taskScan": "Bibliothek scannen", + "taskScanSubtitle": "Findet neue und geänderte Dateien", + "taskGenerate": "Generieren", + "taskGenerateSubtitle": "Vorschauen, Sprites, Cover u. a. für Dateien, denen sie fehlen", + "taskAutoTag": "Automatisch taggen", + "taskAutoTagSubtitle": "Ordnet Darsteller, Studios und Tags zu, die in Dateipfaden vorkommen", + "taskClean": "Aufräumen", + "taskCleanSubtitle": "Entfernt Einträge von Dateien, die es nicht mehr gibt", + "taskCleanConfirm": "Einträge fehlender Dateien aus der Bibliothek entfernen? Ihre Bewertungen, Wiedergaben und Marker gehen verloren.", + "taskScanGenerates": "Für neue Dateien auch erzeugen", + "startTask": "Starten", + "taskStarted": "Aufgabe auf dem Server gestartet", + "sectionJobs": "Laufende Aufgaben", + "noJobs": "Keine laufenden Aufgaben", + "stopJob": "Aufgabe abbrechen", + "jobReady": "Wartet", + "jobRunning": "Läuft", + "jobFinished": "Fertig", + "jobStopping": "Wird abgebrochen", + "jobCancelled": "Abgebrochen", + "jobFailed": "Fehlgeschlagen", + "optionRescan": "Unveränderte Dateien neu scannen", + "optionCovers": "Cover", + "optionPreviews": "Video-Vorschauen (MP4)", + "optionImagePreviews": "Animierte Vorschauen (WebP)", + "optionSprites": "Vorschaubilder der Zeitleiste (Sprites)", + "optionMarkers": "Marker-Vorschauen (MP4)", + "optionMarkerImagePreviews": "Animierte Marker-Vorschauen (WebP)", + "optionMarkerScreenshots": "Marker-Standbilder", + "optionPhashes": "Wahrnehmungs-Hashes (Duplikate)", + "previewSource": "Vorschau-Format", + "previewSourceAnimated": "Animiertes Bild (WebP)", + "previewSourceVideo": "Video (MP4)", + "previewSourceVideoHint": "Video-Vorschauen laufen in einem eigenen Videoplayer: mehr Daten, Akku und Speicher als WebP, und auf älteren Geräten starten sie langsamer. Marker spielen weiter die WebP-Vorschauen.", + "previewsOnServer": "Vorschauen auf dem Server prüfen", + "previewsOnServerSubtitle": "Ob Stash sie erstellt hat, und fehlende erzeugen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 31e585b..af92d98 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -837,5 +837,63 @@ } }, "connectAnyway": "Connect anyway", - "castApiKeyHint": "While casting, the cast device receives the stream address including your API key. Other devices on this Wi-Fi may be able to read it." + "castApiKeyHint": "While casting, the cast device receives the stream address including your API key. Other devices on this Wi-Fi may be able to read it.", + "serverTasks": "Server tasks", + "serverTasksSubtitle": "Scan, generate, check previews", + "sectionPreviewCheck": "Previews on the server", + "previewCheckAnimated": "Animated previews (WebP)", + "previewCheckVideo": "Video previews (MP4)", + "previewCheckMarkers": "Marker previews (WebP)", + "previewCheckFound": "Found for {found} of {checked} random samples", + "@previewCheckFound": { + "placeholders": { + "found": { + "type": "int" + }, + "checked": { + "type": "int" + } + } + }, + "previewCheckNothing": "No scenes to check", + "previewCheckMissingHint": "Lists and markers play the animated previews, which Stash doesn't create by default. Generate them here, or switch the preview format to video in the settings.", + "generateAnimatedPreviews": "Generate animated previews", + "checkAgain": "Check again", + "sectionTasks": "Tasks", + "taskScan": "Scan library", + "taskScanSubtitle": "Finds new and changed files", + "taskGenerate": "Generate", + "taskGenerateSubtitle": "Previews, sprites, covers and more for files that lack them", + "taskAutoTag": "Auto tag", + "taskAutoTagSubtitle": "Assigns performers, studios and tags named in file paths", + "taskClean": "Clean", + "taskCleanSubtitle": "Removes entries of files that no longer exist", + "taskCleanConfirm": "Remove the entries of missing files from the library? Their ratings, play counts and markers are lost.", + "taskScanGenerates": "Also create for new files", + "startTask": "Start", + "taskStarted": "Task started on the server", + "sectionJobs": "Running tasks", + "noJobs": "No tasks running", + "stopJob": "Stop task", + "jobReady": "Waiting", + "jobRunning": "Running", + "jobFinished": "Finished", + "jobStopping": "Stopping", + "jobCancelled": "Cancelled", + "jobFailed": "Failed", + "optionRescan": "Rescan unchanged files", + "optionCovers": "Covers", + "optionPreviews": "Video previews (MP4)", + "optionImagePreviews": "Animated previews (WebP)", + "optionSprites": "Seek bar thumbnails (sprites)", + "optionMarkers": "Marker previews (MP4)", + "optionMarkerImagePreviews": "Marker animated previews (WebP)", + "optionMarkerScreenshots": "Marker screenshots", + "optionPhashes": "Perceptual hashes (duplicates)", + "previewSource": "Preview format", + "previewSourceAnimated": "Animated image (WebP)", + "previewSourceVideo": "Video (MP4)", + "previewSourceVideoHint": "Video previews play in a video player of their own: more data, battery and memory than WebP, and slower to start on older devices. Markers keep playing the WebP previews.", + "previewsOnServer": "Check previews on the server", + "previewsOnServerSubtitle": "Whether Stash has created them, and generate missing ones" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index c0668bb..48ca69c 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2677,6 +2677,294 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'While casting, the cast device receives the stream address including your API key. Other devices on this Wi-Fi may be able to read it.'** String get castApiKeyHint; + + /// No description provided for @serverTasks. + /// + /// In en, this message translates to: + /// **'Server tasks'** + String get serverTasks; + + /// No description provided for @serverTasksSubtitle. + /// + /// In en, this message translates to: + /// **'Scan, generate, check previews'** + String get serverTasksSubtitle; + + /// No description provided for @sectionPreviewCheck. + /// + /// In en, this message translates to: + /// **'Previews on the server'** + String get sectionPreviewCheck; + + /// No description provided for @previewCheckAnimated. + /// + /// In en, this message translates to: + /// **'Animated previews (WebP)'** + String get previewCheckAnimated; + + /// No description provided for @previewCheckVideo. + /// + /// In en, this message translates to: + /// **'Video previews (MP4)'** + String get previewCheckVideo; + + /// No description provided for @previewCheckMarkers. + /// + /// In en, this message translates to: + /// **'Marker previews (WebP)'** + String get previewCheckMarkers; + + /// No description provided for @previewCheckFound. + /// + /// In en, this message translates to: + /// **'Found for {found} of {checked} random samples'** + String previewCheckFound(int found, int checked); + + /// No description provided for @previewCheckNothing. + /// + /// In en, this message translates to: + /// **'No scenes to check'** + String get previewCheckNothing; + + /// No description provided for @previewCheckMissingHint. + /// + /// In en, this message translates to: + /// **'Lists and markers play the animated previews, which Stash doesn\'t create by default. Generate them here, or switch the preview format to video in the settings.'** + String get previewCheckMissingHint; + + /// No description provided for @generateAnimatedPreviews. + /// + /// In en, this message translates to: + /// **'Generate animated previews'** + String get generateAnimatedPreviews; + + /// No description provided for @checkAgain. + /// + /// In en, this message translates to: + /// **'Check again'** + String get checkAgain; + + /// No description provided for @sectionTasks. + /// + /// In en, this message translates to: + /// **'Tasks'** + String get sectionTasks; + + /// No description provided for @taskScan. + /// + /// In en, this message translates to: + /// **'Scan library'** + String get taskScan; + + /// No description provided for @taskScanSubtitle. + /// + /// In en, this message translates to: + /// **'Finds new and changed files'** + String get taskScanSubtitle; + + /// No description provided for @taskGenerate. + /// + /// In en, this message translates to: + /// **'Generate'** + String get taskGenerate; + + /// No description provided for @taskGenerateSubtitle. + /// + /// In en, this message translates to: + /// **'Previews, sprites, covers and more for files that lack them'** + String get taskGenerateSubtitle; + + /// No description provided for @taskAutoTag. + /// + /// In en, this message translates to: + /// **'Auto tag'** + String get taskAutoTag; + + /// No description provided for @taskAutoTagSubtitle. + /// + /// In en, this message translates to: + /// **'Assigns performers, studios and tags named in file paths'** + String get taskAutoTagSubtitle; + + /// No description provided for @taskClean. + /// + /// In en, this message translates to: + /// **'Clean'** + String get taskClean; + + /// No description provided for @taskCleanSubtitle. + /// + /// In en, this message translates to: + /// **'Removes entries of files that no longer exist'** + String get taskCleanSubtitle; + + /// No description provided for @taskCleanConfirm. + /// + /// In en, this message translates to: + /// **'Remove the entries of missing files from the library? Their ratings, play counts and markers are lost.'** + String get taskCleanConfirm; + + /// No description provided for @taskScanGenerates. + /// + /// In en, this message translates to: + /// **'Also create for new files'** + String get taskScanGenerates; + + /// No description provided for @startTask. + /// + /// In en, this message translates to: + /// **'Start'** + String get startTask; + + /// No description provided for @taskStarted. + /// + /// In en, this message translates to: + /// **'Task started on the server'** + String get taskStarted; + + /// No description provided for @sectionJobs. + /// + /// In en, this message translates to: + /// **'Running tasks'** + String get sectionJobs; + + /// No description provided for @noJobs. + /// + /// In en, this message translates to: + /// **'No tasks running'** + String get noJobs; + + /// No description provided for @stopJob. + /// + /// In en, this message translates to: + /// **'Stop task'** + String get stopJob; + + /// No description provided for @jobReady. + /// + /// In en, this message translates to: + /// **'Waiting'** + String get jobReady; + + /// No description provided for @jobRunning. + /// + /// In en, this message translates to: + /// **'Running'** + String get jobRunning; + + /// No description provided for @jobFinished. + /// + /// In en, this message translates to: + /// **'Finished'** + String get jobFinished; + + /// No description provided for @jobStopping. + /// + /// In en, this message translates to: + /// **'Stopping'** + String get jobStopping; + + /// No description provided for @jobCancelled. + /// + /// In en, this message translates to: + /// **'Cancelled'** + String get jobCancelled; + + /// No description provided for @jobFailed. + /// + /// In en, this message translates to: + /// **'Failed'** + String get jobFailed; + + /// No description provided for @optionRescan. + /// + /// In en, this message translates to: + /// **'Rescan unchanged files'** + String get optionRescan; + + /// No description provided for @optionCovers. + /// + /// In en, this message translates to: + /// **'Covers'** + String get optionCovers; + + /// No description provided for @optionPreviews. + /// + /// In en, this message translates to: + /// **'Video previews (MP4)'** + String get optionPreviews; + + /// No description provided for @optionImagePreviews. + /// + /// In en, this message translates to: + /// **'Animated previews (WebP)'** + String get optionImagePreviews; + + /// No description provided for @optionSprites. + /// + /// In en, this message translates to: + /// **'Seek bar thumbnails (sprites)'** + String get optionSprites; + + /// No description provided for @optionMarkers. + /// + /// In en, this message translates to: + /// **'Marker previews (MP4)'** + String get optionMarkers; + + /// No description provided for @optionMarkerImagePreviews. + /// + /// In en, this message translates to: + /// **'Marker animated previews (WebP)'** + String get optionMarkerImagePreviews; + + /// No description provided for @optionMarkerScreenshots. + /// + /// In en, this message translates to: + /// **'Marker screenshots'** + String get optionMarkerScreenshots; + + /// No description provided for @optionPhashes. + /// + /// In en, this message translates to: + /// **'Perceptual hashes (duplicates)'** + String get optionPhashes; + + /// No description provided for @previewSource. + /// + /// In en, this message translates to: + /// **'Preview format'** + String get previewSource; + + /// No description provided for @previewSourceAnimated. + /// + /// In en, this message translates to: + /// **'Animated image (WebP)'** + String get previewSourceAnimated; + + /// No description provided for @previewSourceVideo. + /// + /// In en, this message translates to: + /// **'Video (MP4)'** + String get previewSourceVideo; + + /// No description provided for @previewSourceVideoHint. + /// + /// In en, this message translates to: + /// **'Video previews play in a video player of their own: more data, battery and memory than WebP, and slower to start on older devices. Markers keep playing the WebP previews.'** + String get previewSourceVideoHint; + + /// No description provided for @previewsOnServer. + /// + /// In en, this message translates to: + /// **'Check previews on the server'** + String get previewsOnServer; + + /// No description provided for @previewsOnServerSubtitle. + /// + /// In en, this message translates to: + /// **'Whether Stash has created them, and generate missing ones'** + String get previewsOnServerSubtitle; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 322789a..2a37aca 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1576,4 +1576,157 @@ class AppLocalizationsDe extends AppLocalizations { @override String get castApiKeyHint => 'Beim Streamen erhält das Cast-Gerät die Stream-Adresse samt deinem API-Key. Andere Geräte in diesem WLAN können sie unter Umständen auslesen.'; + + @override + String get serverTasks => 'Server-Aufgaben'; + + @override + String get serverTasksSubtitle => 'Scannen, Generieren, Vorschauen prüfen'; + + @override + String get sectionPreviewCheck => 'Vorschauen auf dem Server'; + + @override + String get previewCheckAnimated => 'Animierte Vorschauen (WebP)'; + + @override + String get previewCheckVideo => 'Video-Vorschauen (MP4)'; + + @override + String get previewCheckMarkers => 'Marker-Vorschauen (WebP)'; + + @override + String previewCheckFound(int found, int checked) { + return 'Bei $found von $checked Stichproben vorhanden'; + } + + @override + String get previewCheckNothing => 'Keine Szenen zum Prüfen'; + + @override + String get previewCheckMissingHint => + 'Listen und Marker spielen die animierten Vorschauen, die Stash standardmäßig nicht erstellt. Erzeuge sie hier oder stelle in den Einstellungen das Vorschau-Format auf Video um.'; + + @override + String get generateAnimatedPreviews => 'Animierte Vorschauen erzeugen'; + + @override + String get checkAgain => 'Erneut prüfen'; + + @override + String get sectionTasks => 'Aufgaben'; + + @override + String get taskScan => 'Bibliothek scannen'; + + @override + String get taskScanSubtitle => 'Findet neue und geänderte Dateien'; + + @override + String get taskGenerate => 'Generieren'; + + @override + String get taskGenerateSubtitle => + 'Vorschauen, Sprites, Cover u. a. für Dateien, denen sie fehlen'; + + @override + String get taskAutoTag => 'Automatisch taggen'; + + @override + String get taskAutoTagSubtitle => + 'Ordnet Darsteller, Studios und Tags zu, die in Dateipfaden vorkommen'; + + @override + String get taskClean => 'Aufräumen'; + + @override + String get taskCleanSubtitle => + 'Entfernt Einträge von Dateien, die es nicht mehr gibt'; + + @override + String get taskCleanConfirm => + 'Einträge fehlender Dateien aus der Bibliothek entfernen? Ihre Bewertungen, Wiedergaben und Marker gehen verloren.'; + + @override + String get taskScanGenerates => 'Für neue Dateien auch erzeugen'; + + @override + String get startTask => 'Starten'; + + @override + String get taskStarted => 'Aufgabe auf dem Server gestartet'; + + @override + String get sectionJobs => 'Laufende Aufgaben'; + + @override + String get noJobs => 'Keine laufenden Aufgaben'; + + @override + String get stopJob => 'Aufgabe abbrechen'; + + @override + String get jobReady => 'Wartet'; + + @override + String get jobRunning => 'Läuft'; + + @override + String get jobFinished => 'Fertig'; + + @override + String get jobStopping => 'Wird abgebrochen'; + + @override + String get jobCancelled => 'Abgebrochen'; + + @override + String get jobFailed => 'Fehlgeschlagen'; + + @override + String get optionRescan => 'Unveränderte Dateien neu scannen'; + + @override + String get optionCovers => 'Cover'; + + @override + String get optionPreviews => 'Video-Vorschauen (MP4)'; + + @override + String get optionImagePreviews => 'Animierte Vorschauen (WebP)'; + + @override + String get optionSprites => 'Vorschaubilder der Zeitleiste (Sprites)'; + + @override + String get optionMarkers => 'Marker-Vorschauen (MP4)'; + + @override + String get optionMarkerImagePreviews => 'Animierte Marker-Vorschauen (WebP)'; + + @override + String get optionMarkerScreenshots => 'Marker-Standbilder'; + + @override + String get optionPhashes => 'Wahrnehmungs-Hashes (Duplikate)'; + + @override + String get previewSource => 'Vorschau-Format'; + + @override + String get previewSourceAnimated => 'Animiertes Bild (WebP)'; + + @override + String get previewSourceVideo => 'Video (MP4)'; + + @override + String get previewSourceVideoHint => + 'Video-Vorschauen laufen in einem eigenen Videoplayer: mehr Daten, Akku und Speicher als WebP, und auf älteren Geräten starten sie langsamer. Marker spielen weiter die WebP-Vorschauen.'; + + @override + String get previewsOnServer => 'Vorschauen auf dem Server prüfen'; + + @override + String get previewsOnServerSubtitle => + 'Ob Stash sie erstellt hat, und fehlende erzeugen'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index efc6b58..4351ce5 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1568,4 +1568,157 @@ class AppLocalizationsEn extends AppLocalizations { @override String get castApiKeyHint => 'While casting, the cast device receives the stream address including your API key. Other devices on this Wi-Fi may be able to read it.'; + + @override + String get serverTasks => 'Server tasks'; + + @override + String get serverTasksSubtitle => 'Scan, generate, check previews'; + + @override + String get sectionPreviewCheck => 'Previews on the server'; + + @override + String get previewCheckAnimated => 'Animated previews (WebP)'; + + @override + String get previewCheckVideo => 'Video previews (MP4)'; + + @override + String get previewCheckMarkers => 'Marker previews (WebP)'; + + @override + String previewCheckFound(int found, int checked) { + return 'Found for $found of $checked random samples'; + } + + @override + String get previewCheckNothing => 'No scenes to check'; + + @override + String get previewCheckMissingHint => + 'Lists and markers play the animated previews, which Stash doesn\'t create by default. Generate them here, or switch the preview format to video in the settings.'; + + @override + String get generateAnimatedPreviews => 'Generate animated previews'; + + @override + String get checkAgain => 'Check again'; + + @override + String get sectionTasks => 'Tasks'; + + @override + String get taskScan => 'Scan library'; + + @override + String get taskScanSubtitle => 'Finds new and changed files'; + + @override + String get taskGenerate => 'Generate'; + + @override + String get taskGenerateSubtitle => + 'Previews, sprites, covers and more for files that lack them'; + + @override + String get taskAutoTag => 'Auto tag'; + + @override + String get taskAutoTagSubtitle => + 'Assigns performers, studios and tags named in file paths'; + + @override + String get taskClean => 'Clean'; + + @override + String get taskCleanSubtitle => + 'Removes entries of files that no longer exist'; + + @override + String get taskCleanConfirm => + 'Remove the entries of missing files from the library? Their ratings, play counts and markers are lost.'; + + @override + String get taskScanGenerates => 'Also create for new files'; + + @override + String get startTask => 'Start'; + + @override + String get taskStarted => 'Task started on the server'; + + @override + String get sectionJobs => 'Running tasks'; + + @override + String get noJobs => 'No tasks running'; + + @override + String get stopJob => 'Stop task'; + + @override + String get jobReady => 'Waiting'; + + @override + String get jobRunning => 'Running'; + + @override + String get jobFinished => 'Finished'; + + @override + String get jobStopping => 'Stopping'; + + @override + String get jobCancelled => 'Cancelled'; + + @override + String get jobFailed => 'Failed'; + + @override + String get optionRescan => 'Rescan unchanged files'; + + @override + String get optionCovers => 'Covers'; + + @override + String get optionPreviews => 'Video previews (MP4)'; + + @override + String get optionImagePreviews => 'Animated previews (WebP)'; + + @override + String get optionSprites => 'Seek bar thumbnails (sprites)'; + + @override + String get optionMarkers => 'Marker previews (MP4)'; + + @override + String get optionMarkerImagePreviews => 'Marker animated previews (WebP)'; + + @override + String get optionMarkerScreenshots => 'Marker screenshots'; + + @override + String get optionPhashes => 'Perceptual hashes (duplicates)'; + + @override + String get previewSource => 'Preview format'; + + @override + String get previewSourceAnimated => 'Animated image (WebP)'; + + @override + String get previewSourceVideo => 'Video (MP4)'; + + @override + String get previewSourceVideoHint => + 'Video previews play in a video player of their own: more data, battery and memory than WebP, and slower to start on older devices. Markers keep playing the WebP previews.'; + + @override + String get previewsOnServer => 'Check previews on the server'; + + @override + String get previewsOnServerSubtitle => + 'Whether Stash has created them, and generate missing ones'; } diff --git a/lib/l10n/labels.dart b/lib/l10n/labels.dart index 7838e8d..e7d5e8d 100644 --- a/lib/l10n/labels.dart +++ b/lib/l10n/labels.dart @@ -1,5 +1,6 @@ import '../data/models/list_queries.dart'; import '../data/models/scene_filter.dart'; +import '../data/models/server_task.dart'; import '../features/shorts/shorts_settings.dart'; import 'gen/app_localizations.dart'; @@ -91,3 +92,38 @@ extension ShortsLengthLabel on ShortsLength { ShortsLength.any => l.durationAny, }; } + +extension JobStateLabel on JobState { + String label(AppLocalizations l) => switch (this) { + JobState.ready => l.jobReady, + JobState.running => l.jobRunning, + JobState.finished => l.jobFinished, + JobState.stopping => l.jobStopping, + JobState.cancelled => l.jobCancelled, + JobState.failed => l.jobFailed, + }; +} + +extension ScanOptionLabel on ScanOption { + String label(AppLocalizations l) => switch (this) { + ScanOption.rescan => l.optionRescan, + ScanOption.covers => l.optionCovers, + ScanOption.previews => l.optionPreviews, + ScanOption.imagePreviews => l.optionImagePreviews, + ScanOption.sprites => l.optionSprites, + ScanOption.phashes => l.optionPhashes, + }; +} + +extension GenerateOptionLabel on GenerateOption { + String label(AppLocalizations l) => switch (this) { + GenerateOption.covers => l.optionCovers, + GenerateOption.previews => l.optionPreviews, + GenerateOption.imagePreviews => l.optionImagePreviews, + GenerateOption.sprites => l.optionSprites, + GenerateOption.markers => l.optionMarkers, + GenerateOption.markerImagePreviews => l.optionMarkerImagePreviews, + GenerateOption.markerScreenshots => l.optionMarkerScreenshots, + GenerateOption.phashes => l.optionPhashes, + }; +} diff --git a/lib/widgets/animated_previews.dart b/lib/widgets/animated_previews.dart index 09f03c7..e567b65 100644 --- a/lib/widgets/animated_previews.dart +++ b/lib/widgets/animated_previews.dart @@ -73,3 +73,29 @@ final feedPreviewDelayProvider = NotifierProvider, bool> setting) => ref.watch(setting) && !MediaQuery.disableAnimationsOf(context); + +/// What the lists' previews play: Stash's animated WebP (light, but Stash +/// only creates them when asked) or its MP4 preview (created by default, +/// but needs a video player). Markers always play the WebP. +enum PreviewSource { animatedImage, video } + +class PreviewSourceNotifier extends Notifier { + static const _key = 'preview_source'; + + @override + PreviewSource build() { + try { + final name = ref.watch(sharedPreferencesProvider).getString(_key); + return PreviewSource.values.where((s) => s.name == name).firstOrNull ?? PreviewSource.animatedImage; + } catch (_) { + return PreviewSource.animatedImage; + } + } + + Future set(PreviewSource source) async { + state = source; + await ref.read(sharedPreferencesProvider).setString(_key, source.name); + } +} + +final previewSourceProvider = NotifierProvider(PreviewSourceNotifier.new); diff --git a/lib/widgets/auto_preview.dart b/lib/widgets/auto_preview.dart index 0bb6f60..22efecd 100644 --- a/lib/widgets/auto_preview.dart +++ b/lib/widgets/auto_preview.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'animated_previews.dart'; +import 'preview_video.dart'; import 'stash_image.dart'; /// Plays the animated preview of the first video fully shown in a list, a @@ -88,9 +89,9 @@ class _AutoPreviewScopeState extends State { @override Widget build(BuildContext context) => _AutoPreviewInherited( - scope: this, - child: NotificationListener(onNotification: _onScroll, child: widget.child), - ); + scope: this, + child: NotificationListener(onNotification: _onScroll, child: widget.child), + ); } class _AutoPreviewInherited extends InheritedWidget { @@ -102,13 +103,15 @@ class _AutoPreviewInherited extends InheritedWidget { bool updateShouldNotify(_AutoPreviewInherited old) => old.scope != scope; } -/// A thumbnail that plays [url] (an animated WebP preview) over [child] -/// while it is the [AutoPreviewScope]'s pick. Just [child] without a scope, -/// without a preview, or when previews are off. +/// A thumbnail that plays [url] (an animated WebP preview), or [videoUrl] +/// with [PreviewSource.video], over [child] while it is the +/// [AutoPreviewScope]'s pick. Just [child] without a scope, without a +/// preview, or when previews are off. class AutoPreview extends ConsumerStatefulWidget { - const AutoPreview({super.key, required this.url, required this.child}); + const AutoPreview({super.key, required this.url, this.videoUrl, required this.child}); final String? url; + final String? videoUrl; final Widget child; @override @@ -132,7 +135,8 @@ class _AutoPreviewState extends ConsumerState { if (scope == _scope) return; _scope?._unregister(this); _scope = scope; - if (widget.url != null) scope?._register(this); + // Registered with either URL: the preview source can change later. + if (widget.url != null || widget.videoUrl != null) scope?._register(this); } @override @@ -144,7 +148,8 @@ class _AutoPreviewState extends ConsumerState { @override Widget build(BuildContext context) { final scope = _scope; - final url = widget.url; + final video = ref.watch(previewSourceProvider) == PreviewSource.video; + final url = video ? widget.videoUrl : widget.url; if (scope == null || url == null || !previewsPlay(context, ref, feedPreviewsProvider)) return widget.child; return ValueListenableBuilder<_AutoPreviewState?>( valueListenable: scope._active, @@ -155,7 +160,7 @@ class _AutoPreviewState extends ConsumerState { child!, // Loops by itself; the thumbnail shows until it loaded, and if // it fails. - if (active == this) StashImage(url, standIn: const SizedBox.shrink()), + if (active == this) video ? PreviewVideo(url) : StashImage(url, standIn: const SizedBox.shrink()), ], ), ); diff --git a/lib/widgets/preview_video.dart b/lib/widgets/preview_video.dart new file mode 100644 index 0000000..5874760 --- /dev/null +++ b/lib/widgets/preview_video.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +import '../data/repositories/stash_session.dart'; + +/// Loops Stash's MP4 preview of a scene, muted, in a player of its own +/// (not the main player's). Transparent until the first frame, so the +/// thumbnail below shows while it loads and if it fails. Lists show one at +/// a time ([AutoPreviewScope]); each costs a native player while shown. +class PreviewVideo extends ConsumerStatefulWidget { + const PreviewVideo(this.url, {super.key}); + + final String url; + + @override + ConsumerState createState() => _PreviewVideoState(); +} + +class _PreviewVideoState extends ConsumerState { + final _player = Player(); + late final _controller = VideoController(_player); + var _shown = false; + + @override + void initState() { + super.initState(); + _player + ..setVolume(0) + ..setPlaylistMode(PlaylistMode.single) + ..open(Media(widget.url, httpHeaders: ref.read(authHeadersProvider))).ignore(); + _controller.waitUntilFirstFrameRendered.then((_) { + if (mounted) setState(() => _shown = true); + }); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AnimatedOpacity( + opacity: _shown ? 1 : 0, + duration: const Duration(milliseconds: 200), + child: Video(controller: _controller, fit: BoxFit.cover, fill: Colors.transparent, controls: _noControls), + ); +} + +/// media_kit's `NoVideoControls` is `null`, which `Video` ignores. +Widget _noControls(VideoState state) => const SizedBox.shrink(); diff --git a/lib/widgets/scene_card.dart b/lib/widgets/scene_card.dart index b534b0a..2eb5afe 100644 --- a/lib/widgets/scene_card.dart +++ b/lib/widgets/scene_card.dart @@ -225,6 +225,7 @@ class SceneThumbnail extends ConsumerWidget { // In lists, the first fully shown video plays its preview. AutoPreview( url: scene.webpUrl, + videoUrl: scene.previewUrl, child: StashImage(scene.screenshotUrl, fallbackIcon: Icons.movie_outlined), ), if (resolution != null && !compact) diff --git a/test/features/demo_test.dart b/test/features/demo_test.dart index 1551647..64ee5b0 100644 --- a/test/features/demo_test.dart +++ b/test/features/demo_test.dart @@ -7,6 +7,7 @@ import 'package:http/testing.dart'; import 'package:stash_app_mobile/core/config/server_config.dart'; import 'package:stash_app_mobile/data/models/list_queries.dart'; import 'package:stash_app_mobile/data/models/scene_filter.dart'; +import 'package:stash_app_mobile/data/models/server_task.dart'; import 'package:stash_app_mobile/data/repositories/stash_repository.dart'; import 'package:stash_app_mobile/features/demo/demo_library.dart'; import 'package:stash_app_mobile/features/demo/demo_server.dart'; @@ -92,6 +93,21 @@ void main() { expect(scene.height, greaterThan(scene.width!)); }); + test('server tasks and the preview check', () async { + expect(await repo.jobQueue(), isEmpty); + expect(await repo.scanLibrary(ScanOption.standard), isNotEmpty); + expect(await repo.generate(GenerateOption.standard), isNotEmpty); + expect(await repo.autoTag(), isNotEmpty); + expect(await repo.clean(), isNotEmpty); + // The demo bundles every preview. + final check = await repo.checkPreviews(); + expect(check.scenes, 8); + expect(check.animated, 8); + expect(check.videos, 8); + expect(check.markers, 4); + expect(check.markerAnimated, 4); + }); + test('scene filters', () async { Future> titles(SceneQuery query) async => [ for (final s in (await repo.findScenes(query)).items) s.title, diff --git a/test/features/server_tasks_test.dart b/test/features/server_tasks_test.dart new file mode 100644 index 0000000..e1102c6 --- /dev/null +++ b/test/features/server_tasks_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stash_app_mobile/data/models/server_task.dart'; +import 'package:stash_app_mobile/data/repositories/stash_repository.dart'; +import 'package:stash_app_mobile/features/server_tasks/server_tasks_page.dart'; +import 'package:stash_app_mobile/l10n/gen/app_localizations.dart'; + +import '../helpers.dart'; + +class FakeRepository implements StashRepository { + final jobs = []; + final generated = >[]; + final stopped = []; + var check = const PreviewCheck(scenes: 8, animated: 0, videos: 8, markers: 4, markerAnimated: 0); + + @override + Future> jobQueue() async => [...jobs]; + + @override + Future checkPreviews({int sample = 8}) async => check; + + @override + Future generate(Set options) async { + generated.add(options); + jobs.add(const ServerJob(id: '7', state: JobState.running, description: 'Generating...', progress: 0.25)); + return '7'; + } + + @override + Future stopJob(String id) async { + stopped.add(id); + jobs.clear(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late FakeRepository repo; + + Future pumpPage(WidgetTester tester) async { + // Tall enough for the whole list. + tester.view + ..physicalSize = const Size(800, 2000) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [stashRepositoryProvider.overrideWithValue(repo), ...testServer], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ServerTasksPage(), + ), + ), + ); + await tester.pump(); + } + + setUp(() => repo = FakeRepository()); + + testWidgets('missing animated previews can be generated, the job shows and stops', (tester) async { + await pumpPage(tester); + expect(find.text('Found for 0 of 8 random samples'), findsOneWidget); + expect(find.text('Found for 8 of 8 random samples'), findsOneWidget); + expect(find.text('No tasks running'), findsOneWidget); + + await tester.tap(find.text('Generate animated previews')); + await tester.pump(); + expect(repo.generated.single, GenerateOption.animatedPreviews); + expect(find.text('Task started on the server'), findsOneWidget); + expect(find.text('Generating...'), findsOneWidget); + expect(find.text('Running · 25 %'), findsOneWidget); + + // The job finished: the check runs again. + repo.check = const PreviewCheck(scenes: 8, animated: 8, videos: 8, markers: 4, markerAnimated: 4); + await tester.tap(find.byTooltip('Stop task')); + await tester.pump(); + expect(repo.stopped, ['7']); + await tester.pump(); + expect(find.text('No tasks running'), findsOneWidget); + expect(find.text('Generate animated previews'), findsNothing); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('the generate sheet starts the chosen options', (tester) async { + repo.check = const PreviewCheck(scenes: 8, animated: 8, videos: 8); + await pumpPage(tester); + await tester.tap(find.text('Generate')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Covers')); + await tester.pump(); + await tester.tap(find.text('Start')); + await tester.pump(); + expect(repo.generated.single, GenerateOption.standard.difference({GenerateOption.covers})); + // Unmount, so the queue's poll timer stops. + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/test/features/settings_sheet_test.dart b/test/features/settings_sheet_test.dart index ff390ae..cfc7abb 100644 --- a/test/features/settings_sheet_test.dart +++ b/test/features/settings_sheet_test.dart @@ -59,7 +59,12 @@ void main() { await tester.tap(find.byType(SettingsButton)); await tester.pumpAndSettle(); - await tester.ensureVisible(find.byIcon(Icons.space_dashboard_outlined)); + // The list builds lazily. + await tester.scrollUntilVisible( + find.byIcon(Icons.space_dashboard_outlined), + 200, + scrollable: find.descendant(of: find.byType(SettingsPage), matching: find.byType(Scrollable)).first, + ); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.space_dashboard_outlined)); await tester.pumpAndSettle(); From 5e7854397e9de992b0e4b9a7fef510b100e8590c Mon Sep 17 00:00:00 2001 From: Philippe Date: Sat, 24 Oct 2026 16:10:16 +0200 Subject: [PATCH 2/2] Previews in lists start after 0.5 s by default Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/BACKLOG.md | 2 +- lib/widgets/animated_previews.dart | 6 +++--- test/widgets/auto_preview_test.dart | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index deef404..97f803f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ lib/ **Security.** `features/security/`. `AppLockGate` in `MaterialApp.builder` shows `LockScreen` while `appLockedProvider` is true, and a `PrivacyCover` while the app is inactive (with the lock on or "hide in app switcher"). Without a delay the lock engages as the app is hidden, not on return. `LockScreen` asks for biometrics only once the app is resumed (iOS cancels a prompt while still inactive), again after the app was in the background but not after its own prompt. `confirmPin`/`showCreatePin` push full-screen routes on the root navigator, so they cover the app above the settings sheet too. `AppLockSettings.hidesApp` (lock on or "hide in app switcher"; the switch is fixed on with the lock) drives `SecureWindow`: Android's FLAG_SECURE, and on iOS `PrivacyCoverController` (AppDelegate.swift), a native cover added on `UIScene.willDeactivateNotification`, because the app is in the background before Flutter draws another frame (locking the phone showed the old content on return). Whether to cover it reads from UserDefaults itself (`flutter.lock_enabled`, `flutter.lock_hide_in_switcher`): a channel message sent at startup can arrive before the handler exists. Dart removes it (`SecureWindow.uncover`) once a frame was drawn after resuming; a 2 s fallback removes it otherwise. The PIN is stored only as a salted SHA-256 hash, as `:` under `lock_pin` in the `SecretStore` (older versions' SharedPreferences values are moved there). `PinThrottle` blocks the PIN pad for a growing time after 5 wrong PINs (stored, so restarts don't reset it); `LockScreen` then shows `PinBlockedView`. While locked, `AppLockGate` excludes focus and semantics, and `lockBackGuard` (registered in `main()` before `runApp`) turns back into leaving the app. The main player keeps playing (background playback, PiP); the shorts stop. Native channels: `stash/privacy` (Android `FLAG_SECURE`) and `stash/appicon` (Android activity-aliases / iOS alternate icons). On Android the launcher entries are the `activity-alias`es in `src/main/AndroidManifest.xml`. MainActivity keeps a MAIN/LAUNCHER filter there only because `flutter run` looks for it; the debug/profile/release manifests remove that filter again (`tools:node="remove"`). Android uses `FlutterFragmentActivity` and an AppCompat launch theme for `local_auth`. -**Animated previews.** Stash's looping WebP previews, switched separately (`widgets/animated_previews.dart`, on by default; `previewsPlay` also checks reduced motion): `markerPreviewsProvider` for marker tiles (`Marker.previewUrl`), `feedPreviewsProvider` for the first fully shown video in `SceneFeedView`: `AutoPreviewScope` around the list picks it `feedPreviewDelayProvider` (1.5 s by default) after scrolling stopped, and the `AutoPreview` in `SceneThumbnail` plays `Scene.webpUrl` over the still. Scrolling stops it. Thumbnails outside a scope (the player's "Up next") never play. `previewSourceProvider` switches the lists' previews to Stash's MP4 preview (`Scene.previewUrl`), played by `PreviewVideo` in a media_kit player of its own (one at a time; the settings warn about data, battery and memory); markers always play the WebP. +**Animated previews.** Stash's looping WebP previews, switched separately (`widgets/animated_previews.dart`, on by default; `previewsPlay` also checks reduced motion): `markerPreviewsProvider` for marker tiles (`Marker.previewUrl`), `feedPreviewsProvider` for the first fully shown video in `SceneFeedView`: `AutoPreviewScope` around the list picks it `feedPreviewDelayProvider` (0.5 s by default) after scrolling stopped, and the `AutoPreview` in `SceneThumbnail` plays `Scene.webpUrl` over the still. Scrolling stops it. Thumbnails outside a scope (the player's "Up next") never play. `previewSourceProvider` switches the lists' previews to Stash's MP4 preview (`Scene.previewUrl`), played by `PreviewVideo` in a media_kit player of its own (one at a time; the settings warn about data, battery and memory); markers always play the WebP. **Server tasks.** `features/server_tasks/` (Settings → Server tasks, pushed inside the settings sheet; its own `ScaffoldMessenger` so snack bars show above the sheet). Starts Stash's scan, generate, auto tag and clean (`StashRepository.scanLibrary/generate/autoTag/clean`; `ScanOption`/`GenerateOption` are the input keys), shows `jobQueueProvider` (polls `jobQueue` every 2 s while watched; `stopJob`) and `previewCheckProvider`: `checkPreviews` fetches one byte of the WebP/MP4 preview URLs of a few random scenes and markers (Stash answers 404 for missing files) and runs again when a job finishes. The demo accepts the mutations and has an empty queue. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 360e0fa..9f0601d 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -169,7 +169,7 @@ Eine wartbare Basis, damit Features unabhängig voneinander wachsen können. | 13.13 | Als Nutzer möchte ich nach dem ersten Login das Design wählen. | Einmaliger Dialog „Wähle dein Design“ nach dem Hinzufügen des ersten Servers: Hell/System/Dunkel und Akzentfarbe, sofort sichtbar; bleibt bis zum Anzeigen gespeichert (auch über einen Neustart). Bestehende Nutzer und weitere Server sehen ihn nicht. Dieselben Bedienelemente wie in den Einstellungen (`appearance_picker.dart`). | Next | ✅ | | 13.14 | Als Nutzer möchte ich spürbares haptisches Feedback und dessen Stärke wählen. | Einstellung „Haptisches Feedback“: Aus, Wenig (nur die spürbaren Rückmeldungen: Favorit, O-Zähler, Langdruck-Menü, Aktualisieren, Entsperren, falsche PIN), Normal (zusätzlich Tabs, Sortier- und Filter-Chips, Schalter, Play/Pause, Wischen in den Shorts, Später ansehen, Theme/Farbe). | Next | ✅ | | 13.15 | Als Nutzer möchte ich durch erneutes Tippen auf den aktiven Tab nach oben scrollen. | Aus einer Unterseite geht es zurück zur Startseite des Tabs, dort scrollen die Listen sanft nach oben (wie YouTube). | Next | ✅ | -| 13.16 | Als Nutzer möchte ich in Listen eine bewegte Vorschau sehen. | Ruht das Scrollen eine Weile (Standard 1,5 s, einstellbar 0,5–3 s), spielt das oberste vollständig sichtbare Video seine WebP-Vorschau (Stash `paths.webp`) über dem Standbild in Dauerschleife, wie YouTube; Scrollen stoppt sie. Gilt für alle Szenenlisten (Start, Bibliothek, Suche, Verlauf, Kanäle, Gruppen). Einstellung „Vorschauen in Listen abspielen“, bei „Bewegung reduzieren“ aus. Vorschau-Format wählbar: animiertes Bild (WebP, Standard) oder Video (MP4, 3.4). | Next | ✅ | +| 13.16 | Als Nutzer möchte ich in Listen eine bewegte Vorschau sehen. | Ruht das Scrollen eine Weile (Standard 0,5 s, einstellbar 0,5–3 s), spielt das oberste vollständig sichtbare Video seine WebP-Vorschau (Stash `paths.webp`) über dem Standbild in Dauerschleife, wie YouTube; Scrollen stoppt sie. Gilt für alle Szenenlisten (Start, Bibliothek, Suche, Verlauf, Kanäle, Gruppen). Einstellung „Vorschauen in Listen abspielen“, bei „Bewegung reduzieren“ aus. Vorschau-Format wählbar: animiertes Bild (WebP, Standard) oder Video (MP4, 3.4). | Next | ✅ | | 13.17 | Als Nutzer möchte ich die App-Version und die Open-Source-Lizenzen sehen. | Einstellungen → „Über“: Version mit Build-Nummer (`package_info_plus`) und „Open-Source-Lizenzen“ (Flutters Lizenzseite mit allen Paketen, dazu StashTubes eigene AGPL-3.0 und die nativen Bibliotheken des Videoplayers aus media_kits libmpv-Build: mpv, FFmpeg, libass, FreeType, FriBidi, HarfBuzz, dav1d, Mbed TLS, libxml2, unter iOS zusätzlich libpng, uchardet, Protocol Buffers; Lizenztexte in `assets/licenses/`). | Next | ✅ | | 13.18 | Als Nutzer möchte ich Stashs Server-Aufgaben aus der App starten und sehen, ob die Vorschauen erzeugt sind. | Einstellungen → „Server-Aufgaben“ (auch „Vorschauen auf dem Server prüfen“ bei den Vorschau-Einstellungen): Bibliothek scannen (mit Auswahl, was für neue Dateien erzeugt wird), Generieren (Cover, Video- und animierte Vorschauen, Sprites, Marker-Vorschauen und -Standbilder, pHashes; nur Fehlendes), Automatisch taggen, Aufräumen (mit Bestätigung). Laufende Aufgaben aus Stashs Warteschlange mit Fortschritt, alle 2 s aktualisiert, abbrechbar. Vorschau-Check: Stichprobe zufälliger Szenen und Marker, ob WebP- und MP4-Vorschauen vorhanden sind; fehlen die WebP-Vorschauen, ein Hinweis und „Animierte Vorschauen erzeugen“. Nach einer beendeten Aufgabe prüft er erneut. | Next | ✅ | diff --git a/lib/widgets/animated_previews.dart b/lib/widgets/animated_previews.dart index e567b65..9475f6f 100644 --- a/lib/widgets/animated_previews.dart +++ b/lib/widgets/animated_previews.dart @@ -42,11 +42,11 @@ final markerPreviewsProvider = NotifierProvider<_PreviewSwitch, bool>(() => _Pre /// How long a list rests before its first video plays the preview. class FeedPreviewDelayNotifier extends Notifier { static const _key = 'feed_preview_delay_ms'; - static const standard = Duration(milliseconds: 1500); + static const standard = Duration(milliseconds: 500); static const choices = [ - Duration(milliseconds: 500), - Duration(seconds: 1), standard, + Duration(seconds: 1), + Duration(milliseconds: 1500), Duration(seconds: 2), Duration(seconds: 3), ]; diff --git a/test/widgets/auto_preview_test.dart b/test/widgets/auto_preview_test.dart index 60f5718..e7b41c7 100644 --- a/test/widgets/auto_preview_test.dart +++ b/test/widgets/auto_preview_test.dart @@ -42,9 +42,9 @@ void main() { List playing(WidgetTester tester) => [for (final image in tester.widgetList(find.byType(StashImage))) image.url]; - testWidgets('the first fully shown video plays after 1.5 s by default', (tester) async { + testWidgets('the first fully shown video plays after 0.5 s by default', (tester) async { await pumpList(tester); - await tester.pump(const Duration(milliseconds: 1400)); + await tester.pump(const Duration(milliseconds: 400)); expect(playing(tester), isEmpty, reason: 'not right away'); await tester.pump(const Duration(milliseconds: 200)); expect(playing(tester), ['http://s/0.webp']); @@ -69,7 +69,7 @@ void main() { await c.read(markerPreviewsProvider.notifier).set(true); expect([c.read(feedPreviewsProvider), c.read(markerPreviewsProvider)], [false, true]); - expect(c.read(feedPreviewDelayProvider), const Duration(milliseconds: 1500)); + expect(c.read(feedPreviewDelayProvider), const Duration(milliseconds: 500)); await c.read(feedPreviewDelayProvider.notifier).set(const Duration(seconds: 3)); expect(prefs.getInt('feed_preview_delay_ms'), 3000); });