Implement watched toggle for films in details modal

- Watched status is stored in the local database
- Gave details modals a set size
- Added a for now useless play button to film details modal
This commit is contained in:
Reinout Meliesie 2026-01-24 18:25:59 +01:00
commit 629f2ba9d0
Signed by: zedfrigg
GPG key ID: 3AFCC06481308BC6
5 changed files with 135 additions and 19 deletions

View file

@ -79,9 +79,10 @@ impl SqliteManager {
move |connection| {
connection.execute(
"
update watched_status
set watched = :watched
where watched_status.media_uuid is :uuid
insert into watched_status (media_uuid, watched)
values (:uuid, :watched)
on conflict (media_uuid) do
update set watched = :watched
",
named_params! { ":uuid": uuid, ":watched": watched },
)?;

View file

@ -1,7 +1,9 @@
use gtk4::prelude::{BoxExt, ButtonExt, OrientableExt, WidgetExt};
use gtk4::{Button, Label, Orientation};
use relm4::{ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent, component};
use gtk4::glib::clone;
use gtk4::prelude::{BoxExt, ButtonExt, OrientableExt, ToggleButtonExt, WidgetExt};
use gtk4::{Align, Button, IconSize, Image, Justification, Label, Orientation, ToggleButton};
use relm4::{Component, ComponentParts, ComponentSender, RelmWidgetExt, component};
use crate::persist::data_manager::{DataManager, DataManagerError};
use crate::views::overview::FilmOverview;
@ -10,11 +12,28 @@ pub struct FilmDetails {
film_overview: FilmOverview,
}
#[derive(Debug)]
pub enum FilmDetailsInput {
WatchedStatusChanged(bool),
}
#[derive(Debug)]
pub enum FilmDetailsOutput {
WatchedStatusChanged(bool),
}
#[derive(Debug)]
pub enum FilmDetailsCmdOutput {
WatchedStatusPersistSucceeded,
WatchedStatusPersistFailed(DataManagerError),
}
#[component(pub)]
impl SimpleComponent for FilmDetails {
impl Component for FilmDetails {
type Init = FilmOverview;
type Input = ();
type Output = ();
type Input = FilmDetailsInput;
type Output = FilmDetailsOutput;
type CommandOutput = FilmDetailsCmdOutput;
view! {
gtk4::Box {
@ -23,24 +42,91 @@ impl SimpleComponent for FilmDetails {
set_margin_all: 100,
Label {
set_wrap: true,
// Not the actual limit, used instead to avoid the text width propagating to the
// requested widget width, behaving similarly to hexpand: false.
set_max_width_chars: 1,
// Keeps wrapped text centered.
set_justify: Justification::Center,
set_css_classes: &["title-1"],
set_label: model.film_overview.name.as_str(),
},
Button {
set_css_classes: &["suggested-action", "circular"],
set_icon_name: "media-playback-start-symbolic",
gtk4::Box {
set_align: Align::Center,
ToggleButton {
#[watch]
set_label: if model.film_overview.watched { "Watched" } else { "Watch" },
set_active: model.film_overview.watched,
connect_toggled[sender] => move |watch_button| {
sender.input(FilmDetailsInput::WatchedStatusChanged(watch_button.is_active()));
},
},
},
}
gtk4::Box {
set_align: Align::Center,
Button {
set_css_classes: &["suggested-action", "circular"],
Image {
set_icon_size: IconSize::Large,
set_margin_all: 15,
set_icon_name: Some("media-playback-start-symbolic"),
},
},
},
},
}
fn init(
film_overview: FilmOverview,
root: gtk4::Box,
_sender: ComponentSender<FilmDetails>,
sender: ComponentSender<FilmDetails>,
) -> ComponentParts<FilmDetails> {
let model = FilmDetails { film_overview };
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(
&mut self,
message: FilmDetailsInput,
sender: ComponentSender<FilmDetails>,
_root: &gtk4::Box,
) {
match message {
FilmDetailsInput::WatchedStatusChanged(watched) => {
self.film_overview.watched = watched;
sender
.output_sender()
.emit(FilmDetailsOutput::WatchedStatusChanged(watched));
sender.oneshot_command(clone!(
#[strong(rename_to = uuid)]
self.film_overview.uuid,
async move {
let result = DataManager::set_film_watched_status(uuid.as_str(), watched).await;
match result {
Ok(()) => FilmDetailsCmdOutput::WatchedStatusPersistSucceeded,
Err(error) => FilmDetailsCmdOutput::WatchedStatusPersistFailed(error),
}
}
));
}
}
}
fn update_cmd(
&mut self,
message: FilmDetailsCmdOutput,
_sender: ComponentSender<FilmDetails>,
_root: &gtk4::Box,
) {
match message {
FilmDetailsCmdOutput::WatchedStatusPersistSucceeded => {}
FilmDetailsCmdOutput::WatchedStatusPersistFailed(error) => {
println!("Watched status persist failed: {error:?}");
}
}
}
}

View file

@ -1,5 +1,5 @@
use gtk4::prelude::{BoxExt, OrientableExt, WidgetExt};
use gtk4::{Label, Orientation};
use gtk4::{Justification, Label, Orientation};
use relm4::{ComponentParts, ComponentSender, RelmWidgetExt, SimpleComponent, component};
use crate::views::overview::SeriesOverview;
@ -23,6 +23,12 @@ impl SimpleComponent for SeriesDetails {
set_margin_all: 100,
Label {
set_wrap: true,
// Not the actual limit, used instead to avoid the text width propagating to the
// requested widget width, behaving similarly to hexpand: false.
set_max_width_chars: 1,
// Keeps wrapped text centered.
set_justify: Justification::Center,
set_css_classes: &["title-1"],
set_label: model.series_overview.name.as_str(),
}

View file

@ -9,7 +9,7 @@ use relm4::factory::{DynamicIndex, FactoryComponent};
use relm4::{Component, ComponentController, Controller, FactorySender, RelmWidgetExt, factory};
use crate::persist::data_manager::{DataManager, DataManagerError};
use crate::ui::components::media_details::FilmDetails;
use crate::ui::components::media_details::{FilmDetails, FilmDetailsOutput};
use crate::ui::widget_extensions::{AttrListExt, CondDialogExt, CondLabelExt};
use crate::views::overview::FilmOverview;
@ -38,13 +38,19 @@ pub enum FilmGridItemCmdOutput {
pub enum FilmGridItemInput {
ItemClicked,
DetailsClosed,
WatchedStatusChanged(bool),
}
#[derive(Debug)]
pub enum FilmGridItemOutput {
WatchedStatusChanged(String, bool),
}
#[factory(pub)]
impl FactoryComponent for FilmGridItem {
type Init = FilmOverview;
type Input = FilmGridItemInput;
type Output = ();
type Output = FilmGridItemOutput;
type CommandOutput = FilmGridItemCmdOutput;
type ParentWidget = FlowBox;
@ -123,6 +129,8 @@ impl FactoryComponent for FilmGridItem {
// TODO: Consider extracting this into its own component.
Dialog {
set_content_height: 600,
set_content_width: 600,
#[watch]
cond_present: self.details.as_ref().map(|_| {
root.toplevel_window().expect("root widget of FilmGridItem should be ancestor of a window")
@ -159,15 +167,28 @@ impl FactoryComponent for FilmGridItem {
}
}
fn update(&mut self, message: FilmGridItemInput, _sender: FactorySender<FilmGridItem>) {
fn update(&mut self, message: FilmGridItemInput, sender: FactorySender<FilmGridItem>) {
match message {
FilmGridItemInput::ItemClicked => {
let details_controller = FilmDetails::builder().launch(self.film.clone()).detach();
let details_controller = FilmDetails::builder()
.launch(self.film.clone())
.connect_receiver(clone!(
#[strong]
sender,
move |_, details_output| match details_output {
FilmDetailsOutput::WatchedStatusChanged(watched) => {
sender.input(FilmGridItemInput::WatchedStatusChanged(watched));
}
}
));
self.details = Some(details_controller);
}
FilmGridItemInput::DetailsClosed => {
self.details = None;
}
FilmGridItemInput::WatchedStatusChanged(watched) => {
self.film.watched = watched;
}
}
}

View file

@ -104,6 +104,8 @@ impl FactoryComponent for SeriesGridItem {
// TODO: Consider extracting this into its own component.
Dialog {
set_content_height: 600,
set_content_width: 600,
#[watch]
cond_present: self.details.as_ref().map(|_| {
root.toplevel_window().expect("root widget of FilmGridItem should be ancestor of a window")