OneLauncher-Cracked/packages/oneclient_app/src/layout/app_shell.rs

585 lines
20 KiB
Rust

use bytes::Bytes;
use freya::animation::*;
use freya::engine::prelude::{Paint, SamplingOptions, SkData, SkImage, SkRect};
use freya::prelude::*;
use freya::query::QueryStateData;
use freya::router::*;
use skia_safe::canvas::SrcRectConstraint;
use skia_safe::sampling_options::CubicResampler;
use crate::Route;
use crate::components::Button;
use crate::components::{
ART_PREVIEW_EDGE, AppNavbar, CodeBlock, DynamicArt, FileDropOverlay, Icon, IconType,
OverlayPopup, accept_drop, copy_button,
};
use crate::layout::AnimatedAppOutlet;
use crate::use_settings_snapshot;
use oneclient_core::clusters::Cluster;
use oneclient_core::images::BACKGROUND_IMAGE_EDGE;
use oneclient_db::models::ClusterId;
use crate::hooks::{
ActiveClusterState, BROWSER_COMPAT_DEFAULT, BrowserCompatState, BrowserStateStore,
BrowserTypeState, DataPackWorldState, use_active_cluster_id, use_clusters, use_game_snapshot,
use_launcher, use_provide_active_cluster, use_provide_browser_compat,
use_provide_browser_state, use_provide_browser_type, use_provide_datapack_world, use_splash,
};
use crate::theme::colors;
use crate::utils::home_cluster;
use oneclient_events::LaunchStage;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(PartialEq)]
pub struct AppShell;
impl Component for AppShell {
fn render(&self) -> impl IntoElement {
let active_cluster = use_state(|| None::<ClusterId>);
use_provide_active_cluster(ActiveClusterState(active_cluster));
let browser_compat = use_state(|| BROWSER_COMPAT_DEFAULT);
use_provide_browser_compat(BrowserCompatState(browser_compat));
let browser_state = use_state(HashMap::new);
use_provide_browser_state(BrowserStateStore(browser_state));
let browser_type = use_state(|| "mod".to_string());
use_provide_browser_type(BrowserTypeState(browser_type));
let datapack_world = use_state(HashMap::new);
use_provide_datapack_world(DataPackWorldState(datapack_world));
// `FileDrop` bubbles so anything a drop zone doesn't `stop_propagation()` lands here
let mut drop_hovering = use_state(|| false);
let drop_pending = use_state(Vec::<PathBuf>::new);
let dropped_modpacks = use_state(Vec::<PathBuf>::new);
let game = use_game_snapshot();
let running_cluster = game
.stages
.iter()
.find(|(_, s)| **s == LaunchStage::Running)
.map(|(id, _)| *id);
use_side_effect_with_deps(&running_cluster, move |running| {
if let Some(cluster_id) = *running {
spawn(async move {
let _ = RouterContext::get().push(Route::ProcessLogs { cluster_id });
});
}
});
rect()
.vertical()
.width(Size::fill())
.height(Size::fill())
.color(colors::fg_primary())
.overflow(Overflow::Clip)
.on_global_file_hover(move |_| drop_hovering.set(true))
.on_global_file_hover_cancelled(move |_| drop_hovering.set(false))
.on_file_drop(move |e: Event<FileEventData>| {
drop_hovering.set(false);
accept_drop(&e.file_paths, drop_pending, dropped_modpacks);
})
.child(AppNavbar)
.child(AppHomeBackground)
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.child(AnimatedRouter::<Route>::new(AnimatedAppOutlet)),
)
.child(FileDropOverlay {
hovering: drop_hovering,
pending: drop_pending,
modpacks: dropped_modpacks,
})
.maybe_child(
game.error
.clone()
.map(|message| LaunchErrorDialog { message }.into_element()),
)
}
}
pub fn hides_overlay(route: &Route) -> bool {
matches!(route, Route::Home {})
}
const DIALOG_BG: Color = Color::from_rgb(21, 28, 34);
const CODE_BG: Color = Color::from_rgb(13, 18, 22);
#[derive(PartialEq)]
struct LaunchErrorDialog {
message: String,
}
impl Component for LaunchErrorDialog {
fn render(&self) -> impl IntoElement {
let dispatch = crate::hooks::use_dispatch();
let close = dispatch.clone();
let outside_close = dispatch.clone();
let copy_dispatch = dispatch.clone();
OverlayPopup::new()
.on_close(move |_| close.dismiss_game_error())
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.on_press(move |_| outside_close.dismiss_game_error())
.child(
rect()
.vertical()
.width(Size::px(560.))
.background(DIALOG_BG)
.corner_radius(CornerRadius::new_all(16.))
.padding(Gaps::new_all(24.))
.spacing(16.)
.border(crate::ui::border_all_color(1., colors::component_border()))
.on_press(|e: Event<PressEventData>| e.stop_propagation())
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(10.)
.child(
Icon::new(IconType::AlertTriangle)
.size(20.)
.color(colors::danger()),
)
.child(
label()
.text("Game failed to launch")
.font_size(18.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
)
.child(
label()
.text("Minecraft could not be launched. Error details:")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
CodeBlock::new(self.message.clone())
.background(CODE_BG)
.height(260.),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.spacing(10.)
.child(copy_button(
self.message.clone(),
"Error message copied to your clipboard.",
copy_dispatch,
))
.child(
Button::new()
.primary()
.on_press(move |_| dispatch.dismiss_game_error())
.text("Close"),
),
),
),
)
}
}
pub(crate) fn appshell_overlay(alpha: f32) -> Rect {
let alpha = alpha.clamp(0., 1.);
let scale = |a: u8| (f32::from(a) * alpha).round() as u8;
rect()
.width(Size::fill())
.height(Size::fill())
.position(Position::new_absolute())
.interactive(false)
.layer(Layer::Relative(2))
.child(
rect()
.height(Size::px(200.))
.width(Size::fill())
.background(
LinearGradient::new()
.stop((Color::BLACK.with_a(scale(100)), 0.))
.stop((colors::page().with_a(scale(255)), 95.0)),
),
)
.child(
rect()
.height(Size::fill())
.width(Size::fill())
.background(colors::page().with_a(scale(255))),
)
}
pub const HOME_BACKGROUND_ASSET: &str = "backgrounds/CavesAndCliffs.jpg";
fn home_art(cluster: Option<&Cluster>) -> DynamicArt {
cluster
.map_or_else(DynamicArt::fallback, DynamicArt::for_cluster)
.max_edge(BACKGROUND_IMAGE_EDGE)
.preview_edge(ART_PREVIEW_EDGE)
}
/// Warms the home art at the root so its three async hops overlap startup behind the
/// splash left to Home the fallback lingers on screen after the curtain lifts
#[derive(PartialEq, Clone, Copy)]
pub struct HomeArtPrefetch;
impl Component for HomeArtPrefetch {
fn render(&self) -> impl IntoElement {
// Before the launcher is ready every query errors and a settled error is not
// retried until a new subscriber mounts
if !use_launcher().ready {
return rect().into_element();
}
HomeArtWarm.into_element()
}
}
#[derive(PartialEq, Clone, Copy)]
struct HomeArtWarm;
impl Component for HomeArtWarm {
fn render(&self) -> impl IntoElement {
let clusters_query = use_clusters();
let art = {
let reader = clusters_query.read();
let state = reader.state();
// No active cluster this early so this picks the same one `AppHomeBackground` will
let clusters = state.ok().map_or(&[][..], Vec::as_slice);
home_art(home_cluster(clusters, None))
};
let _ = art.use_bytes();
rect().into_element()
}
}
#[derive(PartialEq, Clone, Copy)]
pub struct AppHomeBackground;
impl Component for AppHomeBackground {
fn render(&self) -> impl IntoElement {
let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled;
let clusters_query = use_clusters();
let active_id = use_active_cluster_id();
let launcher = use_launcher();
let splash = use_splash();
// The home view is populated once these settle which releases the splash curtain
let clusters_settled = matches!(
&*clusters_query.read().state(),
QueryStateData::Settled { .. }
);
let home_ready = clusters_settled && !launcher.fetching;
use_side_effect_with_deps(&home_ready, move |&ready| {
if ready {
let mut flag = splash.home_ready;
if !*flag.peek() {
flag.set(true);
}
}
});
let (art, art_dep) = {
let reader = clusters_query.read();
let state = reader.state();
let clusters = state.ok().map_or(&[][..], Vec::as_slice);
let chosen = home_cluster(clusters, *active_id.read());
let dep = chosen.map(|c| c.id);
(home_art(chosen), dep)
};
let (_, art_bytes) = art.use_bytes();
let fade = use_animation_with_dependencies(&art_dep, |conf, _| {
conf.on_creation(OnCreation::Run);
conf.on_change(OnChange::Rerun);
AnimNum::new(0., 1.)
.time(520)
.ease(Ease::Out)
.function(Function::Cubic)
});
let fade_opacity = fade.get().value();
ParallaxArt {
art_bytes,
fade_opacity,
parallax_enabled,
}
.into_element()
}
}
#[derive(PartialEq, Clone)]
pub struct ParallaxArt {
pub art_bytes: Bytes,
pub fade_opacity: f32,
pub parallax_enabled: bool,
}
impl Component for ParallaxArt {
fn render(&self) -> impl IntoElement {
let mut size = use_state(|| (0f32, 0f32));
let mut target = use_state(|| (0f32, 0f32));
let mut current = use_state(|| (0f32, 0f32));
let mut anim_task = use_state(|| None::<OwnedTaskHandle>);
use_side_effect_with_deps(&self.parallax_enabled, move |enabled| {
if *enabled {
let handle = spawn(async move {
let platform = Platform::get();
const FRAME: std::time::Duration = std::time::Duration::from_millis(33);
const STEP: f32 = 0.12;
const SETTLE: f32 = 0.002;
loop {
let (tx, ty) = *target.peek();
let (cx, cy) = *current.peek();
if (tx - cx).abs() > SETTLE || (ty - cy).abs() > SETTLE {
current.set((cx + (tx - cx) * STEP, cy + (ty - cy) * STEP));
platform.send(UserEvent::RequestRedraw);
}
tokio::time::sleep(FRAME).await;
}
});
anim_task.set(Some(handle.owned()));
} else {
anim_task.set(None);
target.set((0.0, 0.0));
current.set((0.0, 0.0));
}
});
let mut img_cache = use_state(|| None::<(usize, SkImage)>);
let src_ptr = self.art_bytes.as_ptr() as usize;
let cached = img_cache.peek().clone();
let image = match cached {
Some((ptr, img)) if ptr == src_ptr => Some(img),
_ => {
let decoded = decode_background_image(&self.art_bytes);
if let Some(img) = &decoded {
img_cache.set(Some((src_ptr, img.clone())));
}
decoded
}
};
let parallax_enabled = self.parallax_enabled;
let fade_opacity = self.fade_opacity;
let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| {
let Some(image) = &image else {
return;
};
let (w, h) = (ctx.size.width, ctx.size.height);
if w <= 0.0 || h <= 0.0 {
return;
}
let iw = image.width() as f32;
let ih = image.height() as f32;
if iw <= 0.0 || ih <= 0.0 {
return;
}
let (cx, cy) = if parallax_enabled {
current()
} else {
(0.0, 0.0)
};
let canvas_aspect = w / h;
let img_aspect = iw / ih;
let (mut crop_w, mut crop_h) = if img_aspect > canvas_aspect {
(ih * canvas_aspect, ih)
} else {
(iw, iw / canvas_aspect)
};
const SCALE: f32 = 1.10;
crop_w /= SCALE;
crop_h /= SCALE;
const STRENGTH: f32 = 0.01;
let pan_x = cx * w * 0.5 * STRENGTH * (crop_w / w);
let pan_y = cy * h * 0.5 * STRENGTH * (crop_h / h);
let ccx = iw * 0.5 - pan_x;
let ccy = ih * 0.5 - pan_y;
let src = SkRect::new(
ccx - crop_w * 0.5,
ccy - crop_h * 0.5,
ccx + crop_w * 0.5,
ccy + crop_h * 0.5,
);
let dst = SkRect::new(0.0, 0.0, w, h);
let mut paint = Paint::default();
paint.set_anti_alias(true);
let sampling = SamplingOptions::from(CubicResampler::mitchell());
ctx.canvas.draw_image_rect_with_sampling_options(
image,
Some((&src, SrcRectConstraint::Strict)),
dst,
sampling,
&paint,
);
});
rect()
.width(Size::fill())
.height(Size::fill())
.overflow(Overflow::Clip)
.position(Position::new_absolute())
.interactive(false)
.on_sized(move |e: Event<SizedEventData>| {
let (sw, sh) = (e.area.width(), e.area.height());
let (pw, ph) = *size.peek();
if (pw - sw).abs() > 0.5 || (ph - sh).abs() > 0.5 {
size.set((sw, sh));
}
})
.maybe(parallax_enabled, |el| {
el.on_capture_global_pointer_move(move |e: Event<PointerEventData>| {
let (sw, sh) = *size.peek();
if sw > 0.0 && sh > 0.0 {
let loc = e.global_location();
target.set((
(loc.x as f32 / sw) * 2.0 - 1.0,
(loc.y as f32 / sh) * 2.0 - 1.0,
));
}
})
})
.child(
rect()
.expanded()
.position(Position::new_absolute())
.layer(Layer::Relative(1))
.child(gradient_overlay_radial()),
)
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.position(Position::new_absolute())
.layer(Layer::Relative(0))
.opacity(fade_opacity)
.child(
canvas(render_cb)
.key(src_ptr as u64)
.width(Size::fill())
.height(Size::fill()),
),
)
}
}
fn decode_background_image(bytes: &Bytes) -> Option<SkImage> {
let data = unsafe { SkData::new_bytes(bytes) };
let img = SkImage::from_encoded(data)?;
Some(img.make_raster_image(None, None).unwrap_or(img))
}
const VIGNETTE_SPOTLIGHT_SHADER: &str = r#"
uniform float2 iResolution;
half4 main(float2 fragCoord) {
float2 uv = fragCoord / iResolution;
float2 center = float2(0.80, 0.45);
float2 distVec = uv - center;
float2 dir = normalize(distVec + float2(0.00001));
float leftScale = 1.8;
float rightScale = 4.5;
float scaleX = mix(leftScale, rightScale, smoothstep(-1.0, 1.0, dir.x));
float topScale = 2.2;
float bottomScale = 2.2;
float scaleY = mix(topScale, bottomScale, smoothstep(-1.0, 1.0, dir.y));
distVec.x *= scaleX;
distVec.y *= scaleY;
float dist = length(distVec);
float vignette = smoothstep(0.0, 1.2, dist);
vignette = pow(vignette, 1.3);
float maxDarkness = 0.8;
float alpha = vignette * maxDarkness;
return half4(0.0, 0.0, 0.0, alpha);
}
"#;
fn gradient_overlay_radial() -> impl IntoElement {
let effect = use_hook(|| {
freya::engine::prelude::RuntimeEffect::make_for_shader(VIGNETTE_SPOTLIGHT_SHADER, None)
.expect("Failed to compile vignette shader")
});
rect()
.width(Size::fill())
.height(Size::fill())
.position(Position::new_absolute())
.background(ShaderFill::new(
VIGNETTE_SPOTLIGHT_SHADER,
effect,
move |effect, bounds| {
let w = bounds.width();
let h = bounds.height();
#[rustfmt::skip]
let uniforms: [f32; 2] = [
w, h,
];
let bytes = unsafe {
std::slice::from_raw_parts(
uniforms.as_ptr() as *const u8,
std::mem::size_of_val(&uniforms),
)
};
effect.make_shader(freya::engine::prelude::Data::new_copy(bytes), &[], None)
},
))
}
pub(crate) fn navigate_back(at_home: bool) {
let router = RouterContext::get();
if router.can_go_back() {
router.go_back();
} else if !at_home {
let _ = router.push(Route::Home {});
}
}
pub(crate) fn back_button(destination: &str) -> impl IntoElement {
Button::new()
.ghost()
.small()
.on_press(|_| {
// Only rendered off home so the history-empty fallback is reachable
navigate_back(false);
})
.margin(Gaps::new(0., 0., 0., 32.))
.child(Icon::new(IconType::ArrowLeft).size(12.))
.text(format!("Back to {destination}"))
}