✨ Attachment rendering
This commit is contained in:
81
lib/widgets/content/cloud_file_collection.dart
Normal file
81
lib/widgets/content/cloud_file_collection.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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!),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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%';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user