Add file viewer dispatch for handling multiple file types and extend download timeout

This commit is contained in:
Leon Bösche
2026-01-25 16:14:03 +01:00
parent d5aecdfba8
commit 9bc03f6db8
7 changed files with 222 additions and 81 deletions

View File

@@ -59,4 +59,6 @@ class DocumentCapabilities extends Equatable {
mimeType.contains('word') || mimeType.contains('word') ||
mimeType.contains('spreadsheet') || mimeType.contains('spreadsheet') ||
mimeType.contains('presentation'); mimeType.contains('presentation');
bool get isVideo => mimeType.startsWith('video/');
bool get isAudio => mimeType.startsWith('audio/');
} }

View File

@@ -16,6 +16,7 @@ import 'package:syncfusion_flutter_core/theme.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../widgets/file_viewer_dispatch.dart';
// Modal version for overlay display // Modal version for overlay display
class DocumentViewerModal extends StatefulWidget { class DocumentViewerModal extends StatefulWidget {
@@ -201,51 +202,42 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
if (state is DocumentViewerReady) { if (state is DocumentViewerReady) {
// Handle different file types based on MIME type // Handle different file types based on MIME type
if (state.caps.isPdf) { if (state.caps.isPdf) {
// PDF viewer using SfPdfViewer, wrapped in SfTheme for custom accent color return FileViewerDispatch.buildFileViewer(
return SfTheme( context,
data: SfThemeData( state.viewUrl.toString(),
pdfViewerThemeData: SfPdfViewerThemeData( state.caps.mimeType,
backgroundColor: AppTheme.primaryBackground, token: state.token,
progressBarColor: AppTheme.accentColor, fileName: state.fileInfo?.name,
scrollStatusStyle: PdfScrollStatusStyle( viewerId: 'internal-pdf-${state.viewUrl.hashCode}',
backgroundColor: AppTheme.primaryBackground, onHyperlinkClicked: (details) =>
), _handleHyperlink(details.uri),
scrollHeadStyle: PdfScrollHeadStyle(
backgroundColor: AppTheme.accentColor,
),
),
),
child: SfPdfViewer.network(
state.viewUrl.toString(),
headers: {'Authorization': 'Bearer ${state.token}'},
onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
canShowHyperlinkDialog: false,
onHyperlinkClicked: (details) =>
_handleHyperlink(details.uri),
),
); );
} else if (state.caps.isImage) { } else if (state.caps.isImage) {
// Image viewer return FileViewerDispatch.buildFileViewer(
return Container( context,
color: AppTheme.primaryBackground, state.viewUrl.toString(),
child: InteractiveViewer( state.caps.mimeType,
minScale: 0.5, token: state.token,
maxScale: 4.0, fileName: state.fileInfo?.name,
child: Image.network( viewerId: 'internal-image-${state.viewUrl.hashCode}',
state.viewUrl.toString(), );
headers: {'Authorization': 'Bearer ${state.token}'}, } else if (state.caps.isVideo) {
fit: BoxFit.contain, return FileViewerDispatch.buildFileViewer(
errorBuilder: (context, error, stackTrace) { context,
return Center( state.viewUrl.toString(),
child: Text( state.caps.mimeType,
'Failed to load image', token: state.token,
style: TextStyle(color: Colors.red[400]), fileName: state.fileInfo?.name,
), viewerId: 'internal-video-${state.viewUrl.hashCode}',
); );
}, } else if (state.caps.isAudio) {
), return FileViewerDispatch.buildFileViewer(
), context,
state.viewUrl.toString(),
state.caps.mimeType,
token: state.token,
fileName: state.fileInfo?.name,
viewerId: 'internal-audio-${state.viewUrl.hashCode}',
); );
} else if (state.caps.isText) { } else if (state.caps.isText) {
// Text file viewer // Text file viewer

View File

@@ -2,15 +2,13 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:web/web.dart' as web; import 'package:web/web.dart' as web;
import 'dart:ui_web' as ui_web; import 'dart:ui_web' as ui_web;
import 'package:syncfusion_flutter_core/theme.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../services/api_client.dart'; import '../services/api_client.dart';
import '../injection.dart'; import '../injection.dart';
import '../widgets/audio_player_bar.dart';
import '../theme/modern_glass_button.dart'; import '../theme/modern_glass_button.dart';
import '../widgets/file_viewer_dispatch.dart';
class PublicFileViewer extends StatefulWidget { class PublicFileViewer extends StatefulWidget {
final String token; final String token;
@@ -158,31 +156,12 @@ class _PublicFileViewerState extends State<PublicFileViewer> {
if (_isPdfFile()) { if (_isPdfFile()) {
return Expanded( return Expanded(
child: SfTheme( child: FileViewerDispatch.buildFileViewer(
data: SfThemeData( context,
pdfViewerThemeData: SfPdfViewerThemeData( viewUrl,
backgroundColor: AppTheme.primaryBackground, _fileData?['capabilities']?['mimeType'],
progressBarColor: AppTheme.accentColor, fileName: _fileData!['fileName'],
scrollStatusStyle: PdfScrollStatusStyle( viewerId: 'public-pdf-${widget.token.hashCode}',
backgroundColor: AppTheme.primaryBackground,
),
scrollHeadStyle: PdfScrollHeadStyle(
backgroundColor: AppTheme.accentColor,
),
),
),
child: SfPdfViewer.network(
viewUrl,
canShowScrollHead: false,
canShowScrollStatus: false,
enableDoubleTapZooming: true,
enableTextSelection: false,
onDocumentLoadFailed: (details) {
setState(() {
_error = 'Failed to load PDF content.';
});
},
),
), ),
); );
} else if (_isVideoFile()) { } else if (_isVideoFile()) {
@@ -222,12 +201,12 @@ class _PublicFileViewerState extends State<PublicFileViewer> {
); );
} }
} else if (_isAudioFile()) { } else if (_isAudioFile()) {
return Center( return FileViewerDispatch.buildFileViewer(
child: AudioPlayerBar( context,
fileName: _fileData!['fileName'] ?? 'Audio', viewUrl,
fileUrl: viewUrl, _fileData?['capabilities']?['mimeType'],
mimeType: _fileData?['capabilities']?['mimeType'], fileName: _fileData!['fileName'],
), viewerId: 'public-audio-${widget.token.hashCode}',
); );
} else if (_isDocumentFile()) { } else if (_isDocumentFile()) {
if (kIsWeb) { if (kIsWeb) {

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:syncfusion_flutter_core/theme.dart';
import 'package:video_player/video_player.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:web/web.dart' as web;
import 'dart:ui_web' as ui_web;
import '../theme/app_theme.dart';
import 'audio_player_bar.dart';
class FileViewerDispatch {
static Widget buildFileViewer(
BuildContext context,
String url,
String mimeType, {
String? token,
String? fileName,
required String viewerId,
void Function(PdfHyperlinkClickedDetails)? onHyperlinkClicked,
}) {
final headers = token != null
? {'Authorization': 'Bearer $token'}
: <String, String>{};
if (mimeType == 'application/pdf') {
return SfTheme(
data: SfThemeData(
pdfViewerThemeData: SfPdfViewerThemeData(
backgroundColor: AppTheme.primaryBackground,
progressBarColor: AppTheme.accentColor,
scrollStatusStyle: PdfScrollStatusStyle(
backgroundColor: AppTheme.primaryBackground,
),
scrollHeadStyle: PdfScrollHeadStyle(
backgroundColor: AppTheme.accentColor,
),
),
),
child: SfPdfViewer.network(
url,
headers: headers,
canShowScrollHead: false,
canShowScrollStatus: false,
enableDoubleTapZooming: true,
enableTextSelection: false,
onHyperlinkClicked: onHyperlinkClicked,
onDocumentLoadFailed: (details) {
// Handle error
},
),
);
} else if (mimeType.startsWith('video/')) {
if (kIsWeb) {
// Use HTML video element for web
ui_web.platformViewRegistry.registerViewFactory(viewerId, (int viewId) {
final videoElement = web.HTMLVideoElement()
..src = url
..controls = true
..autoplay = false
..crossOrigin = 'anonymous'
..style.width = '100%'
..style.height = '100%'
..style.objectFit = 'contain';
// Add headers if token
if (token != null) {
// For web, headers are not directly supported, but since it's public or auth, assume ok
}
videoElement.onError.listen((event) {
// Handle error
});
return videoElement;
});
return Container(
color: Colors.black,
child: HtmlElementView(viewType: viewerId),
);
} else {
// Use VideoPlayer for mobile
final controller = VideoPlayerController.networkUrl(Uri.parse(url));
return FutureBuilder<void>(
future: controller.initialize(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}
} else if (mimeType.startsWith('audio/')) {
return Center(
child: AudioPlayerBar(
fileName: fileName ?? 'Audio',
fileUrl: url,
mimeType: mimeType,
),
);
} else if (mimeType.startsWith('image/')) {
return Container(
color: AppTheme.primaryBackground,
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4.0,
child: Image.network(
url,
headers: headers,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
'Failed to load image',
style: TextStyle(color: Colors.red[400]),
),
);
},
),
),
);
} else {
// Fallback
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.description,
size: 80,
color: AppTheme.primaryText.withValues(alpha: 0.7),
),
const SizedBox(height: 16),
Text(
'File type not supported for preview',
style: TextStyle(color: AppTheme.primaryText, fontSize: 18),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Download
final anchor = web.HTMLAnchorElement()
..href = url
..download = fileName ?? 'download';
anchor.click();
},
child: const Text('Download File'),
),
],
),
),
);
}
}
}

View File

@@ -3080,8 +3080,12 @@ func publicFileDownloadHandler(w http.ResponseWriter, r *http.Request, db *datab
return return
} }
// Create context with longer timeout for file downloads
downloadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// Stream file // Stream file
resp, err := client.Download(r.Context(), file.Path, "") resp, err := client.Download(downloadCtx, file.Path, "")
if err != nil { if err != nil {
errors.LogError(r, err, "Failed to download file") errors.LogError(r, err, "Failed to download file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
@@ -3189,7 +3193,7 @@ func publicFileViewHandler(w http.ResponseWriter, r *http.Request, db *database.
} }
// Create context with longer timeout for file downloads // Create context with longer timeout for file downloads
downloadCtx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) downloadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel() defer cancel()
// Stream file // Stream file

View File

@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
) )
// CreateNextcloudUser creates a new Nextcloud user account via OCS API // CreateNextcloudUser creates a new Nextcloud user account via OCS API
@@ -71,6 +72,6 @@ func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVCli
user: username, user: username,
pass: password, pass: password,
basePrefix: "/", basePrefix: "/",
httpClient: &http.Client{}, httpClient: &http.Client{Timeout: 10 * time.Minute},
} }
} }

View File

@@ -8,6 +8,7 @@ import (
"net/url" "net/url"
"path" "path"
"strings" "strings"
"time"
"go.b0esche.cloud/backend/internal/config" "go.b0esche.cloud/backend/internal/config"
) )
@@ -37,7 +38,7 @@ func NewWebDAVClient(cfg *config.Config) *WebDAVClient {
user: cfg.NextcloudUser, user: cfg.NextcloudUser,
pass: cfg.NextcloudPass, pass: cfg.NextcloudPass,
basePrefix: strings.TrimRight(base, "/"), basePrefix: strings.TrimRight(base, "/"),
httpClient: &http.Client{}, httpClient: &http.Client{Timeout: 10 * time.Minute},
} }
} }