implemented Nextcloud file previews for unknown file types using fileId and has-preview flags, updated file models, and refined manual refresh logic.
This commit is contained in:
@@ -15,6 +15,16 @@ class CacheableFile {
|
|||||||
DateTime? modifiedAt;
|
DateTime? modifiedAt;
|
||||||
String? sort;
|
String? sort;
|
||||||
|
|
||||||
|
/// Nextcloud's instance-local file id (`oc:fileid`). Used to address the
|
||||||
|
/// preview API by id, which is more reliable than the path-based variant
|
||||||
|
/// on some server configurations.
|
||||||
|
int? fileId;
|
||||||
|
|
||||||
|
/// Server's answer to "can I render a thumbnail for this file?"
|
||||||
|
/// (`nc:has-preview`). Lets the file viewer skip the placeholder text
|
||||||
|
/// when a preview is going to load anyway.
|
||||||
|
bool? hasPreview;
|
||||||
|
|
||||||
CacheableFile({
|
CacheableFile({
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.isDirectory,
|
required this.isDirectory,
|
||||||
@@ -24,6 +34,8 @@ class CacheableFile {
|
|||||||
this.eTag,
|
this.eTag,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
this.modifiedAt,
|
this.modifiedAt,
|
||||||
|
this.fileId,
|
||||||
|
this.hasPreview,
|
||||||
});
|
});
|
||||||
|
|
||||||
CacheableFile.fromDavFile(WebDavFile file) {
|
CacheableFile.fromDavFile(WebDavFile file) {
|
||||||
@@ -35,6 +47,8 @@ class CacheableFile {
|
|||||||
eTag = file.etag;
|
eTag = file.etag;
|
||||||
createdAt = file.createdDate;
|
createdAt = file.createdDate;
|
||||||
modifiedAt = file.lastModified;
|
modifiedAt = file.lastModified;
|
||||||
|
fileId = int.tryParse(file.fileId ?? '');
|
||||||
|
hasPreview = file.hasPreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
factory CacheableFile.fromJson(Map<String, dynamic> json) =>
|
factory CacheableFile.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
|
|||||||
modifiedAt: json['modifiedAt'] == null
|
modifiedAt: json['modifiedAt'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(json['modifiedAt'] as String),
|
: DateTime.parse(json['modifiedAt'] as String),
|
||||||
|
fileId: (json['fileId'] as num?)?.toInt(),
|
||||||
|
hasPreview: json['hasPreview'] as bool?,
|
||||||
)..sort = json['sort'] as String?;
|
)..sort = json['sort'] as String?;
|
||||||
|
|
||||||
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
||||||
@@ -33,4 +35,6 @@ Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
|||||||
'createdAt': instance.createdAt?.toIso8601String(),
|
'createdAt': instance.createdAt?.toIso8601String(),
|
||||||
'modifiedAt': instance.modifiedAt?.toIso8601String(),
|
'modifiedAt': instance.modifiedAt?.toIso8601String(),
|
||||||
'sort': instance.sort,
|
'sort': instance.sort,
|
||||||
|
'fileId': instance.fileId,
|
||||||
|
'hasPreview': instance.hasPreview,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,8 +25,23 @@ class ListFiles extends WebdavApi<ListFilesParams> {
|
|||||||
Future<ListFilesResponse> run() async {
|
Future<ListFilesResponse> run() async {
|
||||||
final webdav = await WebdavApi.webdav;
|
final webdav = await WebdavApi.webdav;
|
||||||
final timeout = _isRoot ? _rootTimeout : _subfolderTimeout;
|
final timeout = _isRoot ? _rootTimeout : _subfolderTimeout;
|
||||||
|
// Explicit prop list — without it the server omits OC-namespaced
|
||||||
|
// properties like oc:fileid, which the preview endpoint relies on.
|
||||||
|
final prop = WebDavPropWithoutValues.fromBools(
|
||||||
|
davgetlastmodified: true,
|
||||||
|
davgetetag: true,
|
||||||
|
davgetcontenttype: true,
|
||||||
|
davgetcontentlength: true,
|
||||||
|
davresourcetype: true,
|
||||||
|
ocfileid: true,
|
||||||
|
ocsize: true,
|
||||||
|
nccreationtime: true,
|
||||||
|
nchaspreview: true,
|
||||||
|
);
|
||||||
final davFiles =
|
final davFiles =
|
||||||
(await webdav.propfind(PathUri.parse(params.path)).timeout(timeout))
|
(await webdav
|
||||||
|
.propfind(PathUri.parse(params.path), prop: prop)
|
||||||
|
.timeout(timeout))
|
||||||
.toWebDavFiles();
|
.toWebDavFiles();
|
||||||
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
|
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,33 @@ class RemoteFileRef {
|
|||||||
final String path;
|
final String path;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
const RemoteFileRef({required this.path, required this.name});
|
/// Nextcloud's instance-local file id, when known. Preferred over [path]
|
||||||
|
/// for hitting the preview API — the path-based variant is unreliable on
|
||||||
|
/// some server configurations.
|
||||||
|
final int? fileId;
|
||||||
|
|
||||||
|
/// Server's `nc:has-preview` flag when known. null = unknown.
|
||||||
|
final bool? hasPreview;
|
||||||
|
|
||||||
|
const RemoteFileRef({
|
||||||
|
required this.path,
|
||||||
|
required this.name,
|
||||||
|
this.fileId,
|
||||||
|
this.hasPreview,
|
||||||
|
});
|
||||||
|
|
||||||
/// Caller must verify `file.path != null` first — Talk message parameters
|
/// Caller must verify `file.path != null` first — Talk message parameters
|
||||||
/// without a path (system events, mentions, polls) are not file refs.
|
/// without a path (system events, mentions, polls) are not file refs.
|
||||||
factory RemoteFileRef.fromTalk(RichObjectString file) =>
|
factory RemoteFileRef.fromTalk(RichObjectString file) => RemoteFileRef(
|
||||||
RemoteFileRef(path: file.path!, name: file.name);
|
path: file.path!,
|
||||||
|
name: file.name,
|
||||||
|
fileId: int.tryParse(file.id),
|
||||||
|
);
|
||||||
|
|
||||||
factory RemoteFileRef.fromCacheable(CacheableFile file) =>
|
factory RemoteFileRef.fromCacheable(CacheableFile file) => RemoteFileRef(
|
||||||
RemoteFileRef(path: file.path, name: file.name);
|
path: file.path,
|
||||||
|
name: file.name,
|
||||||
|
fileId: file.fileId,
|
||||||
|
hasPreview: file.hasPreview,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.dart';
|
import '../../../../../api/errors/error_mapper.dart';
|
||||||
@@ -42,6 +44,16 @@ class FilesBloc
|
|||||||
await _query(path, renew: true);
|
await _query(path, renew: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LoadableState.reFetch (used by the pull-to-refresh indicator and the
|
||||||
|
/// error-screen retry button) routes through here. The inherited retry()
|
||||||
|
/// goes via gatherData() which respects the cache TTL — for an explicit
|
||||||
|
/// user-initiated reload we must bypass it, otherwise the root listing
|
||||||
|
/// silently returns its day-old cached payload without hitting the server.
|
||||||
|
@override
|
||||||
|
void retry() {
|
||||||
|
unawaited(refresh());
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setPath(List<String> path) async {
|
Future<void> setPath(List<String> path) async {
|
||||||
add(Emit((s) => s.copyWith(currentPath: path, listing: null)));
|
add(Emit((s) => s.copyWith(currentPath: path, listing: null)));
|
||||||
add(RefetchStarted<FilesState>());
|
add(RefetchStarted<FilesState>());
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:chewie/chewie.dart';
|
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';
|
||||||
@@ -14,6 +15,8 @@ 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 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../model/account_data.dart';
|
||||||
|
import '../model/endpoint_data.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';
|
||||||
@@ -22,6 +25,21 @@ import 'centered_leading.dart';
|
|||||||
import 'info_dialog.dart';
|
import 'info_dialog.dart';
|
||||||
import 'share_position_origin.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 {
|
class FileViewer extends StatefulWidget {
|
||||||
final String path;
|
final String path;
|
||||||
final bool openExternal;
|
final bool openExternal;
|
||||||
@@ -482,26 +500,33 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildUnknownView() {
|
Widget _buildUnknownView() => Scaffold(
|
||||||
|
appBar: _appbar(showActionsMenu: false),
|
||||||
|
body: _buildUnknownPlaceholder(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildUnknownPlaceholder() {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final descriptors = _availableActions();
|
final descriptors = _availableActions();
|
||||||
return Scaffold(
|
final remote = widget.remoteFile;
|
||||||
appBar: _appbar(showActionsMenu: false),
|
final hasPreview = remote?.hasPreview == true;
|
||||||
body: ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.insert_drive_file_outlined, size: 60),
|
_UnknownPreviewHeader(remoteFile: remote),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (!hasPreview) ...[
|
||||||
Text(
|
Text(
|
||||||
'Vorschau nicht verfügbar',
|
'Vorschau nicht verfügbar',
|
||||||
style: theme.textTheme.titleMedium,
|
style: theme.textTheme.titleMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
widget.path.split('/').last,
|
widget.path.split('/').last,
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
@@ -527,7 +552,6 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,6 +607,50 @@ class _TextPayload {
|
|||||||
const _TextPayload({required this.content, required this.truncated});
|
const _TextPayload({required this.content, required this.truncated});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Header for the "Vorschau nicht verfügbar" screen: tries to fetch the
|
||||||
|
/// Nextcloud thumbnail and shows it mid-sized if available, otherwise
|
||||||
|
/// falls back to the generic file icon.
|
||||||
|
class _UnknownPreviewHeader extends StatelessWidget {
|
||||||
|
final RemoteFileRef? remoteFile;
|
||||||
|
const _UnknownPreviewHeader({required this.remoteFile});
|
||||||
|
|
||||||
|
static const double _previewSize = 180;
|
||||||
|
|
||||||
|
Widget _fallbackIcon() =>
|
||||||
|
const Icon(Icons.insert_drive_file_outlined, size: 60);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final remote = remoteFile;
|
||||||
|
// Skip the probe outright when the server already told us there is no
|
||||||
|
// preview — saves an HTTP request that would 404 anyway.
|
||||||
|
if (remote == null || remote.hasPreview == false) return _fallbackIcon();
|
||||||
|
return SizedBox(
|
||||||
|
width: _previewSize,
|
||||||
|
height: _previewSize,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
httpHeaders: AccountData().authHeaders(),
|
||||||
|
imageUrl: _ncPreviewUrl(remote, width: 360),
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorListener: (_) {},
|
||||||
|
placeholder: (_, _) =>
|
||||||
|
const Center(child: AppProgressIndicator.large()),
|
||||||
|
errorWidget: (_, _, _) => Center(child: _fallbackIcon()),
|
||||||
|
imageBuilder: (_, imageProvider) => ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image(
|
||||||
|
image: imageProvider,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
width: _previewSize,
|
||||||
|
height: _previewSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _MediaPlayer extends StatefulWidget {
|
class _MediaPlayer extends StatefulWidget {
|
||||||
final String path;
|
final String path;
|
||||||
final bool isAudio;
|
final bool isAudio;
|
||||||
|
|||||||
Reference in New Issue
Block a user