Attachment rendering

This commit is contained in:
2025-04-23 00:07:20 +08:00
parent 8bb365c974
commit 36905e0cd5
18 changed files with 519 additions and 113 deletions

View File

@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/models/file.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:styled_widget/styled_widget.dart';
class CloudFileList extends StatelessWidget {
final List<SnCloudFile> files;
final double maxHeight;
const CloudFileList({super.key, required this.files, this.maxHeight = 360});
double calculateAspectRatio() {
double total = 0;
for (var ratio in files.map(
(e) =>
e.fileMeta?['ratio'] ??
((e.mimeType?.startsWith('image') ?? false) ? 1 : 16 / 9),
)) {
total += ratio;
}
return total / files.length;
}
@override
Widget build(BuildContext context) {
if (files.isEmpty) return const SizedBox.shrink();
if (files.length == 1) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: CloudFileWidget(item: files.first),
),
),
).padding(horizontal: 3);
}
final allImages =
!files.any(
(e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
);
if (allImages) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: CarouselView(
itemExtent: MediaQuery.of(context).size.width * 0.85,
itemSnapping: true,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
),
children: [for (final item in files) CloudFileWidget(item: item)],
),
),
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: files.length,
padding: EdgeInsets.symmetric(horizontal: 3),
itemBuilder: (context, index) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: CloudFileWidget(item: files[index]),
);
},
separatorBuilder: (_, __) => const Gap(8),
),
),
);
}
}

View File

@ -2,13 +2,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'image.dart';
import 'video.dart';
class CloudFileWidget extends ConsumerWidget {
final SnCloudFile item;
const CloudFileWidget({super.key, required this.item});
final BoxFit fit;
const CloudFileWidget({
super.key,
required this.item,
this.fit = BoxFit.cover,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -23,10 +29,36 @@ class CloudFileWidget extends ConsumerWidget {
case "video":
return AspectRatio(
aspectRatio: (item.fileMeta?['ratio'] ?? 16 / 9).toDouble(),
child: UniversalVideo(uri: uri),
child: UniversalVideo(
uri: uri,
aspectRatio: (item.fileMeta?['ratio'] ?? 16 / 9).toDouble(),
),
);
default:
return Placeholder();
}
}
}
class ProfilePictureWidget extends ConsumerWidget {
final SnCloudFile? item;
final double radius;
const ProfilePictureWidget({super.key, required this.item, this.radius = 24});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (item == null) return const SizedBox.shrink();
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(radius)),
child: Container(
width: radius * 2,
height: radius * 2,
color: Theme.of(context).colorScheme.primaryContainer,
child:
item == null
? Icon(MdiIcons.account)
: CloudFileWidget(item: item!),
),
);
}
}

View File

@ -1,33 +1,26 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
class UniversalImage extends StatelessWidget {
final String uri;
final String? blurHash;
const UniversalImage({super.key, required this.uri, this.blurHash});
final BoxFit fit;
const UniversalImage({
super.key,
required this.uri,
this.blurHash,
this.fit = BoxFit.cover,
});
@override
Widget build(BuildContext context) {
final params = {'src': uri, 'blur': blurHash};
if (Platform.isAndroid) {
return AndroidView(
viewType: 'native-image',
layoutDirection: TextDirection.ltr,
creationParams: params,
creationParamsCodec: const StandardMessageCodec(),
);
}
if (Platform.isIOS) {
// For iOS: Use UiKitView to embed a native iOS image view
return UiKitView(
viewType: 'native-image',
layoutDirection: TextDirection.ltr,
creationParams: params,
creationParamsCodec: const StandardMessageCodec(),
);
}
return Image.network(uri);
return Stack(
fit: StackFit.expand,
children: [
if (blurHash != null) BlurHash(hash: blurHash!),
CachedNetworkImage(imageUrl: uri, fit: fit),
],
);
}
}

View File

@ -3,15 +3,18 @@ import 'package:flutter/material.dart';
class UniversalImage extends StatelessWidget {
final String uri;
const UniversalImage({super.key, required this.uri});
final String? blurHash;
const UniversalImage({super.key, required this.uri, this.blurHash});
@override
Widget build(BuildContext context) {
return HtmlElementView(
viewType: 'native-image',
onPlatformViewCreated: (int viewId) {
final element = web.HTMLImageElement()..src = uri;
web.document.body!.append(element);
return HtmlElementView.fromTagName(
tagName: 'img',
onElementCreated: (element) {
element as web.HTMLImageElement;
element.src = uri;
element.style.width = '100%';
element.style.height = '100%';
},
);
}

View File

@ -1,65 +1,88 @@
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:island/widgets/alert.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
class UniversalVideo extends StatefulWidget {
final String uri;
const UniversalVideo({super.key, required this.uri});
final double aspectRatio;
const UniversalVideo({
super.key,
required this.uri,
this.aspectRatio = 16 / 9,
});
@override
State<UniversalVideo> createState() => _UniversalVideoState();
}
class _UniversalVideoState extends State<UniversalVideo> {
NativeVideoPlayerController? _controller;
bool _isPlaying = false;
Player? _player;
VideoController? _videoController;
Future<void> _togglePlayback() async {
final controller = _controller;
if (controller == null) return;
void _openVideo() async {
final url = widget.uri;
MediaKit.ensureInitialized();
if (_isPlaying) {
await controller.pause();
_player = Player();
_videoController = VideoController(_player!);
String? uri;
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) {
log('[MediaPlayer] Miss cache: $url');
final fileStream = DefaultCacheManager().getFileStream(
url,
// headers: {'Authorization': 'Bearer ${await ua.atk}'},
withProgress: true,
);
await for (var fileInfo in fileStream) {
if (fileInfo is FileInfo) {
uri = fileInfo.file.path;
break;
}
}
} else {
await controller.play();
uri = inCacheInfo.file.path;
log('[MediaPlayer] Hit cache: $url');
}
if (uri == null) {
showErrorAlert('Failed to open media... $url');
return;
}
final isPlaying = await controller.isPlaying();
setState(() {
_isPlaying = isPlaying;
});
_player!.open(Media(uri));
}
@override
void initState() {
super.initState();
_openVideo();
}
@override
void dispose() {
super.dispose();
_player?.dispose();
}
@override
Widget build(BuildContext context) {
if (Platform.isAndroid || Platform.isIOS) {
return Stack(
children: [
NativeVideoPlayerView(
onViewReady: (controller) async {
_controller = controller;
await controller.loadVideo(
VideoSource(path: widget.uri, type: VideoSourceType.network),
);
},
),
Material(
type: MaterialType.transparency,
child: InkWell(
onTap: _togglePlayback,
child: Center(
child: Icon(
_isPlaying ? Icons.pause : Icons.play_arrow,
size: 64,
color: Colors.white,
),
),
),
),
],
);
if (_videoController == null) {
return Center(child: CircularProgressIndicator());
}
return Image.network(widget.uri);
return Video(
controller: _videoController!,
aspectRatio: widget.aspectRatio,
controls:
!kIsWeb && (Platform.isAndroid || Platform.isIOS)
? MaterialVideoControls
: MaterialDesktopVideoControls,
);
}
}

View File

@ -7,12 +7,14 @@ class UniversalVideo extends StatelessWidget {
@override
Widget build(BuildContext context) {
return HtmlElementView(
viewType: 'native-video',
onPlatformViewCreated: (int viewId) {
final element = web.HTMLVideoElement()..src = uri;
return HtmlElementView.fromTagName(
tagName: 'video',
onElementCreated: (element) {
element as web.HTMLVideoElement;
element.src = uri;
element.style.width = '100%';
element.style.height = '100%';
element.controls = true;
web.document.body!.append(element);
},
);
}