diff --git a/b0esche_cloud/lib/models/document_capabilities.dart b/b0esche_cloud/lib/models/document_capabilities.dart index 443be5d..ed0ea52 100644 --- a/b0esche_cloud/lib/models/document_capabilities.dart +++ b/b0esche_cloud/lib/models/document_capabilities.dart @@ -59,4 +59,6 @@ class DocumentCapabilities extends Equatable { mimeType.contains('word') || mimeType.contains('spreadsheet') || mimeType.contains('presentation'); + bool get isVideo => mimeType.startsWith('video/'); + bool get isAudio => mimeType.startsWith('audio/'); } diff --git a/b0esche_cloud/lib/pages/document_viewer.dart b/b0esche_cloud/lib/pages/document_viewer.dart index 1fda88b..026e7b3 100644 --- a/b0esche_cloud/lib/pages/document_viewer.dart +++ b/b0esche_cloud/lib/pages/document_viewer.dart @@ -16,6 +16,7 @@ import 'package:syncfusion_flutter_core/theme.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; +import '../widgets/file_viewer_dispatch.dart'; // Modal version for overlay display class DocumentViewerModal extends StatefulWidget { @@ -201,51 +202,42 @@ class _DocumentViewerModalState extends State { if (state is DocumentViewerReady) { // Handle different file types based on MIME type if (state.caps.isPdf) { - // PDF viewer using SfPdfViewer, wrapped in SfTheme for custom accent color - 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( - state.viewUrl.toString(), - headers: {'Authorization': 'Bearer ${state.token}'}, - onDocumentLoadFailed: (details) {}, - onDocumentLoaded: (PdfDocumentLoadedDetails details) {}, - canShowHyperlinkDialog: false, - onHyperlinkClicked: (details) => - _handleHyperlink(details.uri), - ), + return FileViewerDispatch.buildFileViewer( + context, + state.viewUrl.toString(), + state.caps.mimeType, + token: state.token, + fileName: state.fileInfo?.name, + viewerId: 'internal-pdf-${state.viewUrl.hashCode}', + onHyperlinkClicked: (details) => + _handleHyperlink(details.uri), ); } else if (state.caps.isImage) { - // Image viewer - return Container( - color: AppTheme.primaryBackground, - child: InteractiveViewer( - minScale: 0.5, - maxScale: 4.0, - child: Image.network( - state.viewUrl.toString(), - headers: {'Authorization': 'Bearer ${state.token}'}, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return Center( - child: Text( - 'Failed to load image', - style: TextStyle(color: Colors.red[400]), - ), - ); - }, - ), - ), + return FileViewerDispatch.buildFileViewer( + context, + state.viewUrl.toString(), + state.caps.mimeType, + token: state.token, + fileName: state.fileInfo?.name, + viewerId: 'internal-image-${state.viewUrl.hashCode}', + ); + } else if (state.caps.isVideo) { + return FileViewerDispatch.buildFileViewer( + context, + state.viewUrl.toString(), + state.caps.mimeType, + token: state.token, + 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) { // Text file viewer diff --git a/b0esche_cloud/lib/pages/public_file_viewer.dart b/b0esche_cloud/lib/pages/public_file_viewer.dart index 86b47c9..a4310a2 100644 --- a/b0esche_cloud/lib/pages/public_file_viewer.dart +++ b/b0esche_cloud/lib/pages/public_file_viewer.dart @@ -2,15 +2,13 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:web/web.dart' as 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:flutter/foundation.dart' show kIsWeb; import '../theme/app_theme.dart'; import '../services/api_client.dart'; import '../injection.dart'; -import '../widgets/audio_player_bar.dart'; import '../theme/modern_glass_button.dart'; +import '../widgets/file_viewer_dispatch.dart'; class PublicFileViewer extends StatefulWidget { final String token; @@ -158,31 +156,12 @@ class _PublicFileViewerState extends State { if (_isPdfFile()) { return Expanded( - child: SfTheme( - data: SfThemeData( - pdfViewerThemeData: SfPdfViewerThemeData( - backgroundColor: AppTheme.primaryBackground, - progressBarColor: AppTheme.accentColor, - scrollStatusStyle: PdfScrollStatusStyle( - 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.'; - }); - }, - ), + child: FileViewerDispatch.buildFileViewer( + context, + viewUrl, + _fileData?['capabilities']?['mimeType'], + fileName: _fileData!['fileName'], + viewerId: 'public-pdf-${widget.token.hashCode}', ), ); } else if (_isVideoFile()) { @@ -222,12 +201,12 @@ class _PublicFileViewerState extends State { ); } } else if (_isAudioFile()) { - return Center( - child: AudioPlayerBar( - fileName: _fileData!['fileName'] ?? 'Audio', - fileUrl: viewUrl, - mimeType: _fileData?['capabilities']?['mimeType'], - ), + return FileViewerDispatch.buildFileViewer( + context, + viewUrl, + _fileData?['capabilities']?['mimeType'], + fileName: _fileData!['fileName'], + viewerId: 'public-audio-${widget.token.hashCode}', ); } else if (_isDocumentFile()) { if (kIsWeb) { diff --git a/b0esche_cloud/lib/widgets/file_viewer_dispatch.dart b/b0esche_cloud/lib/widgets/file_viewer_dispatch.dart new file mode 100644 index 0000000..62d6ec5 --- /dev/null +++ b/b0esche_cloud/lib/widgets/file_viewer_dispatch.dart @@ -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'} + : {}; + + 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( + 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'), + ), + ], + ), + ), + ); + } + } +} diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index fd67207..59f46ae 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -3080,8 +3080,12 @@ func publicFileDownloadHandler(w http.ResponseWriter, r *http.Request, db *datab return } + // Create context with longer timeout for file downloads + downloadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + // Stream file - resp, err := client.Download(r.Context(), file.Path, "") + resp, err := client.Download(downloadCtx, file.Path, "") if err != nil { errors.LogError(r, err, "Failed to download file") 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 - downloadCtx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + downloadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() // Stream file diff --git a/go_cloud/internal/storage/nextcloud.go b/go_cloud/internal/storage/nextcloud.go index 6ff4d73..c025042 100644 --- a/go_cloud/internal/storage/nextcloud.go +++ b/go_cloud/internal/storage/nextcloud.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "time" ) // CreateNextcloudUser creates a new Nextcloud user account via OCS API @@ -71,6 +72,6 @@ func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVCli user: username, pass: password, basePrefix: "/", - httpClient: &http.Client{}, + httpClient: &http.Client{Timeout: 10 * time.Minute}, } } diff --git a/go_cloud/internal/storage/webdav.go b/go_cloud/internal/storage/webdav.go index a020319..dbf65ea 100644 --- a/go_cloud/internal/storage/webdav.go +++ b/go_cloud/internal/storage/webdav.go @@ -8,6 +8,7 @@ import ( "net/url" "path" "strings" + "time" "go.b0esche.cloud/backend/internal/config" ) @@ -37,7 +38,7 @@ func NewWebDAVClient(cfg *config.Config) *WebDAVClient { user: cfg.NextcloudUser, pass: cfg.NextcloudPass, basePrefix: strings.TrimRight(base, "/"), - httpClient: &http.Client{}, + httpClient: &http.Client{Timeout: 10 * time.Minute}, } }