diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 12ed37b..fcbdfcc 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -3,8 +3,6 @@ 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'; @@ -12,34 +10,20 @@ 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 'file_viewer/code_line.dart'; +import 'file_viewer/deferred_pdf_viewer.dart'; +import 'file_viewer/file_kind.dart'; +import 'file_viewer/media_player.dart'; +import 'file_viewer/unknown_preview_block.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; @@ -61,133 +45,12 @@ class FileViewer extends StatefulWidget { enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } -enum _FileKind { image, svg, pdf, text, video, audio, unknown } - -const Set _imageExtensions = { - 'png', - 'jpg', - 'jpeg', - 'webp', - 'gif', - 'bmp', - 'wbmp', -}; - -const Set _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 _audioExtensions = { - 'mp3', - 'm4a', - 'aac', - 'wav', - 'flac', - 'ogg', - 'oga', - 'opus', -}; - -/// Unknown extensions still get a content sniff via [_looksLikeText]. -const Set _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 _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? _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 { final PhotoViewController photoViewController = PhotoViewController(); late SettingsCubit settings = context.read(); late bool openExternal; - Future<_FileKind>? _fileKind; + Future? _fileKind; @override void initState() { @@ -200,7 +63,7 @@ class _FileViewerState extends State { (_) => _openExternallyAndPop(), ); } else { - _fileKind = _detectKind(); + _fileKind = detectFileKind(widget.path); } } @@ -219,18 +82,6 @@ class _FileViewerState extends State { 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 _handleAction(FileViewingActions value) async { switch (value) { case FileViewingActions.openExternal: @@ -360,7 +211,7 @@ class _FileViewerState extends State { body: const Center(child: AppProgressIndicator.large()), ); } - return FutureBuilder<_FileKind>( + return FutureBuilder( future: _fileKind, builder: (context, snapshot) { if (!snapshot.hasData) { @@ -370,19 +221,19 @@ class _FileViewerState extends State { ); } switch (snapshot.data!) { - case _FileKind.image: + case FileKind.image: return _buildImageView(); - case _FileKind.svg: + case FileKind.svg: return _buildSvgView(); - case _FileKind.pdf: + case FileKind.pdf: return _buildPdfView(); - case _FileKind.video: + case FileKind.video: return _buildVideoView(); - case _FileKind.audio: + case FileKind.audio: return _buildAudioView(); - case _FileKind.text: + case FileKind.text: return _buildTextView(); - case _FileKind.unknown: + case FileKind.unknown: return _buildUnknownView(); } }, @@ -431,17 +282,17 @@ class _FileViewerState extends State { ); Widget _buildPdfView() => - Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path)); + Scaffold(appBar: _appbar(), body: DeferredPdfViewer(path: widget.path)); Widget _buildVideoView() => Scaffold( appBar: _appbar(), backgroundColor: Colors.black, - body: _MediaPlayer(path: widget.path, isAudio: false), + body: MediaPlayer(path: widget.path, isAudio: false), ); Widget _buildAudioView() => Scaffold( appBar: _appbar(), - body: _MediaPlayer( + body: MediaPlayer( path: widget.path, isAudio: true, filename: widget.path.split('/').last, @@ -485,7 +336,7 @@ class _FileViewerState extends State { ), SliverList.builder( itemCount: lines.length, - itemBuilder: (context, i) => _CodeLine( + itemBuilder: (context, i) => CodeLine( number: i + 1, text: lines[i], gutterWidth: gutterWidth, @@ -515,7 +366,7 @@ class _FileViewerState extends State { padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( children: [ - _UnknownPreviewBlock(remoteFile: widget.remoteFile), + UnknownPreviewBlock(remoteFile: widget.remoteFile), const SizedBox(height: 16), Text( widget.path.split('/').last, @@ -596,313 +447,3 @@ class _TextPayload { 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 _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)), - ], - ), - ); - } -} diff --git a/lib/widget/file_viewer/code_line.dart b/lib/widget/file_viewer/code_line.dart new file mode 100644 index 0000000..618c1aa --- /dev/null +++ b/lib/widget/file_viewer/code_line.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +/// A single line of the text/code viewer: a right-aligned, non-selectable line +/// number gutter plus the selectable line content, with zebra striping. +class CodeLine extends StatelessWidget { + final int number; + final String text; + final double gutterWidth; + const CodeLine({ + super.key, + 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)), + ], + ), + ); + } +} diff --git a/lib/widget/file_viewer/deferred_pdf_viewer.dart b/lib/widget/file_viewer/deferred_pdf_viewer.dart new file mode 100644 index 0000000..a3428aa --- /dev/null +++ b/lib/widget/file_viewer/deferred_pdf_viewer.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; + +import '../app_progress_indicator.dart'; + +/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push +/// animation. Defer until the route enter animation completes. +class DeferredPdfViewer extends StatefulWidget { + const DeferredPdfViewer({super.key, required this.path}); + final String path; + + @override + State createState() => _DeferredPdfViewerState(); +} + +class _DeferredPdfViewerState extends State { + bool _ready = false; + Animation? _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)); + } +} diff --git a/lib/widget/file_viewer/file_kind.dart b/lib/widget/file_viewer/file_kind.dart new file mode 100644 index 0000000..417db0c --- /dev/null +++ b/lib/widget/file_viewer/file_kind.dart @@ -0,0 +1,92 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +enum FileKind { image, svg, pdf, text, video, audio, unknown } + +const Set _imageExtensions = { + 'png', + 'jpg', + 'jpeg', + 'webp', + 'gif', + 'bmp', + 'wbmp', +}; + +const Set _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 _audioExtensions = { + 'mp3', + 'm4a', + 'aac', + 'wav', + 'flac', + 'ogg', + 'oga', + 'opus', +}; + +/// Unknown extensions still get a content sniff via [_looksLikeText]. +const Set _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', +}; + +/// Detects the [FileKind] of the file at [path] from its extension, falling +/// back to an 8 KB content sniff ([_looksLikeText]) for unknown extensions. +Future detectFileKind(String path) async { + final ext = 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(path)) return FileKind.text; + return FileKind.unknown; +} + +/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify. +Future _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(); + } +} diff --git a/lib/widget/file_viewer/media_player.dart b/lib/widget/file_viewer/media_player.dart new file mode 100644 index 0000000..1e461ea --- /dev/null +++ b/lib/widget/file_viewer/media_player.dart @@ -0,0 +1,200 @@ +import 'dart:io'; + +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../app_progress_indicator.dart'; + +/// Plays a local video (via Chewie) or audio file (via [_AudioControls]). +/// Reports an inline "format not supported" message when the platform can't +/// initialize the file. +class MediaPlayer extends StatefulWidget { + final String path; + final bool isAudio; + final String? filename; + const MediaPlayer({ + super.key, + required this.path, + required this.isAudio, + this.filename, + }); + + @override + State createState() => _MediaPlayerState(); +} + +class _MediaPlayerState extends State { + VideoPlayerController? _video; + ChewieController? _chewie; + Object? _initError; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widget/file_viewer/unknown_preview_block.dart b/lib/widget/file_viewer/unknown_preview_block.dart new file mode 100644 index 0000000..af297de --- /dev/null +++ b/lib/widget/file_viewer/unknown_preview_block.dart @@ -0,0 +1,99 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../model/account_data.dart'; +import '../../model/endpoint_data.dart'; +import '../../share_intent/remote_file_ref.dart'; +import '../app_progress_indicator.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'; +} + +/// 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({super.key, required this.remoteFile}); + + @override + State createState() => _UnknownPreviewBlockState(); +} + +class _UnknownPreviewBlockState extends State { + 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, + ), + ), + ), + ); + } +}