✨ Native image, video rendering
This commit is contained in:
32
lib/widgets/content/cloud_files.dart
Normal file
32
lib/widgets/content/cloud_files.dart
Normal file
@ -0,0 +1,32 @@
|
||||
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 'image.dart';
|
||||
import 'video.dart';
|
||||
|
||||
class CloudFileWidget extends ConsumerWidget {
|
||||
final SnCloudFile item;
|
||||
const CloudFileWidget({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
final uri = '$serverUrl/files/${item.id}';
|
||||
switch (item.mimeType?.split('/').firstOrNull) {
|
||||
case "image":
|
||||
return AspectRatio(
|
||||
aspectRatio: (item.fileMeta?['ratio'] ?? 1).toDouble(),
|
||||
child: UniversalImage(uri: uri, blurHash: item.fileMeta?['blur']),
|
||||
);
|
||||
case "video":
|
||||
return AspectRatio(
|
||||
aspectRatio: (item.fileMeta?['ratio'] ?? 16 / 9).toDouble(),
|
||||
child: UniversalVideo(uri: uri),
|
||||
);
|
||||
default:
|
||||
return Placeholder();
|
||||
}
|
||||
}
|
||||
}
|
1
lib/widgets/content/image.dart
Normal file
1
lib/widgets/content/image.dart
Normal file
@ -0,0 +1 @@
|
||||
export 'image.native.dart' if (dart.library.html) 'image.web.dart';
|
33
lib/widgets/content/image.native.dart
Normal file
33
lib/widgets/content/image.native.dart
Normal file
@ -0,0 +1,33 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class UniversalImage extends StatelessWidget {
|
||||
final String uri;
|
||||
final String? blurHash;
|
||||
const UniversalImage({super.key, required this.uri, this.blurHash});
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
18
lib/widgets/content/image.web.dart
Normal file
18
lib/widgets/content/image.web.dart
Normal file
@ -0,0 +1,18 @@
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class UniversalImage extends StatelessWidget {
|
||||
final String uri;
|
||||
const UniversalImage({super.key, required this.uri});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HtmlElementView(
|
||||
viewType: 'native-image',
|
||||
onPlatformViewCreated: (int viewId) {
|
||||
final element = web.HTMLImageElement()..src = uri;
|
||||
web.document.body!.append(element);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
157
lib/widgets/content/markdown.dart
Normal file
157
lib/widgets/content/markdown.dart
Normal file
@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||
import 'package:flutter_highlight/theme_map.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_markdown_latex/flutter_markdown_latex.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:markdown/markdown.dart' as markdown;
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class MarkdownTextContent extends StatelessWidget {
|
||||
final String content;
|
||||
final bool isAutoWarp;
|
||||
final bool isEnlargeSticker;
|
||||
final TextScaler? textScaler;
|
||||
final Color? textColor;
|
||||
|
||||
const MarkdownTextContent({
|
||||
super.key,
|
||||
required this.content,
|
||||
this.isAutoWarp = false,
|
||||
this.isEnlargeSticker = false,
|
||||
this.textScaler,
|
||||
this.textColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Markdown(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: content,
|
||||
padding: EdgeInsets.zero,
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
||||
textScaler: textScaler,
|
||||
p:
|
||||
textColor != null
|
||||
? Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium!.copyWith(color: textColor)
|
||||
: null,
|
||||
blockquote: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
horizontalRuleDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(width: 1.0, color: Theme.of(context).dividerColor),
|
||||
),
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).dividerColor, width: 0.3),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
),
|
||||
code: GoogleFonts.robotoMono(height: 1),
|
||||
),
|
||||
builders: {'latex': LatexElementBuilder(), 'code': HighlightBuilder()},
|
||||
softLineBreak: true,
|
||||
extensionSet: markdown.ExtensionSet(
|
||||
<markdown.BlockSyntax>[
|
||||
...markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
|
||||
markdown.CodeBlockSyntax(),
|
||||
markdown.FencedCodeBlockSyntax(),
|
||||
LatexBlockSyntax(),
|
||||
],
|
||||
<markdown.InlineSyntax>[
|
||||
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes,
|
||||
if (isAutoWarp) markdown.LineBreakSyntax(),
|
||||
_UserNameCardInlineSyntax(),
|
||||
markdown.AutolinkSyntax(),
|
||||
markdown.AutolinkExtensionSyntax(),
|
||||
markdown.CodeSyntax(),
|
||||
LatexInlineSyntax(),
|
||||
],
|
||||
),
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href == null) return;
|
||||
await launchUrlString(href, mode: LaunchMode.externalApplication);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UserNameCardInlineSyntax extends markdown.InlineSyntax {
|
||||
_UserNameCardInlineSyntax() : super(r'@[a-zA-Z0-9_]+');
|
||||
|
||||
@override
|
||||
bool onMatch(markdown.InlineParser parser, Match match) {
|
||||
final alias = match[0]!;
|
||||
final anchor = markdown.Element.text('a', alias)
|
||||
..attributes['href'] = Uri.encodeFull(
|
||||
'solink://accounts/${alias.substring(1)}',
|
||||
);
|
||||
parser.addNode(anchor);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class HighlightBuilder extends MarkdownElementBuilder {
|
||||
@override
|
||||
Widget? visitElementAfterWithContext(
|
||||
BuildContext context,
|
||||
markdown.Element element,
|
||||
TextStyle? preferredStyle,
|
||||
TextStyle? parentStyle,
|
||||
) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
if (element.attributes['class'] == null &&
|
||||
!element.textContent.trim().contains('\n')) {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: 0.0, right: 4.0, bottom: 1.75, left: 4.0),
|
||||
margin: EdgeInsets.symmetric(horizontal: 2.0),
|
||||
color: Colors.black12,
|
||||
child: Text(
|
||||
element.textContent,
|
||||
style: GoogleFonts.robotoMono(textStyle: preferredStyle),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
var language = 'plaintext';
|
||||
final pattern = RegExp(r'^language-(.+)$');
|
||||
if (element.attributes['class'] != null &&
|
||||
pattern.hasMatch(element.attributes['class'] ?? '')) {
|
||||
language =
|
||||
pattern.firstMatch(element.attributes['class'] ?? '')?.group(1) ??
|
||||
'plaintext';
|
||||
}
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: HighlightView(
|
||||
element.textContent.trim(),
|
||||
language: language,
|
||||
theme: {
|
||||
...(isDark ? themeMap['a11y-dark']! : themeMap['a11y-light']!),
|
||||
'root':
|
||||
(isDark
|
||||
? TextStyle(
|
||||
backgroundColor: Colors.transparent,
|
||||
color: Color(0xfff8f8f2),
|
||||
)
|
||||
: TextStyle(
|
||||
backgroundColor: Colors.transparent,
|
||||
color: Color(0xff545454),
|
||||
)),
|
||||
},
|
||||
padding: EdgeInsets.all(12),
|
||||
textStyle: GoogleFonts.robotoMono(textStyle: preferredStyle),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
1
lib/widgets/content/video.dart
Normal file
1
lib/widgets/content/video.dart
Normal file
@ -0,0 +1 @@
|
||||
export 'video.native.dart' if (dart.library.html) 'video.web.dart';
|
65
lib/widgets/content/video.native.dart
Normal file
65
lib/widgets/content/video.native.dart
Normal file
@ -0,0 +1,65 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
|
||||
class UniversalVideo extends StatefulWidget {
|
||||
final String uri;
|
||||
const UniversalVideo({super.key, required this.uri});
|
||||
|
||||
@override
|
||||
State<UniversalVideo> createState() => _UniversalVideoState();
|
||||
}
|
||||
|
||||
class _UniversalVideoState extends State<UniversalVideo> {
|
||||
NativeVideoPlayerController? _controller;
|
||||
bool _isPlaying = false;
|
||||
|
||||
Future<void> _togglePlayback() async {
|
||||
final controller = _controller;
|
||||
if (controller == null) return;
|
||||
|
||||
if (_isPlaying) {
|
||||
await controller.pause();
|
||||
} else {
|
||||
await controller.play();
|
||||
}
|
||||
|
||||
final isPlaying = await controller.isPlaying();
|
||||
setState(() {
|
||||
_isPlaying = isPlaying;
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Image.network(widget.uri);
|
||||
}
|
||||
}
|
19
lib/widgets/content/video.web.dart
Normal file
19
lib/widgets/content/video.web.dart
Normal file
@ -0,0 +1,19 @@
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class UniversalVideo extends StatelessWidget {
|
||||
final String uri;
|
||||
const UniversalVideo({super.key, required this.uri});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HtmlElementView(
|
||||
viewType: 'native-video',
|
||||
onPlatformViewCreated: (int viewId) {
|
||||
final element = web.HTMLVideoElement()..src = uri;
|
||||
element.controls = true;
|
||||
web.document.body!.append(element);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
42
lib/widgets/post/post_item.dart
Normal file
42
lib/widgets/post/post_item.dart
Normal file
@ -0,0 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class PostItem extends StatelessWidget {
|
||||
final SnPost item;
|
||||
final EdgeInsets? padding;
|
||||
const PostItem({super.key, required this.item, this.padding});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final renderingPadding =
|
||||
padding ?? EdgeInsets.symmetric(horizontal: 12, vertical: 16);
|
||||
|
||||
return Padding(
|
||||
padding: renderingPadding,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Avatar...
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.publisher.name).bold(),
|
||||
if (item.content.isNotEmpty)
|
||||
MarkdownTextContent(content: item.content),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final attachment in item.attachments)
|
||||
CloudFileWidget(item: attachment),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user