diff --git a/lib/web/screens/web_bucket_screen.dart b/lib/web/screens/web_bucket_screen.dart index d0ae2f6..d5f478d 100644 --- a/lib/web/screens/web_bucket_screen.dart +++ b/lib/web/screens/web_bucket_screen.dart @@ -26,9 +26,11 @@ import 'package:fula_files/web/services/web_share_service.dart'; import 'package:fula_files/web/services/web_streaming_file.dart'; import 'package:fula_files/web/services/web_tag_service.dart'; import 'package:fula_files/web/services/web_text_viewer_logic.dart'; +import 'package:fula_files/web/services/web_thumbnail_service.dart'; import 'package:fula_files/web/services/web_upload_manager.dart'; import 'package:fula_files/web/utils/cloud_folder_tree.dart'; import 'package:fula_files/web/widgets/media_preview_dialog.dart'; +import 'package:fula_files/web/widgets/web_thumb.dart'; import 'package:fula_files/web/widgets/web_audio_player.dart'; import 'package:fula_files/web/widgets/web_create_share_dialog.dart'; import 'package:fula_files/web/widgets/web_tag_dialogs.dart'; @@ -724,6 +726,26 @@ class _WebBucketScreenState extends State { ); } + /// Leading slot: a lazy thumbnail for images (fetched from the small sidecar, + /// never the full file), the type icon otherwise / as fallback. + Widget _leadingFor(FulaObject o) { + final icon = Icon(_isImage(o) + ? Icons.image_outlined + : _isVideo(o) + ? Icons.movie_outlined + : _isAudio(o) + ? Icons.audiotrack_outlined + : _isText(o) + ? Icons.article_outlined + : Icons.insert_drive_file_outlined); + if (!_isImage(o)) return icon; + return WebThumb( + bucket: o.sourceBucket ?? BucketVersionResolver.writeBucket(widget.base), + objectKey: o.key, + fallback: icon, + ); + } + Future _preview(FulaObject o) async { if (_isVideo(o) || _isAudio(o)) { await _previewMedia(o); @@ -750,6 +772,7 @@ class _WebBucketScreenState extends State { final future = FulaApiService.instance.downloadObject(bucket, o.key); unawaited(future.then((bytes) { _recordRecent(o, bucket, imageBytes: bytes); + WebThumbnailService.instance.backfillFromBytes(bucket, o.key, o.name, bytes); }).catchError((_) {})); showDialog( context: context, @@ -1048,15 +1071,7 @@ class _WebBucketScreenState extends State { final o = _objects![i]; final tags = _fileTags[o.key] ?? const []; return ListTile( - leading: Icon(_isImage(o) - ? Icons.image_outlined - : _isVideo(o) - ? Icons.movie_outlined - : _isAudio(o) - ? Icons.audiotrack_outlined - : _isText(o) - ? Icons.article_outlined - : Icons.insert_drive_file_outlined), + leading: _leadingFor(o), title: Text(_displayName(o), overflow: TextOverflow.ellipsis), subtitle: Column( diff --git a/lib/web/screens/web_cloud_files_screen.dart b/lib/web/screens/web_cloud_files_screen.dart index 7419ded..08ecbf0 100644 --- a/lib/web/screens/web_cloud_files_screen.dart +++ b/lib/web/screens/web_cloud_files_screen.dart @@ -13,9 +13,11 @@ import 'package:fula_files/core/services/ipfs_public_service.dart'; import 'package:fula_files/web/services/web_foreground_activity.dart'; import 'package:fula_files/web/services/web_save.dart'; import 'package:fula_files/web/services/web_streaming_file.dart'; +import 'package:fula_files/web/services/web_thumbnail_service.dart'; import 'package:fula_files/web/services/web_upload_manager.dart'; import 'package:fula_files/web/utils/cloud_folder_tree.dart'; import 'package:fula_files/web/widgets/web_create_share_dialog.dart'; +import 'package:fula_files/web/widgets/web_thumb.dart'; /// Raw cloud file manager ("Cloud Files"). Browses the user's buckets and the /// virtual folder tree inside each one — the same encrypted data the category @@ -80,7 +82,9 @@ class _WebCloudFilesScreenState extends State { final r = await FulaApiService.instance.listBucketsCached(); if (!mounted) return; setState(() { - _buckets = [...r.buckets]..sort(); + // Hide the sidecar thumbnail buckets (e.g. images-v8-thumbs). + _buckets = [for (final b in r.buckets) if (!isThumbsBucket(b)) b] + ..sort(); _bucketsStale = r.stale; _loading = false; }); @@ -238,13 +242,20 @@ class _WebCloudFilesScreenState extends State { Future _previewImage(FulaObject o) async { final bucket = o.sourceBucket ?? _bucket!; + final future = FulaApiService.instance.downloadObject(bucket, o.key); + // The full bytes download anyway — backfill a thumbnail (gated to the + // user's own writable bucket) so the grid shows it next time. + unawaited(future + .then((bytes) => WebThumbnailService.instance + .backfillFromBytes(bucket, o.key, o.name, bytes)) + .catchError((_) {})); showDialog( context: context, builder: (ctx) => Dialog( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 900, maxHeight: 720), child: FutureBuilder( - future: FulaApiService.instance.downloadObject(bucket, o.key), + future: future, builder: (ctx, snap) { if (snap.connectionState != ConnectionState.done) { return const SizedBox( @@ -300,6 +311,7 @@ class _WebCloudFilesScreenState extends State { setState(() => _objects = [for (final x in _objects) if (x.key != o.key) x]); _snack('Deleted'); + unawaited(WebThumbnailService.instance.deleteCloudThumb(bucket, o.key)); } catch (e) { _snack('Delete failed: ${_clean(e)}'); } @@ -324,6 +336,7 @@ class _WebCloudFilesScreenState extends State { for (final o in inFolder) { await WebForegroundActivity.instance .run(() => FulaApiService.instance.deleteObject(bucket, o.key)); + unawaited(WebThumbnailService.instance.deleteCloudThumb(bucket, o.key)); } if (!mounted) return; final deleted = inFolder.map((o) => o.key).toSet(); @@ -497,6 +510,8 @@ class _WebCloudFilesScreenState extends State { setState(() => _objects = [for (final x in _objects) if (x.key != o.key) x]); _snack(okWord); + unawaited(WebThumbnailService.instance + .moveCloudThumb(srcBucket, o.key, destBucket, destKey)); await _loadObjects(silent: true); } @@ -640,10 +655,7 @@ class _WebCloudFilesScreenState extends State { if (s.isEmpty) return 'Enter a name.'; if (s.contains('/')) return 'No "/" in a file name.'; if (s == kFolderMarkerName) return 'Reserved name.'; - final siblings = deriveCloudFolderView(_objects, _prefix) - .files - .map((f) => f.name) - .toSet(); + final siblings = _view().files.map((f) => f.name).toSet(); if (s != exclude && siblings.contains(s)) { return 'A file with this name already exists here.'; } @@ -861,8 +873,27 @@ class _WebCloudFilesScreenState extends State { ); } + // Memoized folder view: deriveCloudFolderView re-scans the whole flat list, + // so cache it and recompute only when the object-list identity or the prefix + // changes — not on every build or every rename-dialog keystroke. + ({List folders, List files})? _viewCache; + int? _viewObjectsId; + String? _viewPrefix; + + ({List folders, List files}) _view() { + final id = identityHashCode(_objects); + if (_viewCache != null && _viewObjectsId == id && _viewPrefix == _prefix) { + return _viewCache!; + } + final v = deriveCloudFolderView(_objects, _prefix); + _viewCache = v; + _viewObjectsId = id; + _viewPrefix = _prefix; + return v; + } + Widget _folderView() { - final view = deriveCloudFolderView(_objects, _prefix); + final view = _view(); final empty = view.folders.isEmpty && view.files.isEmpty; if (empty) { return RefreshIndicator( @@ -906,7 +937,13 @@ class _WebCloudFilesScreenState extends State { } final o = view.files[i - view.folders.length]; return ListTile( - leading: Icon(_iconFor(o)), + leading: o.isImage + ? WebThumb( + bucket: o.sourceBucket ?? _bucket!, + objectKey: o.key, + fallback: Icon(_iconFor(o)), + ) + : Icon(_iconFor(o)), title: Text(o.name), subtitle: Text(o.sizeFormatted), onTap: () => _open(o), diff --git a/lib/web/screens/web_settings_screen.dart b/lib/web/screens/web_settings_screen.dart index 689f90f..b263d87 100644 --- a/lib/web/screens/web_settings_screen.dart +++ b/lib/web/screens/web_settings_screen.dart @@ -14,7 +14,7 @@ import 'package:fula_files/web/services/web_session.dart'; /// App version label shown in About + the home footer. Kept in one place /// so the two stay in sync (the home footer imports this). -const String kWebAppVersion = 'v1.11.4.16'; +const String kWebAppVersion = 'v1.11.4.17'; /// In-app web Settings page. Replaces the old behavior where the gear icon /// opened cloud.fx.land in a new tab. Mirrors the mobile Settings screen's diff --git a/lib/web/services/web_cache_sync.dart b/lib/web/services/web_cache_sync.dart index 7e8806d..d99cf3b 100644 --- a/lib/web/services/web_cache_sync.dart +++ b/lib/web/services/web_cache_sync.dart @@ -7,6 +7,7 @@ import 'package:fula_files/web/services/web_audio_controller.dart'; import 'package:fula_files/web/services/web_listing_cache.dart'; import 'package:fula_files/web/services/web_prefetch_scheduler.dart'; import 'package:fula_files/web/services/web_recent_files_service.dart'; +import 'package:fula_files/web/services/web_thumbnail_service.dart'; /// Cross-tab cache coherence (plan §8 multi-tab): a delete in tab A /// must not survive in tab B, and a sign-out anywhere must drop every @@ -59,6 +60,7 @@ class WebCacheSync { // and no in-flight write can leak into the deleted box. WebListingCache.instance.deactivate(); WebRecentFilesService.instance.deactivate(); + WebThumbnailService.instance.deactivate(); WebPrefetchScheduler.instance.reset(); WebAudioController.instance.stopPlayback(); onRemoteSignOut?.call(); diff --git a/lib/web/services/web_session.dart b/lib/web/services/web_session.dart index b5e4cae..eb62226 100644 --- a/lib/web/services/web_session.dart +++ b/lib/web/services/web_session.dart @@ -16,6 +16,7 @@ import 'package:fula_files/web/services/web_cache_sync.dart'; import 'package:fula_files/web/services/web_listing_cache.dart'; import 'package:fula_files/web/services/web_prefetch_scheduler.dart'; import 'package:fula_files/web/services/web_recent_files_service.dart'; +import 'package:fula_files/web/services/web_thumbnail_service.dart'; import 'package:fula_files/web/services/web_upload_manager.dart'; /// Signed-in identity as the web shell sees it. @@ -462,6 +463,11 @@ class WebSession extends ChangeNotifier { await WebRecentFilesService.instance.clearAll(); } catch (_) {} }()); + unawaited(() async { + try { + await WebThumbnailService.instance.clearAll(); + } catch (_) {} + }()); // Stop background audio so the previous user's track doesn't keep playing // into the next session (s2). WebAudioController.instance.stopPlayback(); diff --git a/lib/web/services/web_thumbnail_service.dart b/lib/web/services/web_thumbnail_service.dart new file mode 100644 index 0000000..ceac31b --- /dev/null +++ b/lib/web/services/web_thumbnail_service.dart @@ -0,0 +1,369 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:web/web.dart' as web; + +import 'package:fula_files/core/services/bucket_version_resolver.dart'; +import 'package:fula_files/core/services/fula_api_service.dart'; +import 'package:fula_files/core/services/secure_storage_service.dart'; +import 'package:fula_files/web/services/web_cache_hkdf.dart'; +import 'package:fula_files/web/utils/web_thumbnail_gen.dart'; + +/// The sibling bucket that holds thumbnails for source bucket [bucket]. +String thumbsBucketFor(String bucket) => '$bucket-thumbs'; + +/// True if [bucket] is a thumbnails sidecar bucket (hidden from the UI). +bool isThumbsBucket(String bucket) => bucket.endsWith('-thumbs'); + +/// Lazy, concurrency-capped, cached fetcher for grid image thumbnails. +/// +/// A thumbnail lives at the SAME key in a sibling `-thumbs` bucket (a +/// tiny ~10 KB encrypted JPEG). [get] returns it from an in-memory LRU → an +/// AES-GCM-encrypted IndexedDB cache → a network fetch of the sidecar, gating +/// network fetches through a small concurrency queue and de-duping in-flight +/// requests. A missing sidecar is remembered (negative cache) so fast +/// re-scrolls don't re-fetch. The grid NEVER downloads the full file — a miss +/// just shows an icon. Crypto mirrors [WebRecentFilesService] (separate box + +/// salt) — TODO: factor a shared encrypted-blob box. +class WebThumbnailService { + WebThumbnailService._(); + static final WebThumbnailService instance = WebThumbnailService._(); + + static const String boxName = 'web_thumb_cache_v1'; + static const int _memCap = 500; + static const int _maxConcurrent = 5; + + final LinkedHashMap _mem = LinkedHashMap(); + final Set _missing = {}; + // Buckets with no `-thumbs` sibling yet → skip ALL their keys, so a + // pre-feature bucket doesn't fire one doomed fetch per visible image. + final Set _missingBuckets = {}; + final Map> _inflight = {}; + + int _active = 0; + final ListQueue> _waiters = ListQueue(); + + Box? _box; + web.CryptoKey? _aesKey; + String? _aesKeyOwner; + final Random _rng = Random.secure(); + + String _ck(String bucket, String key) => '$bucket|$key'; + + /// Synchronous in-memory peek — for a widget's initState (instant hit). + Uint8List? peek(String bucket, String key) => _mem[_ck(bucket, key)]; + + /// Thumbnail for ([bucket],[key]) or null (→ caller shows an icon). + Future get(String bucket, String key) { + final ck = _ck(bucket, key); + final mem = _mem.remove(ck); + if (mem != null) { + _mem[ck] = mem; // LRU touch + return Future.value(mem); + } + if (_missingBuckets.contains(bucket) || _missing.contains(ck)) { + return Future.value(null); + } + final existing = _inflight[ck]; + if (existing != null) return existing; + final fut = _resolve(bucket, key, ck); + _inflight[ck] = fut; + fut.whenComplete(() => _inflight.remove(ck)); + return fut; + } + + Future _resolve(String bucket, String key, String ck) async { + final cached = await _diskGet(bucket, key); + if (cached != null) { + _memPut(ck, cached); + return cached; + } + await _acquire(); + try { + // Bounded so a slow/missing thumb can't wedge a concurrency slot (and + // contend with the real downloadObject path). The listing cache uses 10s. + final bytes = await FulaApiService.instance + .downloadObject(thumbsBucketFor(bucket), key) + .timeout(const Duration(seconds: 10)); + _memPut(ck, bytes); + unawaited(_diskPut(bucket, key, bytes)); + return bytes; + } catch (e) { + // No `-thumbs` bucket yet → mark the WHOLE bucket absent (kills the + // per-image doomed-fetch burst on first view of a pre-feature bucket). + // Any other error / timeout → just this key. Either way → icon. + final msg = '$e'; + if (msg.contains('NoSuchBucket') || msg.contains('bucket not found')) { + _missingBuckets.add(bucket); + } else { + _missing.add(ck); + } + return null; + } finally { + _release(); + } + } + + /// Seed the cache from already-decoded bytes (upload / on-view backfill). + void put(String bucket, String key, Uint8List thumb) { + final ck = _ck(bucket, key); + _missing.remove(ck); + _missingBuckets.remove(bucket); // a thumb now exists → re-probe this bucket + _memPut(ck, thumb); + unawaited(_diskPut(bucket, key, thumb)); + } + + /// Drop a cached thumbnail (file deleted / moved). Best-effort, local only. + void evict(String bucket, String key) { + final ck = _ck(bucket, key); + _mem.remove(ck); + _missing.remove(ck); + unawaited(_diskDelete(bucket, key)); + } + + /// Delete the cloud thumbnail sidecar + local caches. Best-effort. + Future deleteCloudThumb(String bucket, String key) async { + evict(bucket, key); + try { + await FulaApiService.instance.deleteObject(thumbsBucketFor(bucket), key); + } catch (_) {/* no thumb / bucket not writable — fine */} + } + + /// Move the cloud thumbnail to follow a renamed/moved file. Best-effort: + /// copy the small thumb to the dest `-thumbs` bucket, then remove the src. + /// If anything fails the dest simply regenerates its thumbnail on next view. + Future moveCloudThumb( + String srcBucket, String srcKey, String dstBucket, String dstKey) async { + try { + final bytes = await FulaApiService.instance + .downloadObject(thumbsBucketFor(srcBucket), srcKey); + await FulaApiService.instance + .uploadObject(thumbsBucketFor(dstBucket), dstKey, bytes); + put(dstBucket, dstKey, bytes); + } catch (_) {/* no src thumb / dest not writable */} + await deleteCloudThumb(srcBucket, srcKey); + } + + /// On-view backfill: a file's full bytes are already decoded (it was opened), + /// so generate + cache its thumbnail, and — only for the user's own writable + /// bucket — upload the sidecar so the grid shows it next time. Best-effort; + /// never writes to a read-only/legacy bucket. + Future backfillFromBytes( + String bucket, String key, String name, Uint8List fullBytes) async { + try { + if (isThumbsBucket(bucket) || + !isThumbnailableImage(name) || + fullBytes.length > kThumbMaxSourceBytes) { + return; + } + final thumb = await generateImageThumbnail(fullBytes); + if (thumb == null) return; + put(bucket, key, thumb); // local cache → shows on next view + if (!BucketVersionResolver.isForbiddenWriteTarget(bucket)) { + await FulaApiService.instance + .uploadObject(thumbsBucketFor(bucket), key, thumb); + } + } catch (e) { + debugPrint('WebThumbnailService.backfillFromBytes: $e'); + } + } + + void _memPut(String ck, Uint8List bytes) { + _mem.remove(ck); + _mem[ck] = bytes; + while (_mem.length > _memCap) { + _mem.remove(_mem.keys.first); + } + } + + // ---- concurrency gate (counting semaphore, slot handed off on release) ---- + Future _acquire() async { + if (_active < _maxConcurrent) { + _active++; + return; + } + final c = Completer(); + _waiters.add(c); + await c.future; // a released slot was handed to us; _active already counts it + } + + void _release() { + if (_waiters.isNotEmpty) { + _waiters.removeFirst().complete(); // transfer the slot (active unchanged) + } else { + _active--; + } + } + + // ---- encrypted IndexedDB (same scheme as WebRecentFilesService) ---- + Future _ownerHash() async { + final email = await SecureStorageService.instance + .read(SecureStorageKeys.derivationEmail); + if (email == null || email.isEmpty) return null; + final digest = sha256.convert(utf8.encode(email)).bytes; + return base64UrlEncode(digest).substring(0, 16); + } + + Future?> _openBox() async { + final existing = _box; + if (existing != null && existing.isOpen) return existing; + try { + final box = await Hive.openBox(boxName); + _box = box; + return box; + } catch (e) { + debugPrint('WebThumbnailService._openBox: $e'); + return null; + } + } + + Future _cacheKey(String owner) async { + if (_aesKey != null && _aesKeyOwner == owner) return _aesKey; + final kekB64 = await SecureStorageService.instance + .read(SecureStorageKeys.encryptionKey); + if (kekB64 == null || kekB64.isEmpty) return null; + final raw = hkdfSha256( + base64Decode(kekB64), + salt: utf8.encode('fxfiles-web-thumbs-salt-v1'), + info: utf8.encode('web-thumbs-v1'), + ); + final key = await web.window.crypto.subtle + .importKey('raw', raw.toJS, 'AES-GCM'.toJS, false, + ['encrypt'.toJS, 'decrypt'.toJS].toJS) + .toDart; + _aesKey = key; + _aesKeyOwner = owner; + return key; + } + + JSObject _gcmParams(Uint8List nonce, String aad) { + final alg = JSObject(); + alg.setProperty('name'.toJS, 'AES-GCM'.toJS); + alg.setProperty('iv'.toJS, nonce.toJS); + alg.setProperty( + 'additionalData'.toJS, Uint8List.fromList(utf8.encode(aad)).toJS); + return alg; + } + + Future _encrypt( + web.CryptoKey key, String entryKey, Uint8List plain) async { + final nonce = + Uint8List.fromList(List.generate(12, (_) => _rng.nextInt(256))); + final buf = await web.window.crypto.subtle + .encrypt(_gcmParams(nonce, entryKey), key, plain.toJS) + .toDart; + final ct = (buf as JSArrayBuffer).toDart.asUint8List(); + final out = Uint8List(nonce.length + ct.length); + out.setAll(0, nonce); + out.setAll(nonce.length, ct); + return out; + } + + Future _decrypt( + web.CryptoKey key, String entryKey, Uint8List value) async { + if (value.length < 13) return null; + try { + final nonce = Uint8List.sublistView(value, 0, 12); + final ct = Uint8List.sublistView(value, 12); + final buf = await web.window.crypto.subtle + .decrypt(_gcmParams(nonce, entryKey), key, ct.toJS) + .toDart; + return (buf as JSArrayBuffer).toDart.asUint8List(); + } catch (_) { + return null; // wrong key / AAD / corrupt → miss + } + } + + String _entryKey(String owner, String bucket, String key) => + '$owner|t|$bucket|$key'; + + Future _diskGet(String bucket, String key) async { + try { + final owner = await _ownerHash(); + if (owner == null) return null; + final aes = await _cacheKey(owner); + if (aes == null) return null; + final box = await _openBox(); + if (box == null) return null; + final ek = _entryKey(owner, bucket, key); + final v = box.get(ek); + if (v == null) return null; + return _decrypt(aes, ek, v); + } catch (e) { + debugPrint('WebThumbnailService._diskGet: $e'); + return null; + } + } + + Future _diskPut(String bucket, String key, Uint8List thumb) async { + try { + final owner = await _ownerHash(); + if (owner == null) return; + final aes = await _cacheKey(owner); + if (aes == null) return; + final box = await _openBox(); + if (box == null) return; + final ek = _entryKey(owner, bucket, key); + await box.put(ek, await _encrypt(aes, ek, thumb)); + } catch (e) { + debugPrint('WebThumbnailService._diskPut: $e'); + } + } + + Future _diskDelete(String bucket, String key) async { + try { + final owner = await _ownerHash(); + if (owner == null) return; + final box = await _openBox(); + if (box == null) return; + await box.delete(_entryKey(owner, bucket, key)); + } catch (e) { + debugPrint('WebThumbnailService._diskDelete: $e'); + } + } + + /// Sign-out / user-switch: forget the in-memory key + caches, delete the box. + Future clearAll() async { + _aesKey = null; + _aesKeyOwner = null; + _mem.clear(); + _missing.clear(); + _missingBuckets.clear(); + final box = _box; + _box = null; + try { + if (box != null && box.isOpen) await box.close(); + } catch (e) { + debugPrint('WebThumbnailService.clearAll close: $e'); + } + try { + await Hive.deleteBoxFromDisk(boxName); + } catch (e) { + debugPrint('WebThumbnailService.clearAll delete: $e'); + } + } + + /// Remote-sign-out receiver: drop the key + caches + close the box (so the + /// originating tab's delete isn't blocked) without deleting it here. + Future deactivate() async { + _aesKey = null; + _aesKeyOwner = null; + _mem.clear(); + _missing.clear(); + _missingBuckets.clear(); + final box = _box; + _box = null; + try { + if (box != null && box.isOpen) await box.close(); + } catch (e) { + debugPrint('WebThumbnailService.deactivate: $e'); + } + } +} diff --git a/lib/web/services/web_upload_manager.dart b/lib/web/services/web_upload_manager.dart index c554b31..8696103 100644 --- a/lib/web/services/web_upload_manager.dart +++ b/lib/web/services/web_upload_manager.dart @@ -7,7 +7,9 @@ import 'package:fula_files/web/services/web_cache_sync.dart'; import 'package:fula_files/web/services/web_foreground_activity.dart'; import 'package:fula_files/web/services/web_listing_swr.dart'; import 'package:fula_files/web/services/web_streaming_file.dart'; +import 'package:fula_files/web/services/web_thumbnail_service.dart'; import 'package:fula_files/web/services/web_unload_guard.dart'; +import 'package:fula_files/web/utils/web_thumbnail_gen.dart'; /// Files at or below this size take the single-shot [FulaApiService.uploadObject] /// path (read once, one PUT). Larger files stream chunk-by-chunk so they never @@ -166,6 +168,26 @@ class WebUploadManager extends ChangeNotifier { unawaited(_pump()); } + /// Best-effort: generate + upload a tiny thumbnail to the sibling + /// `-thumbs` bucket for an image, so the grids can show it without + /// the full file. Never throws into the drain. [smallBytes] is the + /// already-read body for small uploads (avoids a re-read). + Future _writeThumbnailSidecar(String bucket, String key, String name, + int size, WebPickedFile picked, Uint8List? smallBytes) async { + try { + if (isThumbsBucket(bucket)) return; // never thumbnail a thumbnail + if (!isThumbnailableImage(name) || size > kThumbMaxSourceBytes) return; + final src = smallBytes ?? await picked.readSlice(0, size); + final thumb = await generateImageThumbnail(src); + if (thumb == null) return; + await FulaApiService.instance + .uploadObject(thumbsBucketFor(bucket), key, thumb); + WebThumbnailService.instance.put(bucket, key, thumb); + } catch (e) { + debugPrint('WebUploadManager: thumbnail sidecar skipped: $e'); + } + } + WebUploadJob? _nextQueued() { for (final j in _jobs) { if (j.status == WebUploadStatus.queued) return j; @@ -215,14 +237,15 @@ class WebUploadManager extends ChangeNotifier { } catch (_) {} } + Uint8List? smallBytes; if (job.size <= _kSmallUploadBytes) { // Small file: one read, one PUT (streaming overhead isn't worth it). - final bytes = await picked.readSlice(0, job.size); + smallBytes = await picked.readSlice(0, job.size); if (_epoch != epoch || job.status == WebUploadStatus.cancelled) { throw const _AbandonedUpload(); } await FulaApiService.instance - .uploadObject(job.bucket, job.key, bytes); + .uploadObject(job.bucket, job.key, smallBytes); } else { await _streamUpload(job, picked, epoch); } @@ -232,6 +255,10 @@ class WebUploadManager extends ChangeNotifier { job.status = WebUploadStatus.done; job.progress = 1.0; + // Best-effort image thumbnail sidecar — does NOT block the drain + // (captures `picked`, so nulling job._picked below is safe). + unawaited(_writeThumbnailSidecar( + job.bucket, job.key, job.name, job.size, picked, smallBytes)); job._picked = null; completedBuckets[job.base] = job.bucket; (completedKeys[job.bucket] ??= []).add(job.key); diff --git a/lib/web/utils/web_thumbnail_gen.dart b/lib/web/utils/web_thumbnail_gen.dart new file mode 100644 index 0000000..3243f05 --- /dev/null +++ b/lib/web/utils/web_thumbnail_gen.dart @@ -0,0 +1,68 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; + +import 'package:fula_files/web/services/web_device_class.dart'; + +/// Pure (browser-side) image → tiny JPEG thumbnail generator for the web grids. +/// +/// Pipeline: `ui.instantiateImageCodec(targetWidth:)` does a browser-native +/// decode + downscale (fast, low memory even for big sources), then the +/// `image` package encodes a small JPEG. Aims ~10 KB so the cloud sidecar is +/// cheap to fetch. Returns null (→ caller shows an icon) on low-end devices, +/// oversized sources, or any failure — generation is always best-effort. + +/// Don't decode a huge source just to thumbnail it (transient memory spike). +const int kThumbMaxSourceBytes = 10 * 1024 * 1024; + +/// Target width of the downscaled thumbnail (height scales to preserve ratio). +const int kThumbTargetWidth = 128; + +/// Upper bound on the encoded thumbnail; we step JPEG quality down to fit and +/// give up (icon) if even the lowest quality is over. +const int kThumbMaxBytes = 20 * 1024; + +/// True for image file names the browser codec can decode for thumbnailing. +bool isThumbnailableImage(String name) { + final i = name.lastIndexOf('.'); + if (i < 0) return false; + final ext = name.substring(i + 1).toLowerCase(); + return const {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'}.contains(ext); +} + +/// Generate a small JPEG thumbnail from full-image [bytes], or null to fall +/// back to an icon. +Future generateImageThumbnail(Uint8List bytes) async { + if (WebDeviceClass.lowEnd) return null; + if (bytes.isEmpty || bytes.length > kThumbMaxSourceBytes) return null; + ui.Image? image; + ui.Codec? codec; + try { + codec = + await ui.instantiateImageCodec(bytes, targetWidth: kThumbTargetWidth); + final frame = await codec.getNextFrame(); + image = frame.image; + final rgba = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + if (rgba == null) return null; + final src = img.Image.fromBytes( + width: image.width, + height: image.height, + bytes: rgba.buffer, + numChannels: 4, + order: img.ChannelOrder.rgba, + ); + // Step quality down until it fits the byte budget. + for (final q in const [65, 50, 40]) { + final out = img.encodeJpg(src, quality: q); + if (out.length <= kThumbMaxBytes) return Uint8List.fromList(out); + } + return null; // couldn't compress small enough → icon + } catch (e) { + debugPrint('generateImageThumbnail: $e'); + return null; + } finally { + image?.dispose(); + codec?.dispose(); + } +} diff --git a/lib/web/widgets/web_thumb.dart b/lib/web/widgets/web_thumb.dart new file mode 100644 index 0000000..747b66f --- /dev/null +++ b/lib/web/widgets/web_thumb.dart @@ -0,0 +1,89 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:fula_files/web/services/web_thumbnail_service.dart'; + +/// A small image thumbnail for the file grids at ([bucket],[objectKey]). +/// +/// Shows the cached thumbnail instantly if present; otherwise — after a short +/// debounce so a fast-scrolled-past row never triggers a fetch — lazily pulls +/// the ~10 KB sidecar via [WebThumbnailService] (which is concurrency-capped +/// and de-duped). Falls back to [fallback] (a type icon) while loading or when +/// no thumbnail exists. The full file is never downloaded here. +class WebThumb extends StatefulWidget { + final String bucket; + final String objectKey; + final double size; + final Widget fallback; + + const WebThumb({ + super.key, + required this.bucket, + required this.objectKey, + required this.fallback, + this.size = 40, + }); + + @override + State createState() => _WebThumbState(); +} + +class _WebThumbState extends State { + static const Duration _debounceDelay = Duration(milliseconds: 250); + Uint8List? _bytes; + Timer? _timer; + + @override + void initState() { + super.initState(); + _bytes = WebThumbnailService.instance.peek(widget.bucket, widget.objectKey); + if (_bytes == null) _schedule(); + } + + @override + void didUpdateWidget(WebThumb old) { + super.didUpdateWidget(old); + // A virtualized list row was recycled for a different file. + if (old.bucket != widget.bucket || old.objectKey != widget.objectKey) { + _timer?.cancel(); + _bytes = + WebThumbnailService.instance.peek(widget.bucket, widget.objectKey); + if (_bytes == null) _schedule(); + } + } + + void _schedule() { + _timer?.cancel(); + _timer = Timer(_debounceDelay, () async { + final b = await WebThumbnailService.instance + .get(widget.bucket, widget.objectKey); + if (!mounted || b == null) return; + setState(() => _bytes = b); + }); + } + + @override + void dispose() { + _timer?.cancel(); // scrolled away before the debounce fired → never fetch + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final b = _bytes; + if (b == null) return widget.fallback; + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.memory( + b, + width: widget.size, + height: widget.size, + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, __, ___) => widget.fallback, + ), + ); + } +}