overhauled file viewer with video, audio, text, and SVG support, added media player and line-numbered text views, and fixed search controller recursion
This commit is contained in:
@@ -33,7 +33,7 @@ class FilesSearchController extends ChangeNotifier {
|
|||||||
/// on a disposed `ChangeNotifier`.
|
/// on a disposed `ChangeNotifier`.
|
||||||
void _safeNotify() {
|
void _safeNotify() {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_safeNotify();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
String get query => _query;
|
String get query => _query;
|
||||||
|
|||||||
+631
-76
@@ -1,20 +1,25 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:chewie/chewie.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
import '../share_intent/remote_file_ref.dart';
|
import '../share_intent/remote_file_ref.dart';
|
||||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import 'app_progress_indicator.dart';
|
||||||
|
import 'centered_leading.dart';
|
||||||
import 'info_dialog.dart';
|
import 'info_dialog.dart';
|
||||||
import 'placeholder_view.dart';
|
|
||||||
import 'share_position_origin.dart';
|
import 'share_position_origin.dart';
|
||||||
|
|
||||||
class FileViewer extends StatefulWidget {
|
class FileViewer extends StatefulWidget {
|
||||||
@@ -39,6 +44,88 @@ class FileViewer extends StatefulWidget {
|
|||||||
|
|
||||||
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Video container formats whose playback the platform decoders (ExoPlayer
|
||||||
|
/// on Android, AVPlayer on iOS) handle out of the box.
|
||||||
|
const Set<String> _videoExtensions = {
|
||||||
|
'mp4',
|
||||||
|
'm4v',
|
||||||
|
'mov',
|
||||||
|
'webm',
|
||||||
|
'mkv',
|
||||||
|
'3gp',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Audio formats playable through the same `video_player` pipeline. Some
|
||||||
|
/// (ogg/opus/flac) work on Android only — iOS will surface an init error
|
||||||
|
/// which we catch and surface as a friendly fallback.
|
||||||
|
const Set<String> _audioExtensions = {
|
||||||
|
'mp3',
|
||||||
|
'm4a',
|
||||||
|
'aac',
|
||||||
|
'wav',
|
||||||
|
'flac',
|
||||||
|
'ogg',
|
||||||
|
'oga',
|
||||||
|
'opus',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Extensions whose contents we render directly as plain text. Anything
|
||||||
|
/// outside this list still gets a content-based fallback check (see
|
||||||
|
/// [_looksLikeText]) so generic "what is this file" cases work too.
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Reads up to 8 KB and decides whether the bytes look like UTF-8 text.
|
||||||
|
/// NUL bytes and non-decodable sequences disqualify the file. Used as a
|
||||||
|
/// fallback for unknown extensions so plain text files without a familiar
|
||||||
|
/// suffix still open in the in-app viewer.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
|
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
|
||||||
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
|
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
|
||||||
/// ancestor RenderTransform (from the page-push animation) is still mid-layout.
|
/// ancestor RenderTransform (from the page-push animation) is still mid-layout.
|
||||||
@@ -82,7 +169,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (!_ready) {
|
if (!_ready) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: AppProgressIndicator.large());
|
||||||
}
|
}
|
||||||
return SfPdfViewer.file(File(widget.path));
|
return SfPdfViewer.file(File(widget.path));
|
||||||
}
|
}
|
||||||
@@ -93,13 +180,32 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
|
|
||||||
late SettingsCubit settings = context.read<SettingsCubit>();
|
late SettingsCubit settings = context.read<SettingsCubit>();
|
||||||
late bool openExternal;
|
late bool openExternal;
|
||||||
|
Future<_FileKind>? _fileKind;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
super.initState();
|
||||||
openExternal =
|
openExternal =
|
||||||
settings.val().fileViewSettings.alwaysOpenExternally ||
|
settings.val().fileViewSettings.alwaysOpenExternally ||
|
||||||
widget.openExternal;
|
widget.openExternal;
|
||||||
super.initState();
|
if (openExternal) {
|
||||||
|
// Settings or popup explicitly chose "open externally" — fire and
|
||||||
|
// forget, then pop back. Same one-shot behaviour as the old viewer.
|
||||||
|
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
|
@override
|
||||||
@@ -108,14 +214,19 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<_FileKind> _detectKind() async {
|
||||||
Widget build(BuildContext context) {
|
final ext = widget.path.split('.').last.toLowerCase();
|
||||||
AppBar appbar({List<Widget> actions = const []}) => AppBar(
|
if (_imageExtensions.contains(ext)) return _FileKind.image;
|
||||||
title: Text(widget.path.split('/').last),
|
if (ext == 'svg') return _FileKind.svg;
|
||||||
actions: [
|
if (ext == 'pdf') return _FileKind.pdf;
|
||||||
...actions,
|
if (_videoExtensions.contains(ext)) return _FileKind.video;
|
||||||
PopupMenuButton<FileViewingActions>(
|
if (_audioExtensions.contains(ext)) return _FileKind.audio;
|
||||||
onSelected: (value) async {
|
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) {
|
switch (value) {
|
||||||
case FileViewingActions.openExternal:
|
case FileViewingActions.openExternal:
|
||||||
AppRoutes.openFileViewer(
|
AppRoutes.openFileViewer(
|
||||||
@@ -129,10 +240,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
|
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
|
||||||
break;
|
break;
|
||||||
case FileViewingActions.saveToCloud:
|
case FileViewingActions.saveToCloud:
|
||||||
AppRoutes.openInternalSaveToFolder(
|
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
|
||||||
context,
|
|
||||||
widget.remoteFile!,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case FileViewingActions.share:
|
case FileViewingActions.share:
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -151,12 +259,12 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
fileName: widget.path.split('/').last,
|
fileName: widget.path.split('/').last,
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!mounted) return;
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
InfoDialog.show(context, 'Datei gespeichert.');
|
InfoDialog.show(context, 'Datei gespeichert.');
|
||||||
}
|
}
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!mounted) return;
|
||||||
InfoDialog.show(
|
InfoDialog.show(
|
||||||
context,
|
context,
|
||||||
'Speichern fehlgeschlagen: $e',
|
'Speichern fehlgeschlagen: $e',
|
||||||
@@ -166,63 +274,105 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
itemBuilder: (context) => <PopupMenuEntry<FileViewingActions>>[
|
|
||||||
const PopupMenuItem(
|
List<_ActionDescriptor> _availableActions() => [
|
||||||
value: FileViewingActions.openExternal,
|
_ActionDescriptor(
|
||||||
child: ListTile(
|
action: FileViewingActions.openExternal,
|
||||||
leading: Icon(Icons.open_in_new),
|
// iOS opens the system share sheet (square-with-arrow icon), Android
|
||||||
title: Text('Extern öffnen'),
|
// the standard app picker; mirror that visually and verbally.
|
||||||
dense: true,
|
icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
|
||||||
),
|
label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
|
||||||
),
|
),
|
||||||
if (widget.remoteFile != null) ...[
|
if (widget.remoteFile != null) ...[
|
||||||
const PopupMenuItem(
|
const _ActionDescriptor(
|
||||||
value: FileViewingActions.sendToChat,
|
action: FileViewingActions.sendToChat,
|
||||||
child: ListTile(
|
icon: Icons.chat_bubble_outline,
|
||||||
leading: Icon(Icons.chat_bubble_outline),
|
label: 'An Talk-Chat senden',
|
||||||
title: Text('An Talk-Chat senden'),
|
|
||||||
dense: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: FileViewingActions.saveToCloud,
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.cloud_outlined),
|
|
||||||
title: Text('In Cloud speichern'),
|
|
||||||
dense: true,
|
|
||||||
),
|
),
|
||||||
|
const _ActionDescriptor(
|
||||||
|
action: FileViewingActions.saveToCloud,
|
||||||
|
icon: Icons.cloud_outlined,
|
||||||
|
label: 'In Cloud speichern',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const PopupMenuItem(
|
const _ActionDescriptor(
|
||||||
value: FileViewingActions.share,
|
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(
|
child: ListTile(
|
||||||
leading: Icon(Icons.share_outlined),
|
leading: Icon(a.icon),
|
||||||
title: Text('Teilen'),
|
title: Text(a.label),
|
||||||
dense: true,
|
dense: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
)
|
||||||
value: FileViewingActions.save,
|
.toList(),
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(Icons.save_alt_outlined),
|
|
||||||
title: Text('Speichern'),
|
|
||||||
dense: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (openExternal ? '' : widget.path.split('.').last.toLowerCase()) {
|
@override
|
||||||
case 'png':
|
Widget build(BuildContext context) {
|
||||||
case 'jpg':
|
if (openExternal) {
|
||||||
case 'jpeg':
|
|
||||||
case 'webp':
|
|
||||||
case 'gif':
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: appbar(
|
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: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -246,29 +396,434 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'pdf':
|
Widget _buildSvgView() => Scaffold(
|
||||||
return Scaffold(
|
appBar: _appbar(),
|
||||||
appBar: appbar(),
|
backgroundColor: Colors.white,
|
||||||
body: _DeferredPdfViewer(path: widget.path),
|
body: InteractiveViewer(
|
||||||
|
minScale: 0.5,
|
||||||
|
maxScale: 8,
|
||||||
|
child: Center(
|
||||||
|
child: SvgPicture.file(
|
||||||
|
File(widget.path),
|
||||||
|
placeholderBuilder: (_) =>
|
||||||
|
const Center(child: AppProgressIndicator.large()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
Widget _buildPdfView() =>
|
||||||
OpenFilex.open(widget.path).then((result) {
|
Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path));
|
||||||
if (!context.mounted) return;
|
|
||||||
Navigator.of(context).pop();
|
Widget _buildVideoView() => Scaffold(
|
||||||
if (result.type != ResultType.done) {
|
appBar: _appbar(),
|
||||||
InfoDialog.show(context, result.message);
|
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);
|
||||||
|
// Reserve gutter width by the digit count of the highest line number,
|
||||||
|
// so the gutter stays stable as the user scrolls down.
|
||||||
|
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() {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final descriptors = _availableActions();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: _appbar(showActionsMenu: false),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
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 cannot be reliably re-formatted (parser will
|
||||||
|
// choke on the dangling tail), so they stay raw.
|
||||||
|
return _TextPayload(
|
||||||
|
content: utf8.decode(bytes, allowMalformed: true),
|
||||||
|
truncated: true,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await raf.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-indents JSON so dumped/minified payloads from the server are easier
|
||||||
|
/// to read. Falls through to the original text on parse errors so we
|
||||||
|
/// never destroy the user's content.
|
||||||
|
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});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays back a local file via `video_player`. Renders the standard Chewie
|
||||||
|
/// controls for video files; audio files get a centered icon plus a custom
|
||||||
|
/// transport row (slider, time, play/pause), since Chewie's chrome is
|
||||||
|
/// designed around a video frame.
|
||||||
|
class _MediaPlayer extends StatefulWidget {
|
||||||
|
final String path;
|
||||||
|
final bool isAudio;
|
||||||
|
final String? filename;
|
||||||
|
const _MediaPlayer({
|
||||||
|
required this.path,
|
||||||
|
required this.isAudio,
|
||||||
|
this.filename,
|
||||||
});
|
});
|
||||||
|
|
||||||
return PlaceholderView(
|
@override
|
||||||
text: 'Datei extern geöffnet',
|
State<_MediaPlayer> createState() => _MediaPlayerState();
|
||||||
icon: Icons.open_in_new,
|
}
|
||||||
button: TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
class _MediaPlayerState extends State<_MediaPlayer> {
|
||||||
child: const Text('Zurück'),
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row in the text viewer: line number on the left (not selectable so
|
||||||
|
/// it never ends up in copied selections), monospace content on the right.
|
||||||
|
/// Odd-numbered lines get a slightly tinted background so long files are
|
||||||
|
/// easier to scan.
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ dependencies:
|
|||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
enough_icalendar: ^0.17.0
|
enough_icalendar: ^0.17.0
|
||||||
receive_sharing_intent: ^1.8.1
|
receive_sharing_intent: ^1.8.1
|
||||||
|
video_player: ^2.9.0
|
||||||
|
chewie: ^1.8.5
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user