mirror of
https://github.com/wgh136/pixes.git
synced 2025-09-27 21:07:24 +00:00
support animation illust
This commit is contained in:
@@ -99,6 +99,23 @@ class _IllustWidgetState extends State<IllustWidget> {
|
|||||||
style: TextStyle(fontSize: 12),),
|
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)
|
if(widget.illust.isR18)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 12,
|
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;
|
final int totalBookmarks;
|
||||||
bool isBookmarked;
|
bool isBookmarked;
|
||||||
final bool isAi;
|
final bool isAi;
|
||||||
|
final bool isUgoira;
|
||||||
|
|
||||||
bool get isR18 => tags.contains(const Tag("R-18", null));
|
bool get isR18 => tags.contains(const Tag("R-18", null));
|
||||||
|
|
||||||
@@ -226,7 +227,8 @@ class Illust {
|
|||||||
totalView = json['total_view'],
|
totalView = json['total_view'],
|
||||||
totalBookmarks = json['total_bookmarks'],
|
totalBookmarks = json['total_bookmarks'],
|
||||||
isBookmarked = json['is_bookmarked'],
|
isBookmarked = json['is_bookmarked'],
|
||||||
isAi = json['illust_ai_type'] == 2;
|
isAi = json['illust_ai_type'] == 2,
|
||||||
|
isUgoira = json['type'] == "ugoira";
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrendingTag {
|
class TrendingTag {
|
||||||
|
@@ -19,6 +19,7 @@ import 'package:pixes/utils/translation.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import '../components/md.dart';
|
import '../components/md.dart';
|
||||||
|
import '../components/ugoira.dart';
|
||||||
|
|
||||||
|
|
||||||
const _kBottomBarHeight = 64.0;
|
const _kBottomBarHeight = 64.0;
|
||||||
@@ -100,44 +101,55 @@ class _IllustPageState extends State<IllustPage> {
|
|||||||
imageWidth = imageWidth / scale;
|
imageWidth = imageWidth / scale;
|
||||||
imageHeight = height;
|
imageHeight = height;
|
||||||
}
|
}
|
||||||
var image = SizedBox(
|
Widget image;
|
||||||
width: imageWidth,
|
|
||||||
height: imageHeight,
|
if(!widget.illust.isUgoira) {
|
||||||
child: GestureDetector(
|
image = SizedBox(
|
||||||
onTap: () => ImagePage.show(downloadFile == null
|
width: imageWidth,
|
||||||
? widget.illust.images[index].original
|
height: imageHeight,
|
||||||
: "file://${downloadFile.path}"),
|
child: GestureDetector(
|
||||||
child: Image(
|
onTap: () => ImagePage.show(downloadFile == null
|
||||||
key: ValueKey(index),
|
? widget.illust.images[index].original
|
||||||
image: downloadFile == null
|
: "file://${downloadFile.path}"),
|
||||||
? CachedImageProvider(widget.illust.images[index].large) as ImageProvider
|
child: Image(
|
||||||
: FileImage(downloadFile) as ImageProvider,
|
key: ValueKey(index),
|
||||||
width: imageWidth,
|
image: downloadFile == null
|
||||||
fit: BoxFit.cover,
|
? CachedImageProvider(widget.illust.images[index].large) as ImageProvider
|
||||||
height: imageHeight,
|
: FileImage(downloadFile) as ImageProvider,
|
||||||
loadingBuilder: (context, child, loadingProgress) {
|
width: imageWidth,
|
||||||
if (loadingProgress == null) return child;
|
fit: BoxFit.cover,
|
||||||
double? value;
|
height: imageHeight,
|
||||||
if(loadingProgress.expectedTotalBytes != null) {
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
value = (loadingProgress.cumulativeBytesLoaded /
|
if (loadingProgress == null) return child;
|
||||||
loadingProgress.expectedTotalBytes!)*100;
|
double? value;
|
||||||
}
|
if(loadingProgress.expectedTotalBytes != null) {
|
||||||
if(value != null && (value > 100 || value < 0)) {
|
value = (loadingProgress.cumulativeBytesLoaded /
|
||||||
value = null;
|
loadingProgress.expectedTotalBytes!)*100;
|
||||||
}
|
}
|
||||||
return Center(
|
if(value != null && (value > 100 || value < 0)) {
|
||||||
child: SizedBox(
|
value = null;
|
||||||
width: 24,
|
}
|
||||||
height: 24,
|
return Center(
|
||||||
child: ProgressRing(
|
child: SizedBox(
|
||||||
value: value,
|
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(
|
return Center(
|
||||||
child: image,
|
child: image,
|
||||||
|
@@ -9,6 +9,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.1"
|
version: "6.0.1"
|
||||||
|
archive:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: ecf4273855368121b1caed0d10d4513c7241dfc813f7d3c8933b36622ae9b265
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.5.1"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@@ -54,6 +54,7 @@ dependencies:
|
|||||||
share_plus: ^9.0.0
|
share_plus: ^9.0.0
|
||||||
file_selector: ^1.0.1
|
file_selector: ^1.0.1
|
||||||
flutter_file_dialog: 3.0.1
|
flutter_file_dialog: 3.0.1
|
||||||
|
archive: ^3.5.1
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
Reference in New Issue
Block a user