909 lines
27 KiB
Dart
909 lines
27 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:math';
|
||
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:chewie/chewie.dart';
|
||
import 'package:file_picker/file_picker.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:flutter_svg/flutter_svg.dart';
|
||
import 'package:open_filex/open_filex.dart';
|
||
import 'package:photo_view/photo_view.dart';
|
||
import 'package:share_plus/share_plus.dart';
|
||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||
import 'package:video_player/video_player.dart';
|
||
|
||
import '../model/account_data.dart';
|
||
import '../model/endpoint_data.dart';
|
||
import '../routing/app_routes.dart';
|
||
import '../share_intent/remote_file_ref.dart';
|
||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||
import 'app_progress_indicator.dart';
|
||
import 'centered_leading.dart';
|
||
import 'info_dialog.dart';
|
||
import 'share_position_origin.dart';
|
||
|
||
/// Nextcloud's `/index.php/core/preview` endpoint — returns a rasterized
|
||
/// thumbnail for any file the server has a preview provider for (images,
|
||
/// PDFs with the right backend, Office in some setups). Falls back to an
|
||
/// HTTP 404 when no preview is available, which lets [CachedNetworkImage]
|
||
/// trigger its `errorWidget`. Prefers `fileId` because the path variant
|
||
/// is unreliable on some server configurations.
|
||
String _ncPreviewUrl(RemoteFileRef remote, {int width = 1024}) {
|
||
final host = EndpointData().nextcloud().full();
|
||
final id = remote.fileId;
|
||
final selector = id != null
|
||
? 'fileId=$id'
|
||
: 'file=${Uri.encodeQueryComponent(remote.path)}';
|
||
return 'https://$host/index.php/core/preview?$selector&x=$width&y=-1&a=1';
|
||
}
|
||
|
||
class FileViewer extends StatefulWidget {
|
||
final String path;
|
||
final bool openExternal;
|
||
|
||
/// Enables in-app "An Chat senden" / "In Dateien speichern" — these
|
||
/// need a server-side reference instead of the local cache path.
|
||
final RemoteFileRef? remoteFile;
|
||
|
||
const FileViewer({
|
||
super.key,
|
||
required this.path,
|
||
this.openExternal = false,
|
||
this.remoteFile,
|
||
});
|
||
|
||
@override
|
||
State<FileViewer> createState() => _FileViewerState();
|
||
}
|
||
|
||
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
||
|
||
enum _FileKind { image, svg, pdf, text, video, audio, unknown }
|
||
|
||
const Set<String> _imageExtensions = {
|
||
'png',
|
||
'jpg',
|
||
'jpeg',
|
||
'webp',
|
||
'gif',
|
||
'bmp',
|
||
'wbmp',
|
||
};
|
||
|
||
const Set<String> _videoExtensions = {
|
||
'mp4',
|
||
'm4v',
|
||
'mov',
|
||
'webm',
|
||
'mkv',
|
||
'3gp',
|
||
};
|
||
|
||
/// ogg/opus/flac are Android-only; iOS init errors fall through to the
|
||
/// "format not supported" message.
|
||
const Set<String> _audioExtensions = {
|
||
'mp3',
|
||
'm4a',
|
||
'aac',
|
||
'wav',
|
||
'flac',
|
||
'ogg',
|
||
'oga',
|
||
'opus',
|
||
};
|
||
|
||
/// Unknown extensions still get a content sniff via [_looksLikeText].
|
||
const Set<String> _textExtensions = {
|
||
'txt', 'md', 'markdown', 'rst', 'log',
|
||
'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
|
||
'csv', 'tsv', 'tab',
|
||
'ini', 'conf', 'cfg', 'env', 'properties',
|
||
'html', 'htm', 'xhtml',
|
||
'css', 'scss', 'sass', 'less',
|
||
'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx',
|
||
'dart', 'java', 'kt', 'kts', 'groovy', 'scala', 'swift',
|
||
'py', 'rb', 'pl', 'lua', 'r',
|
||
'go', 'rs', 'zig',
|
||
'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'm', 'mm',
|
||
'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||
'sql', 'graphql', 'gql',
|
||
'gitignore', 'gitattributes', 'editorconfig', 'dockerignore',
|
||
'dockerfile', 'makefile', 'cmake',
|
||
'tex', 'bib',
|
||
'srt', 'vtt',
|
||
};
|
||
|
||
/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify.
|
||
Future<bool> _looksLikeText(String path) async {
|
||
final file = File(path);
|
||
RandomAccessFile? raf;
|
||
try {
|
||
final length = await file.length();
|
||
if (length == 0) return true;
|
||
raf = await file.open();
|
||
final sample = await raf.read(min(length, 8192));
|
||
if (sample.contains(0)) return false;
|
||
utf8.decode(sample);
|
||
return true;
|
||
} on Object {
|
||
return false;
|
||
} finally {
|
||
await raf?.close();
|
||
}
|
||
}
|
||
|
||
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
|
||
/// animation. Defer until the route enter animation completes.
|
||
class _DeferredPdfViewer extends StatefulWidget {
|
||
const _DeferredPdfViewer({required this.path});
|
||
final String path;
|
||
|
||
@override
|
||
State<_DeferredPdfViewer> createState() => _DeferredPdfViewerState();
|
||
}
|
||
|
||
class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
|
||
bool _ready = false;
|
||
Animation<double>? _routeAnimation;
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
if (_ready || _routeAnimation != null) return;
|
||
final animation = ModalRoute.of(context)?.animation;
|
||
if (animation == null || animation.isCompleted) {
|
||
_ready = true;
|
||
return;
|
||
}
|
||
_routeAnimation = animation..addStatusListener(_onAnimationStatus);
|
||
}
|
||
|
||
void _onAnimationStatus(AnimationStatus status) {
|
||
if (status == AnimationStatus.completed && mounted) {
|
||
setState(() => _ready = true);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_routeAnimation?.removeStatusListener(_onAnimationStatus);
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (!_ready) {
|
||
return const Center(child: AppProgressIndicator.large());
|
||
}
|
||
return SfPdfViewer.file(File(widget.path));
|
||
}
|
||
}
|
||
|
||
class _FileViewerState extends State<FileViewer> {
|
||
final PhotoViewController photoViewController = PhotoViewController();
|
||
|
||
late SettingsCubit settings = context.read<SettingsCubit>();
|
||
late bool openExternal;
|
||
Future<_FileKind>? _fileKind;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
openExternal =
|
||
settings.val().fileViewSettings.alwaysOpenExternally ||
|
||
widget.openExternal;
|
||
if (openExternal) {
|
||
WidgetsBinding.instance.addPostFrameCallback(
|
||
(_) => _openExternallyAndPop(),
|
||
);
|
||
} else {
|
||
_fileKind = _detectKind();
|
||
}
|
||
}
|
||
|
||
Future<void> _openExternallyAndPop() async {
|
||
final result = await OpenFilex.open(widget.path);
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop();
|
||
if (result.type != ResultType.done) {
|
||
InfoDialog.show(context, result.message);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
photoViewController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<_FileKind> _detectKind() async {
|
||
final ext = widget.path.split('.').last.toLowerCase();
|
||
if (_imageExtensions.contains(ext)) return _FileKind.image;
|
||
if (ext == 'svg') return _FileKind.svg;
|
||
if (ext == 'pdf') return _FileKind.pdf;
|
||
if (_videoExtensions.contains(ext)) return _FileKind.video;
|
||
if (_audioExtensions.contains(ext)) return _FileKind.audio;
|
||
if (_textExtensions.contains(ext)) return _FileKind.text;
|
||
if (await _looksLikeText(widget.path)) return _FileKind.text;
|
||
return _FileKind.unknown;
|
||
}
|
||
|
||
Future<void> _handleAction(FileViewingActions value) async {
|
||
switch (value) {
|
||
case FileViewingActions.openExternal:
|
||
AppRoutes.openFileViewer(
|
||
context,
|
||
widget.path,
|
||
openExternal: true,
|
||
remoteFile: widget.remoteFile,
|
||
);
|
||
break;
|
||
case FileViewingActions.sendToChat:
|
||
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
|
||
break;
|
||
case FileViewingActions.saveToCloud:
|
||
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
|
||
break;
|
||
case FileViewingActions.share:
|
||
unawaited(
|
||
SharePlus.instance.share(
|
||
ShareParams(
|
||
files: [XFile(widget.path)],
|
||
sharePositionOrigin: SharePositionOrigin.get(context),
|
||
),
|
||
),
|
||
);
|
||
break;
|
||
case FileViewingActions.save:
|
||
try {
|
||
final source = File(widget.path);
|
||
final size = await source.length();
|
||
// file_picker has no path/stream save API, so the whole file
|
||
// gets loaded into RAM. Cap big media; user falls back to share.
|
||
const maxBytes = 200 * 1024 * 1024;
|
||
if (size > maxBytes) {
|
||
if (!mounted) return;
|
||
InfoDialog.show(
|
||
context,
|
||
'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), '
|
||
'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.',
|
||
title: 'Speichern nicht möglich',
|
||
);
|
||
return;
|
||
}
|
||
final bytes = await source.readAsBytes();
|
||
final saved = await FilePicker.saveFile(
|
||
fileName: widget.path.split('/').last,
|
||
bytes: bytes,
|
||
);
|
||
if (!mounted) return;
|
||
if (saved != null) {
|
||
InfoDialog.show(context, 'Datei gespeichert.');
|
||
}
|
||
} on Object catch (e) {
|
||
if (!mounted) return;
|
||
InfoDialog.show(
|
||
context,
|
||
'Speichern fehlgeschlagen: $e',
|
||
copyable: true,
|
||
title: 'Fehler',
|
||
);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
List<_ActionDescriptor> _availableActions() => [
|
||
_ActionDescriptor(
|
||
action: FileViewingActions.openExternal,
|
||
icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
|
||
label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
|
||
),
|
||
if (widget.remoteFile != null) ...[
|
||
const _ActionDescriptor(
|
||
action: FileViewingActions.sendToChat,
|
||
icon: Icons.chat_bubble_outline,
|
||
label: 'An Talk-Chat senden',
|
||
),
|
||
const _ActionDescriptor(
|
||
action: FileViewingActions.saveToCloud,
|
||
icon: Icons.cloud_outlined,
|
||
label: 'In Cloud speichern',
|
||
),
|
||
],
|
||
const _ActionDescriptor(
|
||
action: FileViewingActions.share,
|
||
icon: Icons.share_outlined,
|
||
label: 'Teilen',
|
||
),
|
||
const _ActionDescriptor(
|
||
action: FileViewingActions.save,
|
||
icon: Icons.save_alt_outlined,
|
||
label: 'Speichern',
|
||
),
|
||
];
|
||
|
||
AppBar _appbar({
|
||
List<Widget> actions = const [],
|
||
bool showActionsMenu = true,
|
||
}) => AppBar(
|
||
title: Text(widget.path.split('/').last),
|
||
actions: [
|
||
...actions,
|
||
if (showActionsMenu)
|
||
PopupMenuButton<FileViewingActions>(
|
||
onSelected: _handleAction,
|
||
itemBuilder: (context) => _availableActions()
|
||
.map(
|
||
(a) => PopupMenuItem(
|
||
value: a.action,
|
||
child: ListTile(
|
||
leading: Icon(a.icon),
|
||
title: Text(a.label),
|
||
dense: true,
|
||
),
|
||
),
|
||
)
|
||
.toList(),
|
||
),
|
||
],
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (openExternal) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: Text(widget.path.split('/').last)),
|
||
body: const Center(child: AppProgressIndicator.large()),
|
||
);
|
||
}
|
||
return FutureBuilder<_FileKind>(
|
||
future: _fileKind,
|
||
builder: (context, snapshot) {
|
||
if (!snapshot.hasData) {
|
||
return Scaffold(
|
||
appBar: _appbar(),
|
||
body: const Center(child: AppProgressIndicator.large()),
|
||
);
|
||
}
|
||
switch (snapshot.data!) {
|
||
case _FileKind.image:
|
||
return _buildImageView();
|
||
case _FileKind.svg:
|
||
return _buildSvgView();
|
||
case _FileKind.pdf:
|
||
return _buildPdfView();
|
||
case _FileKind.video:
|
||
return _buildVideoView();
|
||
case _FileKind.audio:
|
||
return _buildAudioView();
|
||
case _FileKind.text:
|
||
return _buildTextView();
|
||
case _FileKind.unknown:
|
||
return _buildUnknownView();
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildImageView() => Scaffold(
|
||
appBar: _appbar(
|
||
actions: [
|
||
IconButton(
|
||
onPressed: () {
|
||
setState(() {
|
||
photoViewController.rotation += pi / 2;
|
||
});
|
||
},
|
||
icon: const Icon(Icons.rotate_right),
|
||
),
|
||
],
|
||
),
|
||
backgroundColor: Colors.white,
|
||
body: PhotoView(
|
||
controller: photoViewController,
|
||
maxScale: 3.0,
|
||
minScale: 0.1,
|
||
imageProvider: Image.file(File(widget.path)).image,
|
||
backgroundDecoration: BoxDecoration(
|
||
color: Theme.of(context).colorScheme.surface,
|
||
),
|
||
),
|
||
);
|
||
|
||
Widget _buildSvgView() => Scaffold(
|
||
appBar: _appbar(),
|
||
backgroundColor: Colors.white,
|
||
body: InteractiveViewer(
|
||
minScale: 0.5,
|
||
maxScale: 8,
|
||
child: Center(
|
||
child: SvgPicture.file(
|
||
File(widget.path),
|
||
placeholderBuilder: (_) =>
|
||
const Center(child: AppProgressIndicator.large()),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
Widget _buildPdfView() =>
|
||
Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path));
|
||
|
||
Widget _buildVideoView() => Scaffold(
|
||
appBar: _appbar(),
|
||
backgroundColor: Colors.black,
|
||
body: _MediaPlayer(path: widget.path, isAudio: false),
|
||
);
|
||
|
||
Widget _buildAudioView() => Scaffold(
|
||
appBar: _appbar(),
|
||
body: _MediaPlayer(
|
||
path: widget.path,
|
||
isAudio: true,
|
||
filename: widget.path.split('/').last,
|
||
),
|
||
);
|
||
|
||
Widget _buildTextView() => Scaffold(
|
||
appBar: _appbar(),
|
||
body: FutureBuilder<_TextPayload>(
|
||
future: _readTextPayload(),
|
||
builder: (context, snapshot) {
|
||
if (!snapshot.hasData) {
|
||
return const Center(child: AppProgressIndicator.large());
|
||
}
|
||
final payload = snapshot.data!;
|
||
final lines = const LineSplitter().convert(payload.content);
|
||
// Stable gutter width — sized by the highest line number's digit count.
|
||
final gutterWidth = (lines.length.toString().length * 9.0) + 16;
|
||
return SelectionArea(
|
||
child: Scrollbar(
|
||
child: CustomScrollView(
|
||
slivers: [
|
||
if (payload.truncated)
|
||
SliverToBoxAdapter(
|
||
child: SelectionContainer.disabled(
|
||
child: Container(
|
||
width: double.infinity,
|
||
color: Theme.of(
|
||
context,
|
||
).colorScheme.surfaceContainerHigh,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 16,
|
||
vertical: 8,
|
||
),
|
||
child: Text(
|
||
'Datei ist groß — Anzeige auf die ersten ${(_textViewMaxBytes / 1024).round()} KB begrenzt.',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SliverList.builder(
|
||
itemCount: lines.length,
|
||
itemBuilder: (context, i) => _CodeLine(
|
||
number: i + 1,
|
||
text: lines[i],
|
||
gutterWidth: gutterWidth,
|
||
),
|
||
),
|
||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
|
||
Widget _buildUnknownView() => Scaffold(
|
||
appBar: _appbar(showActionsMenu: false),
|
||
body: _buildUnknownPlaceholder(),
|
||
);
|
||
|
||
Widget _buildUnknownPlaceholder() {
|
||
final theme = Theme.of(context);
|
||
final descriptors = _availableActions();
|
||
return ListView(
|
||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
child: Column(
|
||
children: [
|
||
_UnknownPreviewBlock(remoteFile: widget.remoteFile),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
widget.path.split('/').last,
|
||
style: theme.textTheme.titleSmall?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Wähle eine Aktion, um mit der Datei weiterzuarbeiten.',
|
||
style: theme.textTheme.bodySmall,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
...descriptors.map(
|
||
(d) => ListTile(
|
||
leading: CenteredLeading(Icon(d.icon)),
|
||
title: Text(d.label),
|
||
onTap: () => _handleAction(d.action),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
static const int _textViewMaxBytes = 5 * 1024 * 1024;
|
||
|
||
Future<_TextPayload> _readTextPayload() async {
|
||
final file = File(widget.path);
|
||
final size = await file.length();
|
||
final ext = widget.path.split('.').last.toLowerCase();
|
||
if (size <= _textViewMaxBytes) {
|
||
final raw = await file.readAsString();
|
||
return _TextPayload(content: _maybePrettify(raw, ext), truncated: false);
|
||
}
|
||
final raf = await file.open();
|
||
try {
|
||
final bytes = await raf.read(_textViewMaxBytes);
|
||
// Truncated payloads stay raw — a parser would choke on the dangling tail.
|
||
return _TextPayload(
|
||
content: utf8.decode(bytes, allowMalformed: true),
|
||
truncated: true,
|
||
);
|
||
} finally {
|
||
await raf.close();
|
||
}
|
||
}
|
||
|
||
/// Falls through to the original text on parse errors.
|
||
String _maybePrettify(String content, String ext) {
|
||
if (ext != 'json') return content;
|
||
try {
|
||
final parsed = jsonDecode(content);
|
||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||
} on Object {
|
||
return content;
|
||
}
|
||
}
|
||
}
|
||
|
||
class _ActionDescriptor {
|
||
final FileViewingActions action;
|
||
final IconData icon;
|
||
final String label;
|
||
const _ActionDescriptor({
|
||
required this.action,
|
||
required this.icon,
|
||
required this.label,
|
||
});
|
||
}
|
||
|
||
class _TextPayload {
|
||
final String content;
|
||
final bool truncated;
|
||
const _TextPayload({required this.content, required this.truncated});
|
||
}
|
||
|
||
/// Header block for the "Vorschau nicht verfügbar" screen.
|
||
///
|
||
/// Two visual modes — kept layout-equivalent so the screen looks identical
|
||
/// whether the server already said "no preview" or the probe failed late:
|
||
/// * **No preview available** (server said no, no remoteFile, or probe
|
||
/// errored): compact "file icon + 'Vorschau nicht verfügbar' text".
|
||
/// * **Preview rendering / loaded**: mid-sized thumbnail without text.
|
||
class _UnknownPreviewBlock extends StatefulWidget {
|
||
final RemoteFileRef? remoteFile;
|
||
const _UnknownPreviewBlock({required this.remoteFile});
|
||
|
||
@override
|
||
State<_UnknownPreviewBlock> createState() => _UnknownPreviewBlockState();
|
||
}
|
||
|
||
class _UnknownPreviewBlockState extends State<_UnknownPreviewBlock> {
|
||
static const double _previewSize = 180;
|
||
bool _failed = false;
|
||
|
||
Widget _compact(ThemeData theme) => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.insert_drive_file_outlined, size: 60),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Vorschau nicht verfügbar',
|
||
style: theme.textTheme.titleMedium,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final remote = widget.remoteFile;
|
||
final canProbe =
|
||
remote != null &&
|
||
remote.hasPreview != false &&
|
||
remote.fileId != null &&
|
||
!_failed;
|
||
if (!canProbe) return _compact(theme);
|
||
return SizedBox(
|
||
width: _previewSize,
|
||
height: _previewSize,
|
||
child: CachedNetworkImage(
|
||
httpHeaders: AccountData().authHeaders(),
|
||
imageUrl: _ncPreviewUrl(remote, width: 360),
|
||
fadeInDuration: Duration.zero,
|
||
fadeOutDuration: Duration.zero,
|
||
// Late probe failure: re-render into the compact layout so the
|
||
// screen doesn't keep a 180×180 box around a tiny icon. Deferred
|
||
// to the next frame because setState during build is illegal.
|
||
errorListener: (_) {
|
||
if (!mounted) return;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) setState(() => _failed = true);
|
||
});
|
||
},
|
||
placeholder: (_, _) =>
|
||
const Center(child: AppProgressIndicator.large()),
|
||
// Briefly empty while the post-frame setState swaps layouts.
|
||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||
imageBuilder: (_, imageProvider) => ClipRRect(
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: Image(
|
||
image: imageProvider,
|
||
fit: BoxFit.contain,
|
||
width: _previewSize,
|
||
height: _previewSize,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MediaPlayer extends StatefulWidget {
|
||
final String path;
|
||
final bool isAudio;
|
||
final String? filename;
|
||
const _MediaPlayer({
|
||
required this.path,
|
||
required this.isAudio,
|
||
this.filename,
|
||
});
|
||
|
||
@override
|
||
State<_MediaPlayer> createState() => _MediaPlayerState();
|
||
}
|
||
|
||
class _MediaPlayerState extends State<_MediaPlayer> {
|
||
VideoPlayerController? _video;
|
||
ChewieController? _chewie;
|
||
Object? _initError;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_initialize();
|
||
}
|
||
|
||
Future<void> _initialize() async {
|
||
final controller = VideoPlayerController.file(File(widget.path));
|
||
try {
|
||
await controller.initialize();
|
||
} on Object catch (e) {
|
||
await controller.dispose();
|
||
if (!mounted) return;
|
||
setState(() => _initError = e);
|
||
return;
|
||
}
|
||
if (!mounted) {
|
||
await controller.dispose();
|
||
return;
|
||
}
|
||
if (widget.isAudio) {
|
||
controller.addListener(_onAudioTick);
|
||
setState(() => _video = controller);
|
||
} else {
|
||
setState(() {
|
||
_video = controller;
|
||
_chewie = ChewieController(
|
||
videoPlayerController: controller,
|
||
autoPlay: false,
|
||
looping: false,
|
||
allowFullScreen: true,
|
||
allowPlaybackSpeedChanging: true,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
void _onAudioTick() {
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_video?.removeListener(_onAudioTick);
|
||
_chewie?.dispose();
|
||
_video?.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_initError != null) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.error_outline, size: 48),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
widget.isAudio
|
||
? 'Audio kann nicht abgespielt werden'
|
||
: 'Video kann nicht abgespielt werden',
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Format wird auf diesem Gerät nicht unterstützt. Über das Menü kannst du die Datei in einer anderen App öffnen.',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
if (_video == null) {
|
||
return const Center(child: AppProgressIndicator.large());
|
||
}
|
||
if (widget.isAudio) {
|
||
return _AudioControls(
|
||
controller: _video!,
|
||
filename: widget.filename ?? '',
|
||
);
|
||
}
|
||
return Chewie(controller: _chewie!);
|
||
}
|
||
}
|
||
|
||
class _AudioControls extends StatelessWidget {
|
||
final VideoPlayerController controller;
|
||
final String filename;
|
||
const _AudioControls({required this.controller, required this.filename});
|
||
|
||
String _format(Duration d) {
|
||
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||
if (d.inHours > 0) return '${d.inHours}:$m:$s';
|
||
return '$m:$s';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final value = controller.value;
|
||
final duration = value.duration;
|
||
final position = value.position;
|
||
final maxMs = duration.inMilliseconds == 0 ? 1 : duration.inMilliseconds;
|
||
final posMs = position.inMilliseconds.clamp(0, maxMs).toDouble();
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.audiotrack,
|
||
size: 96,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
const SizedBox(height: 24),
|
||
Text(
|
||
filename,
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 32),
|
||
Slider(
|
||
min: 0,
|
||
max: maxMs.toDouble(),
|
||
value: posMs,
|
||
onChanged: (v) =>
|
||
controller.seekTo(Duration(milliseconds: v.toInt())),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
_format(position),
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
Text(
|
||
_format(duration),
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
FloatingActionButton(
|
||
heroTag: 'audioPlayPause',
|
||
onPressed: () {
|
||
if (value.isPlaying) {
|
||
controller.pause();
|
||
} else {
|
||
controller.play();
|
||
}
|
||
},
|
||
child: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CodeLine extends StatelessWidget {
|
||
final int number;
|
||
final String text;
|
||
final double gutterWidth;
|
||
const _CodeLine({
|
||
required this.number,
|
||
required this.text,
|
||
required this.gutterWidth,
|
||
});
|
||
|
||
static const TextStyle _codeStyle = TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 13,
|
||
height: 1.4,
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final isEven = number.isEven;
|
||
return Container(
|
||
color: isEven ? theme.colorScheme.surfaceContainerLow : null,
|
||
padding: const EdgeInsets.only(left: 4, right: 12),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SelectionContainer.disabled(
|
||
child: SizedBox(
|
||
width: gutterWidth,
|
||
child: Text(
|
||
'$number',
|
||
textAlign: TextAlign.right,
|
||
style: _codeStyle.copyWith(color: theme.hintColor),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: Text(text.isEmpty ? ' ' : text, style: _codeStyle)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|