Native image, video rendering

This commit is contained in:
2025-04-22 01:12:56 +08:00
parent be08c7c806
commit 8bb365c974
26 changed files with 1047 additions and 17 deletions

View 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();
}
}
}

View File

@ -0,0 +1 @@
export 'image.native.dart' if (dart.library.html) 'image.web.dart';

View 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);
}
}

View 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);
},
);
}
}

View 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),
),
);
}
}
}

View File

@ -0,0 +1 @@
export 'video.native.dart' if (dart.library.html) 'video.web.dart';

View 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);
}
}

View 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);
},
);
}
}

View 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),
],
),
);
}
}