mirror of
https://github.com/wgh136/pixes.git
synced 2025-09-27 12:57:24 +00:00
support animation illust
This commit is contained in:
@@ -99,6 +99,23 @@ class _IllustWidgetState extends State<IllustWidget> {
|
||||
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,
|
||||
|
265
lib/components/ugoira.dart
Normal file
265
lib/components/ugoira.dart
Normal file
@@ -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<UgoiraWidget> createState() => _UgoiraWidgetState();
|
||||
}
|
||||
|
||||
class _UgoiraWidgetState extends State<UgoiraWidget> {
|
||||
_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<ResponseBody>(
|
||||
_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<void> 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<int>(
|
||||
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;
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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,7 +101,10 @@ class _IllustPageState extends State<IllustPage> {
|
||||
imageWidth = imageWidth / scale;
|
||||
imageHeight = height;
|
||||
}
|
||||
var image = SizedBox(
|
||||
Widget image;
|
||||
|
||||
if(!widget.illust.isUgoira) {
|
||||
image = SizedBox(
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
child: GestureDetector(
|
||||
@@ -138,6 +142,14 @@ class _IllustPageState extends State<IllustPage> {
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
image = UgoiraWidget(
|
||||
id: widget.illust.id.toString(),
|
||||
previewImage: CachedImageProvider(widget.illust.images[index].large),
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
);
|
||||
}
|
||||
|
||||
return Center(
|
||||
child: image,
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user