mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Compare commits
37 Commits
feat/LBX-3
...
feat/wrapp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35736dd2d9 | ||
|
|
86d5547aa1 | ||
|
|
358f41b4ba | ||
|
|
7f0dc5eee4 | ||
|
|
067f7a00be | ||
|
|
37f085e2c0 | ||
|
|
f8ac284bc2 | ||
|
|
b1d72828bb | ||
|
|
4c09f915c6 | ||
|
|
24d65b50b4 | ||
|
|
f77b2116c1 | ||
|
|
34681b3bc2 | ||
|
|
4b71a3f5bb | ||
|
|
a0f669c97b | ||
|
|
96fca88601 | ||
|
|
3f65bb86a8 | ||
|
|
b494c7c8ec | ||
|
|
d5e6bed3b7 | ||
|
|
214267df7e | ||
|
|
21f46c9af3 | ||
|
|
cc5e0014f7 | ||
|
|
ccb754fa13 | ||
|
|
142bd3156c | ||
|
|
95a7bc2236 | ||
|
|
78d2be85f2 | ||
|
|
67ea9e78a2 | ||
|
|
67f863e0f3 | ||
|
|
77b6f1b2ad | ||
|
|
5329cc446f | ||
|
|
21a0ad1500 | ||
|
|
9ffaee12d1 | ||
|
|
8555274589 | ||
|
|
a152c89d7f | ||
|
|
879f0baad7 | ||
|
|
c025dc199d | ||
|
|
5b4b258526 | ||
|
|
0268829946 |
@@ -420,7 +420,9 @@
|
||||
"delete_archive_title": "Would you like to delete {{fileName}}?",
|
||||
"delete_archive_description": "The file has been successfully extracted and it's no longer needed.",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
"no": "No",
|
||||
"network": "NETWORK",
|
||||
"peak": "PEAK"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Downloads path",
|
||||
@@ -721,7 +723,10 @@
|
||||
"karma_description": "Earned from positive likes on reviews",
|
||||
"user_reviews": "Reviews",
|
||||
"delete_review": "Delete Review",
|
||||
"loading_reviews": "Loading reviews..."
|
||||
"loading_reviews": "Loading reviews...",
|
||||
"wrapped_2025": "Wrapped 2025",
|
||||
"view_my_wrapped_button": "View My Wrapped 2025",
|
||||
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
|
||||
},
|
||||
"library": {
|
||||
"library": "Library",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
|
||||
"filter": "Könyvtár szűrése",
|
||||
"home": "Főoldal",
|
||||
"queued": "A(z) {{title}} (Várakozósorban van)",
|
||||
"queued": "{{title}} (Várakozásban)",
|
||||
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
|
||||
"sign_in": "Bejelentkezés",
|
||||
"friends": "Barátok",
|
||||
@@ -94,6 +94,12 @@
|
||||
"header": {
|
||||
"search": "Keresés",
|
||||
"search_library": "Könyvtár böngészése",
|
||||
"recent_searches": "Korábbi Keresések",
|
||||
"suggestions": "Találatok",
|
||||
"clear_history": "Törlés",
|
||||
"remove_from_history": "Törlés az előzményekből",
|
||||
"loading": "Töltés...",
|
||||
"no_results": "Nincs találat",
|
||||
"home": "Főoldal",
|
||||
"catalogue": "Katalógus",
|
||||
"library": "Könyvtár",
|
||||
@@ -109,6 +115,7 @@
|
||||
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
|
||||
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
|
||||
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
|
||||
"extracting": "{{title}} kicsomagolása… ({{percentage}} kicsomagolva)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Telepítés befejezve",
|
||||
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
|
||||
@@ -165,7 +172,7 @@
|
||||
"playing_now": "Játékban: ",
|
||||
"change": "Változtatás",
|
||||
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
|
||||
"select_folder_hint": "A letöltési mappát a <0>Beállítások</0> menüjében változtathatod meg",
|
||||
"select_folder_hint": "A letöltési mappát a <0>Beállításokban</0> változtathatod meg",
|
||||
"download_now": "Letöltés",
|
||||
"no_shop_details": "A bolt adatai nem érhetőek el.",
|
||||
"download_options": "Letöltési opciók",
|
||||
@@ -196,6 +203,7 @@
|
||||
"danger_zone_section_description": "Itt eltávolítható a játék a könyvtáradból, vagy a fájlok amelyek a Hydra által lettek letöltve",
|
||||
"download_in_progress": "Letöltés folyamatban",
|
||||
"download_paused": "Letöltés szüneteltetve",
|
||||
"extracting": "Kicsomagolás",
|
||||
"last_downloaded_option": "Utoljára letöltött",
|
||||
"new_download_option": "Új",
|
||||
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
||||
@@ -397,7 +405,7 @@
|
||||
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
|
||||
"install": "Telepít",
|
||||
"download_in_progress": "Folyamatban lévő",
|
||||
"queued_downloads": "Várakozósoron lévő letöltések",
|
||||
"queued_downloads": "Várakozásban lévő letöltések",
|
||||
"downloads_completed": "Befejezett",
|
||||
"queued": "Várakozásban",
|
||||
"no_downloads_title": "Oly üres..",
|
||||
@@ -408,7 +416,11 @@
|
||||
"resume_seeding": "Seedelés folytatása",
|
||||
"options": "Kezelés",
|
||||
"extract": "Fájlok kibontása",
|
||||
"extracting": "Fájlok kibontása…"
|
||||
"extracting": "Fájlok kibontása…",
|
||||
"delete_archive_title": "Szeretnéd törölni ezt a fájlt? {{fileName}}",
|
||||
"delete_archive_description": "A tömörített fájl ki lett csomagolva, s többé nincs rá szükség. ",
|
||||
"yes": "Igen",
|
||||
"no": "Nem"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Letöltési útvonalak",
|
||||
@@ -669,7 +681,7 @@
|
||||
"no_blocked_users": "Nincs letiltott felhasználó",
|
||||
"friend_code_copied": "Barát kód kimásolva",
|
||||
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
|
||||
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe",
|
||||
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállításokba</0>",
|
||||
"locked_profile": "Ez a profil privát",
|
||||
"image_process_failure": "Hiba a kép feldolgozása közben",
|
||||
"required_field": "Ez a mező kötelező",
|
||||
|
||||
@@ -408,7 +408,9 @@
|
||||
"delete_archive_title": "Deseja deletar {{fileName}}?",
|
||||
"delete_archive_description": "O arquivo foi extraído com sucesso e não é mais necessário.",
|
||||
"yes": "Sim",
|
||||
"no": "Não"
|
||||
"no": "Não",
|
||||
"network": "REDE",
|
||||
"peak": "PICO"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Diretório dos downloads",
|
||||
|
||||
@@ -41,7 +41,6 @@ const startGameDownload = async (
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
|
||||
|
||||
/* Delete any previous download */
|
||||
await downloadsSublevel.del(gameKey);
|
||||
|
||||
if (game) {
|
||||
@@ -124,6 +123,42 @@ const startGameDownload = async (
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (downloader === Downloader.Buzzheavier) {
|
||||
if (err.message.includes("Rate limit")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Buzzheavier: Rate limit exceeded",
|
||||
};
|
||||
}
|
||||
if (
|
||||
err.message.includes("not found") ||
|
||||
err.message.includes("deleted")
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Buzzheavier: File not found",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (downloader === Downloader.FuckingFast) {
|
||||
if (err.message.includes("Rate limit")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "FuckingFast: Rate limit exceeded",
|
||||
};
|
||||
}
|
||||
if (
|
||||
err.message.includes("not found") ||
|
||||
err.message.includes("deleted")
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "FuckingFast: File not found",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,59 @@ import { RealDebridClient } from "./real-debrid";
|
||||
import path from "path";
|
||||
import { logger } from "../logger";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
import { orderBy } from "lodash-es";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { TorBoxClient } from "./torbox";
|
||||
import { GameFilesManager } from "../game-files-manager";
|
||||
import { HydraDebridClient } from "./hydra-debrid";
|
||||
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
|
||||
|
||||
export class DownloadManager {
|
||||
private static downloadingGameId: string | null = null;
|
||||
|
||||
private static extractFilename(url: string, originalUrl?: string): string | undefined {
|
||||
if (originalUrl?.includes('#')) {
|
||||
const hashPart = originalUrl.split('#')[1];
|
||||
if (hashPart && !hashPart.startsWith('http')) return hashPart;
|
||||
}
|
||||
|
||||
if (url.includes('#')) {
|
||||
const hashPart = url.split('#')[1];
|
||||
if (hashPart && !hashPart.startsWith('http')) return hashPart;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const filename = urlObj.pathname.split('/').pop();
|
||||
if (filename?.length) return filename;
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static sanitizeFilename(filename: string): string {
|
||||
return filename.replace(/[<>:"/\\|?*]/g, '_');
|
||||
}
|
||||
|
||||
private static createDownloadPayload(directUrl: string, originalUrl: string, downloadId: string, savePath: string) {
|
||||
const filename = this.extractFilename(directUrl, originalUrl);
|
||||
const sanitizedFilename = filename ? this.sanitizeFilename(filename) : undefined;
|
||||
|
||||
if (sanitizedFilename) {
|
||||
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
|
||||
}
|
||||
|
||||
return {
|
||||
action: "start" as const,
|
||||
game_id: downloadId,
|
||||
url: directUrl,
|
||||
save_path: savePath,
|
||||
out: sanitizedFilename,
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
|
||||
public static async startRPC(
|
||||
download?: Download,
|
||||
downloadsToSeed?: Download[]
|
||||
@@ -53,9 +98,7 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
private static async getDownloadStatus() {
|
||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
|
||||
"/status"
|
||||
);
|
||||
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>("/status");
|
||||
if (response.data === null || !this.downloadingGameId) return null;
|
||||
const downloadId = this.downloadingGameId;
|
||||
|
||||
@@ -71,8 +114,7 @@ export class DownloadManager {
|
||||
status,
|
||||
} = response.data;
|
||||
|
||||
const isDownloadingMetadata =
|
||||
status === LibtorrentStatus.DownloadingMetadata;
|
||||
const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata;
|
||||
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
|
||||
|
||||
const download = await downloadsSublevel.get(downloadId);
|
||||
@@ -121,29 +163,29 @@ export class DownloadManager {
|
||||
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
levelKeys.userPreferences,
|
||||
{
|
||||
valueEncoding: "json",
|
||||
}
|
||||
{ valueEncoding: "json" }
|
||||
);
|
||||
|
||||
if (WindowManager.mainWindow && download) {
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
JSON.parse(JSON.stringify({ ...status, game }))
|
||||
);
|
||||
}
|
||||
|
||||
const shouldExtract = download.automaticallyExtract;
|
||||
|
||||
// Handle download completion BEFORE sending progress to renderer
|
||||
// This ensures extraction starts and DB is updated before UI reacts
|
||||
if (progress === 1 && download) {
|
||||
publishDownloadCompleteNotification(game);
|
||||
|
||||
if (
|
||||
userPreferences?.seedAfterDownloadComplete &&
|
||||
download.downloader === Downloader.Torrent
|
||||
) {
|
||||
if (userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent) {
|
||||
await downloadsSublevel.put(gameId, {
|
||||
...download,
|
||||
status: "seeding",
|
||||
shouldSeed: true,
|
||||
queued: false,
|
||||
extracting: shouldExtract,
|
||||
extractionProgress: shouldExtract ? 0 : download.extractionProgress,
|
||||
});
|
||||
} else {
|
||||
await downloadsSublevel.put(gameId, {
|
||||
@@ -152,54 +194,31 @@ export class DownloadManager {
|
||||
shouldSeed: false,
|
||||
queued: false,
|
||||
extracting: shouldExtract,
|
||||
extractionProgress: shouldExtract ? 0 : download.extractionProgress,
|
||||
});
|
||||
|
||||
this.cancelDownload(gameId);
|
||||
}
|
||||
|
||||
if (shouldExtract) {
|
||||
// Send initial extraction progress BEFORE download progress
|
||||
// This ensures the UI shows extraction immediately
|
||||
WindowManager.mainWindow?.webContents.send(
|
||||
"on-extraction-progress",
|
||||
game.shop,
|
||||
game.objectId,
|
||||
0
|
||||
);
|
||||
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
|
||||
|
||||
const gameFilesManager = new GameFilesManager(
|
||||
game.shop,
|
||||
game.objectId
|
||||
);
|
||||
|
||||
if (
|
||||
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
|
||||
download.folderName?.endsWith(ext)
|
||||
)
|
||||
) {
|
||||
if (FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext))) {
|
||||
gameFilesManager.extractDownloadedFile();
|
||||
} else {
|
||||
gameFilesManager
|
||||
.extractFilesInDirectory(
|
||||
path.join(download.downloadPath, download.folderName!)
|
||||
)
|
||||
.then(() => {
|
||||
gameFilesManager.setExtractionComplete();
|
||||
});
|
||||
.extractFilesInDirectory(path.join(download.downloadPath, download.folderName!))
|
||||
.then(() => gameFilesManager.setExtractionComplete());
|
||||
}
|
||||
}
|
||||
|
||||
const downloads = await downloadsSublevel
|
||||
.values()
|
||||
.all()
|
||||
.then((games) => {
|
||||
return orderBy(
|
||||
games.filter((game) => game.status === "paused" && game.queued),
|
||||
"timestamp",
|
||||
"desc"
|
||||
);
|
||||
});
|
||||
.then((games) => sortBy(
|
||||
games.filter((game) => game.status === "paused" && game.queued),
|
||||
"timestamp",
|
||||
"DESC"
|
||||
));
|
||||
|
||||
const [nextItemOnQueue] = downloads;
|
||||
|
||||
@@ -209,18 +228,6 @@ export class DownloadManager {
|
||||
this.downloadingGameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Send progress to renderer after completion handling
|
||||
if (WindowManager.mainWindow && download) {
|
||||
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
|
||||
WindowManager.mainWindow.webContents.send(
|
||||
"on-download-progress",
|
||||
structuredClone({
|
||||
...status,
|
||||
game,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,9 +245,7 @@ export class DownloadManager {
|
||||
|
||||
if (!download) return;
|
||||
|
||||
const totalSize = await getDirSize(
|
||||
path.join(download.downloadPath, status.folderName)
|
||||
);
|
||||
const totalSize = await getDirSize(path.join(download.downloadPath, status.folderName));
|
||||
|
||||
if (totalSize < status.fileSize) {
|
||||
await this.cancelDownload(status.gameId);
|
||||
@@ -261,10 +266,7 @@ export class DownloadManager {
|
||||
|
||||
static async pauseDownload(downloadKey = this.downloadingGameId) {
|
||||
await PythonRPC.rpc
|
||||
.post("/action", {
|
||||
action: "pause",
|
||||
game_id: downloadKey,
|
||||
} as PauseDownloadPayload)
|
||||
.post("/action", { action: "pause", game_id: downloadKey } as PauseDownloadPayload)
|
||||
.catch(() => {});
|
||||
|
||||
if (downloadKey === this.downloadingGameId) {
|
||||
@@ -279,13 +281,8 @@ export class DownloadManager {
|
||||
|
||||
static async cancelDownload(downloadKey = this.downloadingGameId) {
|
||||
await PythonRPC.rpc
|
||||
.post("/action", {
|
||||
action: "cancel",
|
||||
game_id: downloadKey,
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to cancel game download", err);
|
||||
});
|
||||
.post("/action", { action: "cancel", game_id: downloadKey })
|
||||
.catch((err) => logger.error("Failed to cancel game download", err));
|
||||
|
||||
if (downloadKey === this.downloadingGameId) {
|
||||
WindowManager.mainWindow?.setProgressBar(-1);
|
||||
@@ -318,7 +315,6 @@ export class DownloadManager {
|
||||
const id = download.uri.split("/").pop();
|
||||
const token = await GofileApi.authorize();
|
||||
const downloadLink = await GofileApi.getDownloadLink(id!);
|
||||
|
||||
await GofileApi.checkDownloadUrl(downloadLink);
|
||||
|
||||
return {
|
||||
@@ -360,9 +356,30 @@ export class DownloadManager {
|
||||
save_path: download.downloadPath,
|
||||
};
|
||||
}
|
||||
case Downloader.Buzzheavier: {
|
||||
logger.log(`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`);
|
||||
try {
|
||||
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
|
||||
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
|
||||
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
|
||||
} catch (error) {
|
||||
logger.error(`[DownloadManager] Error processing Buzzheavier download:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case Downloader.FuckingFast: {
|
||||
logger.log(`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`);
|
||||
try {
|
||||
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
|
||||
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
|
||||
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
|
||||
} catch (error) {
|
||||
logger.error(`[DownloadManager] Error processing FuckingFast download:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case Downloader.Mediafire: {
|
||||
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
@@ -379,7 +396,6 @@ export class DownloadManager {
|
||||
};
|
||||
case Downloader.RealDebrid: {
|
||||
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
|
||||
|
||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
|
||||
|
||||
return {
|
||||
@@ -392,7 +408,6 @@ export class DownloadManager {
|
||||
}
|
||||
case Downloader.TorBox: {
|
||||
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||
|
||||
if (!url) return;
|
||||
return {
|
||||
action: "start",
|
||||
@@ -404,10 +419,7 @@ export class DownloadManager {
|
||||
};
|
||||
}
|
||||
case Downloader.Hydra: {
|
||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(
|
||||
download.uri
|
||||
);
|
||||
|
||||
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
|
||||
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
|
||||
|
||||
return {
|
||||
|
||||
82
src/main/services/hosters/buzzheavier.ts
Normal file
82
src/main/services/hosters/buzzheavier.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import axios from "axios";
|
||||
import {
|
||||
HOSTER_USER_AGENT,
|
||||
extractHosterFilename,
|
||||
handleHosterError,
|
||||
} from "./fuckingfast";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
export class BuzzheavierApi {
|
||||
private static readonly BUZZHEAVIER_DOMAINS = [
|
||||
"buzzheavier.com",
|
||||
"bzzhr.co",
|
||||
"fuckingfast.net",
|
||||
];
|
||||
|
||||
private static isSupportedDomain(url: string): boolean {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||
}
|
||||
|
||||
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
|
||||
try {
|
||||
const baseUrl = url.split("#")[0];
|
||||
logger.log(`[Buzzheavier] Starting download link extraction for: ${baseUrl}`);
|
||||
|
||||
await axios.get(baseUrl, {
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const downloadUrl = `${baseUrl}/download`;
|
||||
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
|
||||
const headResponse = await axios.head(downloadUrl, {
|
||||
headers: {
|
||||
"hx-current-url": baseUrl,
|
||||
"hx-request": "true",
|
||||
referer: baseUrl,
|
||||
"User-Agent": HOSTER_USER_AGENT,
|
||||
},
|
||||
maxRedirects: 0,
|
||||
validateStatus: (status) =>
|
||||
status === 200 || status === 204 || status === 301 || status === 302,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const hxRedirect = headResponse.headers["hx-redirect"];
|
||||
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
|
||||
if (!hxRedirect) {
|
||||
logger.error(`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`);
|
||||
throw new Error(
|
||||
"Could not extract download link. File may be deleted or is a directory."
|
||||
);
|
||||
}
|
||||
|
||||
const domain = new URL(baseUrl).hostname;
|
||||
const directLink = hxRedirect.startsWith("/dl/")
|
||||
? `https://${domain}${hxRedirect}`
|
||||
: hxRedirect;
|
||||
logger.log(`[Buzzheavier] Extracted direct link`);
|
||||
return directLink;
|
||||
} catch (error) {
|
||||
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
|
||||
handleHosterError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDirectLink(url: string): Promise<string> {
|
||||
if (!this.isSupportedDomain(url)) {
|
||||
throw new Error(
|
||||
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
|
||||
);
|
||||
}
|
||||
return this.getBuzzheavierDirectLink(url);
|
||||
}
|
||||
|
||||
public static async getFilename(
|
||||
url: string,
|
||||
directUrl?: string
|
||||
): Promise<string> {
|
||||
return extractHosterFilename(url, directUrl);
|
||||
}
|
||||
}
|
||||
129
src/main/services/hosters/fuckingfast.ts
Normal file
129
src/main/services/hosters/fuckingfast.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import axios from "axios";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
export const HOSTER_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
|
||||
|
||||
export async function extractHosterFilename(
|
||||
url: string,
|
||||
directUrl?: string
|
||||
): Promise<string> {
|
||||
if (url.includes("#")) {
|
||||
const fragment = url.split("#")[1];
|
||||
if (fragment && !fragment.startsWith("http")) {
|
||||
return fragment;
|
||||
}
|
||||
}
|
||||
|
||||
if (directUrl) {
|
||||
try {
|
||||
const response = await axios.head(directUrl, {
|
||||
timeout: 10000,
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
});
|
||||
|
||||
const contentDisposition = response.headers["content-disposition"];
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
|
||||
contentDisposition
|
||||
);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
return filenameMatch[1].replace(/['"]/g, "");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
const urlPath = new URL(directUrl).pathname;
|
||||
const filename = urlPath.split("/").pop()?.split("?")[0];
|
||||
if (filename) {
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
return "downloaded_file";
|
||||
}
|
||||
|
||||
export function handleHosterError(error: unknown): never {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
throw new Error("File not found");
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
throw new Error("Rate limit exceeded. Please try again later.");
|
||||
}
|
||||
if (error.response?.status === 403) {
|
||||
throw new Error("Access denied. File may be private or deleted.");
|
||||
}
|
||||
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FuckingFast API Class
|
||||
// ============================================
|
||||
export class FuckingFastApi {
|
||||
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
|
||||
|
||||
private static readonly FUCKINGFAST_REGEX =
|
||||
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
|
||||
|
||||
private static isSupportedDomain(url: string): boolean {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
|
||||
}
|
||||
|
||||
private static async getFuckingFastDirectLink(url: string): Promise<string> {
|
||||
try {
|
||||
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
|
||||
const response = await axios.get(url, {
|
||||
headers: { "User-Agent": HOSTER_USER_AGENT },
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
|
||||
if (html.toLowerCase().includes("rate limit")) {
|
||||
logger.error(`[FuckingFast] Rate limit detected`);
|
||||
throw new Error(
|
||||
"Rate limit exceeded. Please wait a few minutes and try again."
|
||||
);
|
||||
}
|
||||
|
||||
if (html.includes("File Not Found Or Deleted")) {
|
||||
logger.error(`[FuckingFast] File not found or deleted`);
|
||||
throw new Error("File not found or deleted");
|
||||
}
|
||||
|
||||
const match = this.FUCKINGFAST_REGEX.exec(html);
|
||||
if (!match || !match[1]) {
|
||||
logger.error(`[FuckingFast] Could not extract download link`);
|
||||
throw new Error("Could not extract download link from page");
|
||||
}
|
||||
|
||||
logger.log(`[FuckingFast] Successfully extracted direct link`);
|
||||
return match[1];
|
||||
} catch (error) {
|
||||
logger.error(`[FuckingFast] Error:`, error);
|
||||
handleHosterError(error);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDirectLink(url: string): Promise<string> {
|
||||
if (!this.isSupportedDomain(url)) {
|
||||
throw new Error(
|
||||
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
|
||||
);
|
||||
}
|
||||
return this.getFuckingFastDirectLink(url);
|
||||
}
|
||||
|
||||
public static async getFilename(
|
||||
url: string,
|
||||
directUrl?: string
|
||||
): Promise<string> {
|
||||
return extractHosterFilename(url, directUrl);
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,5 @@ export * from "./qiwi";
|
||||
export * from "./datanodes";
|
||||
export * from "./mediafire";
|
||||
export * from "./pixeldrain";
|
||||
export * from "./buzzheavier";
|
||||
export * from "./fuckingfast";
|
||||
|
||||
@@ -36,9 +36,9 @@ export class WindowManager {
|
||||
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
|
||||
{
|
||||
width: 1200,
|
||||
height: 720,
|
||||
height: 860,
|
||||
minWidth: 1024,
|
||||
minHeight: 540,
|
||||
minHeight: 860,
|
||||
backgroundColor: "#1c1c1c",
|
||||
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
|
||||
icon,
|
||||
@@ -106,7 +106,7 @@ export class WindowManager {
|
||||
valueEncoding: "json",
|
||||
}
|
||||
);
|
||||
return data ?? { isMaximized: false, height: 720, width: 1200 };
|
||||
return data ?? { isMaximized: false, height: 860, width: 1200 };
|
||||
}
|
||||
|
||||
private static updateInitialConfig(
|
||||
@@ -224,7 +224,7 @@ export class WindowManager {
|
||||
? {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
height: this.initialConfigInitializationMainWindow.height ?? 720,
|
||||
height: this.initialConfigInitializationMainWindow.height ?? 860,
|
||||
width: this.initialConfigInitializationMainWindow.width ?? 1200,
|
||||
isMaximized: true,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.fullscreen-media-modal__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: globals.$backdrop-z-index;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fullscreen-media-modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -7,12 +23,17 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
|
||||
&__close-button {
|
||||
position: absolute;
|
||||
top: calc(globals.$spacing-unit * 4);
|
||||
right: calc(globals.$spacing-unit * 3);
|
||||
top: calc(globals.$spacing-unit * 5);
|
||||
right: calc(globals.$spacing-unit * 4);
|
||||
cursor: pointer;
|
||||
color: globals.$body-color;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createPortal } from "react-dom";
|
||||
import { XIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Backdrop } from "../backdrop/backdrop";
|
||||
import "./fullscreen-media-modal.scss";
|
||||
|
||||
export interface FullscreenMediaModalProps {
|
||||
@@ -64,7 +63,7 @@ export function FullscreenMediaModal({
|
||||
if (!visible || !src) return null;
|
||||
|
||||
return createPortal(
|
||||
<Backdrop>
|
||||
<div className="fullscreen-media-modal__overlay">
|
||||
<dialog className="fullscreen-media-modal" open aria-label={alt}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -82,7 +81,7 @@ export function FullscreenMediaModal({
|
||||
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
|
||||
</div>
|
||||
</dialog>
|
||||
</Backdrop>,
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.Qiwi]: "Qiwi",
|
||||
[Downloader.Datanodes]: "Datanodes",
|
||||
[Downloader.Mediafire]: "Mediafire",
|
||||
[Downloader.Buzzheavier]: "Buzzheavier",
|
||||
[Downloader.FuckingFast]: "FuckingFast",
|
||||
[Downloader.TorBox]: "TorBox",
|
||||
[Downloader.Hydra]: "Nimbus",
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface DownloadState {
|
||||
gameId: string | null;
|
||||
gamesWithDeletionInProgress: string[];
|
||||
extraction: ExtractionInfo | null;
|
||||
peakSpeeds: Record<string, number>;
|
||||
speedHistory: Record<string, number[]>;
|
||||
}
|
||||
|
||||
const initialState: DownloadState = {
|
||||
@@ -19,6 +21,8 @@ const initialState: DownloadState = {
|
||||
gameId: null,
|
||||
gamesWithDeletionInProgress: [],
|
||||
extraction: null,
|
||||
peakSpeeds: {},
|
||||
speedHistory: {},
|
||||
};
|
||||
|
||||
export const downloadSlice = createSlice({
|
||||
@@ -28,6 +32,27 @@ export const downloadSlice = createSlice({
|
||||
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
|
||||
state.lastPacket = action.payload;
|
||||
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
|
||||
|
||||
// Track peak speed and speed history atomically when packet arrives
|
||||
if (action.payload?.gameId && action.payload.downloadSpeed != null) {
|
||||
const { gameId, downloadSpeed } = action.payload;
|
||||
|
||||
// Update peak speed if this is higher
|
||||
const currentPeak = state.peakSpeeds[gameId] || 0;
|
||||
if (downloadSpeed > currentPeak) {
|
||||
state.peakSpeeds[gameId] = downloadSpeed;
|
||||
}
|
||||
|
||||
// Update speed history for chart
|
||||
if (!state.speedHistory[gameId]) {
|
||||
state.speedHistory[gameId] = [];
|
||||
}
|
||||
state.speedHistory[gameId].push(downloadSpeed);
|
||||
// Keep only last 120 entries
|
||||
if (state.speedHistory[gameId].length > 120) {
|
||||
state.speedHistory[gameId].shift();
|
||||
}
|
||||
}
|
||||
},
|
||||
clearDownload: (state) => {
|
||||
state.lastPacket = null;
|
||||
@@ -62,6 +87,20 @@ export const downloadSlice = createSlice({
|
||||
clearExtraction: (state) => {
|
||||
state.extraction = null;
|
||||
},
|
||||
updatePeakSpeed: (
|
||||
state,
|
||||
action: PayloadAction<{ gameId: string; speed: number }>
|
||||
) => {
|
||||
const { gameId, speed } = action.payload;
|
||||
const currentPeak = state.peakSpeeds[gameId] || 0;
|
||||
if (speed > currentPeak) {
|
||||
state.peakSpeeds[gameId] = speed;
|
||||
}
|
||||
},
|
||||
clearPeakSpeed: (state, action: PayloadAction<string>) => {
|
||||
state.peakSpeeds[action.payload] = 0;
|
||||
state.speedHistory[action.payload] = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,4 +111,6 @@ export const {
|
||||
removeGameFromDeleting,
|
||||
setExtractionProgress,
|
||||
clearExtraction,
|
||||
updatePeakSpeed,
|
||||
clearPeakSpeed,
|
||||
} = downloadSlice.actions;
|
||||
|
||||
@@ -412,10 +412,12 @@ function HeroDownloadView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.download?.downloader && (
|
||||
{game.download?.downloader !== undefined && (
|
||||
<div className="download-group__stat-item">
|
||||
<div className="download-group__stat-content">
|
||||
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
|
||||
<Badge>
|
||||
{DOWNLOADER_NAME[Number(game.download.downloader)]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -512,8 +514,9 @@ export function DownloadGroup({
|
||||
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({});
|
||||
const speedHistoryRef = useRef<Record<string, number[]>>({});
|
||||
// Get speed history and peak speeds from Redux (centralized state)
|
||||
const speedHistory = useAppSelector((state) => state.download.speedHistory);
|
||||
const peakSpeeds = useAppSelector((state) => state.download.peakSpeeds);
|
||||
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
@@ -576,68 +579,8 @@ export function DownloadGroup({
|
||||
});
|
||||
}, [library, lastPacket?.gameId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) {
|
||||
const gameId = lastPacket.gameId;
|
||||
|
||||
const currentPeak = peakSpeeds[gameId] || 0;
|
||||
if (lastPacket.downloadSpeed > currentPeak) {
|
||||
setPeakSpeeds((prev) => ({
|
||||
...prev,
|
||||
[gameId]: lastPacket.downloadSpeed,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!speedHistoryRef.current[gameId]) {
|
||||
speedHistoryRef.current[gameId] = [];
|
||||
}
|
||||
|
||||
speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed);
|
||||
|
||||
if (speedHistoryRef.current[gameId].length > 120) {
|
||||
speedHistoryRef.current[gameId].shift();
|
||||
}
|
||||
}
|
||||
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const game of library) {
|
||||
if (
|
||||
game.download &&
|
||||
game.download.progress < 0.01 &&
|
||||
game.download.status !== "paused"
|
||||
) {
|
||||
// Fresh download - clear any old data
|
||||
if (speedHistoryRef.current[game.id]?.length > 0) {
|
||||
speedHistoryRef.current[game.id] = [];
|
||||
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [library]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts: NodeJS.Timeout[] = [];
|
||||
|
||||
for (const game of library) {
|
||||
if (
|
||||
game.download?.progress === 1 &&
|
||||
speedHistoryRef.current[game.id]?.length > 0
|
||||
) {
|
||||
const timeout = setTimeout(() => {
|
||||
speedHistoryRef.current[game.id] = [];
|
||||
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
|
||||
}, 10_000);
|
||||
timeouts.push(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const timeout of timeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
}, [library]);
|
||||
// Speed history and peak speeds are now tracked in Redux (in setLastPacket reducer)
|
||||
// No local effect needed - data is updated atomically when packets arrive
|
||||
|
||||
useEffect(() => {
|
||||
if (library.length > 0 && title === t("download_in_progress")) {
|
||||
@@ -842,7 +785,14 @@ export function DownloadGroup({
|
||||
? (lastPacket?.downloadSpeed ?? 0)
|
||||
: 0;
|
||||
const finalDownloadSize = getFinalDownloadSize(game);
|
||||
const peakSpeed = peakSpeeds[game.id] || 0;
|
||||
// Use lastPacket.gameId for lookup since that's the key used to store the data
|
||||
// Fall back to game.id if lastPacket is not available
|
||||
const dataKey = lastPacket?.gameId ?? game.id;
|
||||
const gameSpeedHistory = speedHistory[dataKey] ?? [];
|
||||
const storedPeak = peakSpeeds[dataKey];
|
||||
// Use stored peak if available and > 0, otherwise use current speed as initial value
|
||||
const peakSpeed =
|
||||
storedPeak !== undefined && storedPeak > 0 ? storedPeak : downloadSpeed;
|
||||
|
||||
let currentProgress = game.download?.progress || 0;
|
||||
if (isGameExtracting) {
|
||||
@@ -864,7 +814,7 @@ export function DownloadGroup({
|
||||
currentProgress={currentProgress}
|
||||
dominantColor={dominantColor}
|
||||
lastPacket={lastPacket}
|
||||
speedHistory={speedHistoryRef.current[game.id] || []}
|
||||
speedHistory={gameSpeedHistory}
|
||||
formatSpeed={formatSpeed}
|
||||
calculateETA={calculateETA}
|
||||
pauseDownload={pauseDownload}
|
||||
@@ -908,7 +858,9 @@ export function DownloadGroup({
|
||||
</button>
|
||||
<div className="download-group__simple-meta">
|
||||
<div className="download-group__simple-meta-row">
|
||||
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
|
||||
<Badge>
|
||||
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="download-group__simple-meta-row">
|
||||
{extraction?.visibleId === game.id ? (
|
||||
|
||||
@@ -21,7 +21,7 @@ import { UserKarmaBox } from "./user-karma-box";
|
||||
import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
|
||||
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { ProfileTabs } from "./profile-tabs";
|
||||
import { ProfileTabs, type ProfileTabType } from "./profile-tabs";
|
||||
import { LibraryTab } from "./library-tab";
|
||||
import { ReviewsTab } from "./reviews-tab";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
@@ -95,7 +95,7 @@ export function ProfileContent() {
|
||||
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
|
||||
const statsAnimation = useRef(-1);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"library" | "reviews">("library");
|
||||
const [activeTab, setActiveTab] = useState<ProfileTabType>("library");
|
||||
|
||||
// User reviews state
|
||||
const [reviews, setReviews] = useState<UserReview[]>([]);
|
||||
|
||||
@@ -2,10 +2,12 @@ import { motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./profile-content.scss";
|
||||
|
||||
export type ProfileTabType = "library" | "reviews";
|
||||
|
||||
interface ProfileTabsProps {
|
||||
activeTab: "library" | "reviews";
|
||||
activeTab: ProfileTabType;
|
||||
reviewsTotalCount: number;
|
||||
onTabChange: (tab: "library" | "reviews") => void;
|
||||
onTabChange: (tab: ProfileTabType) => void;
|
||||
}
|
||||
|
||||
export function ProfileTabs({
|
||||
|
||||
100
src/renderer/src/pages/profile/profile-content/wrapped-tab.scss
Normal file
100
src/renderer/src/pages/profile/profile-content/wrapped-tab.scss
Normal file
@@ -0,0 +1,100 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.wrapped-fullscreen-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__close-button {
|
||||
position: absolute;
|
||||
top: calc(globals.$spacing-unit * 5);
|
||||
right: calc(globals.$spacing-unit * 5);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: background 0.2s ease;
|
||||
z-index: 10;
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
|
||||
pointer-events: auto;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
&__loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: wrapped-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
&__iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wrapped-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { XIcon } from "@primer/octicons-react";
|
||||
import "./wrapped-tab.scss";
|
||||
|
||||
interface WrappedFullscreenModalProps {
|
||||
userId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ScaleConfig {
|
||||
scale: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const SCALE_CONFIGS: Record<number, ScaleConfig> = {
|
||||
0.25: { scale: 0.25, width: 270, height: 480 },
|
||||
0.3: { scale: 0.3, width: 324, height: 576 },
|
||||
0.5: { scale: 0.5, width: 540, height: 960 },
|
||||
};
|
||||
|
||||
const getScaleConfigForHeight = (height: number): ScaleConfig => {
|
||||
if (height >= 1000) return SCALE_CONFIGS[0.5];
|
||||
if (height >= 650) return SCALE_CONFIGS[0.3];
|
||||
return SCALE_CONFIGS[0.25];
|
||||
};
|
||||
|
||||
export function WrappedFullscreenModal({
|
||||
userId,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: Readonly<WrappedFullscreenModalProps>) {
|
||||
const [config, setConfig] = useState<ScaleConfig>(SCALE_CONFIGS[0.5]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const updateConfig = () => {
|
||||
setConfig(getScaleConfigForHeight(window.innerHeight));
|
||||
};
|
||||
|
||||
updateConfig();
|
||||
window.addEventListener("resize", updateConfig);
|
||||
return () => window.removeEventListener("resize", updateConfig);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<dialog className="wrapped-fullscreen-modal" aria-modal="true" open>
|
||||
<button
|
||||
type="button"
|
||||
className="wrapped-fullscreen-modal__backdrop"
|
||||
onClick={onClose}
|
||||
aria-label="Close wrapped"
|
||||
/>
|
||||
<div className="wrapped-fullscreen-modal__container">
|
||||
<button
|
||||
type="button"
|
||||
className="wrapped-fullscreen-modal__close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close wrapped"
|
||||
>
|
||||
<XIcon size={24} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="wrapped-fullscreen-modal__content"
|
||||
style={{ width: config.width, height: config.height }}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="wrapped-fullscreen-modal__loader">
|
||||
<div className="wrapped-fullscreen-modal__spinner" />
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
src={`https://hydrawrapped.com/embed/${userId}?scale=${config.scale}`}
|
||||
className="wrapped-fullscreen-modal__iframe"
|
||||
title="Wrapped 2025"
|
||||
onLoad={() => setIsLoading(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -120,6 +120,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__left-actions {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
@@ -131,5 +136,35 @@
|
||||
&--outline {
|
||||
border-color: globals.$body-color;
|
||||
}
|
||||
|
||||
&--wrapped {
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
#2a57ff 0%,
|
||||
#2951e6 11%,
|
||||
#2f5bff 16%,
|
||||
#2c56e8 29%,
|
||||
#244acc 34%,
|
||||
#2245c2 40%,
|
||||
#3a6bff 45%,
|
||||
#3766f2 50%,
|
||||
#2444b8 56%,
|
||||
#122a73 82%,
|
||||
#2348b3 86%,
|
||||
#1f429e 87%,
|
||||
#10286a 93%,
|
||||
#0e2a63 100%
|
||||
);
|
||||
background-color: #2a57ff;
|
||||
background-size: 105% 100%;
|
||||
background-position: 100% 50%;
|
||||
border: none;
|
||||
color: white;
|
||||
transition: background-position 0.4s ease;
|
||||
|
||||
&:hover {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PencilIcon,
|
||||
PersonAddIcon,
|
||||
SignOutIcon,
|
||||
TrophyIcon,
|
||||
XCircleFillIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
@@ -27,6 +28,7 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { FriendRequestAction } from "@types";
|
||||
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
|
||||
import { WrappedFullscreenModal } from "../profile-content/wrapped-tab";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
@@ -38,6 +40,7 @@ type FriendAction =
|
||||
|
||||
export function ProfileHero() {
|
||||
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
|
||||
const [showWrappedModal, setShowWrappedModal] = useState(false);
|
||||
const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false);
|
||||
const [isPerformingAction, setIsPerformingAction] = useState(false);
|
||||
|
||||
@@ -280,6 +283,13 @@ export function ProfileHero() {
|
||||
onClose={() => setShowEditProfileModal(false)}
|
||||
/>
|
||||
|
||||
{userProfile && (
|
||||
<WrappedFullscreenModal
|
||||
userId={userProfile.id}
|
||||
isOpen={showWrappedModal}
|
||||
onClose={() => setShowWrappedModal(false)}
|
||||
/>
|
||||
)}
|
||||
<FullscreenMediaModal
|
||||
visible={showFullscreenAvatar}
|
||||
onClose={() => setShowFullscreenAvatar(false)}
|
||||
@@ -393,6 +403,22 @@ export function ProfileHero() {
|
||||
background: !backgroundImage ? heroBackground : undefined,
|
||||
}}
|
||||
>
|
||||
{userProfile?.hasCompletedWrapped2025 && (
|
||||
<div className="profile-hero__left-actions">
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => setShowWrappedModal(true)}
|
||||
className="profile-hero__button--wrapped"
|
||||
>
|
||||
<TrophyIcon />
|
||||
{isMe
|
||||
? t("view_my_wrapped_button")
|
||||
: t("view_wrapped_button", {
|
||||
displayName: userProfile.displayName,
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="profile-hero__actions">{profileActions}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,6 +8,8 @@ export enum Downloader {
|
||||
Mediafire,
|
||||
TorBox,
|
||||
Hydra,
|
||||
Buzzheavier,
|
||||
FuckingFast,
|
||||
}
|
||||
|
||||
export enum DownloadSourceStatus {
|
||||
|
||||
@@ -114,6 +114,16 @@ export const getDownloadersForUri = (uri: string) => {
|
||||
if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes];
|
||||
if (uri.startsWith("https://www.mediafire.com"))
|
||||
return [Downloader.Mediafire];
|
||||
if (
|
||||
uri.startsWith("https://buzzheavier.com") ||
|
||||
uri.startsWith("https://bzzhr.co") ||
|
||||
uri.startsWith("https://fuckingfast.net")
|
||||
) {
|
||||
return [Downloader.Buzzheavier];
|
||||
}
|
||||
if (uri.startsWith("https://fuckingfast.co")) {
|
||||
return [Downloader.FuckingFast];
|
||||
}
|
||||
|
||||
if (realDebridHosts.some((host) => uri.startsWith(host)))
|
||||
return [Downloader.RealDebrid];
|
||||
|
||||
@@ -209,6 +209,7 @@ export interface UserProfile {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
badges: string[];
|
||||
hasCompletedWrapped2025: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateProfileRequest {
|
||||
|
||||
Reference in New Issue
Block a user