feat(app): List/Card/Grid video list render modes

This commit is contained in:
Luna 2026-06-27 19:14:45 -07:00
parent 91f3d8500d
commit ada5b6d802

View file

@ -4240,12 +4240,37 @@ impl App {
} }
let density = self.card_density; let density = self.card_density;
let view_mode = self.view_mode_for(&self.sidebar_view);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
match view_mode {
ViewMode::List | ViewMode::Card => {
self.render_video_rows(ui, ctx, &cards, density, show_channel, view_mode == ViewMode::Card);
}
ViewMode::Grid => {
self.render_video_grid(ui, ctx, &cards, density, show_channel);
}
}
});
self.cards_cache = cards;
}
/// Row-based render path shared by List and Card modes. When `as_card`
/// is true, each row is wrapped in a rounded faint-bg card with a hover
/// accent ring; otherwise the row is rendered flat (legacy List style).
fn render_video_rows(
&mut self,
ui: &mut egui::Ui,
ctx: &egui::Context,
cards: &[Card],
density: f32,
show_channel: bool,
as_card: bool,
) {
let thumb_w = (176.0 * density).round(); let thumb_w = (176.0 * density).round();
let thumb_h = (99.0 * density).round(); let thumb_h = (99.0 * density).round();
let thumb_size = egui::vec2(thumb_w, thumb_h); let thumb_size = egui::vec2(thumb_w, thumb_h);
for card in &cards { for card in cards {
let selected = self.selected_video.as_deref() == Some(card.id.as_str()); let selected = self.selected_video.as_deref() == Some(card.id.as_str());
let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str()); let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str());
let bulk_checked = self.bulk_selected.contains(&card.id); let bulk_checked = self.bulk_selected.contains(&card.id);
@ -4256,7 +4281,155 @@ impl App {
// mutate `self.flags` + DB without fighting the borrow checker. // mutate `self.flags` + DB without fighting the borrow checker.
let mut toggle_flag_card: Option<&'static str> = None; let mut toggle_flag_card: Option<&'static str> = None;
// Wrap each row in a Frame when rendering as a Card.
let mut card_rect: Option<egui::Rect> = None;
if as_card {
let frame = egui::Frame::default()
.fill(ui.visuals().faint_bg_color)
.stroke(egui::Stroke::new(
1.0,
ui.visuals().widgets.noninteractive.bg_stroke.color,
))
.rounding(egui::Rounding::same(8.0))
.inner_margin(egui::Margin::same(8.0))
.outer_margin(egui::Margin::symmetric(0.0, 3.0));
let resp = frame.show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
self.render_row_body(
ui, ctx, card, density, thumb_size, show_channel,
selected, is_playing, bulk_checked,
&mut clicked_card, &mut play_card,
&mut toggle_watched_card, &mut toggle_flag_card,
);
});
}).response;
if resp.hovered() {
card_rect = Some(resp.rect);
}
} else {
ui.horizontal(|ui| {
self.render_row_body(
ui, ctx, card, density, thumb_size, show_channel,
selected, is_playing, bulk_checked,
&mut clicked_card, &mut play_card,
&mut toggle_watched_card, &mut toggle_flag_card,
);
});
}
if let Some(rect) = card_rect {
ui.painter().rect_stroke(
rect,
8.0,
egui::Stroke::new(1.5, self.theme_accents.accent),
);
}
self.apply_card_actions(
card, clicked_card, play_card, toggle_watched_card, toggle_flag_card,
);
ui.separator();
}
}
/// Grid mode: YouTube/Plex-style vertical cards in a responsive grid.
fn render_video_grid(
&mut self,
ui: &mut egui::Ui,
ctx: &egui::Context,
cards: &[Card],
density: f32,
show_channel: bool,
) {
let thumb_w = (176.0 * density).round();
let thumb_h = (99.0 * density).round();
let card_w = thumb_w + 8.0;
let avail = ui.available_width();
let cols = ((avail / card_w).floor() as usize).max(1);
// Grid mode renders one cell at a time but still uses the deferred-
// flags pattern: actions are applied per-card right after its cell.
egui::Grid::new("video_grid")
.num_columns(cols)
.spacing([8.0, 8.0])
.show(ui, |ui| {
for card in cards {
let selected = self.selected_video.as_deref() == Some(card.id.as_str());
let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str());
let bulk_checked = self.bulk_selected.contains(&card.id);
let mut clicked_card = false;
let mut play_card = false;
let mut toggle_watched_card = false;
let mut toggle_flag_card: Option<&'static str> = None;
ui.vertical(|ui| {
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(thumb_w, thumb_h),
egui::Sense::click(),
);
let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p));
match &texture {
Some(handle) => {
egui::Image::new(handle)
.maintain_aspect_ratio(true)
.paint_at(ui, rect);
}
None => {
self.paint_thumb_placeholder(ui, rect, "🎬", density);
}
}
if selected {
ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(2.0, self.theme_accents.accent));
}
if is_playing {
ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(2.0, self.theme_accents.success));
}
if bulk_checked {
ui.painter().rect_stroke(rect, 4.0, egui::Stroke::new(3.0, self.theme_accents.warning));
}
if resp.clicked() {
clicked_card = true;
}
if resp.double_clicked() {
play_card = true;
}
ui.add_space(4.0);
self.render_row_body(
ui, ctx, card, density, egui::vec2(thumb_w, thumb_h), show_channel,
selected, is_playing, bulk_checked,
&mut clicked_card, &mut play_card,
&mut toggle_watched_card, &mut toggle_flag_card,
);
});
self.apply_card_actions(
card, clicked_card, play_card, toggle_watched_card, toggle_flag_card,
);
ui.end_row();
}
});
}
/// The thumbnail + metadata + flag-button body shared by List/Card/Grid.
/// In List/Card it's laid out inside an existing horizontal; in Grid the
/// thumbnail is already painted by the caller, so `thumb_size` is only
/// used here for the watched banner placement (Grid passes its own).
fn render_row_body(
&mut self,
ui: &mut egui::Ui,
ctx: &egui::Context,
card: &Card,
density: f32,
thumb_size: egui::Vec2,
show_channel: bool,
selected: bool,
is_playing: bool,
bulk_checked: bool,
clicked_card: &mut bool,
play_card: &mut bool,
toggle_watched_card: &mut bool,
toggle_flag_card: &mut Option<&'static str>,
) {
let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click()); let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click());
let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p)); let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p));
match &texture { match &texture {
@ -4310,10 +4483,10 @@ impl App {
} }
if resp.clicked() { if resp.clicked() {
clicked_card = true; *clicked_card = true;
} }
if resp.double_clicked() { if resp.double_clicked() {
play_card = true; *play_card = true;
} }
ui.vertical(|ui| { ui.vertical(|ui| {
@ -4331,7 +4504,7 @@ impl App {
) )
.clicked() .clicked()
{ {
clicked_card = true; *clicked_card = true;
} }
ui.horizontal(|ui| { ui.horizontal(|ui| {
if show_channel { if show_channel {
@ -4368,41 +4541,50 @@ impl App {
if self.bulk_mode { if self.bulk_mode {
let chk_label = if bulk_checked { "" } else { "" }; let chk_label = if bulk_checked { "" } else { "" };
if ui.small_button(chk_label).clicked() { if ui.small_button(chk_label).clicked() {
clicked_card = true; // handled below *clicked_card = true; // handled below
} }
} else { } else {
if card.video_path.is_some() && ui.small_button("▶ Play").clicked() { if card.video_path.is_some() && ui.small_button("▶ Play").clicked() {
play_card = true; *play_card = true;
} }
if let Some(pos) = card.resume_pos { if let Some(pos) = card.resume_pos {
if pos > 5.0 && ui.small_button(format!("{}", format_duration(pos))).clicked() { if pos > 5.0 && ui.small_button(format!("{}", format_duration(pos))).clicked() {
play_card = true; *play_card = true;
} }
} }
if ui.small_button("Details").clicked() { if ui.small_button("Details").clicked() {
clicked_card = true; *clicked_card = true;
} }
let w_label = if card.watched { "" } else { "" }; let w_label = if card.watched { "" } else { "" };
if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() { if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() {
toggle_watched_card = true; *toggle_watched_card = true;
} }
let fav_label = if card.favourite { "" } else { "" }; let fav_label = if card.favourite { "" } else { "" };
if ui.small_button(fav_label).on_hover_text("Toggle favourite").clicked() { if ui.small_button(fav_label).on_hover_text("Toggle favourite").clicked() {
toggle_flag_card = Some("favourite"); *toggle_flag_card = Some("favourite");
} }
let bm_label = if card.bookmark { "🔖" } else { "🔖̲" }; let bm_label = if card.bookmark { "🔖" } else { "🔖̲" };
if ui.small_button(bm_label).on_hover_text("Toggle bookmark").clicked() { if ui.small_button(bm_label).on_hover_text("Toggle bookmark").clicked() {
toggle_flag_card = Some("bookmark"); *toggle_flag_card = Some("bookmark");
} }
let wait_label = if card.waiting { "" } else { "" }; let wait_label = if card.waiting { "" } else { "" };
if ui.small_button(wait_label).on_hover_text("Toggle waiting").clicked() { if ui.small_button(wait_label).on_hover_text("Toggle waiting").clicked() {
toggle_flag_card = Some("waiting"); *toggle_flag_card = Some("waiting");
} }
} }
}); });
}); });
}); }
/// Apply the per-card deferred actions (click/play/watch/flag toggles).
fn apply_card_actions(
&mut self,
card: &Card,
clicked_card: bool,
play_card: bool,
toggle_watched_card: bool,
toggle_flag_card: Option<&'static str>,
) {
if self.bulk_mode { if self.bulk_mode {
if clicked_card { if clicked_card {
let id = card.id.clone(); let id = card.id.clone();
@ -4429,11 +4611,6 @@ impl App {
let id = card.id.clone(); let id = card.id.clone();
self.toggle_video_flag(&id, flag); self.toggle_video_flag(&id, flag);
} }
ui.separator();
}
});
self.cards_cache = cards;
} }
} }