// episode.rs // // Copyright 2017 Jordan Petridis // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later use glib; use gtk; use gtk::prelude::*; use chrono; use chrono::prelude::*; use crossbeam_channel::Sender; use failure::Error; use humansize::{file_size_opts as size_opts, FileSize}; #[allow(unused_imports)] use open; use podcasts_data::dbqueries; use podcasts_data::utils::get_download_folder; use podcasts_data::EpisodeWidgetModel; use podcasts_downloader::downloader::DownloadProgress; use crate::app::Action; use crate::manager; use std::cell::RefCell; use std::rc::{Rc, Weak}; use std::sync::{Arc, Mutex, TryLockError}; use crate::i18n::i18n_f; lazy_static! { static ref SIZE_OPTS: Arc = { // Declare a custom humansize option struct // See: https://docs.rs/humansize/1.0.2/humansize/file_size_opts/struct.FileSizeOpts.html Arc::new(size_opts::FileSizeOpts { divider: size_opts::Kilo::Binary, units: size_opts::Kilo::Decimal, decimal_places: 0, decimal_zeroes: 0, fixed_at: size_opts::FixedAt::No, long_units: false, space: true, suffix: "", allow_negative: false, }) }; } #[derive(Clone, Debug)] pub(crate) struct EpisodeWidget { pub(crate) container: gtk::Box, info: InfoLabels, buttons: Buttons, progressbar: gtk::ProgressBar, } #[derive(Clone, Debug)] struct InfoLabels { container: gtk::Box, title: gtk::Label, date: gtk::Label, separator1: gtk::Label, duration: gtk::Label, separator2: gtk::Label, local_size: gtk::Label, size_separator: gtk::Label, total_size: gtk::Label, } #[derive(Clone, Debug)] struct Buttons { container: gtk::Box, play: gtk::Button, download: gtk::Button, cancel: gtk::Button, } impl InfoLabels { fn init(&self, episode: &EpisodeWidgetModel) { // Set the title label state. self.set_title(episode); // Set the date label. self.set_date(episode.epoch()); // Set the duration label. self.set_duration(episode.duration()); // Set the total_size label. self.set_size(episode.length()) } fn set_title(&self, episode: &EpisodeWidgetModel) { self.title.set_text(episode.title()); if episode.played().is_some() { self.title.get_style_context().add_class("dim-label"); } else { self.title.get_style_context().remove_class("dim-label"); } } // Set the date label of the episode widget. fn set_date(&self, epoch: i32) { lazy_static! { static ref NOW: DateTime = Utc::now(); }; let ts = Utc.timestamp(i64::from(epoch), 0); // If the episode is from a different year, print year as well if NOW.year() != ts.year() { self.date.set_text(ts.format("%e %b %Y").to_string().trim()); // Else omit the year from the label } else { self.date.set_text(ts.format("%e %b").to_string().trim()); } } // Set the duration label of the episode widget. fn set_duration(&self, seconds: Option) { // If length is provided if let Some(s) = seconds { // Convert seconds to minutes let minutes = chrono::Duration::seconds(s.into()).num_minutes(); // If the length is 1 or more minutes if minutes != 0 { // Set the label and show them. self.duration .set_text(&i18n_f("{} min", &[&minutes.to_string()])); self.duration.show(); self.separator1.show(); return; } } // Else hide the labels self.separator1.hide(); self.duration.hide(); } // Set the size label of the episode widget. fn set_size(&self, bytes: Option) { // Convert the bytes to a String label let size = || -> Option { let s = bytes?; if s == 0 { return None; } s.file_size(SIZE_OPTS.clone()).ok() }; if let Some(s) = size() { self.total_size.set_text(&s); self.total_size.show(); self.separator2.show(); } else { self.total_size.hide(); self.separator2.hide(); } } } impl Default for EpisodeWidget { fn default() -> Self { let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/episode_widget.ui"); let container = builder.get_object("episode_container").unwrap(); let progressbar = builder.get_object("progress_bar").unwrap(); let buttons_container = builder.get_object("button_box").unwrap(); let download = builder.get_object("download_button").unwrap(); let play = builder.get_object("play_button").unwrap(); let cancel = builder.get_object("cancel_button").unwrap(); let info_container = builder.get_object("info_container").unwrap(); let title = builder.get_object("title_label").unwrap(); let date = builder.get_object("date_label").unwrap(); let duration = builder.get_object("duration_label").unwrap(); let local_size = builder.get_object("local_size").unwrap(); let total_size = builder.get_object("total_size").unwrap(); let separator1 = builder.get_object("separator1").unwrap(); let separator2 = builder.get_object("separator2").unwrap(); let size_separator = builder.get_object("prog_separator").unwrap(); EpisodeWidget { info: InfoLabels { container: info_container, title, date, separator1, duration, separator2, local_size, total_size, size_separator, }, buttons: Buttons { container: buttons_container, play, download, cancel, }, progressbar, container, } } } impl EpisodeWidget { pub(crate) fn new(episode: EpisodeWidgetModel, sender: &Sender) -> Rc { let widget = Rc::new(Self::default()); let episode = RefCell::new(Some(episode)); let weak = Rc::downgrade(&widget); widget.container.connect_draw(clone!(sender => move |_, _| { episode.borrow_mut().take().map(|ep| { weak.upgrade().map(|w| w.info.init(&ep)); Self::determine_buttons_state(&weak, &ep, &sender) .map_err(|err| error!("Error: {}", err)) .ok(); }); gtk::Inhibit(false) })); // When the widget is attached to a parent, // since it's a rust struct and not a widget the // compiler drops the reference to it at the end of // scope. That's cause we only attach the `self.container` // to the parent. // // So this callback keeps a reference to the Rust Struct // so the compiler won't drop it. // // When the widget is detached from its parent view this // callback runs freeing the last reference we were holding. let foo = RefCell::new(Some(widget.clone())); widget.container.connect_remove(move |_, _| { foo.borrow_mut().take(); }); widget } // fn init(widget: Rc, sender: &Sender) {} // InProgress State: // * Show ProgressBar and Cancel Button. // * Show `total_size`, `local_size` labels and `size_separator`. // * Hide Download and Play Buttons fn state_prog(&self) { self.progressbar.show(); self.buttons.cancel.show(); self.info.total_size.show(); self.info.local_size.show(); self.info.size_separator.show(); self.buttons.play.hide(); self.buttons.download.hide(); } // Playable State: // * Hide ProgressBar and Cancel, Download Buttons. // * Hide `local_size` labels and `size_separator`. // * Show Play Button and `total_size` label fn state_playable(&self) { self.progressbar.hide(); self.buttons.cancel.hide(); self.buttons.download.hide(); self.info.local_size.hide(); self.info.size_separator.hide(); self.info.total_size.show(); self.buttons.play.show(); } // ToDownload State: // * Hide ProgressBar and Cancel, Play Buttons. // * Hide `local_size` labels and `size_separator`. // * Show Download Button // * Determine `total_size` label state (Comes from `episode.lenght`). fn state_download(&self) { self.progressbar.hide(); self.buttons.cancel.hide(); self.buttons.play.hide(); self.info.local_size.hide(); self.info.size_separator.hide(); self.buttons.download.show(); } fn update_progress(&self, local_size: &str, fraction: f64) { self.info.local_size.set_text(local_size); self.progressbar.set_fraction(fraction); } /// Change the state of the `EpisodeWidget`. /// /// Function Flowchart: /// /// ------------------- -------------- /// | Is the Episode | YES | State: | /// | currently being | ----> | InProgress | /// | downloaded? | | | /// ------------------- -------------- /// | /// | NO /// | /// \_/ /// ------------------- -------------- /// | is the episode | YES | State: | /// | downloaded | ----> | Playable | /// | already? | | | /// ------------------- -------------- /// | /// | NO /// | /// \_/ /// ------------------- /// | State: | /// | ToDownload | /// ------------------- fn determine_buttons_state( weak: &Weak, episode: &EpisodeWidgetModel, sender: &Sender, ) -> Result<(), Error> { let widget = weak .upgrade() .ok_or_else(|| format_err!("Widget is already dropped"))?; // Reset the buttons state no matter the glade file. // This is just to make it easier to port to relm in the future. widget.buttons.cancel.hide(); widget.buttons.play.hide(); widget.buttons.download.hide(); // Check if the episode is being downloaded let id = episode.rowid(); let active_dl = move || -> Result, Error> { let m = manager::ACTIVE_DOWNLOADS .read() .map_err(|_| format_err!("Failed to get a lock on the mutex."))?; Ok(m.get(&id).cloned()) }; // State: InProgress if let Some(prog) = active_dl()? { // set a callback that will update the state when the download finishes let callback = clone!(weak, sender => move || { if let Ok(guard) = manager::ACTIVE_DOWNLOADS.read() { if !guard.contains_key(&id) { if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) { Self::determine_buttons_state(&weak, &ep, &sender) .map_err(|err| error!("Error: {}", err)) .ok(); return glib::Continue(false) } } } glib::Continue(true) }); gtk::timeout_add(250, callback); // Wire the cancel button widget .buttons .cancel .connect_clicked(clone!(prog, weak, sender => move |_| { // Cancel the download if let Ok(mut m) = prog.lock() { m.cancel(); } // Cancel is not instant so we have to wait a bit timeout_add(50, clone!(weak, sender => move || { if let Ok(thing) = active_dl() { if thing.is_none() { // Recalculate the widget state dbqueries::get_episode_widget_from_rowid(id) .map_err(From::from) .and_then(|ep| Self::determine_buttons_state(&weak, &ep, &sender)) .map_err(|err| error!("Error: {}", err)) .ok(); return glib::Continue(false) } } glib::Continue(true) })); })); // Setup a callback that will update the total_size label // with the http ContentLength header number rather than // relying to the RSS feed. update_total_size_callback(&weak, &prog); // Setup a callback that will update the progress bar. update_progressbar_callback(&weak, &prog, id); // Change the widget layout/state widget.state_prog(); return Ok(()); } // State: Playable if episode.local_uri().is_some() { // Change the widget layout/state widget.state_playable(); // Wire the play button widget .buttons .play .connect_clicked(clone!(weak, sender => move |_| { if let Ok(mut ep) = dbqueries::get_episode_widget_from_rowid(id) { on_play_bttn_clicked(&weak, &mut ep, &sender) .map_err(|err| error!("Error: {}", err)) .ok(); } })); return Ok(()); } // State: ToDownload // Wire the download button widget .buttons .download .connect_clicked(clone!(weak, sender => move |dl| { // Make the button insensitive so it won't be pressed twice dl.set_sensitive(false); if let Ok(ep) = dbqueries::get_episode_widget_from_rowid(id) { on_download_clicked(&ep, &sender) .and_then(|_| { info!("Download started successfully."); Self::determine_buttons_state(&weak, &ep, &sender) }) .map_err(|err| error!("Error: {}", err)) .ok(); } // Restore sensitivity after operations above complete dl.set_sensitive(true); })); // Change the widget state into `ToDownload` widget.state_download(); Ok(()) } } fn on_download_clicked(ep: &EpisodeWidgetModel, sender: &Sender) -> Result<(), Error> { let pd = dbqueries::get_podcast_from_id(ep.show_id())?; let download_fold = get_download_folder(&pd.title())?; // Start a new download. manager::add(ep.rowid(), download_fold)?; // Update Views sender .send(Action::RefreshEpisodesViewBGR) .expect("Action channel blew up somehow");; Ok(()) } fn on_play_bttn_clicked( widget: &Weak, episode: &mut EpisodeWidgetModel, sender: &Sender, ) -> Result<(), Error> { let widget = widget .upgrade() .ok_or_else(|| format_err!("Widget is already dropped"))?; // Mark played episode.set_played_now()?; // Grey out the title widget.info.set_title(&episode); // Play the episode sender .send(Action::InitEpisode(episode.rowid())) .expect("Action channel blew up somehow");; // Refresh background views to match the normal/greyout title state sender .send(Action::RefreshEpisodesViewBGR) .expect("Action channel blew up somehow");; Ok(()) } // Setup a callback that will update the progress bar. #[inline] #[cfg_attr(feature = "cargo-clippy", allow(if_same_then_else))] fn update_progressbar_callback( widget: &Weak, prog: &Arc>, episode_rowid: i32, ) { let callback = clone!(widget, prog => move || { progress_bar_helper(&widget, &prog, episode_rowid) .unwrap_or(glib::Continue(false)) }); timeout_add(100, callback); } #[allow(if_same_then_else)] fn progress_bar_helper( widget: &Weak, prog: &Arc>, episode_rowid: i32, ) -> Result { let widget = match widget.upgrade() { Some(w) => w, None => return Ok(glib::Continue(false)), }; let (fraction, downloaded, cancel) = match prog.try_lock() { Ok(guard) => ( guard.get_fraction(), guard.get_downloaded(), guard.should_cancel(), ), Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)), Err(TryLockError::Poisoned(_)) => return Err(format_err!("Progress Mutex is poisoned")), }; // I hate floating points. // Update the progress_bar. if (fraction >= 0.0) && (fraction <= 1.0) && (!fraction.is_nan()) { // Update local_size label let size = downloaded .file_size(SIZE_OPTS.clone()) .map_err(|err| format_err!("{}", err))?; widget.update_progress(&size, fraction); } // info!("Fraction: {}", progress_bar.get_fraction()); // info!("Fraction: {}", fraction); // Check if the download is still active let active = match manager::ACTIVE_DOWNLOADS.read() { Ok(guard) => guard.contains_key(&episode_rowid), Err(_) => return Err(format_err!("Failed to get a lock on the mutex.")), }; if (fraction >= 1.0) && (!fraction.is_nan()) { Ok(glib::Continue(false)) } else if !active || cancel { // if the total size is not a number, hide it if widget .info .total_size .get_text() .as_ref() .map(|s| s.trim_end_matches(" MB")) .and_then(|s| s.parse::().ok()) .is_none() { widget.info.total_size.hide(); } Ok(glib::Continue(false)) } else { Ok(glib::Continue(true)) } } // Setup a callback that will update the total_size label // with the http ContentLength header number rather than // relying to the RSS feed. #[inline] fn update_total_size_callback(widget: &Weak, prog: &Arc>) { let callback = clone!(prog, widget => move || { total_size_helper(&widget, &prog).unwrap_or(glib::Continue(true)) }); timeout_add(100, callback); } fn total_size_helper( widget: &Weak, prog: &Arc>, ) -> Result { let widget = match widget.upgrade() { Some(w) => w, None => return Ok(glib::Continue(false)), }; // Get the total_bytes. let total_bytes = match prog.try_lock() { Ok(guard) => guard.get_size(), Err(TryLockError::WouldBlock) => return Ok(glib::Continue(true)), Err(TryLockError::Poisoned(_)) => return Err(format_err!("Progress Mutex is poisoned")), }; debug!("Total Size: {}", total_bytes); if total_bytes != 0 { // Update the total_size label widget.info.set_size(Some(total_bytes as i32)); // Do not call again the callback Ok(glib::Continue(false)) } else { Ok(glib::Continue(true)) } } // fn on_delete_bttn_clicked(episode_id: i32) -> Result<(), Error> { // let mut ep = dbqueries::get_episode_from_rowid(episode_id)?.into(); // delete_local_content(&mut ep).map_err(From::from).map(|_| ()) // }