feat(themes): flag unpublished edits and share parallax motion
Compare theme content hashes with a stored Workshop baseline so creators can see when their local copy is not synced. Extract pointer tracking into a shared action for the launcher and editor preview, and use the slider component for parallax depth.
This commit is contained in:
1 parent
d56ed9d786
commit
323a6f18af
12 files changed
+667
-134
No files matched your search
@@ -195,6 +195,26 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-stat",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"path": "$HOME/.ezpplauncher"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$HOME/.ezpplauncher/*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$HOME/.ezpplauncher/**/*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$HOME/.ezpplauncher/**/.*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "$HOME/.ezpplauncher/**/.*/**"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identifier": "fs:allow-mkdir",
|
"identifier": "fs:allow-mkdir",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
|||||||
@@ -112,6 +112,136 @@ pub async fn hash_file_sha256(path: String) -> Result<String, String> {
|
|||||||
.map_err(|e| format!("Thread error: {e}"))?
|
.map_err(|e| format!("Thread error: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hash the files that make up a theme package. This ignores archive metadata
|
||||||
|
/// and the local workshop-sync sidecar, so it can be compared before and after
|
||||||
|
/// editing the theme folder.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn hash_theme_content(theme_folder: String) -> Result<String, String> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
let folder = checked_theme_folder(&theme_folder)?;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let asset_filter = referenced_theme_assets(&folder.join("theme.json"));
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_theme_content_files(&folder, &folder, asset_filter.as_ref(), &mut files)?;
|
||||||
|
files.sort_by_key(|path| path.to_string_lossy().to_lowercase());
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(b"ezpplauncher-theme-content-v1\0");
|
||||||
|
|
||||||
|
for relative in files {
|
||||||
|
let path = folder.join(&relative);
|
||||||
|
let metadata = std::fs::metadata(&path)
|
||||||
|
.map_err(|e| format!("Failed to inspect theme file {}: {e}", path.display()))?;
|
||||||
|
if !metadata.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = relative.to_string_lossy().replace('\\', "/");
|
||||||
|
hasher.update((name.len() as u64).to_le_bytes());
|
||||||
|
hasher.update(name.as_bytes());
|
||||||
|
|
||||||
|
// Workshop metadata is not theme content. Ignore it so syncing
|
||||||
|
// tags or the server-assigned ID does not look like a local edit.
|
||||||
|
if name == "theme.json" {
|
||||||
|
let text = std::fs::read_to_string(&path).map_err(|e| {
|
||||||
|
format!("Failed to read theme manifest {}: {e}", path.display())
|
||||||
|
})?;
|
||||||
|
let mut config: Value = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| format!("Failed to parse theme manifest: {e}"))?;
|
||||||
|
if let Some(object) = config.as_object_mut() {
|
||||||
|
object.remove("workshopId");
|
||||||
|
object.remove("tags");
|
||||||
|
}
|
||||||
|
let bytes = serde_json::to_vec(&config)
|
||||||
|
.map_err(|e| format!("Failed to serialize theme manifest: {e}"))?;
|
||||||
|
hasher.update((bytes.len() as u64).to_le_bytes());
|
||||||
|
hasher.update(&bytes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.update(metadata.len().to_le_bytes());
|
||||||
|
let mut file = std::fs::File::open(&path)
|
||||||
|
.map_err(|e| format!("Failed to open theme file {}: {e}", path.display()))?;
|
||||||
|
let mut buffer = [0u8; 65536];
|
||||||
|
loop {
|
||||||
|
let bytes_read = file
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|e| format!("Failed to read theme file {}: {e}", path.display()))?;
|
||||||
|
if bytes_read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..bytes_read]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok::<_, String>(hex::encode(hasher.finalize()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Thread error: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_theme_content_files(
|
||||||
|
root: &Path,
|
||||||
|
directory: &Path,
|
||||||
|
asset_filter: Option<&HashSet<String>>,
|
||||||
|
files: &mut Vec<PathBuf>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let entries = std::fs::read_dir(directory)
|
||||||
|
.map_err(|e| format!("Failed to read {}: {e}", directory.display()))?;
|
||||||
|
let assets_root = root.join("assets");
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| format!("Failed to read theme entry: {e}"))?;
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
if file_name.to_string_lossy().starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_type = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|e| format!("Failed to inspect theme entry: {e}"))?;
|
||||||
|
if file_type.is_symlink() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry_path = entry.path();
|
||||||
|
let relative = entry_path
|
||||||
|
.strip_prefix(root)
|
||||||
|
.map_err(|e| format!("Failed to build theme path: {e}"))?
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/");
|
||||||
|
|
||||||
|
if file_type.is_dir() {
|
||||||
|
if asset_filter.is_some() && directory == root && relative != "assets" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
collect_theme_content_files(root, &entry_path, asset_filter, files)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !file_type.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if asset_filter.is_some() && directory == root && relative != "theme.json" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(filter) = asset_filter
|
||||||
|
&& let Ok(asset_path) = entry_path.strip_prefix(&assets_root)
|
||||||
|
{
|
||||||
|
let key = asset_path.to_string_lossy().replace('\\', "/");
|
||||||
|
if !filter.contains(&key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push(PathBuf::from(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Read a chunk of a file on disk starting at `offset` for `length` bytes.
|
/// Read a chunk of a file on disk starting at `offset` for `length` bytes.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn read_file_chunk(path: String, offset: u64, length: u64) -> Result<Vec<u8>, String> {
|
pub async fn read_file_chunk(path: String, offset: u64, length: u64) -> Result<Vec<u8>, String> {
|
||||||
@@ -3080,6 +3210,9 @@ fn add_theme_folder_to_zip(
|
|||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|e| format!("Failed to read theme entry: {e}"))?;
|
let entry = entry.map_err(|e| format!("Failed to read theme entry: {e}"))?;
|
||||||
|
if entry.file_name().to_string_lossy().starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let file_type = entry
|
let file_type = entry
|
||||||
.file_type()
|
.file_type()
|
||||||
.map_err(|e| format!("Failed to inspect theme entry: {e}"))?;
|
.map_err(|e| format!("Failed to inspect theme entry: {e}"))?;
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ use crate::{
|
|||||||
download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit,
|
download_ezpp_launcher_update, download_ezpp_launcher_update_files, encrypt_string, exit,
|
||||||
export_theme, extract_theme, file_size, find_osu_installation, focus_presence_editor, focus_theme_editor, get_beatmapsets_count,
|
export_theme, extract_theme, file_size, find_osu_installation, focus_presence_editor, focus_theme_editor, get_beatmapsets_count,
|
||||||
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
|
get_ezpp_launcher_update_files, get_hwid, get_launcher_version, get_osu_release_stream,
|
||||||
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8, has_osuwinello,
|
get_osu_skin, get_osu_version, get_platform, get_skins_count, has_net8,
|
||||||
has_wmctrl, hash_file_sha256, install_ezpp_launcher_update, installation_diagnostics,
|
has_osuwinello, has_wmctrl, hash_file_sha256, hash_theme_content,
|
||||||
|
install_ezpp_launcher_update, installation_diagnostics,
|
||||||
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
|
is_open_tablet_driver_path_valid, is_open_tablet_driver_running, is_osu_running,
|
||||||
list_theme_backups, log_frontend, open_logs_folder, open_presence_editor, open_theme_editor, open_theme_folder,
|
list_theme_backups, log_frontend, open_logs_folder, open_presence_editor, open_theme_editor, open_theme_folder,
|
||||||
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
open_url_in_browser, opened_urls, optimize_theme_media, pick_osu_installation,
|
||||||
@@ -196,6 +197,7 @@ pub fn run() {
|
|||||||
save_theme_project,
|
save_theme_project,
|
||||||
export_theme,
|
export_theme,
|
||||||
hash_file_sha256,
|
hash_file_sha256,
|
||||||
|
hash_theme_content,
|
||||||
read_file_chunk,
|
read_file_chunk,
|
||||||
open_theme_folder,
|
open_theme_folder,
|
||||||
open_theme_editor,
|
open_theme_editor,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
import { normalizeThemeVersion } from './themeV2';
|
import { normalizeThemeVersion } from './themeV2';
|
||||||
import {
|
import {
|
||||||
|
markThemeSynced,
|
||||||
reloadThemes,
|
reloadThemes,
|
||||||
themesDirectory,
|
themesDirectory,
|
||||||
updateInstalledThemeTags,
|
updateInstalledThemeTags,
|
||||||
@@ -1008,6 +1009,7 @@ export async function downloadAndInstallCommunityTheme(
|
|||||||
// Keep the installed manifest aligned with the workshop record.
|
// Keep the installed manifest aligned with the workshop record.
|
||||||
await updateInstalledThemeTags(folderName, theme.tags);
|
await updateInstalledThemeTags(folderName, theme.tags);
|
||||||
await updateInstalledThemeWorkshopId(folderName, theme.id);
|
await updateInstalledThemeWorkshopId(folderName, theme.id);
|
||||||
|
await markThemeSynced(folderName, { workshopId: theme.id, version: theme.version });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Workshop] Could not write workshop metadata into the installed theme:', err);
|
console.warn('[Workshop] Could not write workshop metadata into the installed theme:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
themeGroupContainsElement,
|
themeGroupContainsElement,
|
||||||
themeGroupInteractiveCount,
|
themeGroupInteractiveCount,
|
||||||
} from '$lib/themeLogic';
|
} from '$lib/themeLogic';
|
||||||
|
import { themeParallax } from '$lib/themeParallax';
|
||||||
import { renderThemePreview } from '$lib/themePreview';
|
import { renderThemePreview } from '$lib/themePreview';
|
||||||
import { themeScriptInitialVariables, themeScriptVariableNames } from '$lib/themeScript';
|
import { themeScriptInitialVariables, themeScriptVariableNames } from '$lib/themeScript';
|
||||||
import {
|
import {
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
} from '$lib/themeV2';
|
} from '$lib/themeV2';
|
||||||
import {
|
import {
|
||||||
exportThemeFolder,
|
exportThemeFolder,
|
||||||
|
markThemeSynced,
|
||||||
saveThemeProject,
|
saveThemeProject,
|
||||||
themeAssetBase,
|
themeAssetBase,
|
||||||
themesDirectory,
|
themesDirectory,
|
||||||
@@ -3545,9 +3547,13 @@
|
|||||||
publishThemeId = result.themeId;
|
publishThemeId = result.themeId;
|
||||||
try {
|
try {
|
||||||
await updateInstalledThemeWorkshopId(savedFolder, result.themeId);
|
await updateInstalledThemeWorkshopId(savedFolder, result.themeId);
|
||||||
|
await markThemeSynced(savedFolder, {
|
||||||
|
workshopId: result.themeId,
|
||||||
|
version: versionCheck.version,
|
||||||
|
});
|
||||||
lastSavedKey = snapshotKey(captureSnapshot());
|
lastSavedKey = snapshotKey(captureSnapshot());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Workshop] Could not store the theme ID in theme.json:', err);
|
console.warn('[Workshop] Could not store the theme sync metadata:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The launcher window owns the installed-theme list. Tell it about the
|
// The launcher window owns the installed-theme list. Tell it about the
|
||||||
@@ -6131,26 +6137,34 @@
|
|||||||
<div
|
<div
|
||||||
style="width:{CANVAS_WIDTH}px;height:{CANVAS_HEIGHT}px;transform:scale({canvasScale});transform-origin:top left;"
|
style="width:{CANVAS_WIDTH}px;height:{CANVAS_HEIGHT}px;transform:scale({canvasScale});transform-origin:top left;"
|
||||||
>
|
>
|
||||||
<ThemeStage
|
<div
|
||||||
{definition}
|
class="contents"
|
||||||
assets={assetBase}
|
use:themeParallax={{
|
||||||
assetOverrides={previewAssets}
|
enabled: definition.parallax?.enabled === true,
|
||||||
{selectedIds}
|
intensity: definition.parallax?.intensity ?? 0.25,
|
||||||
{selectedGroupId}
|
}}
|
||||||
interactive
|
>
|
||||||
scale={canvasScale}
|
<ThemeStage
|
||||||
variables={previewScriptVariables}
|
{definition}
|
||||||
onselect={(id, additive) => selectElement(id, additive)}
|
assets={assetBase}
|
||||||
onselectmany={(ids, additive) => selectMany(ids, additive)}
|
assetOverrides={previewAssets}
|
||||||
onselectgroup={(id) => selectGroup(id)}
|
{selectedIds}
|
||||||
onmove={moveElements}
|
{selectedGroupId}
|
||||||
onpatch={(id, patch) => patchElement(id, patch)}
|
interactive
|
||||||
onpatchgroup={(id, patch) => patchGroup(id, patch)}
|
scale={canvasScale}
|
||||||
oncontext={(id) => prepareContextMenu(id)}
|
variables={previewScriptVariables}
|
||||||
oncontextgroup={(id) => prepareGroupContextMenu(id)}
|
onselect={(id, additive) => selectElement(id, additive)}
|
||||||
ondetach={handleDetachWidget}
|
onselectmany={(ids, additive) => selectMany(ids, additive)}
|
||||||
onbuttonaction={handleTestButtonAction}
|
onselectgroup={(id) => selectGroup(id)}
|
||||||
/>
|
onmove={moveElements}
|
||||||
|
onpatch={(id, patch) => patchElement(id, patch)}
|
||||||
|
onpatchgroup={(id, patch) => patchGroup(id, patch)}
|
||||||
|
oncontext={(id) => prepareContextMenu(id)}
|
||||||
|
oncontextgroup={(id) => prepareGroupContextMenu(id)}
|
||||||
|
ondetach={handleDetachWidget}
|
||||||
|
onbuttonaction={handleTestButtonAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Button from '$lib/components/ui/button/button.svelte';
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
import Input from '$lib/components/ui/input/input.svelte';
|
import Input from '$lib/components/ui/input/input.svelte';
|
||||||
import Label from '$lib/components/ui/label/label.svelte';
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
|
import Slider from '$lib/components/ui/slider/slider.svelte';
|
||||||
import type { ThemeV2Transform } from '$lib/themeV2';
|
import type { ThemeV2Transform } from '$lib/themeV2';
|
||||||
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte';
|
import { FlipHorizontal2, FlipVertical2, RotateCcw, RotateCw } from '@lucide/svelte';
|
||||||
|
|
||||||
@@ -267,14 +268,13 @@
|
|||||||
<span class="text-[9px] text-muted-foreground">3D cursor perspective</span>
|
<span class="text-[9px] text-muted-foreground">3D cursor perspective</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<Slider
|
||||||
type="range"
|
min={-1}
|
||||||
min="-1"
|
max={1}
|
||||||
max="1"
|
step={0.05}
|
||||||
step="0.05"
|
|
||||||
value={transform.parallax ?? 0}
|
value={transform.parallax ?? 0}
|
||||||
oninput={(e) => onchange({ parallax: Number((e.target as HTMLInputElement).value) })}
|
onValueChange={(value) => onchange({ parallax: value })}
|
||||||
class="h-1.5 w-24 cursor-pointer appearance-none rounded-lg bg-theme-800 accent-primary"
|
class="w-24"
|
||||||
/>
|
/>
|
||||||
<span class="w-8 font-mono text-[10px] text-right text-muted-foreground">
|
<span class="w-8 font-mono text-[10px] text-right text-muted-foreground">
|
||||||
{Math.round((transform.parallax ?? 0) * 100)}%
|
{Math.round((transform.parallax ?? 0) * 100)}%
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { custom_theme_volume } from '$lib/global';
|
import { custom_theme_volume } from '$lib/global';
|
||||||
import { playThemeMedia, themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
|
import { playThemeMedia, themeMediaSource, themeVideoLifecycle } from '$lib/themeMedia';
|
||||||
|
import { themeParallax } from '$lib/themeParallax';
|
||||||
import type { ThemeV2Definition } from '$lib/themeV2';
|
import type { ThemeV2Definition } from '$lib/themeV2';
|
||||||
import { themeAssetUrl } from '$lib/themeV2';
|
import { themeAssetUrl } from '$lib/themeV2';
|
||||||
import { reduceAnimations } from '$lib/userSettings';
|
import { reduceAnimations } from '$lib/userSettings';
|
||||||
@@ -20,50 +21,8 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
|
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
|
||||||
let mousePx = $state(0);
|
|
||||||
let mousePy = $state(0);
|
|
||||||
let targetPx = 0;
|
|
||||||
let targetPy = 0;
|
|
||||||
let animFrame: number | undefined;
|
|
||||||
|
|
||||||
function onWindowMouseMove(e: MouseEvent) {
|
|
||||||
if ($reduceAnimations || !definition.parallax?.enabled) return;
|
|
||||||
const intensity = definition.parallax.intensity ?? 0.25;
|
|
||||||
const w = window.innerWidth || 1000;
|
|
||||||
const h = window.innerHeight || 650;
|
|
||||||
targetPx = (e.clientX / w - 0.5) * 2 * intensity;
|
|
||||||
targetPy = (e.clientY / h - 0.5) * 2 * intensity;
|
|
||||||
if (animFrame === undefined) {
|
|
||||||
animFrame = requestAnimationFrame(updateParallax);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateParallax() {
|
|
||||||
animFrame = undefined;
|
|
||||||
mousePx += (targetPx - mousePx) * 0.12;
|
|
||||||
mousePy += (targetPy - mousePy) * 0.12;
|
|
||||||
if (Math.abs(targetPx - mousePx) > 0.001 || Math.abs(targetPy - mousePy) > 0.001) {
|
|
||||||
animFrame = requestAnimationFrame(updateParallax);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!definition.parallax?.enabled) {
|
|
||||||
mousePx = 0;
|
|
||||||
mousePy = 0;
|
|
||||||
targetPx = 0;
|
|
||||||
targetPy = 0;
|
|
||||||
if (animFrame !== undefined) {
|
|
||||||
cancelAnimationFrame(animFrame);
|
|
||||||
animFrame = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (animFrame !== undefined) {
|
|
||||||
cancelAnimationFrame(animFrame);
|
|
||||||
}
|
|
||||||
if (audioElement) {
|
if (audioElement) {
|
||||||
try {
|
try {
|
||||||
audioElement.pause();
|
audioElement.pause();
|
||||||
@@ -140,64 +99,68 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onmousemove={onWindowMouseMove} />
|
<div class="theme-v2 absolute inset-0 overflow-hidden" use:trackMedia>
|
||||||
|
<div
|
||||||
<div
|
class="absolute inset-0"
|
||||||
class="theme-v2 absolute inset-0 overflow-hidden"
|
use:themeParallax={{
|
||||||
use:trackMedia
|
enabled: definition.parallax?.enabled === true,
|
||||||
style="--mouse-px:{mousePx};--mouse-py:{mousePy};"
|
intensity: definition.parallax?.intensity ?? 0.25,
|
||||||
>
|
}}
|
||||||
{#if background.type === 'color'}
|
style="transform:translate3d(calc(var(--mouse-px, 0) * -18px), calc(var(--mouse-py, 0) * -18px), 0);"
|
||||||
<div
|
>
|
||||||
class="absolute inset-0"
|
{#if background.type === 'color'}
|
||||||
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
|
<div
|
||||||
></div>
|
class="absolute inset-0"
|
||||||
{:else if background.type === 'gradient'}
|
style="background-color:{background.color};opacity:{background.opacity ?? 1}"
|
||||||
<div
|
></div>
|
||||||
class="absolute inset-0"
|
{:else if background.type === 'gradient'}
|
||||||
style="background-image:linear-gradient({background.angle ??
|
<div
|
||||||
135}deg, {background.from}, {background.to});opacity:{background.opacity ?? 1}"
|
class="absolute inset-0"
|
||||||
></div>
|
style="background-image:linear-gradient({background.angle ??
|
||||||
{:else if background.type === 'image'}
|
135}deg, {background.from}, {background.to});opacity:{background.opacity ??
|
||||||
{#if background.src}
|
1};transform:{definition.parallax?.enabled ? 'scale(1.06)' : 'none'}"
|
||||||
<img
|
></div>
|
||||||
class="absolute inset-0 h-full w-full"
|
{:else if background.type === 'image'}
|
||||||
src={assetUrl(background.src)}
|
{#if background.src}
|
||||||
alt=""
|
<img
|
||||||
aria-hidden="true"
|
class="absolute inset-0 h-full w-full"
|
||||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
src={assetUrl(background.src)}
|
||||||
1};filter:{background.blur
|
alt=""
|
||||||
? `blur(${background.blur}px)`
|
aria-hidden="true"
|
||||||
: 'none'};transform:{background.blur
|
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||||
? 'scale(1.08)'
|
1};filter:{background.blur
|
||||||
: definition.parallax?.enabled
|
? `blur(${background.blur}px)`
|
||||||
? 'scale(1.06)'
|
: 'none'};transform:{background.blur
|
||||||
: 'none'} translate3d({mousePx * -18}px, {mousePy * -18}px, 0);"
|
? 'scale(1.08)'
|
||||||
/>
|
: definition.parallax?.enabled
|
||||||
|
? 'scale(1.06)'
|
||||||
|
: 'none'};"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else if background.type === 'video'}
|
||||||
|
{#if background.src}
|
||||||
|
<video
|
||||||
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
|
use:themeMediaSource={assetUrl(background.src)}
|
||||||
|
use:themeVideoLifecycle
|
||||||
|
autoplay
|
||||||
|
loop
|
||||||
|
muted={background.muted !== false}
|
||||||
|
use:applyMuted={background.muted !== false}
|
||||||
|
playsinline
|
||||||
|
disablepictureinpicture
|
||||||
|
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
||||||
|
1};filter:{background.blur
|
||||||
|
? `blur(${background.blur}px)`
|
||||||
|
: 'none'};transform:{background.blur
|
||||||
|
? 'scale(1.08)'
|
||||||
|
: definition.parallax?.enabled
|
||||||
|
? 'scale(1.06)'
|
||||||
|
: 'none'};"
|
||||||
|
></video>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if background.type === 'video'}
|
</div>
|
||||||
{#if background.src}
|
|
||||||
<video
|
|
||||||
class="absolute inset-0 h-full w-full object-cover"
|
|
||||||
use:themeMediaSource={assetUrl(background.src)}
|
|
||||||
use:themeVideoLifecycle
|
|
||||||
autoplay
|
|
||||||
loop
|
|
||||||
muted={background.muted !== false}
|
|
||||||
use:applyMuted={background.muted !== false}
|
|
||||||
playsinline
|
|
||||||
disablepictureinpicture
|
|
||||||
style="object-fit:{background.fit ?? 'cover'};opacity:{background.opacity ??
|
|
||||||
1};filter:{background.blur
|
|
||||||
? `blur(${background.blur}px)`
|
|
||||||
: 'none'};transform:{background.blur
|
|
||||||
? 'scale(1.08)'
|
|
||||||
: definition.parallax?.enabled
|
|
||||||
? 'scale(1.06)'
|
|
||||||
: 'none'} translate3d({mousePx * -18}px, {mousePy * -18}px, 0);"
|
|
||||||
></video>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if overlay && (overlay.opacity ?? 0) > 0}
|
{#if overlay && (overlay.opacity ?? 0) > 0}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -141,6 +141,15 @@
|
|||||||
);
|
);
|
||||||
const elementFilter = (element: ThemeV2Element) =>
|
const elementFilter = (element: ThemeV2Element) =>
|
||||||
themeFilterCss(element.filters, ...ancestorsOf(element).map((group) => group.filters));
|
themeFilterCss(element.filters, ...ancestorsOf(element).map((group) => group.filters));
|
||||||
|
/** Layer depth plus every ancestor group's depth; negative follows the background. */
|
||||||
|
const elementParallaxCss = (element: ThemeV2Element) => {
|
||||||
|
const depth =
|
||||||
|
(element.parallax ?? 0) +
|
||||||
|
ancestorsOf(element).reduce((total, group) => total + (group.parallax ?? 0), 0);
|
||||||
|
if (depth === 0) return '';
|
||||||
|
const offset = depth * 30;
|
||||||
|
return ` translate3d(calc(var(--mouse-px, 0) * ${offset}px), calc(var(--mouse-py, 0) * ${offset}px), 0)`;
|
||||||
|
};
|
||||||
const elementClassNames = (element: ThemeV2Element) =>
|
const elementClassNames = (element: ThemeV2Element) =>
|
||||||
themeClassNames(
|
themeClassNames(
|
||||||
[...ancestorsOf(element).map((group) => group.cssClasses), element.cssClasses]
|
[...ancestorsOf(element).map((group) => group.cssClasses), element.cssClasses]
|
||||||
@@ -1227,9 +1236,7 @@
|
|||||||
element,
|
element,
|
||||||
elements,
|
elements,
|
||||||
groups
|
groups
|
||||||
)}{element.parallax
|
)}{elementParallaxCss(element)};transform-origin:0 0;touch-action:none;outline:{selected
|
||||||
? ` translate3d(calc(var(--mouse-px, 0) * ${element.parallax * 30}px), calc(var(--mouse-py, 0) * ${element.parallax * 30}px), 0)`
|
|
||||||
: ''};transform-origin:0 0;touch-action:none;outline:{selected
|
|
||||||
? `${selectedIds.length > 1 ? 1 : 2}px solid #ff006e`
|
? `${selectedIds.length > 1 ? 1 : 2}px solid #ff006e`
|
||||||
: 'none'};outline-offset:2px;"
|
: 'none'};outline-offset:2px;"
|
||||||
role={interactive || element.interactive ? 'button' : undefined}
|
role={interactive || element.interactive ? 'button' : undefined}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { reduceAnimations } from '$lib/userSettings';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
export type ThemeParallaxOptions = {
|
||||||
|
enabled?: boolean;
|
||||||
|
intensity?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampIntensity = (intensity: number | undefined) =>
|
||||||
|
Math.min(1, Math.max(0.05, typeof intensity === 'number' ? intensity : 0.25));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the pointer for a theme host and exposes the smoothed offset as
|
||||||
|
* `--mouse-px` / `--mouse-py`. The host must contain both the background and
|
||||||
|
* the freeform layers so those variables are inherited by every layer.
|
||||||
|
*/
|
||||||
|
export function themeParallax(node: HTMLElement, options: ThemeParallaxOptions = {}) {
|
||||||
|
let enabled = options.enabled === true;
|
||||||
|
let intensity = clampIntensity(options.intensity);
|
||||||
|
let mousePx = 0;
|
||||||
|
let mousePy = 0;
|
||||||
|
let targetPx = 0;
|
||||||
|
let targetPy = 0;
|
||||||
|
let animFrame: number | undefined;
|
||||||
|
let reduced = get(reduceAnimations);
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
node.style.setProperty('--mouse-px', mousePx.toFixed(4));
|
||||||
|
node.style.setProperty('--mouse-py', mousePy.toFixed(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (animFrame !== undefined) {
|
||||||
|
cancelAnimationFrame(animFrame);
|
||||||
|
animFrame = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
stop();
|
||||||
|
mousePx = 0;
|
||||||
|
mousePy = 0;
|
||||||
|
targetPx = 0;
|
||||||
|
targetPy = 0;
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParallax() {
|
||||||
|
animFrame = undefined;
|
||||||
|
const dx = targetPx - mousePx;
|
||||||
|
const dy = targetPy - mousePy;
|
||||||
|
|
||||||
|
if (Math.abs(dx) < 0.0005 && Math.abs(dy) < 0.0005) {
|
||||||
|
mousePx = targetPx;
|
||||||
|
mousePy = targetPy;
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mousePx += dx * 0.12;
|
||||||
|
mousePy += dy * 0.12;
|
||||||
|
apply();
|
||||||
|
animFrame = requestAnimationFrame(updateParallax);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowMouseMove(event: MouseEvent) {
|
||||||
|
if (!enabled || reduced) return;
|
||||||
|
|
||||||
|
const width = window.innerWidth || 1000;
|
||||||
|
const height = window.innerHeight || 650;
|
||||||
|
targetPx = (event.clientX / width - 0.5) * 2 * intensity;
|
||||||
|
targetPy = (event.clientY / height - 0.5) * 2 * intensity;
|
||||||
|
|
||||||
|
if (animFrame === undefined) {
|
||||||
|
animFrame = requestAnimationFrame(updateParallax);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = reduceAnimations.subscribe((value) => {
|
||||||
|
reduced = value;
|
||||||
|
if (reduced) reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onWindowMouseMove);
|
||||||
|
apply();
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next: ThemeParallaxOptions = {}) {
|
||||||
|
enabled = next.enabled === true;
|
||||||
|
intensity = clampIntensity(next.intensity);
|
||||||
|
if (!enabled || reduced) reset();
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
unsubscribe();
|
||||||
|
window.removeEventListener('mousemove', onWindowMouseMove);
|
||||||
|
stop();
|
||||||
|
node.style.removeProperty('--mouse-px');
|
||||||
|
node.style.removeProperty('--mouse-py');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,6 +27,18 @@ export type Theme = {
|
|||||||
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
|
status: 'installed' | 'downloading' | 'extracting' | 'deleting' | 'not-installed';
|
||||||
progress: number;
|
progress: number;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
|
/** The latest Workshop version, when the theme has been published. */
|
||||||
|
workshopVersion?: string;
|
||||||
|
/** Hash of the theme files when this copy was last known to match the Workshop. */
|
||||||
|
syncedContentHash?: string;
|
||||||
|
/** Local version current when the sync baseline was written. */
|
||||||
|
syncedVersion?: string;
|
||||||
|
/** True when the local theme differs from its published Workshop package. */
|
||||||
|
isNotSynced?: boolean;
|
||||||
|
/** Modification time of the generated preview, used to migrate existing installs. */
|
||||||
|
previewModifiedAt?: number;
|
||||||
|
/** Creation time of the theme folder, used to migrate existing installs. */
|
||||||
|
folderCreatedAt?: number;
|
||||||
/** The version available in the repository when an update is pending. */
|
/** The version available in the repository when an update is pending. */
|
||||||
availableVersion?: string;
|
availableVersion?: string;
|
||||||
definition?: ThemeV2Definition;
|
definition?: ThemeV2Definition;
|
||||||
@@ -95,6 +107,75 @@ const encodeThemePath = (value: string) =>
|
|||||||
export const themesDirectory = async () =>
|
export const themesDirectory = async () =>
|
||||||
await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
|
await path.join(await path.homeDir(), '.ezpplauncher', 'themes');
|
||||||
|
|
||||||
|
const THEME_SYNC_FILENAME = '.workshop-sync.json';
|
||||||
|
const CONTENT_HASH_PATTERN = /^[a-f0-9]{64}$/i;
|
||||||
|
|
||||||
|
export type ThemeWorkshopSync = {
|
||||||
|
contentHash: string;
|
||||||
|
version?: string;
|
||||||
|
workshopId?: string;
|
||||||
|
syncedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readThemeWorkshopSyncFile = async (
|
||||||
|
folderPath: string
|
||||||
|
): Promise<ThemeWorkshopSync | undefined> => {
|
||||||
|
const syncPath = await path.join(folderPath, THEME_SYNC_FILENAME);
|
||||||
|
if (!(await fs.exists(syncPath))) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await fs.readTextFile(syncPath)) as Partial<ThemeWorkshopSync>;
|
||||||
|
if (!parsed || typeof parsed !== 'object') return undefined;
|
||||||
|
const contentHash =
|
||||||
|
typeof parsed.contentHash === 'string' && CONTENT_HASH_PATTERN.test(parsed.contentHash)
|
||||||
|
? parsed.contentHash.toLowerCase()
|
||||||
|
: undefined;
|
||||||
|
if (!contentHash) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentHash,
|
||||||
|
version: typeof parsed.version === 'string' ? parsed.version : undefined,
|
||||||
|
workshopId: typeof parsed.workshopId === 'string' ? parsed.workshopId : undefined,
|
||||||
|
syncedAt: typeof parsed.syncedAt === 'number' ? parsed.syncedAt : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readThemeWorkshopSync = async (folderName: string) =>
|
||||||
|
readThemeWorkshopSyncFile(await path.join(await themesDirectory(), folderName));
|
||||||
|
|
||||||
|
export const writeThemeWorkshopSync = async (
|
||||||
|
folderName: string,
|
||||||
|
sync: ThemeWorkshopSync
|
||||||
|
): Promise<void> => {
|
||||||
|
const folderPath = await path.join(await themesDirectory(), folderName);
|
||||||
|
const syncPath = await path.join(folderPath, THEME_SYNC_FILENAME);
|
||||||
|
await fs.writeFile(syncPath, new TextEncoder().encode(`${JSON.stringify(sync, null, 2)}\n`));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hashThemeContent = async (folderName: string) => {
|
||||||
|
const themeFolder = await path.join(await themesDirectory(), folderName);
|
||||||
|
return await invoke<string>('hash_theme_content', { themeFolder });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Record the current local files as the published Workshop baseline. */
|
||||||
|
export const markThemeSynced = async (
|
||||||
|
folderName: string,
|
||||||
|
options: { version?: string; workshopId?: string } = {}
|
||||||
|
): Promise<ThemeWorkshopSync> => {
|
||||||
|
const contentHash = await hashThemeContent(folderName);
|
||||||
|
const sync: ThemeWorkshopSync = {
|
||||||
|
contentHash,
|
||||||
|
version: options.version,
|
||||||
|
workshopId: options.workshopId,
|
||||||
|
syncedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await writeThemeWorkshopSync(folderName, sync);
|
||||||
|
return sync;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace a theme in the store instead of mutating it. The theme list is
|
* Replace a theme in the store instead of mutating it. The theme list is
|
||||||
* rendered from a filtered derived list, and Svelte treats each item as
|
* rendered from a filtered derived list, and Svelte treats each item as
|
||||||
@@ -132,6 +213,8 @@ const themeFromFolder = async (
|
|||||||
const definition = isV2
|
const definition = isV2
|
||||||
? normalizeThemeV2(parsed as unknown as Partial<ThemeV2Definition>)
|
? normalizeThemeV2(parsed as unknown as Partial<ThemeV2Definition>)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const sync = await readThemeWorkshopSyncFile(folderPath);
|
||||||
|
const folderStat = await fs.stat(folderPath).catch(() => undefined);
|
||||||
|
|
||||||
const httpBase = await themeAssetBase();
|
const httpBase = await themeAssetBase();
|
||||||
const httpFolder = httpBase ? `${httpBase}/${encodeURIComponent(folderName)}` : '';
|
const httpFolder = httpBase ? `${httpBase}/${encodeURIComponent(folderName)}` : '';
|
||||||
@@ -158,9 +241,11 @@ const themeFromFolder = async (
|
|||||||
: convertFileSrc(await path.normalize(await path.join(folderPath, 'assets')));
|
: convertFileSrc(await path.normalize(await path.join(folderPath, 'assets')));
|
||||||
const previewName = parsed.preview || (isV2 ? 'preview.png' : '');
|
const previewName = parsed.preview || (isV2 ? 'preview.png' : '');
|
||||||
let preview = '';
|
let preview = '';
|
||||||
|
let previewModifiedAt: number | undefined;
|
||||||
if (previewName) {
|
if (previewName) {
|
||||||
const previewPath = await path.join(folderPath, 'assets', previewName);
|
const previewPath = await path.join(folderPath, 'assets', previewName);
|
||||||
if (await fs.exists(previewPath)) {
|
if (await fs.exists(previewPath)) {
|
||||||
|
previewModifiedAt = (await fs.stat(previewPath).catch(() => undefined))?.mtime?.getTime();
|
||||||
// The editor rewrites preview.png on every save. A cache-busting query
|
// The editor rewrites preview.png on every save. A cache-busting query
|
||||||
// makes the WebView pick up the new file instead of its cached asset.
|
// makes the WebView pick up the new file instead of its cached asset.
|
||||||
preview = httpFolder
|
preview = httpFolder
|
||||||
@@ -190,6 +275,10 @@ const themeFromFolder = async (
|
|||||||
status: 'installed',
|
status: 'installed',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
|
syncedContentHash: sync?.contentHash,
|
||||||
|
syncedVersion: sync?.version,
|
||||||
|
previewModifiedAt,
|
||||||
|
folderCreatedAt: folderStat?.birthtime?.getTime(),
|
||||||
cssStyle,
|
cssStyle,
|
||||||
definition,
|
definition,
|
||||||
};
|
};
|
||||||
@@ -539,6 +628,11 @@ export const reloadThemes = async () => {
|
|||||||
nsfw: theme.nsfw,
|
nsfw: theme.nsfw,
|
||||||
workshopId: theme.workshopId,
|
workshopId: theme.workshopId,
|
||||||
definition: theme.definition,
|
definition: theme.definition,
|
||||||
|
syncedContentHash: theme.syncedContentHash,
|
||||||
|
syncedVersion: theme.syncedVersion,
|
||||||
|
previewModifiedAt: theme.previewModifiedAt,
|
||||||
|
folderCreatedAt: theme.folderCreatedAt,
|
||||||
|
isNotSynced: versionChanged ? false : storeTheme.isNotSynced,
|
||||||
updateAvailable: versionChanged ? false : storeTheme.updateAvailable,
|
updateAvailable: versionChanged ? false : storeTheme.updateAvailable,
|
||||||
availableVersion: versionChanged ? undefined : storeTheme.availableVersion,
|
availableVersion: versionChanged ? undefined : storeTheme.availableVersion,
|
||||||
cssStyle: theme.cssStyle,
|
cssStyle: theme.cssStyle,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
themeMediaSuspended,
|
themeMediaSuspended,
|
||||||
themeMediaSuspension,
|
themeMediaSuspension,
|
||||||
} from '$lib/themeMedia';
|
} from '$lib/themeMedia';
|
||||||
|
import { themeParallax } from '$lib/themeParallax';
|
||||||
import { isThemeTrusted } from '$lib/themeTrust';
|
import { isThemeTrusted } from '$lib/themeTrust';
|
||||||
import { themeV2Css } from '$lib/themeV2';
|
import { themeV2Css } from '$lib/themeV2';
|
||||||
import { getThemes, loadTheme } from '$lib/themes';
|
import { getThemes, loadTheme } from '$lib/themes';
|
||||||
@@ -550,7 +551,13 @@
|
|||||||
</AlertDialog.Content>
|
</AlertDialog.Content>
|
||||||
</AlertDialog.Root>
|
</AlertDialog.Root>
|
||||||
|
|
||||||
<main use:themeMediaSuspension>
|
<main
|
||||||
|
use:themeMediaSuspension
|
||||||
|
use:themeParallax={{
|
||||||
|
enabled: $active_custom_theme?.definition?.parallax?.enabled === true,
|
||||||
|
intensity: $active_custom_theme?.definition?.parallax?.intensity ?? 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class:hidden={!!$active_custom_theme?.definition}>
|
<div class:hidden={!!$active_custom_theme?.definition}>
|
||||||
<div class="opacity-30">
|
<div class="opacity-30">
|
||||||
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
|
<div transition:fade={{ duration: $reduceAnimations ? 0 : 500 }}>
|
||||||
|
|||||||
+191
-1
@@ -122,8 +122,10 @@
|
|||||||
checkThemeFromFile,
|
checkThemeFromFile,
|
||||||
deleteTheme,
|
deleteTheme,
|
||||||
downloadTheme,
|
downloadTheme,
|
||||||
|
hashThemeContent,
|
||||||
importThemeFromFile,
|
importThemeFromFile,
|
||||||
loadTheme,
|
loadTheme,
|
||||||
|
markThemeSynced,
|
||||||
openThemesFolder,
|
openThemesFolder,
|
||||||
reloadThemes,
|
reloadThemes,
|
||||||
restoreThemeBackup,
|
restoreThemeBackup,
|
||||||
@@ -317,6 +319,7 @@
|
|||||||
let ownedWorkshopThemeIds = $state<string[]>([]);
|
let ownedWorkshopThemeIds = $state<string[]>([]);
|
||||||
let ownedWorkshopThemeNames = $state<string[]>([]);
|
let ownedWorkshopThemeNames = $state<string[]>([]);
|
||||||
let ownedWorkshopLoaded = $state(false);
|
let ownedWorkshopLoaded = $state(false);
|
||||||
|
const themeContentHashCache = new SvelteMap<string, string>();
|
||||||
let ownedThemesRequestId = 0;
|
let ownedThemesRequestId = 0;
|
||||||
let logViewerOpen = $state(false);
|
let logViewerOpen = $state(false);
|
||||||
let presenceEditorOpen = $state(false);
|
let presenceEditorOpen = $state(false);
|
||||||
@@ -326,6 +329,9 @@
|
|||||||
|
|
||||||
const communityThemeCache = new SvelteMap<string, CommunityTheme>();
|
const communityThemeCache = new SvelteMap<string, CommunityTheme>();
|
||||||
let lastThemeUpdateCheck = 0;
|
let lastThemeUpdateCheck = 0;
|
||||||
|
let lastSyncUserId: number | undefined;
|
||||||
|
let lastSyncThemeCount = -1;
|
||||||
|
let lastSyncOwnedCount = -1;
|
||||||
|
|
||||||
const themeFilterOptions = [
|
const themeFilterOptions = [
|
||||||
{ id: 'all', label: 'All themes', icon: 'all-themes' },
|
{ id: 'all', label: 'All themes', icon: 'all-themes' },
|
||||||
@@ -410,6 +416,7 @@
|
|||||||
|
|
||||||
const isOwnInstalledTheme = (theme: Theme) => {
|
const isOwnInstalledTheme = (theme: Theme) => {
|
||||||
if (!$currentUser) return false;
|
if (!$currentUser) return false;
|
||||||
|
if (theme.authorId === $currentUser.id) return true;
|
||||||
|
|
||||||
const folderName = theme.folder_name.trim().toLowerCase();
|
const folderName = theme.folder_name.trim().toLowerCase();
|
||||||
const workshopId = theme.workshopId?.trim().toLowerCase();
|
const workshopId = theme.workshopId?.trim().toLowerCase();
|
||||||
@@ -543,6 +550,117 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getThemeContentHash = async (theme: Theme) => {
|
||||||
|
const cached = themeContentHashCache.get(theme.folder_name);
|
||||||
|
if (cached) return cached;
|
||||||
|
const hash = await hashThemeContent(theme.folder_name);
|
||||||
|
themeContentHashCache.set(theme.folder_name, hash);
|
||||||
|
return hash;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Themes installed before content hashes were introduced have no baseline.
|
||||||
|
* Their preview is regenerated on every editor save, so a preview newer than
|
||||||
|
* the Workshop record means the local copy was edited after publication.
|
||||||
|
* The folder creation check keeps fresh downloads from being flagged.
|
||||||
|
*/
|
||||||
|
const legacyThemeChangedAfterPublish = (theme: Theme, remote: CommunityTheme) => {
|
||||||
|
const publishedAt = Date.parse(remote.updatedAt);
|
||||||
|
if (!Number.isFinite(publishedAt)) return false;
|
||||||
|
|
||||||
|
const createdAt = theme.folderCreatedAt;
|
||||||
|
const previewModifiedAt = theme.previewModifiedAt;
|
||||||
|
return (
|
||||||
|
createdAt !== undefined &&
|
||||||
|
previewModifiedAt !== undefined &&
|
||||||
|
createdAt < publishedAt &&
|
||||||
|
previewModifiedAt > publishedAt + 1_000
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThemeSyncState = {
|
||||||
|
syncedContentHash?: string;
|
||||||
|
syncedVersion?: string;
|
||||||
|
isNotSynced: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveThemeSyncState = async (
|
||||||
|
theme: Theme,
|
||||||
|
remote: CommunityTheme,
|
||||||
|
localVersion: string
|
||||||
|
): Promise<ThemeSyncState> => {
|
||||||
|
const versionMismatch = remote.version !== localVersion;
|
||||||
|
let contentHash: string | undefined;
|
||||||
|
try {
|
||||||
|
contentHash = await getThemeContentHash(theme);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Themes] Could not hash "${theme.name}":`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme.syncedContentHash) {
|
||||||
|
const contentMismatch = contentHash !== undefined && contentHash !== theme.syncedContentHash;
|
||||||
|
return {
|
||||||
|
syncedContentHash: theme.syncedContentHash,
|
||||||
|
syncedVersion: theme.syncedVersion,
|
||||||
|
isNotSynced:
|
||||||
|
versionMismatch ||
|
||||||
|
contentMismatch ||
|
||||||
|
(theme.syncedVersion !== undefined && theme.syncedVersion !== localVersion),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionMismatch || legacyThemeChangedAfterPublish(theme, remote)) {
|
||||||
|
return { isNotSynced: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sync = await markThemeSynced(theme.folder_name, {
|
||||||
|
workshopId: remote.id,
|
||||||
|
version: localVersion,
|
||||||
|
});
|
||||||
|
themeContentHashCache.set(theme.folder_name, sync.contentHash);
|
||||||
|
return {
|
||||||
|
syncedContentHash: sync.contentHash,
|
||||||
|
syncedVersion: sync.version,
|
||||||
|
isNotSynced: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Themes] Could not record sync state for "${theme.name}":`, error);
|
||||||
|
return { isNotSynced: false };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshThemeSyncState = async (folderName: string) => {
|
||||||
|
themeContentHashCache.delete(folderName);
|
||||||
|
const theme = get(custom_themes).find((candidate) => candidate.folder_name === folderName);
|
||||||
|
if (!theme || !theme.workshopId) return;
|
||||||
|
|
||||||
|
let contentHash: string;
|
||||||
|
try {
|
||||||
|
contentHash = await getThemeContentHash(theme);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Themes] Could not hash "${theme.name}" after saving:`, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVersion = normalizeThemeVersion(theme.version);
|
||||||
|
const workshopVersion = theme.workshopVersion
|
||||||
|
? normalizeThemeVersion(theme.workshopVersion)
|
||||||
|
: undefined;
|
||||||
|
const baseline = theme.syncedContentHash;
|
||||||
|
const isNotSynced = baseline
|
||||||
|
? baseline !== contentHash ||
|
||||||
|
(theme.syncedVersion !== undefined && theme.syncedVersion !== localVersion) ||
|
||||||
|
(workshopVersion !== undefined && localVersion !== workshopVersion)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
custom_themes.update((themes) =>
|
||||||
|
themes.map((candidate) =>
|
||||||
|
candidate.folder_name === folderName ? { ...candidate, isNotSynced } : candidate
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const checkInstalledThemeUpdates = async (force = false) => {
|
const checkInstalledThemeUpdates = async (force = false) => {
|
||||||
if (checkingThemeUpdates) return;
|
if (checkingThemeUpdates) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -590,6 +708,20 @@
|
|||||||
const workers = Math.min(4, installed.length);
|
const workers = Math.min(4, installed.length);
|
||||||
await Promise.all(Array.from({ length: workers }, () => checkNext()));
|
await Promise.all(Array.from({ length: workers }, () => checkNext()));
|
||||||
|
|
||||||
|
const syncStates = new SvelteMap<string, ThemeSyncState>();
|
||||||
|
for (const theme of installed) {
|
||||||
|
const remote = remoteThemes.get(theme.folder_name);
|
||||||
|
if (!remote) continue;
|
||||||
|
const localVersion = normalizeThemeVersion(theme.version);
|
||||||
|
if (!localVersion) continue;
|
||||||
|
if (isOwnInstalledTheme(theme)) {
|
||||||
|
syncStates.set(
|
||||||
|
theme.folder_name,
|
||||||
|
await resolveThemeSyncState(theme, remote.theme, remote.version)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
custom_themes.update((themes) =>
|
custom_themes.update((themes) =>
|
||||||
themes.map((theme) => {
|
themes.map((theme) => {
|
||||||
const remote = remoteThemes.get(theme.folder_name);
|
const remote = remoteThemes.get(theme.folder_name);
|
||||||
@@ -598,10 +730,13 @@
|
|||||||
if (!localVersion) return theme;
|
if (!localVersion) return theme;
|
||||||
|
|
||||||
const updateAvailable = semver.gt(remote.version, localVersion);
|
const updateAvailable = semver.gt(remote.version, localVersion);
|
||||||
|
const sync = syncStates.get(theme.folder_name);
|
||||||
return {
|
return {
|
||||||
...theme,
|
...theme,
|
||||||
|
workshopVersion: remote.version,
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
availableVersion: updateAvailable ? remote.version : undefined,
|
availableVersion: updateAvailable ? remote.version : undefined,
|
||||||
|
...(sync ?? {}),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -660,6 +795,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
themeContentHashCache.delete(theme.folder_name);
|
||||||
communityThemeCache.delete(theme.folder_name);
|
communityThemeCache.delete(theme.folder_name);
|
||||||
sileo.success({
|
sileo.success({
|
||||||
title: 'Yaay!',
|
title: 'Yaay!',
|
||||||
@@ -677,6 +813,24 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
const userId = $currentUser?.id;
|
||||||
|
const themeCount = $custom_themes.length;
|
||||||
|
const ownedCount = ownedWorkshopThemeIds.length + ownedWorkshopThemeNames.length;
|
||||||
|
|
||||||
|
// Themes and the signed-in creator load asynchronously. Reset the check
|
||||||
|
// throttle when they change so the first badge is not missed by an early
|
||||||
|
// check that ran against an empty library or an unknown user.
|
||||||
|
if (
|
||||||
|
userId !== lastSyncUserId ||
|
||||||
|
themeCount !== lastSyncThemeCount ||
|
||||||
|
ownedCount !== lastSyncOwnedCount
|
||||||
|
) {
|
||||||
|
lastSyncUserId = userId;
|
||||||
|
lastSyncThemeCount = themeCount;
|
||||||
|
lastSyncOwnedCount = ownedCount;
|
||||||
|
lastThemeUpdateCheck = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedView === 'THEMES') {
|
if (selectedView === 'THEMES') {
|
||||||
untrack(() => void checkInstalledThemeUpdates());
|
untrack(() => void checkInstalledThemeUpdates());
|
||||||
}
|
}
|
||||||
@@ -760,6 +914,7 @@
|
|||||||
// copy when the saved theme was already the active one.
|
// copy when the saved theme was already the active one.
|
||||||
const wasActive = $active_custom_theme?.folder_name === folderName;
|
const wasActive = $active_custom_theme?.folder_name === folderName;
|
||||||
await reloadThemes();
|
await reloadThemes();
|
||||||
|
await refreshThemeSyncState(folderName);
|
||||||
if (!wasActive) return;
|
if (!wasActive) return;
|
||||||
|
|
||||||
const savedTheme = get(custom_themes).find((theme) => theme.folder_name === folderName);
|
const savedTheme = get(custom_themes).find((theme) => theme.folder_name === folderName);
|
||||||
@@ -848,8 +1003,10 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const themeFolder = await path.join(await themesDirectory(), theme.folder_name);
|
const themeFolder = await path.join(await themesDirectory(), theme.folder_name);
|
||||||
|
themeContentHashCache.delete(theme.folder_name);
|
||||||
await restoreThemeBackup(themeFolder, backup.path);
|
await restoreThemeBackup(themeFolder, backup.path);
|
||||||
await reloadThemes();
|
await reloadThemes();
|
||||||
|
await refreshThemeSyncState(theme.folder_name);
|
||||||
|
|
||||||
const restoredTheme = get(custom_themes).find((candidate) => candidate.name === theme.name);
|
const restoredTheme = get(custom_themes).find((candidate) => candidate.name === theme.name);
|
||||||
if (wasActive && restoredTheme) {
|
if (wasActive && restoredTheme) {
|
||||||
@@ -885,6 +1042,7 @@
|
|||||||
const refreshThemes = async () => {
|
const refreshThemes = async () => {
|
||||||
refreshingThemes = true;
|
refreshingThemes = true;
|
||||||
try {
|
try {
|
||||||
|
themeContentHashCache.clear();
|
||||||
await reloadThemes();
|
await reloadThemes();
|
||||||
void checkInstalledThemeUpdates(true);
|
void checkInstalledThemeUpdates(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1836,6 +1994,18 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The publish succeeded, so the local version is now the workshop
|
||||||
|
// version. Clear the stale "Not synced" badge before the next check.
|
||||||
|
custom_themes.update((themes) =>
|
||||||
|
themes.map((candidate) => {
|
||||||
|
const matchesId = themeId && candidate.workshopId?.trim().toLowerCase() === themeId;
|
||||||
|
const matchesName = themeName && candidate.name.trim().toLowerCase() === themeName;
|
||||||
|
return matchesId || matchesName
|
||||||
|
? { ...candidate, workshopVersion: candidate.version, isNotSynced: false }
|
||||||
|
: candidate;
|
||||||
|
})
|
||||||
|
);
|
||||||
}).then((unlisten) => (themePublishedUnlisten = unlisten));
|
}).then((unlisten) => (themePublishedUnlisten = unlisten));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -4096,9 +4266,18 @@
|
|||||||
theme.status === 'installed' && (theme.backups?.length ?? 0) > 0}
|
theme.status === 'installed' && (theme.backups?.length ?? 0) > 0}
|
||||||
{@const canUpdateTheme =
|
{@const canUpdateTheme =
|
||||||
theme.status === 'installed' && theme.updateAvailable}
|
theme.status === 'installed' && theme.updateAvailable}
|
||||||
|
{@const isOwnTheme = isOwnInstalledTheme(theme)}
|
||||||
|
{@const localVersion = normalizeThemeVersion(theme.version)}
|
||||||
|
{@const workshopVersion = theme.workshopVersion}
|
||||||
|
{@const versionMismatch =
|
||||||
|
localVersion !== undefined &&
|
||||||
|
workshopVersion !== undefined &&
|
||||||
|
workshopVersion !== localVersion}
|
||||||
|
{@const isNotSynced =
|
||||||
|
isOwnTheme && (theme.isNotSynced === true || versionMismatch)}
|
||||||
{@const canReportTheme =
|
{@const canReportTheme =
|
||||||
Boolean($currentUser && ownedWorkshopLoaded) &&
|
Boolean($currentUser && ownedWorkshopLoaded) &&
|
||||||
!isOwnInstalledTheme(theme) &&
|
!isOwnTheme &&
|
||||||
theme.status === 'installed' &&
|
theme.status === 'installed' &&
|
||||||
theme.name !== 'Default' &&
|
theme.name !== 'Default' &&
|
||||||
theme.folder_name !== 'default'}
|
theme.folder_name !== 'default'}
|
||||||
@@ -4178,6 +4357,17 @@
|
|||||||
API {theme.apiVersion.startsWith('2') ? '2.0' : '1.0'}
|
API {theme.apiVersion.startsWith('2') ? '2.0' : '1.0'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if isNotSynced}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="border-amber-500/40 bg-amber-950/70 px-1.5 py-0 text-[9px] text-amber-200 backdrop-blur-sm"
|
||||||
|
title={versionMismatch
|
||||||
|
? `Local v${theme.version} · Workshop v${workshopVersion}`
|
||||||
|
: 'Local content differs from the published Workshop version'}
|
||||||
|
>
|
||||||
|
Not synced
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-1.5 right-1.5 flex items-center gap-1">
|
<div class="absolute top-1.5 right-1.5 flex items-center gap-1">
|
||||||
{#if theme.nsfw}
|
{#if theme.nsfw}
|
||||||
|
|||||||
Reference in new issue
Block a user