diff --git a/lib/components/illust_widget.dart b/lib/components/illust_widget.dart index 9fc3b2a..2c9d0b6 100644 --- a/lib/components/illust_widget.dart +++ b/lib/components/illust_widget.dart @@ -99,6 +99,23 @@ class _IllustWidgetState extends State { style: TextStyle(fontSize: 12),), )), ), + if(widget.illust.isUgoira) + Positioned( + bottom: 12, + left: 12, + child: Container( + width: 28, + height: 20, + decoration: BoxDecoration( + color: ColorScheme.of(context).primaryContainer.withOpacity(0.8), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: ColorScheme.of(context).outlineVariant, width: 0.6), + ), + child: const Center( + child: Text("GIF", + style: TextStyle(fontSize: 12),), + )), + ), if(widget.illust.isR18) Positioned( bottom: 12, diff --git a/lib/components/ugoira.dart b/lib/components/ugoira.dart new file mode 100644 index 0000000..c19021a --- /dev/null +++ b/lib/components/ugoira.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:archive/archive_io.dart'; + +import 'package:crypto/crypto.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:intl/intl.dart'; +import 'package:pixes/components/md.dart'; +import 'package:pixes/network/network.dart'; + +import '../foundation/cache_manager.dart'; +import '../network/app_dio.dart'; +import 'dart:ui' as ui; + +class UgoiraWidget extends StatefulWidget { + const UgoiraWidget({super.key, required this.id, required this.previewImage, + required this.width, required this.height}); + + final String id; + + final ImageProvider previewImage; + + final double width; + + final double height; + + @override + State createState() => _UgoiraWidgetState(); +} + +class _UgoiraWidgetState extends State { + _UgoiraMetadata? _metadata; + + bool _loading = false; + + bool _finished = false; + + bool _error = false; + + int expectedBytes = 1; + + int receivedBytes = 0; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + height: widget.height, + child: !_finished + ? buildPreview() + : _UgoiraAnimation(metadata: _metadata!, key: Key(widget.id),), + ); + } + + Widget buildPreview() { + return Stack( + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image( + image: widget.previewImage, + fit: BoxFit.cover, + ), + ), + ), + if(_error) + const Positioned.fill( + child: Center( + child: Icon( + MdIcons.error_outline, + size: 36, + ), + )), + if(!_loading) + Positioned.fill( + child: GestureDetector( + onTap: load, + child: const Center( + child: Icon( + MdIcons.play_circle_outline, + size: 36, + ), + ), + ), + ) + else + Center( + child: ProgressRing(value: (receivedBytes / expectedBytes) * 100,), + ), + ], + ); + } + + void load() async { + setState(() { + _loading = true; + }); + var res0 = await Network().apiGet('/v1/ugoira/metadata?illust_id=${widget.id}'); + if(res0.error) { + setState(() { + _error = true; + _loading = false; + }); + return; + } + var json = res0.data; + _metadata = _UgoiraMetadata( + url: json["ugoira_metadata"]["zip_urls"]["medium"], + frames: (json["ugoira_metadata"]["frames"] as List).map<_UgoiraFrame>((e) => _UgoiraFrame( + delay: e["delay"], + fileName: e["file"], + )).toList(), + ); + try { + var key = "ugoira_${widget.id}"; + var cached = await CacheManager().findCache(key); + if(cached != null) { + await extract(cached); + return; + } + var dio = AppDio(); + final time = DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now()); + final hash = md5.convert(utf8.encode(time + Network.hashSalt)).toString(); + var res = await dio.get( + _metadata!.url, + options: Options( + responseType: ResponseType.stream, + validateStatus: (status) => status != null && status < 500, + headers: { + "referer": "https://app-api.pixiv.net/", + "user-agent": "PixivAndroidApp/5.0.234 (Android 14; Pixes)", + "x-client-time": time, + "x-client-hash": hash, + "accept-enconding": "gzip", + } + ) + ); + if(res.statusCode != 200) { + throw "Failed to load image: ${res.statusCode}"; + } + expectedBytes = int.parse(res.headers.value("content-length") ?? "1"); + var cachingFile = await CacheManager().openWrite(key); + await for (var chunk in res.data!.stream) { + await cachingFile.writeBytes(chunk); + setState(() { + receivedBytes += chunk.length; + if(receivedBytes > expectedBytes) { + expectedBytes = receivedBytes + 1; + } + }); + } + await cachingFile.close(); + await extract(cachingFile.file.path); + } + catch(e) { + setState(() { + _error = true; + _loading = false; + }); + return; + } + } + + Future extract(String filePath) async{ + var zip = ZipDecoder().decodeBytes(await File(filePath).readAsBytes()); + for(var file in zip) { + if(file.isFile) { + var frame = _metadata!.frames.firstWhere((element) => element.fileName == file.name); + frame.data = await decodeImageFromList(file.content); + } + } + zip.clear(); + setState(() { + _loading = false; + _finished = true; + }); + } +} + + +class _UgoiraAnimation extends StatefulWidget { + const _UgoiraAnimation({super.key, required this.metadata}); + + final _UgoiraMetadata metadata; + + @override + State<_UgoiraAnimation> createState() => _UgoiraAnimationState(); +} + +class _UgoiraAnimationState extends State<_UgoiraAnimation> with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + final totalDuration = widget.metadata.frames.fold( + 0, (previousValue, element) => previousValue + element.delay); + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: totalDuration), + value: 0, + lowerBound: 0, + upperBound: widget.metadata.frames.length.toDouble(), + ); + _controller.repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final frame = widget.metadata.frames[_controller.value.toInt()]; + return CustomPaint( + painter: _ImagePainter(frame.data!), + ); + }, + ); + } +} + +class _UgoiraMetadata { + final String url; + final List<_UgoiraFrame> frames; + + _UgoiraMetadata({required this.url, required this.frames}); +} + +class _UgoiraFrame { + final int delay; + final String fileName; + ui.Image? data; + + _UgoiraFrame({required this.delay, required this.fileName}); +} + +class _ImagePainter extends CustomPainter { + final ui.Image data; + + _ImagePainter(this.data); + + @override + void paint(Canvas canvas, Size size) { + // 覆盖整个画布 + Rect rect = Offset.zero & size; + canvas.drawImageRect( + data, + Rect.fromLTRB(0, 0, data.width.toDouble(), data.height.toDouble()), + rect, + Paint()..filterQuality = FilterQuality.medium + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return data != (oldDelegate as _ImagePainter).data; + } +} diff --git a/lib/network/models.dart b/lib/network/models.dart index 168feff..3e42d59 100644 --- a/lib/network/models.dart +++ b/lib/network/models.dart @@ -181,6 +181,7 @@ class Illust { final int totalBookmarks; bool isBookmarked; final bool isAi; + final bool isUgoira; bool get isR18 => tags.contains(const Tag("R-18", null)); @@ -226,7 +227,8 @@ class Illust { totalView = json['total_view'], totalBookmarks = json['total_bookmarks'], isBookmarked = json['is_bookmarked'], - isAi = json['illust_ai_type'] == 2; + isAi = json['illust_ai_type'] == 2, + isUgoira = json['type'] == "ugoira"; } class TrendingTag { diff --git a/lib/pages/illust_page.dart b/lib/pages/illust_page.dart index 3f51c8e..2be5286 100644 --- a/lib/pages/illust_page.dart +++ b/lib/pages/illust_page.dart @@ -19,6 +19,7 @@ import 'package:pixes/utils/translation.dart'; import 'package:share_plus/share_plus.dart'; import '../components/md.dart'; +import '../components/ugoira.dart'; const _kBottomBarHeight = 64.0; @@ -100,44 +101,55 @@ class _IllustPageState extends State { imageWidth = imageWidth / scale; imageHeight = height; } - var image = SizedBox( - width: imageWidth, - height: imageHeight, - child: GestureDetector( - onTap: () => ImagePage.show(downloadFile == null - ? widget.illust.images[index].original - : "file://${downloadFile.path}"), - child: Image( - key: ValueKey(index), - image: downloadFile == null - ? CachedImageProvider(widget.illust.images[index].large) as ImageProvider - : FileImage(downloadFile) as ImageProvider, - width: imageWidth, - fit: BoxFit.cover, - height: imageHeight, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - double? value; - if(loadingProgress.expectedTotalBytes != null) { - value = (loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes!)*100; - } - if(value != null && (value > 100 || value < 0)) { - value = null; - } - return Center( - child: SizedBox( - width: 24, - height: 24, - child: ProgressRing( - value: value, - ), - ), - ); - } + Widget image; + + if(!widget.illust.isUgoira) { + image = SizedBox( + width: imageWidth, + height: imageHeight, + child: GestureDetector( + onTap: () => ImagePage.show(downloadFile == null + ? widget.illust.images[index].original + : "file://${downloadFile.path}"), + child: Image( + key: ValueKey(index), + image: downloadFile == null + ? CachedImageProvider(widget.illust.images[index].large) as ImageProvider + : FileImage(downloadFile) as ImageProvider, + width: imageWidth, + fit: BoxFit.cover, + height: imageHeight, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + double? value; + if(loadingProgress.expectedTotalBytes != null) { + value = (loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes!)*100; + } + if(value != null && (value > 100 || value < 0)) { + value = null; + } + return Center( + child: SizedBox( + width: 24, + height: 24, + child: ProgressRing( + value: value, + ), + ), + ); + } + ), ), - ), - ); + ); + } else { + image = UgoiraWidget( + id: widget.illust.id.toString(), + previewImage: CachedImageProvider(widget.illust.images[index].large), + width: imageWidth, + height: imageHeight, + ); + } return Center( child: image, diff --git a/pubspec.lock b/pubspec.lock index b968c63..787047a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.1" + archive: + dependency: "direct main" + description: + name: archive + sha256: ecf4273855368121b1caed0d10d4513c7241dfc813f7d3c8933b36622ae9b265 + url: "https://pub.dev" + source: hosted + version: "3.5.1" async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e033ee7..2ab658a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: share_plus: ^9.0.0 file_selector: ^1.0.1 flutter_file_dialog: 3.0.1 + archive: ^3.5.1 dev_dependencies: flutter_test: sdk: flutter