diff --git a/lib/shared/data_sources/sources/local/file_cache_manager.dart b/lib/shared/data_sources/sources/local/file_cache_manager.dart index 1da2716..4059012 100644 --- a/lib/shared/data_sources/sources/local/file_cache_manager.dart +++ b/lib/shared/data_sources/sources/local/file_cache_manager.dart @@ -15,8 +15,8 @@ class MediaCache { final Duration stalePeriod; final int maxNrOfCacheObjects; - static String get videoCachePath => "$temporaryPath"; - static String get imageCachePath => "$temporaryPath"; + static String get videoCachePath => temporaryPath; + static String get imageCachePath => temporaryPath; MediaCache._internal({ required this.cacheKey, @@ -65,14 +65,24 @@ class MediaCache { /// 下载或获取缓存文件 /// [url] 文件网络地址 /// [ignoreCache] 是否忽略缓存强制重新下载 - Future getFile({required String url, bool ignoreCache = false}) async { + Future getFile({ + required String url, + bool ignoreCache = false, + Map? headers, + }) async { if (ignoreCache) { // 强制重新下载 - final fileInfo = await _cacheManager.downloadFile(url); + final fileInfo = await _cacheManager.downloadFile( + url, + authHeaders: headers, + ); return fileInfo.file; } else { // 优先从缓存获取 - final cacheFile = await _cacheManager.getSingleFile(url); + final cacheFile = await _cacheManager.getSingleFile( + url, + headers: headers, + ); return cacheFile; } } @@ -119,10 +129,8 @@ class MediaCache { localFile.readAsBytesSync(), // 文件字节数据 fileExtension: localFile.path.split('.').last, // 文件扩展名 ); - - // 2. 验证文件已缓存 - final cachedFile = await cacheManager.getFileFromCache(cacheKey); - if (cachedFile != null) {} - } catch (e) {} + } catch (_) { + return; + } } } diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart index a0eaced..bf40c36 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_dialog.dart @@ -390,6 +390,8 @@ class _RocketCanvas extends StatelessWidget { imageUrl: selectedLevelItem.previewImageUrl, fit: BoxFit.contain, preferSvg: true, + pagDisplayScale: 2, + pagClipBehavior: Clip.none, debugName: 'centerPreview level=${selectedLevelItem.level} ' 'preview=${selectedLevelItem.previewImageUrl ?? '-'}', @@ -974,6 +976,8 @@ class _RocketImage extends StatelessWidget { this.imageUrl, this.fit = BoxFit.cover, this.preferSvg = false, + this.pagDisplayScale = 1, + this.pagClipBehavior = Clip.hardEdge, this.debugName, }); @@ -981,6 +985,8 @@ class _RocketImage extends StatelessWidget { final String? imageUrl; final BoxFit fit; final bool preferSvg; + final double pagDisplayScale; + final Clip pagClipBehavior; final String? debugName; @override @@ -1005,6 +1011,8 @@ class _RocketImage extends StatelessWidget { resource: source, loop: true, fit: fit, + displayScale: pagDisplayScale, + clipBehavior: pagClipBehavior, underlayBuilder: (_) => _imageFallback(), defaultBuilder: (_) => const SCRotatingDotsLoading(), ); @@ -1054,6 +1062,8 @@ class _RocketImage extends StatelessWidget { resource: source, loop: true, fit: fit, + displayScale: pagDisplayScale, + clipBehavior: pagClipBehavior, underlayBuilder: (_) => _imageFallback(), defaultBuilder: (_) => _imageFallback(), ); diff --git a/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart index 0c618ad..7eb4d11 100644 --- a/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart +++ b/lib/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart @@ -1,12 +1,17 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:pag/pag.dart'; +import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart'; +import 'package:yumi/shared/tools/sc_network_image_utils.dart'; import 'package:yumi/ui_kit/components/custom_cached_image.dart'; import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; +const Duration _pagMemoryTtl = Duration(minutes: 5); + class RoomRocketPagEffectOverlay extends StatefulWidget { const RoomRocketPagEffectOverlay({ super.key, @@ -71,6 +76,9 @@ class _RoomRocketPagEffectOverlayState height: size.height, loop: false, fit: BoxFit.cover, + displayScale: 1, + clipBehavior: Clip.hardEdge, + memoryTtl: _pagMemoryTtl, onCompleted: _complete, defaultBuilder: (_) => const Center(child: SCRotatingDotsLoading()), @@ -134,6 +142,9 @@ class RoomRocketPagPreview extends StatelessWidget { this.height, this.loop = true, this.fit = BoxFit.contain, + this.displayScale = 1, + this.clipBehavior = Clip.hardEdge, + this.memoryTtl = _pagMemoryTtl, this.underlayBuilder, this.defaultBuilder, }); @@ -143,6 +154,9 @@ class RoomRocketPagPreview extends StatelessWidget { final double? height; final bool loop; final BoxFit fit; + final double displayScale; + final Clip clipBehavior; + final Duration memoryTtl; final WidgetBuilder? underlayBuilder; final WidgetBuilder? defaultBuilder; @@ -162,6 +176,9 @@ class RoomRocketPagPreview extends StatelessWidget { height: resolvedHeight, loop: loop, fit: fit, + displayScale: displayScale, + clipBehavior: clipBehavior, + memoryTtl: memoryTtl, underlayBuilder: underlayBuilder, defaultBuilder: defaultBuilder ?? @@ -179,6 +196,9 @@ class _RoomRocketPagView extends StatefulWidget { this.height, required this.loop, required this.fit, + required this.displayScale, + required this.clipBehavior, + required this.memoryTtl, this.underlayBuilder, this.onCompleted, this.defaultBuilder, @@ -189,6 +209,9 @@ class _RoomRocketPagView extends StatefulWidget { final double? height; final bool loop; final BoxFit fit; + final double displayScale; + final Clip clipBehavior; + final Duration memoryTtl; final WidgetBuilder? underlayBuilder; final VoidCallback? onCompleted; final WidgetBuilder? defaultBuilder; @@ -205,7 +228,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { Uint8List? _pagBytes; bool _initialized = false; bool _loadFailed = false; + bool _releasedFromMemory = false; Timer? _startWatchdog; + Timer? _memoryReleaseTimer; @override void initState() { @@ -223,6 +248,8 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { _pagBytes = null; _initialized = false; _loadFailed = false; + _releasedFromMemory = false; + _memoryReleaseTimer?.cancel(); _debug( 'resource changed old=${_shortPagResource(oldWidget.resource)} ' 'new=${_shortPagResource(widget.resource)}', @@ -235,9 +262,11 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { void dispose() { _debug( 'dispose initialized=$_initialized ' - 'hasBytes=${_pagBytes != null} loadFailed=$_loadFailed', + 'hasBytes=${_pagBytes != null} loadFailed=$_loadFailed ' + 'released=$_releasedFromMemory', ); _startWatchdog?.cancel(); + _memoryReleaseTimer?.cancel(); super.dispose(); } @@ -253,6 +282,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { widget.defaultBuilder?.call(context) ?? const Center(child: SCRotatingDotsLoading()); final underlay = widget.underlayBuilder?.call(context); + if (_releasedFromMemory) { + return _releasedPlaceholder(underlay, defaultChild); + } if (_loadFailed) { return _withUnderlay(underlay, defaultChild); } @@ -274,24 +306,41 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { onAnimationRepeat: _handleAnimationRepeat, defaultBuilder: (_) => const SizedBox(width: 1, height: 1), ); + final visibleUnderlay = _initialized ? null : underlay; + final content = Stack( + fit: StackFit.expand, + clipBehavior: + widget.clipBehavior == Clip.none ? Clip.none : Clip.hardEdge, + children: [ + if (visibleUnderlay != null) visibleUnderlay, + _scaledPagView(pagView), + if (!_initialized) Positioned.fill(child: defaultChild), + ], + ); return SizedBox( width: widget.width, height: widget.height, - child: ClipRect( - child: Stack( - fit: StackFit.expand, - children: [ - if (underlay != null) underlay, - FittedBox(fit: widget.fit, child: pagView), - if (!_initialized) Positioned.fill(child: defaultChild), - ], - ), - ), + child: + widget.clipBehavior == Clip.none ? content : ClipRect(child: content), ); } + Widget _scaledPagView(Widget pagView) { + final scale = + widget.displayScale.isFinite && widget.displayScale > 0 + ? widget.displayScale + : 1.0; + final fitted = FittedBox(fit: widget.fit, child: pagView); + if (scale == 1.0) { + return fitted; + } + return Transform.scale(scale: scale, child: fitted); + } + void _prepareResource() { final path = widget.resource.trim(); + _releasedFromMemory = false; + _memoryReleaseTimer?.cancel(); _debug( 'prepare kind=${_pagSourceKind(path)} ' 'isPag=${RoomRocketPagEffectOverlay.isPag(path)}', @@ -303,18 +352,13 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { Future _loadBytes(String path) async { final stopwatch = Stopwatch()..start(); try { - final isNetwork = _isPagNetwork(path); _debug( - '${isNetwork ? 'download' : 'asset load'} start ' + '${_pagLoadAction(path)} start ' 'host=${_pagHost(path)}', ); - final data = await (isNetwork - ? NetworkAssetBundle(Uri.parse(path)).load(path) - : rootBundle.load(path)) - .timeout(const Duration(seconds: 15)); - final bytes = Uint8List.fromList( - data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), - ); + final bytes = await _readPagBytes( + path, + ).timeout(const Duration(seconds: 15)); if (!mounted || widget.resource.trim() != path) { _debug( 'bytes ignored stale mounted=$mounted ' @@ -323,7 +367,7 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { return; } if (bytes.isEmpty) { - _markLoadFailed('download empty'); + _markLoadFailed('pag bytes empty'); return; } _debug( @@ -333,7 +377,9 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { ); setState(() { _pagBytes = bytes; + _releasedFromMemory = false; }); + _scheduleMemoryRelease(path); _restartWatchdog(); } catch (error) { if (!mounted || widget.resource.trim() != path) { @@ -445,6 +491,26 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { }); } + void _scheduleMemoryRelease(String path) { + _memoryReleaseTimer?.cancel(); + if (widget.memoryTtl <= Duration.zero) { + return; + } + _memoryReleaseTimer = Timer(widget.memoryTtl, () { + if (!mounted || widget.resource.trim() != path || _pagBytes == null) { + return; + } + _debug('release bytes from memory ttl=${widget.memoryTtl.inSeconds}s'); + _startWatchdog?.cancel(); + setState(() { + _pagKey = GlobalKey(); + _pagBytes = null; + _initialized = false; + _releasedFromMemory = true; + }); + }); + } + Widget _withUnderlay(Widget? underlay, Widget foreground) { if (underlay == null) { return foreground; @@ -459,9 +525,18 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { ); } + Widget _releasedPlaceholder(Widget? underlay, Widget fallback) { + return SizedBox( + width: widget.width, + height: widget.height, + child: underlay ?? fallback, + ); + } + void _markLoadFailed(String reason) { _debug('failed reason=$reason'); _startWatchdog?.cancel(); + _memoryReleaseTimer?.cancel(); if (mounted) { setState(() { _initialized = false; @@ -485,23 +560,76 @@ class _RoomRocketPagViewState extends State<_RoomRocketPagView> { 'size=${widget.width?.toStringAsFixed(1)}x' '${widget.height?.toStringAsFixed(1)} ' 'kind=${_pagSourceKind(widget.resource)} ' + 'scale=${widget.displayScale.toStringAsFixed(2)} ' + 'released=$_releasedFromMemory ' 'resource=${_shortPagResource(widget.resource)}', ); } } +@visibleForTesting +Duration debugRoomRocketPagMemoryTtl() { + return _pagMemoryTtl; +} + +@visibleForTesting +String debugNormalizeRoomRocketPagResource(String resource) { + return _normalizePagResource(resource); +} + +@visibleForTesting +Uri? debugRoomRocketPagNetworkUri(String resource) { + return _pagNetworkUri(resource); +} + +@visibleForTesting +String? debugRoomRocketPagFilePath(String resource) { + return _pagFilePath(resource); +} + +@visibleForTesting +String debugRoomRocketPagSourceKind(String resource) { + return _pagSourceKind(resource); +} + +Future _readPagBytes(String value) async { + final normalized = _normalizePagResource(value); + final uri = _pagNetworkUri(normalized); + if (uri != null) { + final url = uri.toString(); + final file = await FileCacheManager.getInstance().getFile( + url: url, + headers: buildNetworkImageHeaders(url), + ); + return file.readAsBytes(); + } + + final filePath = _pagFilePath(normalized); + if (filePath != null) { + return File(filePath).readAsBytes(); + } + + final data = await rootBundle.load(normalized); + return Uint8List.fromList( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ); +} + bool _isPagNetwork(String value) { - return value.startsWith('http://') || value.startsWith('https://'); + return _pagNetworkUri(value) != null; } String _pagSourceKind(String value) { - final text = value.trim(); + final text = _normalizePagResource(value); if (text.isEmpty) { return 'empty'; } if (_isPagNetwork(text)) { return 'network'; } + if (_pagFilePath(text) != null) { + return 'file'; + } if (text.startsWith('sc_images/') || text.startsWith('assets/')) { return 'asset'; } @@ -509,10 +637,67 @@ String _pagSourceKind(String value) { } String _pagHost(String value) { - if (!_isPagNetwork(value)) { - return '-'; + return _pagNetworkUri(value)?.host ?? '-'; +} + +String _pagLoadAction(String value) { + return switch (_pagSourceKind(value)) { + 'network' => 'download', + 'file' => 'file load', + 'asset' => 'asset load', + _ => 'asset load', + }; +} + +String _normalizePagResource(String value) { + final normalized = normalizeImageResourceUrl(value.trim()); + return _pagNetworkUri(normalized)?.toString() ?? normalized; +} + +Uri? _pagNetworkUri(String value) { + final text = normalizeImageResourceUrl(value.trim()); + if (text.isEmpty) { + return null; } - return Uri.tryParse(value)?.host ?? '-'; + final direct = Uri.tryParse(text); + if (_isHttpUri(direct)) { + return direct; + } + final encoded = Uri.tryParse(Uri.encodeFull(text)); + if (_isHttpUri(encoded)) { + return encoded; + } + return null; +} + +bool _isHttpUri(Uri? uri) { + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + return false; + } + final scheme = uri.scheme.toLowerCase(); + return scheme == 'http' || scheme == 'https'; +} + +String? _pagFilePath(String value) { + final text = value.trim(); + if (text.isEmpty) { + return null; + } + final uri = Uri.tryParse(text); + if (uri != null && uri.scheme.toLowerCase() == 'file') { + try { + return uri.toFilePath(); + } catch (_) { + return null; + } + } + if (text.startsWith('/')) { + return text; + } + if (RegExp(r'^[A-Za-z]:[\\/]').hasMatch(text)) { + return text; + } + return null; } String _shortPagResource(String value) { diff --git a/test/room_rocket_pag_resource_test.dart b/test/room_rocket_pag_resource_test.dart new file mode 100644 index 0000000..87d5733 --- /dev/null +++ b/test/room_rocket_pag_resource_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yumi/app/config/app_config.dart'; +import 'package:yumi/app/constants/sc_global_config.dart'; +import 'package:yumi/ui_kit/widgets/room/rocket/room_rocket_pag_effect_overlay.dart'; + +void main() { + setUpAll(() { + AppConfig.initialize(); + }); + + test('normalizes unescaped pag network urls', () { + final uri = debugRoomRocketPagNetworkUri( + 'https://example.com/assets/火箭 输出/火箭1级.pag?name=火箭 1', + ); + + expect(uri, isNotNull); + expect(uri!.scheme, 'https'); + expect(uri.host, 'example.com'); + expect(uri.toString(), contains('%E7%81%AB%E7%AE%AD')); + expect(uri.toString(), contains('%20')); + }); + + test('normalizes protocol-relative and server-relative pag urls', () { + final apiUri = Uri.parse(SCGlobalConfig.apiHost); + final apiScheme = apiUri.scheme == 'http' ? 'http' : 'https'; + expect( + debugNormalizeRoomRocketPagResource('//cdn.example.com/rocket.pag'), + '$apiScheme://cdn.example.com/rocket.pag', + ); + expect( + debugNormalizeRoomRocketPagResource('/external/oss/local/火箭.pag'), + apiUri.resolve('/external/oss/local/火箭.pag').toString(), + ); + }); + + test('detects local file sources before falling back to assets', () { + expect( + debugRoomRocketPagFilePath('file:///tmp/%E7%81%AB%E7%AE%AD.pag'), + '/tmp/火箭.pag', + ); + expect(debugRoomRocketPagSourceKind('/tmp/rocket.pag'), 'file'); + expect(debugRoomRocketPagSourceKind('assets/rocket/rocket.pag'), 'asset'); + }); + + test('keeps pag bytes in memory for five minutes by default', () { + expect(debugRoomRocketPagMemoryTtl(), const Duration(minutes: 5)); + }); +}