Files
Termix/src/ui/Desktop/Apps/File Manager/components/FileWindow.tsx
ZacharyZcR 5b67fa748c FIX: Enhance video playback and implement smart aspect ratio window sizing
- Replace ReactPlayer with native HTML5 video for better MP4 support
- Add proper MIME type mapping for all video formats (mp4, webm, mkv, avi, mov, wmv, flv)
- Implement smart window sizing based on media dimensions
- Auto-adjust window size to match image/video aspect ratio with constraints
- Add media dimension detection for images (naturalWidth/Height) and videos (videoWidth/Height)
- Center windows automatically when resizing for media content
- Apply intelligent scaling with max viewport limits (90% width, 80% height)
- Preserve minimum window sizes and add padding for UI elements
- Enhanced error handling and debug logging for video playback

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-25 09:51:40 +08:00

409 lines
12 KiB
TypeScript

import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow";
import { FileViewer } from "./FileViewer";
import { useWindowManager } from "./WindowManager";
import {
downloadSSHFile,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
interface FileWindowProps {
windowId: string;
file: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
onFileNotFound?: (file: FileItem) => void; // Callback for when file is not found
// readOnly parameter removed, determined internally by FileViewer based on file type
}
export function FileWindow({
windowId,
file,
sshSessionId,
sshHost,
initialX = 100,
initialY = 100,
onFileNotFound,
}: FileWindowProps) {
const {
closeWindow,
maximizeWindow,
focusWindow,
updateWindow,
windows,
} = useWindowManager();
const { t } = useTranslation();
const [content, setContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>("");
const [mediaDimensions, setMediaDimensions] = useState<{ width: number; height: number } | undefined>();
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find((w) => w.id === windowId);
// Ensure SSH connection is valid
const ensureSSHConnection = async () => {
try {
// First check SSH connection status
const status = await getSSHStatus(sshSessionId);
console.log("SSH connection status:", status);
if (!status.connected) {
console.log("SSH not connected, attempting to reconnect...");
// Re-establish connection
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
console.log("SSH reconnection successful");
}
} catch (error) {
console.log("SSH connection check/reconnect failed:", error);
// Even if connection fails, try to continue and let specific API calls handle errors
throw error;
}
};
// Load file content
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
// Ensure SSH connection is valid
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent(fileContent); // Initialize pending content
// If file size is unknown, calculate size based on content
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
// Determine if editable based on file type: all except media files are editable
const mediaExtensions = [
// Image files
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"webp",
"tiff",
"ico",
// Audio files
"mp3",
"wav",
"ogg",
"aac",
"flac",
"m4a",
"wma",
// Video files
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
// Archive files
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
// Binary files
"exe",
"dll",
"so",
"dylib",
"bin",
"iso",
];
const extension = file.name.split(".").pop()?.toLowerCase();
// Only media files and binary files are not editable, all other files are editable
setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: any) {
console.error("Failed to load file:", error);
// Check if it's a large file error
const errorData = error?.response?.data;
if (errorData?.tooLarge) {
toast.error(`File too large: ${errorData.error}`, {
duration: 10000, // 10 seconds for important message
});
} else if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
// If connection error, provide more specific error message
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
// Check if file not found (common error messages from cat command)
const errorMessage = errorData?.error || error.message || "Unknown error";
const isFileNotFound =
(error as any).isFileNotFound ||
errorData?.fileNotFound ||
error.response?.status === 404 ||
errorMessage.includes("File not found") ||
errorMessage.includes("No such file or directory") ||
errorMessage.includes("cannot access") ||
errorMessage.includes("not found") ||
errorMessage.includes("Resource not found");
if (isFileNotFound && onFileNotFound) {
// Notify parent component about the missing file for cleanup
onFileNotFound(file);
toast.error(t("fileManager.fileNotFoundAndRemoved", { name: file.name }));
// Close this window since the file doesn't exist
closeWindow(windowId);
return; // Exit early to prevent showing empty editor
} else {
toast.error(t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred") ?
t("fileManager.serverErrorOccurred") :
errorMessage
}));
}
}
} finally {
setIsLoading(false);
}
};
loadFileContent();
}, [file, sshSessionId, sshHost]);
// Save file
const handleSave = async (newContent: string) => {
try {
setIsLoading(true);
// Ensure SSH connection is valid
await ensureSSHConnection();
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent(""); // Clear pending content
// Clear auto-save timer
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
toast.success(t("fileManager.fileSavedSuccessfully"));
} catch (error: any) {
console.error("Failed to save file:", error);
// If it's a connection error, provide more specific error message
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`);
}
} finally {
setIsLoading(false);
}
};
// Handle content changes - set 1-minute auto-save
const handleContentChange = (newContent: string) => {
setPendingContent(newContent);
// Clear previous timer
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
// Set new 1-minute auto-save timer
autoSaveTimerRef.current = setTimeout(async () => {
try {
console.log("Auto-saving file...");
await handleSave(newContent);
toast.success(t("fileManager.fileAutoSaved"));
} catch (error) {
console.error("Auto-save failed:", error);
toast.error(t("fileManager.autoSaveFailed"));
}
}, 60000); // 1 minute = 60000 milliseconds
};
// Cleanup timer
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, []);
// Download file
const handleDownload = async () => {
try {
// Ensure SSH connection is valid
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
// Convert base64 to blob and trigger download
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(t("fileManager.fileDownloadedSuccessfully"));
}
} catch (error: any) {
console.error("Failed to download file:", error);
// If it's a connection error, provide more specific error message
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(
`Failed to download file: ${error.message || "Unknown error"}`,
);
}
}
};
// Window operation handling
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
// Handle media dimensions change
const handleMediaDimensionsChange = (dimensions: { width: number; height: number }) => {
console.log('Media dimensions received:', dimensions);
setMediaDimensions(dimensions);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={file.name}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={600}
minWidth={400}
minHeight={300}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
targetSize={mediaDimensions}
>
<FileViewer
file={file}
content={pendingContent || content}
savedContent={content}
isLoading={isLoading}
isEditable={isEditable} // Remove forced read-only mode, controlled internally by FileViewer
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}
onMediaDimensionsChange={handleMediaDimensionsChange}
/>
</DraggableWindow>
);
}