Fix rendering efficiency and screencopy Y-flip
Rendering: Skip the full bind/clear/draw/finish/submit cycle when nothing visual has changed (no new Electron frames, no dirty Wayland surfaces, no cursor movement). In the DRM path, also skip when no screencopy or portal streams are active. Frame callbacks are always sent unconditionally to prevent Wayland client stalls. Screencopy: Remove the incorrect row-flip after glReadPixels in both the wlr-screencopy fulfillment and portal stream readback paths. Smithay already applies a Y-correction when rendering to GBM-backed FBOs, so glReadPixels returns top-down data. The manual flip was inverting an already-correct image, producing upside-down output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
0bede114a6
commit
0a72ec60bf
2 changed files with 180 additions and 150 deletions
48
src/main.rs
48
src/main.rs
|
|
@ -90,6 +90,8 @@ struct EventLoopData {
|
||||||
frame_timestamps: std::collections::HashMap<String, Instant>,
|
frame_timestamps: std::collections::HashMap<String, Instant>,
|
||||||
gles_tex_cache: std::collections::HashMap<String, smithay::backend::renderer::gles::GlesTexture>,
|
gles_tex_cache: std::collections::HashMap<String, smithay::backend::renderer::gles::GlesTexture>,
|
||||||
input_events_pending: bool,
|
input_events_pending: bool,
|
||||||
|
/// Track previous cursor position to detect cursor movement for damage tracking.
|
||||||
|
last_cursor_pos: (f64, f64),
|
||||||
session: smithay::backend::session::libseat::LibSeatSession,
|
session: smithay::backend::session::libseat::LibSeatSession,
|
||||||
active: bool,
|
active: bool,
|
||||||
vblank_pending: bool,
|
vblank_pending: bool,
|
||||||
|
|
@ -478,6 +480,32 @@ fn render_frame(data: &mut EventLoopData) {
|
||||||
data.first_render = false;
|
data.first_render = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect cursor movement for damage tracking
|
||||||
|
let cursor_pos = (data.state.pointer_location.x, data.state.pointer_location.y);
|
||||||
|
let cursor_moved = cursor_pos != data.last_cursor_pos;
|
||||||
|
if cursor_moved {
|
||||||
|
data.last_cursor_pos = cursor_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pending screencopy requests that require a fresh render
|
||||||
|
let has_screencopy = data.state.screencopy_state.as_ref()
|
||||||
|
.map_or(false, |s| s.has_pending_frames());
|
||||||
|
let has_portal_streams = data.state.portal.as_ref()
|
||||||
|
.map_or(false, |p| !p.get_active_sessions().is_empty());
|
||||||
|
|
||||||
|
// Skip the expensive render+flip cycle when nothing visual has changed.
|
||||||
|
// Dirty surface exports (SHM/DMABUF to Electron) are handled separately
|
||||||
|
// and still run when dirty_surfaces is non-empty.
|
||||||
|
let frame_dirty = !data.state.damaged_windows.is_empty()
|
||||||
|
|| !data.state.dirty_surfaces.is_empty()
|
||||||
|
|| cursor_moved
|
||||||
|
|| has_screencopy
|
||||||
|
|| has_portal_streams;
|
||||||
|
|
||||||
|
if !frame_dirty {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Acquire swapchain slot
|
// Acquire swapchain slot
|
||||||
let slot = match data.swapchain.acquire() {
|
let slot = match data.swapchain.acquire() {
|
||||||
Ok(Some(slot)) => slot,
|
Ok(Some(slot)) => slot,
|
||||||
|
|
@ -966,15 +994,7 @@ fn render_frame(data: &mut EventLoopData) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if read_ok.is_ok() {
|
if read_ok.is_ok() {
|
||||||
// GL returns bottom-up rows; flip to top-down.
|
// GBM-backed FBO stores pixels top-down; no row flip needed.
|
||||||
let stride = fb_w as usize * 4;
|
|
||||||
for y in 0..(fb_h as usize / 2) {
|
|
||||||
let top = y * stride;
|
|
||||||
let bot = (fb_h as usize - 1 - y) * stride;
|
|
||||||
for x in 0..stride {
|
|
||||||
fb_pixels.swap(top + x, bot + x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert RGBA→ARGB (wl_shm expects ARGB8888)
|
// Convert RGBA→ARGB (wl_shm expects ARGB8888)
|
||||||
for pixel in fb_pixels.chunks_exact_mut(4) {
|
for pixel in fb_pixels.chunks_exact_mut(4) {
|
||||||
|
|
@ -1060,14 +1080,7 @@ fn render_frame(data: &mut EventLoopData) {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if read_ok.is_ok() {
|
if read_ok.is_ok() {
|
||||||
let stride = fb_w as usize * 4;
|
// GBM-backed FBO stores pixels top-down; no row flip needed.
|
||||||
for y in 0..(fb_h as usize / 2) {
|
|
||||||
let top = y * stride;
|
|
||||||
let bot = (fb_h as usize - 1 - y) * stride;
|
|
||||||
for x in 0..stride {
|
|
||||||
fb_pixels.swap(top + x, bot + x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for pixel in fb_pixels.chunks_exact_mut(4) {
|
for pixel in fb_pixels.chunks_exact_mut(4) {
|
||||||
let (r, g, b, a) = (pixel[0], pixel[1], pixel[2], pixel[3]);
|
let (r, g, b, a) = (pixel[0], pixel[1], pixel[2], pixel[3]);
|
||||||
pixel[0] = b;
|
pixel[0] = b;
|
||||||
|
|
@ -1869,6 +1882,7 @@ fn run_drm(mut config: Config) -> Result<(), anyhow::Error> {
|
||||||
wayland_socket,
|
wayland_socket,
|
||||||
frame_timestamps: std::collections::HashMap::new(),
|
frame_timestamps: std::collections::HashMap::new(),
|
||||||
input_events_pending: false,
|
input_events_pending: false,
|
||||||
|
last_cursor_pos: (0.0, 0.0),
|
||||||
session,
|
session,
|
||||||
active: true,
|
active: true,
|
||||||
vblank_pending: false,
|
vblank_pending: false,
|
||||||
|
|
|
||||||
280
src/nested.rs
280
src/nested.rs
|
|
@ -296,6 +296,8 @@ pub fn run_nested(config: Config) -> Result<()> {
|
||||||
// Frame tracking for backpressure management
|
// Frame tracking for backpressure management
|
||||||
let mut frame_timestamps: HashMap<String, Instant> = HashMap::new();
|
let mut frame_timestamps: HashMap<String, Instant> = HashMap::new();
|
||||||
let _max_frame_age = Duration::from_millis(33); // Drop frames older than 2 frames @60Hz
|
let _max_frame_age = Duration::from_millis(33); // Drop frames older than 2 frames @60Hz
|
||||||
|
// Track previous cursor position to detect movement
|
||||||
|
let mut last_cursor_pos: (f64, f64) = (0.0, 0.0);
|
||||||
|
|
||||||
info!("Nested compositor running");
|
info!("Nested compositor running");
|
||||||
|
|
||||||
|
|
@ -551,6 +553,16 @@ pub fn run_nested(config: Config) -> Result<()> {
|
||||||
let output_rect: Rectangle<i32, Physical> =
|
let output_rect: Rectangle<i32, Physical> =
|
||||||
Rectangle::new((0, 0).into(), (size.w as i32, size.h as i32).into());
|
Rectangle::new((0, 0).into(), (size.w as i32, size.h as i32).into());
|
||||||
|
|
||||||
|
// Determine if anything visual changed since last frame.
|
||||||
|
let cursor_pos = (state.pointer_location.x, state.pointer_location.y);
|
||||||
|
let cursor_moved = cursor_pos != last_cursor_pos;
|
||||||
|
if cursor_moved {
|
||||||
|
last_cursor_pos = cursor_pos;
|
||||||
|
}
|
||||||
|
let has_new_frames = !latest_frames.is_empty();
|
||||||
|
let has_dirty_surfaces = !state.dirty_surfaces.is_empty();
|
||||||
|
let frame_dirty = has_new_frames || has_dirty_surfaces || cursor_moved;
|
||||||
|
|
||||||
// Render in a scope to release borrows before submission.
|
// Render in a scope to release borrows before submission.
|
||||||
// Use a labeled block so early exits (bind/render failures) still reach
|
// Use a labeled block so early exits (bind/render failures) still reach
|
||||||
// the frame-callback section below — Wayland clients MUST receive frame
|
// the frame-callback section below — Wayland clients MUST receive frame
|
||||||
|
|
@ -558,6 +570,12 @@ pub fn run_nested(config: Config) -> Result<()> {
|
||||||
let mut render_ok = false;
|
let mut render_ok = false;
|
||||||
let render_t0 = std::time::Instant::now();
|
let render_t0 = std::time::Instant::now();
|
||||||
'render: {
|
'render: {
|
||||||
|
// Skip the expensive bind/render/submit cycle when nothing visual changed.
|
||||||
|
// Dirty surface exports (SHM/DMABUF) still happen via the separate path below.
|
||||||
|
if !frame_dirty {
|
||||||
|
break 'render;
|
||||||
|
}
|
||||||
|
|
||||||
// Bind backend for rendering (this returns (renderer, target))
|
// Bind backend for rendering (this returns (renderer, target))
|
||||||
let bind_t0 = std::time::Instant::now();
|
let bind_t0 = std::time::Instant::now();
|
||||||
let (renderer, mut target) = match backend.bind() {
|
let (renderer, mut target) = match backend.bind() {
|
||||||
|
|
@ -602,83 +620,115 @@ pub fn run_nested(config: Config) -> Result<()> {
|
||||||
|
|
||||||
let imported_textures = &gles_tex_cache;
|
let imported_textures = &gles_tex_cache;
|
||||||
|
|
||||||
// Prepare cursor elements before starting the frame
|
// Only run the full compositing pass when the output needs updating.
|
||||||
let cursor_elements: Vec<WaylandSurfaceRenderElement<GlesRenderer>> =
|
if frame_dirty {
|
||||||
if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = state.cursor_status {
|
// Prepare cursor elements before starting the frame
|
||||||
use smithay::backend::renderer::element::{surface::render_elements_from_surface_tree, Kind};
|
let cursor_elements: Vec<WaylandSurfaceRenderElement<GlesRenderer>> =
|
||||||
use smithay::utils::Scale as ScaleF;
|
if let smithay::input::pointer::CursorImageStatus::Surface(ref cursor_surface) = state.cursor_status {
|
||||||
|
use smithay::backend::renderer::element::{surface::render_elements_from_surface_tree, Kind};
|
||||||
|
use smithay::utils::Scale as ScaleF;
|
||||||
|
|
||||||
let scale = ScaleF::from(1.0);
|
let scale = ScaleF::from(1.0);
|
||||||
render_elements_from_surface_tree(
|
render_elements_from_surface_tree(
|
||||||
renderer,
|
renderer,
|
||||||
cursor_surface,
|
cursor_surface,
|
||||||
(state.pointer_location.x as i32, state.pointer_location.y as i32),
|
(state.pointer_location.x as i32, state.pointer_location.y as i32),
|
||||||
scale,
|
scale,
|
||||||
1.0,
|
1.0,
|
||||||
Kind::Cursor,
|
Kind::Cursor,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load themed cursor texture for Named cursor status
|
// Load themed cursor texture for Named cursor status
|
||||||
let themed_cursor = if let smithay::input::pointer::CursorImageStatus::Named(ref icon) = state.cursor_status {
|
let themed_cursor = if let smithay::input::pointer::CursorImageStatus::Named(ref icon) = state.cursor_status {
|
||||||
state.cursor_theme_manager.get_cursor(icon.name(), renderer).cloned()
|
state.cursor_theme_manager.get_cursor(icon.name(), renderer).cloned()
|
||||||
} else if cursor_elements.is_empty() {
|
} else if cursor_elements.is_empty() {
|
||||||
if !matches!(state.cursor_status, smithay::input::pointer::CursorImageStatus::Hidden) {
|
if !matches!(state.cursor_status, smithay::input::pointer::CursorImageStatus::Hidden) {
|
||||||
state.cursor_theme_manager.get_cursor("default", renderer).cloned()
|
state.cursor_theme_manager.get_cursor("default", renderer).cloned()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wayland windows are exclusively rendered by comraw via off-screen buffers.
|
// Wayland windows are exclusively rendered by comraw via off-screen buffers.
|
||||||
// The native space.elements() rendering pass has been removed here.
|
// The native space.elements() rendering pass has been removed here.
|
||||||
|
|
||||||
let mut frame = match renderer.render(
|
let mut frame = match renderer.render(
|
||||||
&mut target,
|
&mut target,
|
||||||
Size::from((size.w as i32, size.h as i32)),
|
Size::from((size.w as i32, size.h as i32)),
|
||||||
Transform::Flipped180,
|
Transform::Flipped180,
|
||||||
) {
|
) {
|
||||||
Ok(frame) => frame,
|
Ok(frame) => frame,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to begin frame: {}", e);
|
warn!("Failed to begin frame: {}", e);
|
||||||
if is_egl_context_or_surface_lost(&e.to_string()) {
|
if is_egl_context_or_surface_lost(&e.to_string()) {
|
||||||
error!(
|
error!(
|
||||||
"EGL context/surface lost while starting frame; shutting down so context can be recreated"
|
"EGL context/surface lost while starting frame; shutting down so context can be recreated"
|
||||||
);
|
);
|
||||||
state.running = false;
|
state.running = false;
|
||||||
|
}
|
||||||
|
break 'render;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear background
|
||||||
|
if let Err(e) = frame.clear(bg.into(), &[output_rect]) {
|
||||||
|
warn!("Failed to clear frame: {}", e);
|
||||||
break 'render;
|
break 'render;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Clear background
|
let mut windows: Vec<_> = state
|
||||||
if let Err(e) = frame.clear(bg.into(), &[output_rect]) {
|
.config
|
||||||
warn!("Failed to clear frame: {}", e);
|
.windows
|
||||||
break 'render;
|
.iter()
|
||||||
}
|
.chain(state.config.root.iter())
|
||||||
|
.collect();
|
||||||
|
windows.sort_by_key(|w| w.z_order);
|
||||||
|
|
||||||
let mut windows: Vec<_> = state
|
for win in windows {
|
||||||
.config
|
let mapped = state.window_mapped.get(&win.name).copied().unwrap_or(true);
|
||||||
.windows
|
if !mapped {
|
||||||
.iter()
|
continue;
|
||||||
.chain(state.config.root.iter())
|
}
|
||||||
.collect();
|
|
||||||
windows.sort_by_key(|w| w.z_order);
|
|
||||||
|
|
||||||
for win in windows {
|
if let Some(tex) = imported_textures.get(&win.name) {
|
||||||
let mapped = state.window_mapped.get(&win.name).copied().unwrap_or(true);
|
if let Err(e) = frame.render_texture_at(
|
||||||
if !mapped {
|
tex,
|
||||||
continue;
|
(win.x, win.y).into(),
|
||||||
|
1,
|
||||||
|
1.0,
|
||||||
|
Transform::Normal,
|
||||||
|
&[output_rect],
|
||||||
|
&[],
|
||||||
|
1.0,
|
||||||
|
) {
|
||||||
|
warn!("Failed to render texture for {}: {}", win.name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(tex) = imported_textures.get(&win.name) {
|
// Render cursor on top of everything
|
||||||
|
if !cursor_elements.is_empty() {
|
||||||
|
for element in &cursor_elements {
|
||||||
|
use smithay::utils::Scale as ScaleF;
|
||||||
|
let scale = ScaleF::from(1.0);
|
||||||
|
let src = element.src();
|
||||||
|
let dst = element.geometry(scale);
|
||||||
|
if let Err(e) = element.draw(&mut frame, src, dst, &[output_rect], &[]) {
|
||||||
|
trace!("Failed to render cursor element: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(ref cur) = themed_cursor {
|
||||||
|
use smithay::backend::renderer::Frame as _;
|
||||||
|
let cx = state.pointer_location.x as i32 - cur.xhot as i32;
|
||||||
|
let cy = state.pointer_location.y as i32 - cur.yhot as i32;
|
||||||
if let Err(e) = frame.render_texture_at(
|
if let Err(e) = frame.render_texture_at(
|
||||||
tex,
|
&cur.texture,
|
||||||
(win.x, win.y).into(),
|
(cx, cy).into(),
|
||||||
1,
|
1,
|
||||||
1.0,
|
1.0,
|
||||||
Transform::Normal,
|
Transform::Normal,
|
||||||
|
|
@ -686,59 +736,25 @@ pub fn run_nested(config: Config) -> Result<()> {
|
||||||
&[],
|
&[],
|
||||||
1.0,
|
1.0,
|
||||||
) {
|
) {
|
||||||
warn!("Failed to render texture for {}: {}", win.name, e);
|
trace!("Failed to render themed cursor: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// (Native rendering loop was removed)
|
match frame.finish() {
|
||||||
|
Ok(_) => {
|
||||||
// NOTE: Wayland surfaces are rendered via offscreen GL to SHM for comraw,
|
render_ok = true;
|
||||||
// allowing the client to fully control window compositing and layout.
|
}
|
||||||
|
Err(e) => {
|
||||||
// Render cursor on top of everything
|
warn!("Failed to finish frame: {}", e);
|
||||||
if !cursor_elements.is_empty() {
|
if is_egl_context_or_surface_lost(&e.to_string()) {
|
||||||
for element in &cursor_elements {
|
error!(
|
||||||
use smithay::utils::Scale as ScaleF;
|
"EGL context/surface lost while finishing frame; shutting down so context can be recreated"
|
||||||
let scale = ScaleF::from(1.0);
|
);
|
||||||
let src = element.src();
|
state.running = false;
|
||||||
let dst = element.geometry(scale);
|
}
|
||||||
if let Err(e) = element.draw(&mut frame, src, dst, &[output_rect], &[]) {
|
|
||||||
trace!("Failed to render cursor element: {}", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(ref cur) = themed_cursor {
|
} // end if frame_dirty
|
||||||
use smithay::backend::renderer::Frame as _;
|
|
||||||
let cx = state.pointer_location.x as i32 - cur.xhot as i32;
|
|
||||||
let cy = state.pointer_location.y as i32 - cur.yhot as i32;
|
|
||||||
if let Err(e) = frame.render_texture_at(
|
|
||||||
&cur.texture,
|
|
||||||
(cx, cy).into(),
|
|
||||||
1,
|
|
||||||
1.0,
|
|
||||||
Transform::Normal,
|
|
||||||
&[output_rect],
|
|
||||||
&[],
|
|
||||||
1.0,
|
|
||||||
) {
|
|
||||||
trace!("Failed to render themed cursor: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match frame.finish() {
|
|
||||||
Ok(_) => {
|
|
||||||
render_ok = true;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("Failed to finish frame: {}", e);
|
|
||||||
if is_egl_context_or_surface_lost(&e.to_string()) {
|
|
||||||
error!(
|
|
||||||
"EGL context/surface lost while finishing frame; shutting down so context can be recreated"
|
|
||||||
);
|
|
||||||
state.running = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let render_ms = render_t0.elapsed().as_millis();
|
let render_ms = render_t0.elapsed().as_millis();
|
||||||
if render_ms > 50 {
|
if render_ms > 50 {
|
||||||
warn!("render phase took {}ms!", render_ms);
|
warn!("render phase took {}ms!", render_ms);
|
||||||
|
|
@ -1080,7 +1096,7 @@ pub fn run_nested(config: Config) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
state.dirty_surfaces.clear();
|
state.dirty_surfaces.clear();
|
||||||
} // 'render block ends here, backend borrow is released
|
} // inner bind scope ends here, backend borrow is released
|
||||||
|
|
||||||
|
|
||||||
let did_bind = render_ok;
|
let did_bind = render_ok;
|
||||||
|
|
@ -1105,31 +1121,31 @@ pub fn run_nested(config: Config) -> Result<()> {
|
||||||
warn!("backend.submit() (eglSwapBuffers) took {}ms! This may indicate a blocked vsync/EGL fence.", submit_ms);
|
warn!("backend.submit() (eglSwapBuffers) took {}ms! This may indicate a blocked vsync/EGL fence.", submit_ms);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // 'render block ends here
|
||||||
|
|
||||||
let presented_at = std::time::SystemTime::now()
|
// Frame callbacks must be sent unconditionally so Wayland clients
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
// don't stall — even when the compositor skipped rendering.
|
||||||
.unwrap_or_default();
|
let presented_at = std::time::SystemTime::now()
|
||||||
let windows_snapshot: Vec<_> = state.space.elements().cloned().collect();
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
for window in windows_snapshot {
|
.unwrap_or_default();
|
||||||
window.send_frame(&state.output, presented_at, None, |_, _| {
|
let windows_snapshot: Vec<_> = state.space.elements().cloned().collect();
|
||||||
Some(state.output.clone())
|
for window in windows_snapshot {
|
||||||
});
|
window.send_frame(&state.output, presented_at, None, |_, _| {
|
||||||
}
|
Some(state.output.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// SHM exports already written directly to mmap'd memfds and broadcast above.
|
let render_total_ms = render_t_start.elapsed().as_millis();
|
||||||
|
if render_total_ms > 50 {
|
||||||
|
warn!("render+submit+callbacks total took {}ms!", render_total_ms);
|
||||||
|
}
|
||||||
|
|
||||||
let render_total_ms = render_t_start.elapsed().as_millis();
|
std::thread::sleep(Duration::from_micros(100));
|
||||||
if render_total_ms > 50 {
|
|
||||||
warn!("render+submit+callbacks total took {}ms!", render_total_ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::thread::sleep(Duration::from_micros(100));
|
// Log if the entire loop iteration was slow.
|
||||||
|
let total_loop_ms = loop_t0.elapsed().as_millis();
|
||||||
// Log if the entire loop iteration was slow.
|
if total_loop_ms > 50 {
|
||||||
let total_loop_ms = loop_t0.elapsed().as_millis();
|
warn!("SLOW LOOP iter={} total={}ms", loop_iter, total_loop_ms);
|
||||||
if total_loop_ms > 50 {
|
|
||||||
warn!("SLOW LOOP iter={} total={}ms", loop_iter, total_loop_ms);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue