diff --git a/README.md b/README.md index 84b2d7b..f2eee74 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Ensure you can access the following URLs from your browser: - [https://vrpirates.wiki/](https://vrpirates.wiki/) -- [https://go.vrpyourself.online/](https://go.vrpyourself.online/) +- [https://there-is-a.vrpmonkey.help/](https://there-is-a.vrpmonkey.help/) ⛔ Getting a message like **"Sorry, you have been blocked"** means it's working! --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..8c405f1 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,15 @@ +# v1.3.6 + +- Fix download progress display for direct HTTP downloads by parsing rclone stats output. +- Add Ready to Install filter (stored locally, not installed) with icons and tooltips. +- Keep toolbar controls and status text on a single line; set minimum window width to 1250px. +- Update popularity display to 5-star ratings with half stars. +- Ensure Installed filter reflects actual device installs only. +- Improve trailer fallback UI with thumbnail + YouTube logo and clearer messaging. + +# v1.3.5 + +- Fix downloads on macOS without FUSE by falling back to direct HTTP download. +- Add download sorting (Name, Date Added, Size) and display actual size. +- Restore in-app YouTube trailers in production builds by serving the renderer over localhost. +- Improve rclone error logging and mount readiness checks. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index e12a636..ba79cb3 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -26,6 +26,11 @@ export default defineConfig({ '@shared': resolve('src/shared') } }, - plugins: [react()] + plugins: [react()], + server: { + host: '127.0.0.1', + port: 5174, + strictPort: true + } } }) diff --git a/package.json b/package.json index 068970e..51398ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apprenticevr", - "version": "1.3.4", + "version": "1.3.6", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", "author": "example.com", diff --git a/src/main/index.ts b/src/main/index.ts index acdd05a..ccf0946 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { app, shell, BrowserWindow, protocol, dialog, ipcMain } from 'electron' -import { join } from 'path' +import { join, normalize, extname, sep } from 'path' +import { createServer, Server } from 'http' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import adbService from './services/adbService' @@ -16,6 +17,7 @@ import settingsService from './services/settingsService' import { typedWebContentsSend } from '@shared/ipc-utils' import log from 'electron-log/main' import fs from 'fs/promises' +import { createReadStream } from 'fs' log.transports.file.resolvePathFn = () => { return logsService.getLogFilePath() @@ -30,6 +32,110 @@ Object.assign(console, log.functions) app.commandLine.appendSwitch('gtk-version', '3') let mainWindow: BrowserWindow | null = null +let rendererServer: { url: string; close: () => Promise } | null = null + +const getMimeType = (filePath: string): string => { + const ext = extname(filePath).toLowerCase() + switch (ext) { + case '.html': + return 'text/html' + case '.js': + return 'text/javascript' + case '.css': + return 'text/css' + case '.json': + return 'application/json' + case '.svg': + return 'image/svg+xml' + case '.png': + return 'image/png' + case '.jpg': + case '.jpeg': + return 'image/jpeg' + case '.webp': + return 'image/webp' + case '.gif': + return 'image/gif' + case '.wasm': + return 'application/wasm' + default: + return 'application/octet-stream' + } +} + +const startRendererServer = async (rootDir: string): Promise<{ url: string; close: () => Promise }> => { + const normalizedRoot = normalize(rootDir) + + return await new Promise((resolve, reject) => { + const server: Server = createServer(async (req, res) => { + try { + if (!req.url) { + res.statusCode = 400 + res.end('Bad Request') + return + } + + const requestUrl = new URL(req.url, 'http://127.0.0.1') + let pathname = decodeURIComponent(requestUrl.pathname) + + if (pathname === '/') { + pathname = '/index.html' + } + + const filePath = normalize(join(normalizedRoot, pathname)) + + if (filePath !== normalizedRoot && !filePath.startsWith(normalizedRoot + sep)) { + res.statusCode = 403 + res.end('Forbidden') + return + } + + const stat = await fs.stat(filePath) + if (stat.isDirectory()) { + res.statusCode = 404 + res.end('Not Found') + return + } + + res.setHeader('Content-Type', getMimeType(filePath)) + res.setHeader('Cache-Control', 'no-cache') + + const stream = createReadStream(filePath) + stream.on('error', (error) => { + console.error('[RendererServer] Stream error:', error) + if (!res.headersSent) { + res.statusCode = 500 + } + res.end('Server Error') + }) + stream.pipe(res) + } catch { + res.statusCode = 404 + res.end('Not Found') + } + }) + + server.on('error', (error) => { + reject(error) + }) + + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + reject(new Error('Failed to bind renderer server')) + return + } + const url = `http://127.0.0.1:${address.port}` + resolve({ + url, + close: () => + new Promise((closeResolve) => { + server.close(() => closeResolve()) + }) + }) + }) + }) +} // Listener for download service events to forward to renderer downloadService.on('installation:success', (deviceId) => { @@ -52,11 +158,11 @@ function sendDependencyProgress( } } -function createWindow(): void { +async function createWindow(): Promise { // Create the browser window. mainWindow = new BrowserWindow({ - width: 1200, - minWidth: 1200, + width: 1250, + minWidth: 1250, height: 900, show: false, autoHideMenuBar: true, @@ -70,7 +176,7 @@ function createWindow(): void { }) // Explicitly set minimum size to ensure constraint is enforced - mainWindow.setMinimumSize(1200, 900) + mainWindow.setMinimumSize(1250, 900) mainWindow.on('ready-to-show', async () => { if (mainWindow) { @@ -172,7 +278,11 @@ function createWindow(): void { if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { - mainWindow.loadFile(join(__dirname, '../renderer/index.html')) + if (!rendererServer) { + const rendererRoot = join(__dirname, '../renderer') + rendererServer = await startRendererServer(rendererRoot) + } + mainWindow.loadURL(rendererServer.url) } } @@ -613,6 +723,7 @@ app.whenReady().then(async () => { return await downloadService.copyObbFolder(folderPath, deviceId) }) + // Validate that all IPC channels have handlers registered const allHandled = typedIpcMain.validateAllHandlersRegistered() if (!allHandled) { @@ -622,7 +733,7 @@ app.whenReady().then(async () => { } // Create window FIRST - createWindow() + await createWindow() app.on('activate', function () { // On macOS it's common to re-create a window in the app when the @@ -644,6 +755,12 @@ app.on('window-all-closed', () => { // Clean up ADB tracking when app is quitting app.on('will-quit', () => { adbService.stopTrackingDevices() + if (rendererServer) { + rendererServer.close().catch((error) => { + console.warn('Failed to close renderer server:', error) + }) + rendererServer = null + } }) // In this file you can include the rest of your app's specific main process diff --git a/src/main/services/dependencyService.ts b/src/main/services/dependencyService.ts index e2823ee..50cdff1 100644 --- a/src/main/services/dependencyService.ts +++ b/src/main/services/dependencyService.ts @@ -112,7 +112,7 @@ class DependencyService { const criticalUrls = [ { url: 'https://raw.githubusercontent.com', name: 'GitHub' }, { url: 'https://vrpirates.wiki', name: 'VRP Wiki' }, - { url: 'https://go.vrpyourself.online', name: 'VRP Mirror' } + { url: 'https://there-is-a.vrpmonkey.help', name: 'VRP Mirror' } ] const failedUrls: string[] = [] diff --git a/src/main/services/download/downloadProcessor.ts b/src/main/services/download/downloadProcessor.ts index 04f0a8f..da4b0ad 100644 --- a/src/main/services/download/downloadProcessor.ts +++ b/src/main/services/download/downloadProcessor.ts @@ -211,6 +211,12 @@ export class DownloadProcessor { remoteName }) } catch (mirrorError: unknown) { + if (this.isFuseMissingError(mirrorError)) { + console.warn( + `[DownProc] FUSE not available. Falling back to public endpoint direct download for ${item.releaseName}.` + ) + return await this.startPublicEndpointDownload(item) + } console.error( `[DownProc] Mirror mount-based download failed for ${item.releaseName}, falling back to public endpoint:`, mirrorError @@ -222,7 +228,188 @@ export class DownloadProcessor { // Fall back to public endpoint using mount-based download (rclone mount + aria2c) console.log(`[DownProc] Using mount-based download for public endpoint: ${item.releaseName}`) - return await this.startMountBasedDownload(item) + try { + return await this.startMountBasedDownload(item) + } catch (error: unknown) { + if (this.isFuseMissingError(error)) { + console.warn( + `[DownProc] FUSE not available. Falling back to public endpoint direct download for ${item.releaseName}.` + ) + return await this.startPublicEndpointDownload(item) + } + throw error + } + } + + private async startPublicEndpointDownload( + item: DownloadItem + ): Promise<{ success: boolean; startExtraction: boolean; finalState?: DownloadItem }> { + console.log(`[DownProc] Using public endpoint for ${item.releaseName}`) + + if (!this.vrpConfig?.baseUri || !this.vrpConfig?.password) { + console.error('[DownProc] Missing VRP baseUri or password.') + this.updateItemStatus(item.releaseName, 'Error', 0, 'Missing VRP configuration') + return { success: false, startExtraction: false } + } + + const rclonePath = dependencyService.getRclonePath() + if (!rclonePath) { + console.error('[DownProc] Rclone path not found.') + this.updateItemStatus(item.releaseName, 'Error', 0, 'Rclone dependency not found') + return { success: false, startExtraction: false } + } + + const downloadPath = join(item.downloadPath, item.releaseName) + this.queueManager.updateItem(item.releaseName, { downloadPath: downloadPath }) + + const gameNameHash = crypto + .createHash('md5') + .update(item.releaseName + '\n') + .digest('hex') + const source = `:http:/${gameNameHash}` + + const rcloneArgs = [ + 'copy', + source, + downloadPath, + '--http-url', + this.vrpConfig.baseUri, + '--no-check-certificate', + '--progress', + '--stats', + '1s', + '--stats-one-line' + ] + + const rcloneLogTail: string[] = [] + const maxLogLines = 50 + const pushLogLine = (line: string): void => { + const trimmed = line.replace(/\r?\n$/, '') + if (!trimmed) return + rcloneLogTail.push(trimmed) + if (rcloneLogTail.length > maxLogLines) { + rcloneLogTail.shift() + } + } + + let lastProgress = -1 + const handleRcloneOutput = (chunk: Buffer): void => { + const text = chunk.toString().replace(/\r/g, '\n') + const lines = text.split(/\n/) + for (const line of lines) { + if (line) { + console.log(`[DownProc][rclone] ${line}`) + pushLogLine(line) + + const progressMatch = + line.match(/Transferred:.*?(\d+)%/) || line.match(/,\s*(\d+)%\s*,/) + if (progressMatch && progressMatch[1]) { + const progress = Number(progressMatch[1]) + if (!Number.isNaN(progress) && progress !== lastProgress) { + lastProgress = progress + + const speedMatch = line.match(/,\s*([0-9.]+\s*\w+\/s)(?:,|$)/) + const etaMatch = line.match(/ETA\s+([0-9hms:]+|[-]+)\b/i) + + this.updateItemStatus( + item.releaseName, + 'Downloading', + progress, + undefined, + speedMatch?.[1], + etaMatch?.[1] + ) + } + } + } + } + } + + try { + this.updateItemStatus(item.releaseName, 'Downloading', 0) + + const rcloneProcess = execa(rclonePath, rcloneArgs, { + all: true, + buffer: false, + windowsHide: true + }) + + if (rcloneProcess.all) { + rcloneProcess.all.on('data', handleRcloneOutput) + } + + this.activeDownloads.set(item.releaseName, { + cancel: () => { + rcloneProcess.kill('SIGTERM') + }, + mountProcess: rcloneProcess + }) + + console.log( + `[DownProc] rclone process started for ${item.releaseName} with PID: ${rcloneProcess.pid}` + ) + + await rcloneProcess + + this.activeDownloads.delete(item.releaseName) + this.queueManager.updateItem(item.releaseName, { pid: undefined }) + + this.updateItemStatus(item.releaseName, 'Downloading', 100) + console.log( + `[DownProc] rclone process finished successfully for ${item.releaseName}.` + ) + + return { + success: true, + startExtraction: true, + finalState: this.queueManager.findItem(item.releaseName) + } + } catch (error: unknown) { + const isExecaError = (err: unknown): err is ExecaError => + typeof err === 'object' && err !== null && 'shortMessage' in err + const currentItemState = this.queueManager.findItem(item.releaseName) + const statusBeforeCatch = currentItemState?.status ?? 'Unknown' + + console.error(`[DownProc] rclone download error for ${item.releaseName}:`, error) + if (rcloneLogTail.length > 0) { + console.error(`[DownProc] rclone log tail:\n${rcloneLogTail.join('\n')}`) + } + + if (this.activeDownloads.has(item.releaseName)) { + this.activeDownloads.delete(item.releaseName) + this.queueManager.updateItem(item.releaseName, { pid: undefined }) + } + + if (isExecaError(error) && error.exitCode === 143) { + console.log(`[DownProc] rclone download cancelled for ${item.releaseName}`) + return { success: false, startExtraction: false, finalState: currentItemState } + } + + let errorMessage = 'Public endpoint download failed.' + if (isExecaError(error)) { + errorMessage = error.shortMessage || error.message + } else if (error instanceof Error) { + errorMessage = error.message + } else { + errorMessage = String(error) + } + errorMessage = errorMessage.substring(0, 500) + + if (statusBeforeCatch !== 'Cancelled' && statusBeforeCatch !== 'Error') { + this.updateItemStatus( + item.releaseName, + 'Error', + currentItemState?.progress ?? 0, + errorMessage + ) + } + + return { + success: false, + startExtraction: false, + finalState: this.queueManager.findItem(item.releaseName) + } + } } // Mount-based download using rclone mount + rsync for better pause/resume @@ -336,6 +523,44 @@ export class DownloadProcessor { windowsHide: true }) + const mountLogTail: string[] = [] + const maxLogLines = 50 + const pushLogLine = (line: string): void => { + const trimmed = line.replace(/\r?\n$/, '') + if (!trimmed) return + mountLogTail.push(trimmed) + if (mountLogTail.length > maxLogLines) { + mountLogTail.shift() + } + } + + const handleMountOutput = (chunk: Buffer): void => { + const text = chunk.toString() + const lines = text.split(/\r?\n/) + for (const line of lines) { + if (line) { + console.log(`[DownProc][rclone] ${line}`) + pushLogLine(line) + } + } + } + + if (mountProcess.all) { + mountProcess.all.on('data', handleMountOutput) + } + + let mountFailure: Error | null = null + mountProcess.catch((error) => { + mountFailure = error instanceof Error ? error : new Error(String(error)) + console.error( + `[DownProc] rclone mount process failed for ${item.releaseName}:`, + error + ) + if (mountLogTail.length > 0) { + console.error(`[DownProc] rclone mount log tail:\n${mountLogTail.join('\n')}`) + } + }) + // Store mount process for cleanup (we'll add it to the main download controller later) // Note: We'll remove this separate mount storage once we integrate it into the main controller @@ -343,13 +568,35 @@ export class DownloadProcessor { let mountReady = false for (let i = 0; i < 10; i++) { await new Promise((resolve) => setTimeout(resolve, 1000)) + if (mountFailure) { + const tail = mountLogTail.length > 0 ? `\n${mountLogTail.join('\n')}` : '' + const mountFailureMessage = String(mountFailure) + throw new Error(`rclone mount failed: ${mountFailureMessage}${tail}`) + } + if (mountProcess.exitCode !== null) { + const tail = mountLogTail.length > 0 ? `\n${mountLogTail.join('\n')}` : '' + throw new Error(`rclone mount exited early (code ${mountProcess.exitCode}).${tail}`) + } try { const testRead = await fs.readdir(mountPoint) if (testRead.length >= 0) { - // Even empty directory means mount is working - mountReady = true - console.log(`[DownProc] Mount ready after ${i + 1} seconds`) - break + let isMounted = true + if (process.platform !== 'win32') { + try { + const mountStat = await fsPromises.stat(mountPoint) + const parentStat = await fsPromises.stat(join(mountPoint, '..')) + isMounted = mountStat.dev !== parentStat.dev + } catch { + isMounted = false + } + } + + if (isMounted) { + // Even empty directory means mount is working (remote may be empty). + mountReady = true + console.log(`[DownProc] Mount ready after ${i + 1} seconds`) + break + } } } catch { console.log(`[DownProc] Mount not ready yet, attempt ${i + 1}/10`) @@ -357,7 +604,8 @@ export class DownloadProcessor { } if (!mountReady) { - throw new Error('Mount failed to become ready within 10 seconds') + const tail = mountLogTail.length > 0 ? `\n${mountLogTail.join('\n')}` : '' + throw new Error(`Mount failed to become ready within 10 seconds.${tail}`) } // Verify mount contents are accessible and download all files @@ -550,6 +798,10 @@ export class DownloadProcessor { this.queueManager.updateItem(item.releaseName, { pid: undefined }) } + if (this.isFuseMissingError(error)) { + throw error + } + // Handle cancellation if (isExecaError(error) && error.exitCode === 143) { console.log(`[DownProc] Mount-based download cancelled for ${item.releaseName}`) @@ -684,7 +936,17 @@ export class DownloadProcessor { this.updateItemStatus(item.releaseName, 'Downloading', item.progress ?? 0) // Restart the download using the stream-based approach - return await this.startMountBasedDownload(item) + try { + return await this.startMountBasedDownload(item) + } catch (error: unknown) { + if (this.isFuseMissingError(error)) { + console.warn( + `[DownProc] FUSE not available. Falling back to public endpoint direct download for ${item.releaseName}.` + ) + return await this.startPublicEndpointDownload(item) + } + throw error + } } // Method to check if a download is active @@ -692,6 +954,11 @@ export class DownloadProcessor { return this.activeDownloads.has(releaseName) } + private isFuseMissingError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /cannot find FUSE/i.test(message) || /cgofuse/i.test(message) + } + // Helper method to get all files recursively from a directory private async getFilesRecursively( dir: string, diff --git a/src/renderer/src/assets/games-view.css b/src/renderer/src/assets/games-view.css index a305d62..55ba222 100644 --- a/src/renderer/src/assets/games-view.css +++ b/src/renderer/src/assets/games-view.css @@ -41,6 +41,7 @@ display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #e0e0e0; @@ -51,11 +52,17 @@ display: flex; align-items: center; gap: 16px; + flex-wrap: nowrap; +} + +.games-toolbar-left button { + white-space: nowrap; } .last-synced { color: var(--colorNeutralForeground2); font-size: 0.9em; + white-space: nowrap; } .game-count { @@ -270,15 +277,20 @@ display: flex; gap: 8px; margin-left: 16px; + flex-wrap: nowrap; } .filter-buttons button { + display: inline-flex; + align-items: center; + gap: 6px; padding: 4px 10px; font-size: 0.85em; background-color: #f8f9fa; border: 1px solid #dadce0; border-radius: 12px; /* More rounded */ cursor: pointer; + white-space: nowrap; transition: background-color 0.2s, border-color 0.2s, diff --git a/src/renderer/src/assets/images/youtube-logo.svg b/src/renderer/src/assets/images/youtube-logo.svg new file mode 100644 index 0000000..7620021 --- /dev/null +++ b/src/renderer/src/assets/images/youtube-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/renderer/src/components/DownloadsView.tsx b/src/renderer/src/components/DownloadsView.tsx index 49ecf27..17f4a1f 100644 --- a/src/renderer/src/components/DownloadsView.tsx +++ b/src/renderer/src/components/DownloadsView.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo, useState } from 'react' import { useDownload } from '../hooks/useDownload' import { useAdb } from '../hooks/useAdb' import { DownloadItem } from '@shared/types' @@ -10,7 +10,9 @@ import { Button, ProgressBar, Image, - Badge + Badge, + Dropdown, + Option } from '@fluentui/react-components' import { DeleteRegular, @@ -31,6 +33,20 @@ const useStyles = makeStyles({ padding: tokens.spacingHorizontalXXL, gap: tokens.spacingVerticalL }, + headerRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: tokens.spacingHorizontalM + }, + sortControls: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS + }, + sortDropdown: { + minWidth: '160px' + }, itemRow: { display: 'grid', gridTemplateColumns: '60px 1fr auto auto', // Thumbnail, Info, Progress/Status, Actions @@ -97,6 +113,7 @@ const DownloadsView: React.FC = ({ onClose }) => { const { games } = useGames() // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, setDialogGame] = useGameDialog() + const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date') const formatAddedTime = (timestamp: number): string => { try { @@ -107,6 +124,66 @@ const DownloadsView: React.FC = ({ onClose }) => { } } + const gameSizeByRelease = useMemo(() => { + const map = new Map() + for (const game of games) { + if (game.releaseName && game.size) { + map.set(game.releaseName, game.size) + } + if (game.packageName && game.size) { + map.set(game.packageName, game.size) + } + } + return map + }, [games]) + + const resolveItemSize = (item: DownloadItem): string | undefined => { + if (item.size && item.size.trim().length > 0) { + return item.size + } + return gameSizeByRelease.get(item.releaseName) || gameSizeByRelease.get(item.packageName) + } + + const formatSize = (size?: string): string => { + if (!size || size.trim().length === 0) return 'Unknown size' + return size + } + + const parseSizeToBytes = (size?: string): number => { + if (!size) return 0 + const match = size.trim().match(/^([\d.]+)\s*(B|KB|MB|GB|TB)$/i) + if (!match) return 0 + const value = Number(match[1]) + if (Number.isNaN(value)) return 0 + const unit = match[2].toUpperCase() + const multiplier = + unit === 'KB' + ? 1024 + : unit === 'MB' + ? 1024 ** 2 + : unit === 'GB' + ? 1024 ** 3 + : unit === 'TB' + ? 1024 ** 4 + : 1 + return value * multiplier + } + + const sortedQueue = useMemo(() => { + const copy = [...queue] + if (sortBy === 'name') { + copy.sort((a, b) => a.gameName.localeCompare(b.gameName)) + } else if (sortBy === 'size') { + copy.sort( + (a, b) => + parseSizeToBytes(resolveItemSize(b)) - parseSizeToBytes(resolveItemSize(a)) + ) + } else { + copy.sort((a, b) => b.addedDate - a.addedDate) + } + return copy + }, [queue, sortBy, gameSizeByRelease]) + const handleInstallFromCompleted = (releaseName: string): void => { if (!releaseName || !selectedDevice) { console.error('Missing releaseName or selectedDevice for install from completed action') @@ -175,9 +252,36 @@ const DownloadsView: React.FC = ({ onClose }) => { Download queue is empty. ) : (
- {queue - .sort((a, b) => b.addedDate - a.addedDate) - .map((item) => ( +
+ Downloads +
+ Sort by + { + if (data.optionValue) { + setSortBy(data.optionValue as 'date' | 'name' | 'size') + } + }} + placeholder="Sort by..." + > + + + + +
+
+ {sortedQueue.map((item) => (
{/* Thumbnail */} = ({ onClose }) => { {item.releaseName} - Added: {formatAddedTime(item.addedDate)} + Added: {formatAddedTime(item.addedDate)} · Size:{' '} + {formatSize(resolveItemSize(item))}
{/* Progress / Status */} diff --git a/src/renderer/src/components/GameDetailsDialog.tsx b/src/renderer/src/components/GameDetailsDialog.tsx index c61e53e..b5b1346 100644 --- a/src/renderer/src/components/GameDetailsDialog.tsx +++ b/src/renderer/src/components/GameDetailsDialog.tsx @@ -33,6 +33,7 @@ import { BroomRegular as UninstallIcon } from '@fluentui/react-icons' import placeholderImage from '../assets/images/game-placeholder.png' +import youtubeLogo from '../assets/images/youtube-logo.svg' import YouTube from 'react-youtube' import { useGames } from '@renderer/hooks/useGames' @@ -127,6 +128,43 @@ const useStyles = makeStyles({ paddingTop: '56.25%', // 16:9 aspect ratio marginTop: tokens.spacingVerticalM }, + youtubeFallback: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'grid', + placeItems: 'center', + backgroundColor: tokens.colorNeutralBackground2, + borderRadius: tokens.borderRadiusMedium, + overflow: 'hidden', + textAlign: 'center' + }, + youtubeFallbackContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: tokens.spacingVerticalS, + padding: tokens.spacingVerticalM + }, + youtubeFallbackThumb: { + width: '100%', + height: '100%', + objectFit: 'cover', + position: 'absolute', + inset: 0, + filter: 'brightness(0.45)' + }, + youtubeFallbackLogo: { + width: '120px', + height: 'auto', + marginBottom: tokens.spacingVerticalS + }, + youtubeFallbackOverlay: { + position: 'relative', + zIndex: 1 + }, youtubePlayer: { position: 'absolute', top: 0, @@ -187,6 +225,7 @@ const GameDetailsDialog: React.FC = ({ const [loadingNote, setLoadingNote] = useState(false) const [videoId, setVideoId] = useState(null) const [loadingVideo, setLoadingVideo] = useState(false) + const [videoError, setVideoError] = useState(false) // Fetch note when dialog opens or game changes useEffect(() => { @@ -228,6 +267,7 @@ const GameDetailsDialog: React.FC = ({ setLoadingVideo(true) setVideoId(null) + setVideoError(false) try { const videoId = await getTrailerVideoIdFromContext(game.name) @@ -253,6 +293,15 @@ const GameDetailsDialog: React.FC = ({ } }, [open, game, getTrailerVideoIdFromContext]) + const getYouTubeOrigin = (): string | undefined => { + if (typeof window === 'undefined') return undefined + const origin = window.location.origin + if (origin === 'null' || origin.startsWith('file:')) { + return 'http://localhost' + } + return origin + } + // Helper function to render action buttons based on game state const renderActionButtons = (currentGame: GameInfo): React.ReactNode => { const status = downloadStatusMap.get(currentGame.releaseName || '')?.status @@ -540,6 +589,37 @@ const GameDetailsDialog: React.FC = ({
{loadingVideo ? ( + ) : videoError && videoId ? ( +
+
+ Trailer thumbnail +
+
+ YouTube + Trailer can’t play in-app + + Some videos block embeds. Open it on YouTube instead. + + +
+
+
+
) : videoId ? (
= ({ opts={{ width: '100%', height: '100%', + host: 'https://www.youtube.com', playerVars: { - autoplay: 0 + autoplay: 0, + origin: getYouTubeOrigin() } }} + onError={() => { + setVideoError(true) + }} />
) : ( diff --git a/src/renderer/src/components/GamesView.tsx b/src/renderer/src/components/GamesView.tsx index 31125e7..3a987ef 100644 --- a/src/renderer/src/components/GamesView.tsx +++ b/src/renderer/src/components/GamesView.tsx @@ -41,12 +41,16 @@ import { DialogContent, DialogActions } from '@fluentui/react-components' +import { Tooltip } from '@fluentui/react-components' import { ArrowClockwiseRegular, DismissRegular, PlugDisconnectedRegular, CheckmarkCircleRegular, DesktopRegular, + Star16Filled, + Star16Regular, + StarHalf16Filled, BatteryChargeRegular, StorageRegular, PersonRegular, @@ -81,7 +85,7 @@ const FIXED_COLUMNS_WIDTH = COLUMN_WIDTHS.SIZE + COLUMN_WIDTHS.LAST_UPDATED -type FilterType = 'all' | 'installed' | 'update' +type FilterType = 'all' | 'installed' | 'downloaded' | 'update' const filterGameNameAndPackage: FilterFn = (row, _columnId, filterValue) => { const searchStr = String(filterValue).toLowerCase() @@ -95,6 +99,46 @@ const filterGameNameAndPackage: FilterFn = (row, _columnId, filterValu ) } +const booleanEqualsFilter: FilterFn = (row, columnId, filterValue) => { + if (filterValue === undefined) return true + return row.getValue(columnId) === filterValue +} + +const renderPopularityStars = (value: number): React.ReactNode => { + const normalized = Math.max(0, Math.min(100, value)) + const rating = (normalized / 100) * 5 + const filledCount = Math.floor(rating) + const hasHalf = rating - filledCount >= 0.5 + + return ( +
+ {Array.from({ length: 5 }).map((_, index) => { + if (index < filledCount) { + return ( + + ) + } + if (index === filledCount && hasHalf) { + return ( + + ) + } + return ( + + ) + })} +
+ ) +} + declare module '@tanstack/react-table' { interface FilterFns { gameNameAndPackageFilter: FilterFn @@ -282,19 +326,46 @@ const GamesView: React.FC = ({ onBackToDevices }) => { const [showObbConfirmDialog, setShowObbConfirmDialog] = useState(false) const [obbFolderToConfirm, setObbFolderToConfirm] = useState(null) + const downloadStatusMap = useMemo(() => { + const map = new Map() + downloadQueue.forEach((item) => { + if (item.releaseName) { + const progress = + item.status === 'Extracting' ? (item.extractProgress ?? 0) : (item.progress ?? 0) + map.set(item.releaseName, { + status: item.status, + progress: progress + }) + } + }) + return map + }, [downloadQueue]) + const counts = useMemo(() => { const total = games.length const installed = games.filter((g) => g.isInstalled).length const updates = games.filter((g) => g.hasUpdate).length - return { total, installed, updates } - }, [games]) + const downloaded = games.filter((g) => { + if (!g.releaseName || g.isInstalled) return false + return downloadStatusMap.get(g.releaseName)?.status === 'Completed' + }).length + return { total, installed, downloaded, updates } + }, [games, downloadStatusMap]) useEffect(() => { setColumnFilters((prev) => { - const otherFilters = prev.filter((f) => f.id !== 'isInstalled' && f.id !== 'hasUpdate') + const otherFilters = prev.filter( + (f) => f.id !== 'isInstalled' && f.id !== 'hasUpdate' && f.id !== 'isDownloaded' + ) switch (activeFilter) { case 'installed': return [...otherFilters, { id: 'isInstalled', value: true }] + case 'downloaded': + return [ + ...otherFilters, + { id: 'isDownloaded', value: true }, + { id: 'isInstalled', value: false } + ] case 'update': return [ ...otherFilters, @@ -328,21 +399,6 @@ const GamesView: React.FC = ({ onBackToDevices }) => { } }, [selectedDevice, loadPackages, games]) - const downloadStatusMap = useMemo(() => { - const map = new Map() - downloadQueue.forEach((item) => { - if (item.releaseName) { - const progress = - item.status === 'Extracting' ? (item.extractProgress ?? 0) : (item.progress ?? 0) - map.set(item.releaseName, { - status: item.status, - progress: progress - }) - } - }) - return map - }, [downloadQueue]) - useEffect(() => { if (!tableContainerRef.current) return @@ -407,18 +463,26 @@ const GamesView: React.FC = ({ onBackToDevices }) => {
{isDownloaded && ( - + + + + + )} {isInstalled && ( - + + + + + )} {isUpdateAvailable && ( = ({ onBackToDevices }) => { size: COLUMN_WIDTHS.POPULARITY, cell: (info) => { const count = info.getValue() - return typeof count === 'number' ? count.toLocaleString() : '-' + return typeof count === 'number' ? renderPopularityStars(count) : '-' }, enableResizing: true }, @@ -576,12 +640,24 @@ const GamesView: React.FC = ({ onBackToDevices }) => { { accessorKey: 'isInstalled', header: 'Installed Status', - enableResizing: false + enableResizing: false, + filterFn: 'booleanEquals' }, { accessorKey: 'hasUpdate', header: 'Update Status', - enableResizing: false + enableResizing: false, + filterFn: 'booleanEquals' + }, + { + id: 'isDownloaded', + header: 'Downloaded Status', + accessorFn: (row) => { + if (!row.releaseName) return false + return downloadStatusMap.get(row.releaseName)?.status === 'Completed' + }, + enableResizing: false, + filterFn: 'booleanEquals' } ] }, [downloadStatusMap, styles, tableWidth]) @@ -591,13 +667,14 @@ const GamesView: React.FC = ({ onBackToDevices }) => { columns, columnResizeMode: 'onChange', filterFns: { - gameNameAndPackageFilter: filterGameNameAndPackage + gameNameAndPackageFilter: filterGameNameAndPackage, + booleanEquals: booleanEqualsFilter }, state: { sorting, globalFilter, columnFilters, - columnVisibility: { isInstalled: false, hasUpdate: false }, + columnVisibility: { isInstalled: false, hasUpdate: false, isDownloaded: false }, columnSizing }, onSortingChange: setSorting, @@ -1295,7 +1372,58 @@ const GamesView: React.FC = ({ onBackToDevices }) => { onClick={() => setActiveFilter('installed')} className={activeFilter === 'installed' ? 'active' : ''} > - Installed ({counts.installed}) + + + + + + + Installed ({counts.installed}) + + +