diff --git a/CLAUDE.md b/CLAUDE.md index d460584..97f803f 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` (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. **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..9f0601d 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 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 | ✅ | ## 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..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), ]; @@ -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(); 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); });