Add file viewer dispatch for handling multiple file types and extend download timeout
This commit is contained in:
@@ -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/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
162
b0esche_cloud/lib/widgets/file_viewer_dispatch.dart
Normal file
162
b0esche_cloud/lib/widgets/file_viewer_dispatch.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user