Initial massive renaming.
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
#![allow(new_without_default)]
|
||||
|
||||
use gio::{self, prelude::*, ApplicationFlags, SettingsBindFlags, SettingsExt, SimpleAction};
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crossbeam_channel::{unbounded, Receiver, Sender};
|
||||
use podcasts_data::Show;
|
||||
use send_cell::SendCell;
|
||||
|
||||
use headerbar::Header;
|
||||
use prefs::Prefs;
|
||||
use settings::{self, WindowGeometry};
|
||||
use stacks::{Content, PopulatedState};
|
||||
use utils;
|
||||
use widgets::about_dialog;
|
||||
use widgets::appnotif::{InAppNotification, UndoState};
|
||||
use widgets::player;
|
||||
use widgets::show_menu::{mark_all_notif, remove_show_notif, ShowMenu};
|
||||
|
||||
use std::env;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const APP_ID: &str = "org.gnome.Podcasts";
|
||||
|
||||
/// Creates an action named $called in the action map $on with the handler $handle
|
||||
macro_rules! action {
|
||||
($on:expr, $called:expr, $handle:expr) => {{
|
||||
// Create a stateless, parameterless action
|
||||
let act = SimpleAction::new($called, None);
|
||||
// Connect the handler
|
||||
act.connect_activate($handle);
|
||||
// Add it to the map
|
||||
$on.add_action(&act);
|
||||
// Return the action
|
||||
act
|
||||
}};
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Action {
|
||||
RefreshAllViews,
|
||||
RefreshEpisodesView,
|
||||
RefreshEpisodesViewBGR,
|
||||
RefreshShowsView,
|
||||
ReplaceWidget(Arc<Show>),
|
||||
RefreshWidgetIfSame(i32),
|
||||
ShowWidgetAnimated,
|
||||
ShowShowsAnimated,
|
||||
HeaderBarShowTile(String),
|
||||
HeaderBarNormal,
|
||||
HeaderBarShowUpdateIndicator,
|
||||
HeaderBarHideUpdateIndicator,
|
||||
MarkAllPlayerNotification(Arc<Show>),
|
||||
RemoveShow(Arc<Show>),
|
||||
ErrorNotification(String),
|
||||
InitEpisode(i32),
|
||||
InitShowMenu(SendCell<ShowMenu>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct App {
|
||||
instance: gtk::Application,
|
||||
window: gtk::ApplicationWindow,
|
||||
overlay: gtk::Overlay,
|
||||
settings: gio::Settings,
|
||||
content: Rc<Content>,
|
||||
headerbar: Rc<Header>,
|
||||
player: Rc<player::PlayerWidget>,
|
||||
sender: Sender<Action>,
|
||||
receiver: Receiver<Action>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(application: >k::Application) -> Rc<Self> {
|
||||
let settings = gio::Settings::new(APP_ID);
|
||||
|
||||
let (sender, receiver) = unbounded();
|
||||
|
||||
let window = gtk::ApplicationWindow::new(application);
|
||||
window.set_title("Podcasts");
|
||||
window.connect_delete_event(clone!(settings => move |window, _| {
|
||||
WindowGeometry::from_window(&window).write(&settings);
|
||||
Inhibit(false)
|
||||
}));
|
||||
|
||||
// Create a content instance
|
||||
let content = Content::new(&sender).expect("Content Initialization failed.");
|
||||
|
||||
// Create the headerbar
|
||||
let header = Header::new(&content, &sender);
|
||||
// Add the Headerbar to the window.
|
||||
window.set_titlebar(&header.container);
|
||||
|
||||
// Add the content main stack to the overlay.
|
||||
let overlay = gtk::Overlay::new();
|
||||
overlay.add(&content.get_stack());
|
||||
|
||||
let wrap = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
// Add the overlay to the main Box
|
||||
wrap.add(&overlay);
|
||||
|
||||
let player = player::PlayerWidget::new(&sender);
|
||||
// Add the player to the main Box
|
||||
wrap.add(&player.action_bar);
|
||||
|
||||
window.add(&wrap);
|
||||
|
||||
let app = App {
|
||||
instance: application.clone(),
|
||||
window,
|
||||
settings,
|
||||
overlay,
|
||||
headerbar: header,
|
||||
content,
|
||||
player,
|
||||
sender,
|
||||
receiver,
|
||||
};
|
||||
|
||||
Rc::new(app)
|
||||
}
|
||||
|
||||
fn init(app: &Rc<Self>) {
|
||||
let cleanup_date = settings::get_cleanup_date(&app.settings);
|
||||
// Garbage collect watched episodes from the disk
|
||||
utils::cleanup(cleanup_date);
|
||||
|
||||
app.setup_gactions();
|
||||
app.setup_timed_callbacks();
|
||||
|
||||
app.instance.connect_activate(move |_| ());
|
||||
|
||||
// Retrieve the previous window position and size.
|
||||
WindowGeometry::from_settings(&app.settings).apply(&app.window);
|
||||
|
||||
// Setup the Action channel
|
||||
gtk::timeout_add(25, clone!(app => move || app.setup_action_channel()));
|
||||
}
|
||||
|
||||
fn setup_timed_callbacks(&self) {
|
||||
self.setup_dark_theme();
|
||||
self.setup_refresh_on_startup();
|
||||
self.setup_auto_refresh();
|
||||
}
|
||||
|
||||
fn setup_dark_theme(&self) {
|
||||
let gtk_settings = gtk::Settings::get_default().unwrap();
|
||||
self.settings.bind(
|
||||
"dark-theme",
|
||||
>k_settings,
|
||||
"gtk-application-prefer-dark-theme",
|
||||
SettingsBindFlags::DEFAULT,
|
||||
);
|
||||
}
|
||||
|
||||
fn setup_refresh_on_startup(&self) {
|
||||
// Update the feeds right after the Application is initialized.
|
||||
let sender = self.sender.clone();
|
||||
if self.settings.get_boolean("refresh-on-startup") {
|
||||
info!("Refresh on startup.");
|
||||
let s: Option<Vec<_>> = None;
|
||||
utils::refresh(s, sender.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_auto_refresh(&self) {
|
||||
let refresh_interval = settings::get_refresh_interval(&self.settings).num_seconds() as u32;
|
||||
info!("Auto-refresh every {:?} seconds.", refresh_interval);
|
||||
|
||||
let sender = self.sender.clone();
|
||||
gtk::timeout_add_seconds(refresh_interval, move || {
|
||||
let s: Option<Vec<_>> = None;
|
||||
utils::refresh(s, sender.clone());
|
||||
|
||||
glib::Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
/// Define the `GAction`s.
|
||||
///
|
||||
/// Used in menus and the keyboard shortcuts dialog.
|
||||
#[cfg_attr(rustfmt, rustfmt_skip)]
|
||||
fn setup_gactions(&self) {
|
||||
let sender = &self.sender;
|
||||
let win = &self.window;
|
||||
let instance = &self.instance;
|
||||
let header = &self.headerbar;
|
||||
let settings = &self.settings;
|
||||
|
||||
// Create the `refresh` action.
|
||||
//
|
||||
// This will trigger a refresh of all the shows in the database.
|
||||
action!(win, "refresh", clone!(sender => move |_, _| {
|
||||
gtk::idle_add(clone!(sender => move || {
|
||||
let s: Option<Vec<_>> = None;
|
||||
utils::refresh(s, sender.clone());
|
||||
glib::Continue(false)
|
||||
}));
|
||||
}));
|
||||
self.instance.set_accels_for_action("win.refresh", &["<primary>r"]);
|
||||
|
||||
// Create the `OPML` import action
|
||||
action!(win, "import", clone!(sender, win => move |_, _| {
|
||||
utils::on_import_clicked(&win, &sender)
|
||||
}));
|
||||
|
||||
// Create the action that shows a `gtk::AboutDialog`
|
||||
action!(win, "about", clone!(win => move |_, _| about_dialog(&win)));
|
||||
|
||||
// Create the quit action
|
||||
action!(win, "quit", clone!(instance => move |_, _| instance.quit()));
|
||||
self.instance.set_accels_for_action("win.quit", &["<primary>q"]);
|
||||
|
||||
action!(
|
||||
win,
|
||||
"preferences",
|
||||
clone!(win, settings => move |_, _| {
|
||||
let dialog = Prefs::new(&settings);
|
||||
dialog.show(&win);
|
||||
})
|
||||
);
|
||||
self.instance.set_accels_for_action("win.preferences", &["<primary>e"]);
|
||||
|
||||
// Create the menu action
|
||||
action!(win, "menu",clone!(header => move |_, _| header.open_menu()));
|
||||
// Bind the hamburger menu button to `F10`
|
||||
self.instance.set_accels_for_action("win.menu", &["F10"]);
|
||||
}
|
||||
|
||||
fn setup_action_channel(&self) -> glib::Continue {
|
||||
if let Some(action) = self.receiver.try_recv() {
|
||||
trace!("Incoming channel action: {:?}", action);
|
||||
match action {
|
||||
Action::RefreshAllViews => self.content.update(),
|
||||
Action::RefreshShowsView => self.content.update_shows_view(),
|
||||
Action::RefreshWidgetIfSame(id) => self.content.update_widget_if_same(id),
|
||||
Action::RefreshEpisodesView => self.content.update_home(),
|
||||
Action::RefreshEpisodesViewBGR => self.content.update_home_if_background(),
|
||||
Action::ReplaceWidget(pd) => {
|
||||
let shows = self.content.get_shows();
|
||||
let mut pop = shows.borrow().populated();
|
||||
pop.borrow_mut()
|
||||
.replace_widget(pd.clone())
|
||||
.map_err(|err| error!("Failed to update ShowWidget: {}", err))
|
||||
.map_err(|_| error!("Failed ot update ShowWidget {}", pd.title()))
|
||||
.ok();
|
||||
}
|
||||
Action::ShowWidgetAnimated => {
|
||||
let shows = self.content.get_shows();
|
||||
let mut pop = shows.borrow().populated();
|
||||
pop.borrow_mut().switch_visible(
|
||||
PopulatedState::Widget,
|
||||
gtk::StackTransitionType::SlideLeft,
|
||||
);
|
||||
}
|
||||
Action::ShowShowsAnimated => {
|
||||
let shows = self.content.get_shows();
|
||||
let mut pop = shows.borrow().populated();
|
||||
pop.borrow_mut()
|
||||
.switch_visible(PopulatedState::View, gtk::StackTransitionType::SlideRight);
|
||||
}
|
||||
Action::HeaderBarShowTile(title) => self.headerbar.switch_to_back(&title),
|
||||
Action::HeaderBarNormal => self.headerbar.switch_to_normal(),
|
||||
Action::HeaderBarShowUpdateIndicator => self.headerbar.show_update_notification(),
|
||||
Action::HeaderBarHideUpdateIndicator => self.headerbar.hide_update_notification(),
|
||||
Action::MarkAllPlayerNotification(pd) => {
|
||||
let notif = mark_all_notif(pd, &self.sender);
|
||||
notif.show(&self.overlay);
|
||||
}
|
||||
Action::RemoveShow(pd) => {
|
||||
let notif = remove_show_notif(pd, self.sender.clone());
|
||||
notif.show(&self.overlay);
|
||||
}
|
||||
Action::ErrorNotification(err) => {
|
||||
error!("An error notification was triggered: {}", err);
|
||||
let callback = || glib::Continue(false);
|
||||
let notif = InAppNotification::new(&err, callback, || {}, UndoState::Hidden);
|
||||
notif.show(&self.overlay);
|
||||
}
|
||||
Action::InitEpisode(rowid) => {
|
||||
let res = self.player.initialize_episode(rowid);
|
||||
debug_assert!(res.is_ok());
|
||||
}
|
||||
Action::InitShowMenu(s) => {
|
||||
let menu = s.borrow();
|
||||
self.headerbar.set_secondary_menu(&menu.container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glib::Continue(true)
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let application = gtk::Application::new(APP_ID, ApplicationFlags::empty())
|
||||
.expect("Application Initialization failed...");
|
||||
|
||||
application.connect_startup(clone!(application => move |_| {
|
||||
info!("CONNECT STARTUP RUN");
|
||||
let app = Self::new(&application);
|
||||
Self::init(&app);
|
||||
info!("Init complete");
|
||||
application.connect_activate(clone!(app => move |_| {
|
||||
info!("GApplication::activate");
|
||||
app.window.show_all();
|
||||
app.window.activate();
|
||||
}));
|
||||
}));
|
||||
|
||||
// Weird magic I copy-pasted that sets the Application Name in the Shell.
|
||||
glib::set_application_name("Podcasts");
|
||||
glib::set_prgname(Some("gnome-podcasts"));
|
||||
gtk::Window::set_default_icon_name(APP_ID);
|
||||
let args: Vec<String> = env::args().collect();
|
||||
ApplicationExtManual::run(&application, &args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
use gio;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use failure::Error;
|
||||
use rayon;
|
||||
use url::Url;
|
||||
|
||||
use podcasts_data::{dbqueries, Source};
|
||||
|
||||
use app::Action;
|
||||
use stacks::Content;
|
||||
use utils::{itunes_to_rss, refresh};
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
// TODO: Factor out the hamburger menu
|
||||
// TODO: Make a proper state machine for the headerbar states
|
||||
pub struct Header {
|
||||
pub container: gtk::HeaderBar,
|
||||
switch: gtk::StackSwitcher,
|
||||
back: gtk::Button,
|
||||
show_title: gtk::Label,
|
||||
menu_button: gtk::MenuButton,
|
||||
updater: UpdateIndicator,
|
||||
add: AddPopover,
|
||||
dots: gtk::MenuButton,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UpdateIndicator {
|
||||
container: gtk::Box,
|
||||
text: gtk::Label,
|
||||
spinner: gtk::Spinner,
|
||||
}
|
||||
|
||||
impl UpdateIndicator {
|
||||
fn show(&self) {
|
||||
self.spinner.start();
|
||||
self.spinner.show();
|
||||
self.container.show();
|
||||
self.text.show();
|
||||
}
|
||||
|
||||
fn hide(&self) {
|
||||
self.spinner.stop();
|
||||
self.spinner.hide();
|
||||
self.container.hide();
|
||||
self.text.hide();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AddPopover {
|
||||
container: gtk::Popover,
|
||||
result: gtk::Label,
|
||||
entry: gtk::Entry,
|
||||
add: gtk::Button,
|
||||
toggle: gtk::MenuButton,
|
||||
}
|
||||
|
||||
impl AddPopover {
|
||||
// FIXME: THIS ALSO SUCKS!
|
||||
fn on_add_clicked(&self, sender: &Sender<Action>) -> Result<(), Error> {
|
||||
let mut url = self
|
||||
.entry
|
||||
.get_text()
|
||||
.ok_or_else(|| format_err!("GtkEntry blew up somehow."))?;
|
||||
|
||||
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
||||
url = format!("http://{}", url);
|
||||
};
|
||||
|
||||
debug!("Url: {}", url);
|
||||
let url = if url.contains("itunes.com") || url.contains("apple.com") {
|
||||
info!("Detected itunes url.");
|
||||
let foo = itunes_to_rss(&url)?;
|
||||
info!("Resolved to {}", foo);
|
||||
foo
|
||||
} else {
|
||||
url.to_owned()
|
||||
};
|
||||
|
||||
rayon::spawn(clone!(sender => move || {
|
||||
if let Ok(source) = Source::from_url(&url) {
|
||||
refresh(Some(vec![source]), sender.clone());
|
||||
} else {
|
||||
error!("Failed to convert, url: {}, to a source entry", url);
|
||||
}
|
||||
}));
|
||||
|
||||
self.container.hide();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// FIXME: THIS SUCKS! REFACTOR ME.
|
||||
fn on_entry_changed(&self) -> Result<(), Error> {
|
||||
let mut url = self
|
||||
.entry
|
||||
.get_text()
|
||||
.ok_or_else(|| format_err!("GtkEntry blew up somehow."))?;
|
||||
debug!("Url: {}", url);
|
||||
|
||||
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
||||
url = format!("http://{}", url);
|
||||
};
|
||||
|
||||
debug!("Url: {}", url);
|
||||
// TODO: refactor to avoid duplication
|
||||
match Url::parse(&url) {
|
||||
Ok(u) => {
|
||||
if !dbqueries::source_exists(u.as_str())? {
|
||||
self.add.set_sensitive(true);
|
||||
self.result.hide();
|
||||
self.result.set_label("");
|
||||
} else {
|
||||
self.add.set_sensitive(false);
|
||||
self.result
|
||||
.set_label("You are already subscribed to this Show");
|
||||
self.result.show();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
self.add.set_sensitive(false);
|
||||
if !url.is_empty() {
|
||||
self.result.set_label("Invalid url");
|
||||
self.result.show();
|
||||
error!("Error: {}", err);
|
||||
} else {
|
||||
self.result.hide();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Header {
|
||||
fn default() -> Header {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/headerbar.ui");
|
||||
let menus = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/hamburger.ui");
|
||||
|
||||
let header = builder.get_object("headerbar").unwrap();
|
||||
let switch = builder.get_object("switch").unwrap();
|
||||
let back = builder.get_object("back").unwrap();
|
||||
let show_title = builder.get_object("show_title").unwrap();
|
||||
|
||||
// The hamburger menu
|
||||
let menu_button: gtk::MenuButton = builder.get_object("menu_button").unwrap();
|
||||
let app_menu: gio::MenuModel = menus.get_object("menu").unwrap();
|
||||
menu_button.set_menu_model(Some(&app_menu));
|
||||
|
||||
// The 3 dots secondary menu
|
||||
let dots = builder.get_object("secondary_menu").unwrap();
|
||||
|
||||
let update_box = builder.get_object("update_notification").unwrap();
|
||||
let update_label = builder.get_object("update_label").unwrap();
|
||||
let update_spinner = builder.get_object("update_spinner").unwrap();
|
||||
|
||||
let updater = UpdateIndicator {
|
||||
container: update_box,
|
||||
text: update_label,
|
||||
spinner: update_spinner,
|
||||
};
|
||||
|
||||
let add_toggle = builder.get_object("add_toggle").unwrap();
|
||||
let add_popover = builder.get_object("add_popover").unwrap();
|
||||
let new_url = builder.get_object("new_url").unwrap();
|
||||
let add_button = builder.get_object("add_button").unwrap();
|
||||
let result = builder.get_object("result_label").unwrap();
|
||||
let add = AddPopover {
|
||||
container: add_popover,
|
||||
entry: new_url,
|
||||
toggle: add_toggle,
|
||||
add: add_button,
|
||||
result,
|
||||
};
|
||||
|
||||
Header {
|
||||
container: header,
|
||||
switch,
|
||||
back,
|
||||
show_title,
|
||||
menu_button,
|
||||
updater,
|
||||
add,
|
||||
dots,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Make a proper state machine for the headerbar states
|
||||
impl Header {
|
||||
pub fn new(content: &Content, sender: &Sender<Action>) -> Rc<Self> {
|
||||
let h = Rc::new(Header::default());
|
||||
Self::init(&h, content, &sender);
|
||||
h
|
||||
}
|
||||
|
||||
pub fn init(s: &Rc<Self>, content: &Content, sender: &Sender<Action>) {
|
||||
let weak = Rc::downgrade(s);
|
||||
|
||||
s.switch.set_stack(&content.get_stack());
|
||||
|
||||
s.add.entry.connect_changed(clone!(weak => move |_| {
|
||||
weak.upgrade().map(|h| {
|
||||
h.add.on_entry_changed()
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.ok();
|
||||
});
|
||||
}));
|
||||
|
||||
s.add.add.connect_clicked(clone!(weak, sender => move |_| {
|
||||
weak.upgrade().map(|h| h.add.on_add_clicked(&sender));
|
||||
}));
|
||||
|
||||
let switch = &s.switch;
|
||||
let add_toggle = &s.add.toggle;
|
||||
let show_title = &s.show_title;
|
||||
let menu = &s.menu_button;
|
||||
let dots = &s.dots;
|
||||
s.back.connect_clicked(
|
||||
clone!(switch, add_toggle, show_title, sender, menu, dots => move |back| {
|
||||
switch.show();
|
||||
add_toggle.show();
|
||||
back.hide();
|
||||
show_title.hide();
|
||||
menu.show();
|
||||
dots.hide();
|
||||
sender.send(Action::ShowShowsAnimated);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn switch_to_back(&self, title: &str) {
|
||||
self.switch.hide();
|
||||
self.add.toggle.hide();
|
||||
self.back.show();
|
||||
self.set_show_title(title);
|
||||
self.show_title.show();
|
||||
self.menu_button.hide();
|
||||
self.dots.show();
|
||||
}
|
||||
|
||||
pub fn switch_to_normal(&self) {
|
||||
self.switch.show();
|
||||
self.add.toggle.show();
|
||||
self.back.hide();
|
||||
self.show_title.hide();
|
||||
self.menu_button.show();
|
||||
self.dots.hide();
|
||||
}
|
||||
|
||||
pub fn set_show_title(&self, title: &str) {
|
||||
self.show_title.set_text(title)
|
||||
}
|
||||
|
||||
pub fn show_update_notification(&self) {
|
||||
self.updater.show();
|
||||
}
|
||||
|
||||
pub fn hide_update_notification(&self) {
|
||||
self.updater.hide();
|
||||
}
|
||||
|
||||
pub fn open_menu(&self) {
|
||||
self.menu_button.clicked();
|
||||
}
|
||||
|
||||
pub fn set_secondary_menu(&self, pop: >k::PopoverMenu) {
|
||||
self.dots.set_popover(Some(pop));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
#![cfg_attr(
|
||||
feature = "cargo-clippy",
|
||||
allow(
|
||||
clone_on_ref_ptr,
|
||||
blacklisted_name,
|
||||
match_same_arms,
|
||||
option_map_unit_fn
|
||||
)
|
||||
)]
|
||||
#![allow(unknown_lints)]
|
||||
// Enable lint group collections
|
||||
#![warn(
|
||||
nonstandard_style,
|
||||
edition_2018,
|
||||
rust_2018_idioms,
|
||||
bad_style,
|
||||
unused
|
||||
)]
|
||||
// standalone lints
|
||||
#![warn(
|
||||
const_err,
|
||||
improper_ctypes,
|
||||
non_shorthand_field_patterns,
|
||||
no_mangle_generic_items,
|
||||
overflowing_literals,
|
||||
plugin_as_library,
|
||||
private_no_mangle_fns,
|
||||
private_no_mangle_statics,
|
||||
unconditional_recursion,
|
||||
unions_with_drop_fields,
|
||||
while_true,
|
||||
missing_debug_implementations,
|
||||
trivial_casts,
|
||||
trivial_numeric_casts,
|
||||
elided_lifetime_in_paths,
|
||||
missing_copy_implementations
|
||||
)]
|
||||
#![deny(warnings)]
|
||||
|
||||
extern crate gdk;
|
||||
extern crate gdk_pixbuf;
|
||||
extern crate gio;
|
||||
extern crate glib;
|
||||
extern crate gstreamer as gst;
|
||||
extern crate gstreamer_player as gst_player;
|
||||
extern crate gtk;
|
||||
|
||||
#[macro_use]
|
||||
extern crate failure;
|
||||
// #[macro_use]
|
||||
// extern crate failure_derive;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
extern crate pretty_assertions;
|
||||
|
||||
extern crate chrono;
|
||||
extern crate crossbeam_channel;
|
||||
extern crate html2text;
|
||||
extern crate humansize;
|
||||
extern crate loggerv;
|
||||
extern crate open;
|
||||
extern crate podcasts_data;
|
||||
extern crate podcasts_downloader;
|
||||
extern crate rayon;
|
||||
extern crate regex;
|
||||
extern crate reqwest;
|
||||
extern crate send_cell;
|
||||
extern crate serde_json;
|
||||
extern crate url;
|
||||
|
||||
use log::Level;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
// http://gtk-rs.org/tuto/closures
|
||||
#[macro_export]
|
||||
macro_rules! clone {
|
||||
(@param _) => ( _ );
|
||||
(@param $x:ident) => ( $x );
|
||||
($($n:ident),+ => move || $body:expr) => (
|
||||
{
|
||||
$( let $n = $n.clone(); )+
|
||||
move || $body
|
||||
}
|
||||
);
|
||||
($($n:ident),+ => move |$($p:tt),+| $body:expr) => (
|
||||
{
|
||||
$( let $n = $n.clone(); )+
|
||||
move |$(clone!(@param $p),)+| $body
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
mod stacks;
|
||||
mod widgets;
|
||||
|
||||
mod app;
|
||||
mod headerbar;
|
||||
mod prefs;
|
||||
|
||||
mod manager;
|
||||
mod settings;
|
||||
mod static_resource;
|
||||
mod utils;
|
||||
|
||||
use app::App;
|
||||
|
||||
fn main() {
|
||||
// TODO: make the the logger a cli -vv option
|
||||
loggerv::init_with_level(Level::Info).expect("Error initializing loggerv.");
|
||||
gtk::init().expect("Error initializing gtk.");
|
||||
gst::init().expect("Error initializing gstreamer");
|
||||
static_resource::init().expect("Something went wrong with the resource file initialization.");
|
||||
|
||||
// Add custom style
|
||||
let provider = gtk::CssProvider::new();
|
||||
gtk::CssProvider::load_from_resource(&provider, "/org/gnome/Podcasts/gtk/style.css");
|
||||
gtk::StyleContext::add_provider_for_screen(
|
||||
&gdk::Screen::get_default().expect("Error initializing gtk css provider."),
|
||||
&provider,
|
||||
600,
|
||||
);
|
||||
|
||||
App::run();
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use failure::Error;
|
||||
use rayon;
|
||||
|
||||
// use podcasts_data::Episode;
|
||||
use podcasts_data::dbqueries;
|
||||
use podcasts_downloader::downloader::{get_episode, DownloadProgress};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
// use std::sync::atomic::AtomicUsize;
|
||||
// use std::path::PathBuf;
|
||||
|
||||
// This is messy, undocumented and hacky af.
|
||||
// I am terrible at writting downloaders and download managers.
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Progress {
|
||||
total_bytes: u64,
|
||||
downloaded_bytes: u64,
|
||||
cancel: bool,
|
||||
}
|
||||
|
||||
impl Default for Progress {
|
||||
fn default() -> Self {
|
||||
Progress {
|
||||
total_bytes: 0,
|
||||
downloaded_bytes: 0,
|
||||
cancel: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
pub fn get_fraction(&self) -> f64 {
|
||||
let ratio = self.downloaded_bytes as f64 / self.total_bytes as f64;
|
||||
debug!("{:?}", self);
|
||||
debug!("Ratio completed: {}", ratio);
|
||||
|
||||
if ratio >= 1.0 {
|
||||
return 1.0;
|
||||
};
|
||||
ratio
|
||||
}
|
||||
|
||||
pub fn get_total_size(&self) -> u64 {
|
||||
self.total_bytes
|
||||
}
|
||||
|
||||
pub fn get_downloaded(&self) -> u64 {
|
||||
self.downloaded_bytes
|
||||
}
|
||||
|
||||
pub fn cancel(&mut self) {
|
||||
self.cancel = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl DownloadProgress for Progress {
|
||||
fn set_downloaded(&mut self, downloaded: u64) {
|
||||
self.downloaded_bytes = downloaded
|
||||
}
|
||||
|
||||
fn set_size(&mut self, bytes: u64) {
|
||||
self.total_bytes = bytes;
|
||||
}
|
||||
|
||||
fn should_cancel(&self) -> bool {
|
||||
self.cancel
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref ACTIVE_DOWNLOADS: Arc<RwLock<HashMap<i32, Arc<Mutex<Progress>>>>> =
|
||||
{ Arc::new(RwLock::new(HashMap::new())) };
|
||||
static ref DLPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
|
||||
}
|
||||
|
||||
pub fn add(id: i32, directory: String) -> Result<(), Error> {
|
||||
// Create a new `Progress` struct to keep track of dl progress.
|
||||
let prog = Arc::new(Mutex::new(Progress::default()));
|
||||
|
||||
match ACTIVE_DOWNLOADS.write() {
|
||||
Ok(mut guard) => guard.insert(id, prog.clone()),
|
||||
Err(err) => return Err(format_err!("ActiveDonwloads: {}.", err)),
|
||||
};
|
||||
|
||||
DLPOOL.spawn(move || {
|
||||
if let Ok(mut episode) = dbqueries::get_episode_widget_from_rowid(id) {
|
||||
let id = episode.rowid();
|
||||
|
||||
get_episode(&mut episode, directory.as_str(), Some(prog))
|
||||
.map_err(|err| error!("Download Failed: {}", err))
|
||||
.ok();
|
||||
|
||||
if let Ok(mut m) = ACTIVE_DOWNLOADS.write() {
|
||||
let foo = m.remove(&id);
|
||||
debug!("Removed: {:?}", foo);
|
||||
}
|
||||
|
||||
// if let Ok(m) = ACTIVE_DOWNLOADS.read() {
|
||||
// debug!("ACTIVE DOWNLOADS: {:#?}", m);
|
||||
// }
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use podcasts_data::dbqueries;
|
||||
use podcasts_data::pipeline;
|
||||
use podcasts_data::utils::get_download_folder;
|
||||
use podcasts_data::{Episode, Source};
|
||||
|
||||
use podcasts_downloader::downloader::get_episode;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::{thread, time};
|
||||
|
||||
#[test]
|
||||
// This test inserts an rss feed to your `XDG_DATA/podcasts/podcasts.db` so we make it explicit
|
||||
// to run it.
|
||||
#[ignore]
|
||||
// THIS IS NOT A RELIABLE TEST
|
||||
// Just quick sanity check
|
||||
fn test_start_dl() {
|
||||
let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
|
||||
// Create and index a source
|
||||
let source = Source::from_url(url).unwrap();
|
||||
// Copy it's id
|
||||
let sid = source.id();
|
||||
pipeline::run(vec![source], true).unwrap();
|
||||
|
||||
// Get the Podcast
|
||||
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
|
||||
let title = "Coming soon... The Tip Off";
|
||||
// Get an episode
|
||||
let episode: Episode = dbqueries::get_episode_from_pk(title, pd.id()).unwrap();
|
||||
|
||||
let download_fold = get_download_folder(&pd.title()).unwrap();
|
||||
let fold2 = download_fold.clone();
|
||||
add(episode.rowid(), download_fold).unwrap();
|
||||
assert_eq!(ACTIVE_DOWNLOADS.read().unwrap().len(), 1);
|
||||
|
||||
// Give it soem time to download the file
|
||||
thread::sleep(time::Duration::from_secs(20));
|
||||
|
||||
let final_path = format!("{}/{}.mp3", &fold2, episode.rowid());
|
||||
assert!(Path::new(&final_path).exists());
|
||||
fs::remove_file(final_path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
// This test needs access to local system so we ignore it by default.
|
||||
#[ignore]
|
||||
fn test_dl_steal_the_stars() {
|
||||
let url =
|
||||
"https://web.archive.org/web/20180120104957if_/https://rss.art19.com/steal-the-stars";
|
||||
// Create and index a source
|
||||
let source = Source::from_url(url).unwrap();
|
||||
// Copy it's id
|
||||
let sid = source.id();
|
||||
pipeline::run(vec![source], true).unwrap();
|
||||
|
||||
// Get the Podcast
|
||||
let pd = dbqueries::get_podcast_from_source_id(sid).unwrap();
|
||||
let title = "Introducing Steal the Stars";
|
||||
// Get an episode
|
||||
let mut episode = dbqueries::get_episode_from_pk(title, pd.id())
|
||||
.unwrap()
|
||||
.into();
|
||||
let download_fold = get_download_folder(&pd.title()).unwrap();
|
||||
|
||||
get_episode(&mut episode, &download_fold, None).unwrap();
|
||||
|
||||
let final_path = format!("{}/{}.mp3", &download_fold, episode.rowid());
|
||||
assert!(Path::new(&final_path).exists());
|
||||
fs::remove_file(final_path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use gio;
|
||||
use gio::{Settings, SettingsExt};
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Prefs {
|
||||
dialog: gtk::Window,
|
||||
dark_toggle: gtk::Switch,
|
||||
cleanup_value: gtk::SpinButton,
|
||||
cleanup_type: gtk::ComboBox,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
fn default() -> Prefs {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/prefs.ui");
|
||||
|
||||
let dialog = builder.get_object("prefs").unwrap();
|
||||
let dark_toggle = builder.get_object("dark_toggle").unwrap();
|
||||
let cleanup_value = builder.get_object("cleanup_value").unwrap();
|
||||
let cleanup_type = builder.get_object("cleanup_type").unwrap();
|
||||
|
||||
Prefs {
|
||||
dialog,
|
||||
dark_toggle,
|
||||
cleanup_value,
|
||||
cleanup_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Refactor components into smaller state machines
|
||||
impl Prefs {
|
||||
pub fn new(settings: &Settings) -> Prefs {
|
||||
let h = Prefs::default();
|
||||
h.init(settings);
|
||||
h
|
||||
}
|
||||
|
||||
pub fn init(&self, settings: &Settings) {
|
||||
settings.bind(
|
||||
"dark-theme",
|
||||
&self.dark_toggle,
|
||||
"active",
|
||||
gio::SettingsBindFlags::DEFAULT,
|
||||
);
|
||||
settings.bind(
|
||||
"cleanup-age-time",
|
||||
&self.cleanup_value,
|
||||
"value",
|
||||
gio::SettingsBindFlags::DEFAULT,
|
||||
);
|
||||
let cleanup_p = settings.get_string("cleanup-age-period").unwrap();
|
||||
let mut cleanup_pos = 0;
|
||||
let store = gtk::ListStore::new(&[gtk::Type::String]);
|
||||
for (i, item) in ["Seconds", "Minutes", "Hours", "Days", "Weeks"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let row: &[&ToValue] = &[item];
|
||||
if item.to_lowercase() == cleanup_p {
|
||||
cleanup_pos = i as i32;
|
||||
}
|
||||
store.insert_with_values(None, &[0], &row);
|
||||
}
|
||||
self.cleanup_type.set_model(Some(&store));
|
||||
let renderer = gtk::CellRendererText::new();
|
||||
self.cleanup_type.pack_start(&renderer, true);
|
||||
self.cleanup_type.add_attribute(&renderer, "text", 0);
|
||||
self.cleanup_type.set_active(cleanup_pos);
|
||||
self.cleanup_type
|
||||
.connect_changed(clone!(settings, store => move |combo| {
|
||||
if let Some(ref treeiter) = combo.get_active_iter() {
|
||||
if let Some(s) = store.get_value(treeiter, 0).get::<&str>() {
|
||||
settings.set_string("cleanup-age-period", &s.to_lowercase());
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn show(&self, parent: >k::ApplicationWindow) {
|
||||
self.dialog.set_transient_for(Some(parent));
|
||||
self.dialog.set_modal(true);
|
||||
self.dialog.show_all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use gio;
|
||||
use gio::{Settings, SettingsExt};
|
||||
use gtk;
|
||||
use gtk::GtkWindowExt;
|
||||
|
||||
use chrono::prelude::*;
|
||||
use chrono::Duration;
|
||||
|
||||
pub struct WindowGeometry {
|
||||
left: i32,
|
||||
top: i32,
|
||||
width: i32,
|
||||
height: i32,
|
||||
is_maximized: bool,
|
||||
}
|
||||
|
||||
impl WindowGeometry {
|
||||
pub fn from_window(window: >k::ApplicationWindow) -> WindowGeometry {
|
||||
let position = window.get_position();
|
||||
let size = window.get_size();
|
||||
let left = position.0;
|
||||
let top = position.1;
|
||||
let width = size.0;
|
||||
let height = size.1;
|
||||
let is_maximized = window.is_maximized();
|
||||
|
||||
WindowGeometry {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
is_maximized,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_settings(settings: &gio::Settings) -> WindowGeometry {
|
||||
let top = settings.get_int("persist-window-geometry-top");
|
||||
let left = settings.get_int("persist-window-geometry-left");
|
||||
let width = settings.get_int("persist-window-geometry-width");
|
||||
let height = settings.get_int("persist-window-geometry-height");
|
||||
let is_maximized = settings.get_boolean("persist-window-geometry-maximized");
|
||||
|
||||
WindowGeometry {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
is_maximized,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(&self, window: >k::ApplicationWindow) {
|
||||
if self.width > 0 && self.height > 0 {
|
||||
window.resize(self.width, self.height);
|
||||
}
|
||||
|
||||
if self.is_maximized {
|
||||
window.maximize();
|
||||
} else if self.top > 0 && self.left > 0 {
|
||||
window.move_(self.left, self.top);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, settings: &gio::Settings) {
|
||||
settings.set_int("persist-window-geometry-left", self.left);
|
||||
settings.set_int("persist-window-geometry-top", self.top);
|
||||
settings.set_int("persist-window-geometry-width", self.width);
|
||||
settings.set_int("persist-window-geometry-height", self.height);
|
||||
settings.set_boolean("persist-window-geometry-maximized", self.is_maximized);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_refresh_interval(settings: &Settings) -> Duration {
|
||||
let time = i64::from(settings.get_int("refresh-interval-time"));
|
||||
let period = settings.get_string("refresh-interval-period").unwrap();
|
||||
|
||||
time_period_to_duration(time, period.as_str())
|
||||
}
|
||||
|
||||
pub fn get_cleanup_date(settings: &Settings) -> DateTime<Utc> {
|
||||
let time = i64::from(settings.get_int("cleanup-age-time"));
|
||||
let period = settings.get_string("cleanup-age-period").unwrap();
|
||||
let duration = time_period_to_duration(time, period.as_str());
|
||||
|
||||
Utc::now() - duration
|
||||
}
|
||||
|
||||
pub fn time_period_to_duration(time: i64, period: &str) -> Duration {
|
||||
match period {
|
||||
"weeks" => Duration::weeks(time),
|
||||
"days" => Duration::days(time),
|
||||
"hours" => Duration::hours(time),
|
||||
"minutes" => Duration::minutes(time),
|
||||
_ => Duration::seconds(time),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_period_to_duration() {
|
||||
let time = 2;
|
||||
let week = 604800 * time;
|
||||
let day = 86400 * time;
|
||||
let hour = 3600 * time;
|
||||
let minute = 60 * time;
|
||||
|
||||
assert_eq!(week, time_period_to_duration(time, "weeks").num_seconds());
|
||||
assert_eq!(day, time_period_to_duration(time, "days").num_seconds());
|
||||
assert_eq!(hour, time_period_to_duration(time, "hours").num_seconds());
|
||||
assert_eq!(
|
||||
minute,
|
||||
time_period_to_duration(time, "minutes").num_seconds()
|
||||
);
|
||||
assert_eq!(time, time_period_to_duration(time, "seconds").num_seconds());
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn test_apply_window_geometry() {
|
||||
// gtk::init().expect("Error initializing gtk.");
|
||||
|
||||
// let window = gtk::Window::new(gtk::WindowType::Toplevel);
|
||||
// let _geometry = WindowGeometry {
|
||||
// left: 0,
|
||||
// top: 0,
|
||||
// width: 100,
|
||||
// height: 100,
|
||||
// is_maximized: true
|
||||
// };
|
||||
|
||||
// assert!(!window.is_maximized());
|
||||
|
||||
// window.show();
|
||||
// window.activate();
|
||||
// geometry.apply(&window);
|
||||
|
||||
// assert!(window.is_maximized());
|
||||
// }
|
||||
@@ -0,0 +1,89 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use failure::Error;
|
||||
|
||||
use app::Action;
|
||||
use stacks::{HomeStack, ShowStack};
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Content {
|
||||
stack: gtk::Stack,
|
||||
shows: Rc<RefCell<ShowStack>>,
|
||||
home: Rc<RefCell<HomeStack>>,
|
||||
sender: Sender<Action>,
|
||||
}
|
||||
|
||||
impl Content {
|
||||
pub fn new(sender: &Sender<Action>) -> Result<Rc<Content>, Error> {
|
||||
let stack = gtk::Stack::new();
|
||||
let home = Rc::new(RefCell::new(HomeStack::new(sender.clone())?));
|
||||
let shows = Rc::new(RefCell::new(ShowStack::new(sender.clone())));
|
||||
|
||||
stack.add_titled(&home.borrow().get_stack(), "home", "New");
|
||||
stack.add_titled(&shows.borrow().get_stack(), "shows", "Shows");
|
||||
|
||||
let con = Content {
|
||||
stack,
|
||||
shows,
|
||||
home,
|
||||
sender: sender.clone(),
|
||||
};
|
||||
Ok(Rc::new(con))
|
||||
}
|
||||
|
||||
pub fn update(&self) {
|
||||
self.update_home();
|
||||
self.update_shows();
|
||||
}
|
||||
|
||||
pub fn update_home(&self) {
|
||||
self.home
|
||||
.borrow_mut()
|
||||
.update()
|
||||
.map_err(|err| error!("Failed to update HomeView: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn update_home_if_background(&self) {
|
||||
if self.stack.get_visible_child_name() != Some("home".into()) {
|
||||
self.update_home();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_shows(&self) {
|
||||
self.shows
|
||||
.borrow_mut()
|
||||
.update()
|
||||
.map_err(|err| error!("Failed to update ShowsView: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn update_shows_view(&self) {
|
||||
self.shows
|
||||
.borrow_mut()
|
||||
.update()
|
||||
.map_err(|err| error!("Failed to update ShowsView: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn update_widget_if_same(&self, pid: i32) {
|
||||
let pop = self.shows.borrow().populated();
|
||||
pop.borrow_mut()
|
||||
.update_widget_if_same(pid)
|
||||
.map_err(|err| error!("Failed to update ShowsWidget: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn get_stack(&self) -> gtk::Stack {
|
||||
self.stack.clone()
|
||||
}
|
||||
|
||||
pub fn get_shows(&self) -> Rc<RefCell<ShowStack>> {
|
||||
self.shows.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
use gtk::StackTransitionType;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use failure::Error;
|
||||
use podcasts_data::dbqueries::is_episodes_populated;
|
||||
use podcasts_data::errors::DataError;
|
||||
|
||||
use app::Action;
|
||||
use widgets::{EmptyView, HomeView};
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum State {
|
||||
Home,
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HomeStack {
|
||||
empty: EmptyView,
|
||||
episodes: Rc<HomeView>,
|
||||
stack: gtk::Stack,
|
||||
state: State,
|
||||
sender: Sender<Action>,
|
||||
}
|
||||
|
||||
impl HomeStack {
|
||||
pub fn new(sender: Sender<Action>) -> Result<HomeStack, Error> {
|
||||
let episodes = HomeView::new(sender.clone())?;
|
||||
let empty = EmptyView::new();
|
||||
let stack = gtk::Stack::new();
|
||||
let state = State::Empty;
|
||||
|
||||
stack.add_named(&episodes.container, "home");
|
||||
stack.add_named(&empty.container, "empty");
|
||||
|
||||
let mut home = HomeStack {
|
||||
empty,
|
||||
episodes,
|
||||
stack,
|
||||
state,
|
||||
sender,
|
||||
};
|
||||
|
||||
home.determine_state()?;
|
||||
Ok(home)
|
||||
}
|
||||
|
||||
pub fn get_stack(&self) -> gtk::Stack {
|
||||
self.stack.clone()
|
||||
}
|
||||
|
||||
pub fn update(&mut self) -> Result<(), Error> {
|
||||
// Copy the vertical scrollbar adjustment from the old view.
|
||||
self.episodes
|
||||
.save_alignment()
|
||||
.map_err(|err| error!("Failed to set episodes_view allignment: {}", err))
|
||||
.ok();
|
||||
|
||||
self.replace_view()?;
|
||||
// Determine the actuall state.
|
||||
self.determine_state().map_err(From::from)
|
||||
}
|
||||
|
||||
fn replace_view(&mut self) -> Result<(), Error> {
|
||||
// Get the container of the view
|
||||
let old = &self.episodes.container.clone();
|
||||
let eps = HomeView::new(self.sender.clone())?;
|
||||
|
||||
// Remove the old widget and add the new one
|
||||
// during this the previous view is removed,
|
||||
// and the visibile child fallsback to empty view.
|
||||
self.stack.remove(old);
|
||||
self.stack.add_named(&eps.container, "home");
|
||||
// Keep the previous state.
|
||||
let s = self.state;
|
||||
// Set the visible child back to the previous one to avoid
|
||||
// the stack transition animation to show the empty view
|
||||
self.switch_visible(s, StackTransitionType::None);
|
||||
|
||||
// replace view in the struct too
|
||||
self.episodes = eps;
|
||||
|
||||
// This might not be needed
|
||||
old.destroy();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn switch_visible(&mut self, s: State, animation: StackTransitionType) {
|
||||
use self::State::*;
|
||||
|
||||
match s {
|
||||
Home => {
|
||||
self.stack.set_visible_child_full("home", animation);
|
||||
self.state = Home;
|
||||
}
|
||||
Empty => {
|
||||
self.stack.set_visible_child_full("empty", animation);
|
||||
self.state = Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_state(&mut self) -> Result<(), DataError> {
|
||||
if is_episodes_populated()? {
|
||||
self.switch_visible(State::Home, StackTransitionType::Crossfade);
|
||||
} else {
|
||||
self.switch_visible(State::Empty, StackTransitionType::Crossfade);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod content;
|
||||
mod home;
|
||||
mod populated;
|
||||
mod show;
|
||||
|
||||
pub use self::content::Content;
|
||||
pub use self::home::HomeStack;
|
||||
pub use self::populated::{PopulatedStack, PopulatedState};
|
||||
pub use self::show::{ShowStack, ShowState};
|
||||
@@ -0,0 +1,158 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
use gtk::StackTransitionType;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use failure::Error;
|
||||
|
||||
use podcasts_data::dbqueries;
|
||||
use podcasts_data::Show;
|
||||
|
||||
use app::Action;
|
||||
use widgets::{ShowWidget, ShowsView};
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PopulatedState {
|
||||
View,
|
||||
Widget,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PopulatedStack {
|
||||
container: gtk::Box,
|
||||
populated: Rc<ShowsView>,
|
||||
show: Rc<ShowWidget>,
|
||||
stack: gtk::Stack,
|
||||
state: PopulatedState,
|
||||
sender: Sender<Action>,
|
||||
}
|
||||
|
||||
impl PopulatedStack {
|
||||
pub fn new(sender: Sender<Action>) -> PopulatedStack {
|
||||
let stack = gtk::Stack::new();
|
||||
let state = PopulatedState::View;
|
||||
let populated = ShowsView::new(sender.clone());
|
||||
let show = Rc::new(ShowWidget::default());
|
||||
let container = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
|
||||
stack.add_named(&populated.container, "shows");
|
||||
stack.add_named(&show.container, "widget");
|
||||
container.add(&stack);
|
||||
container.show_all();
|
||||
|
||||
PopulatedStack {
|
||||
container,
|
||||
stack,
|
||||
populated,
|
||||
show,
|
||||
state,
|
||||
sender,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self) {
|
||||
self.update_widget().map_err(|err| format!("{}", err)).ok();
|
||||
self.update_shows().map_err(|err| format!("{}", err)).ok();
|
||||
}
|
||||
|
||||
pub fn update_shows(&mut self) -> Result<(), Error> {
|
||||
// The current visible child might change depending on
|
||||
// removal and insertion in the gtk::Stack, so we have
|
||||
// to make sure it will stay the same.
|
||||
let s = self.state;
|
||||
self.replace_shows()?;
|
||||
self.switch_visible(s, StackTransitionType::Crossfade);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_shows(&mut self) -> Result<(), Error> {
|
||||
let old = &self.populated.container.clone();
|
||||
debug!("Name: {:?}", WidgetExt::get_name(old));
|
||||
|
||||
self.populated
|
||||
.save_alignment()
|
||||
.map_err(|err| error!("Failed to set episodes_view allignment: {}", err))
|
||||
.ok();
|
||||
|
||||
let pop = ShowsView::new(self.sender.clone());
|
||||
self.populated = pop;
|
||||
self.stack.remove(old);
|
||||
self.stack.add_named(&self.populated.container, "shows");
|
||||
|
||||
old.destroy();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_widget(&mut self, pd: Arc<Show>) -> Result<(), Error> {
|
||||
let old = self.show.container.clone();
|
||||
|
||||
// save the ShowWidget vertical scrollabar alignment
|
||||
self.show.show_id().map(|id| self.show.save_vadjustment(id));
|
||||
|
||||
let new = ShowWidget::new(pd, self.sender.clone());
|
||||
self.show = new;
|
||||
self.stack.remove(&old);
|
||||
self.stack.add_named(&self.show.container, "widget");
|
||||
|
||||
// The current visible child might change depending on
|
||||
// removal and insertion in the gtk::Stack, so we have
|
||||
// to make sure it will stay the same.
|
||||
let s = self.state;
|
||||
self.switch_visible(s, StackTransitionType::None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_widget(&mut self) -> Result<(), Error> {
|
||||
let old = self.show.container.clone();
|
||||
let id = self.show.show_id();
|
||||
if id.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pd = dbqueries::get_podcast_from_id(id.unwrap_or_default())?;
|
||||
self.replace_widget(Arc::new(pd))?;
|
||||
|
||||
// The current visible child might change depending on
|
||||
// removal and insertion in the gtk::Stack, so we have
|
||||
// to make sure it will stay the same.
|
||||
let s = self.state;
|
||||
self.switch_visible(s, StackTransitionType::Crossfade);
|
||||
|
||||
old.destroy();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Only update widget if its show_id is equal to pid.
|
||||
pub fn update_widget_if_same(&mut self, pid: i32) -> Result<(), Error> {
|
||||
if self.show.show_id() != Some(pid) {
|
||||
debug!("Different widget. Early return");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.update_widget()
|
||||
}
|
||||
|
||||
pub fn container(&self) -> gtk::Box {
|
||||
self.container.clone()
|
||||
}
|
||||
|
||||
pub fn switch_visible(&mut self, state: PopulatedState, animation: StackTransitionType) {
|
||||
use self::PopulatedState::*;
|
||||
|
||||
match state {
|
||||
View => {
|
||||
self.stack.set_visible_child_full("shows", animation);
|
||||
self.state = View;
|
||||
}
|
||||
Widget => {
|
||||
self.stack.set_visible_child_full("widget", animation);
|
||||
self.state = Widget;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use failure::Error;
|
||||
use podcasts_data::dbqueries::is_podcasts_populated;
|
||||
|
||||
use app::Action;
|
||||
use stacks::PopulatedStack;
|
||||
use utils::get_ignored_shows;
|
||||
use widgets::EmptyView;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ShowState {
|
||||
Populated,
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShowStack {
|
||||
empty: EmptyView,
|
||||
populated: Rc<RefCell<PopulatedStack>>,
|
||||
stack: gtk::Stack,
|
||||
state: ShowState,
|
||||
sender: Sender<Action>,
|
||||
}
|
||||
|
||||
impl ShowStack {
|
||||
pub fn new(sender: Sender<Action>) -> Self {
|
||||
let populated = Rc::new(RefCell::new(PopulatedStack::new(sender.clone())));
|
||||
let empty = EmptyView::new();
|
||||
let stack = gtk::Stack::new();
|
||||
let state = ShowState::Empty;
|
||||
|
||||
stack.add_named(&populated.borrow().container(), "populated");
|
||||
stack.add_named(&empty.container, "empty");
|
||||
|
||||
let mut show = ShowStack {
|
||||
empty,
|
||||
populated,
|
||||
stack,
|
||||
state,
|
||||
sender,
|
||||
};
|
||||
|
||||
let res = show.determine_state();
|
||||
debug_assert!(res.is_ok());
|
||||
show
|
||||
}
|
||||
|
||||
pub fn get_stack(&self) -> gtk::Stack {
|
||||
self.stack.clone()
|
||||
}
|
||||
|
||||
pub fn populated(&self) -> Rc<RefCell<PopulatedStack>> {
|
||||
self.populated.clone()
|
||||
}
|
||||
|
||||
pub fn update(&mut self) -> Result<(), Error> {
|
||||
self.populated.borrow_mut().update();
|
||||
self.determine_state()
|
||||
}
|
||||
|
||||
fn switch_visible(&mut self, s: ShowState) {
|
||||
use self::ShowState::*;
|
||||
|
||||
match s {
|
||||
Populated => {
|
||||
self.stack.set_visible_child_name("populated");
|
||||
self.state = Populated;
|
||||
}
|
||||
Empty => {
|
||||
self.stack.set_visible_child_name("empty");
|
||||
self.state = Empty;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn determine_state(&mut self) -> Result<(), Error> {
|
||||
use self::ShowState::*;
|
||||
|
||||
let ign = get_ignored_shows()?;
|
||||
debug!("IGNORED SHOWS {:?}", ign);
|
||||
if is_podcasts_populated(&ign)? {
|
||||
self.switch_visible(Populated);
|
||||
} else {
|
||||
self.switch_visible(Empty);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use gio::{resources_register, Error, Resource};
|
||||
use glib::Bytes;
|
||||
|
||||
pub fn init() -> Result<(), Error> {
|
||||
// load the gresource binary at build time and include/link it into the final
|
||||
// binary.
|
||||
let res_bytes = include_bytes!("../resources/resources.gresource");
|
||||
|
||||
// Create Resource it will live as long the value lives.
|
||||
let gbytes = Bytes::from_static(res_bytes.as_ref());
|
||||
let resource = Resource::new_from_data(&gbytes)?;
|
||||
|
||||
// Register the resource so It wont be dropped and will continue to live in
|
||||
// memory.
|
||||
resources_register(&resource);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
#![cfg_attr(feature = "cargo-clippy", allow(type_complexity))]
|
||||
|
||||
use gdk::FrameClockExt;
|
||||
use gdk_pixbuf::Pixbuf;
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IsA, Widget};
|
||||
|
||||
use chrono::prelude::*;
|
||||
use crossbeam_channel::{unbounded, Sender};
|
||||
use failure::Error;
|
||||
use rayon;
|
||||
use regex::Regex;
|
||||
use reqwest;
|
||||
use send_cell::SendCell;
|
||||
use serde_json::Value;
|
||||
|
||||
// use podcasts_data::feed;
|
||||
use podcasts_data::dbqueries;
|
||||
use podcasts_data::opml;
|
||||
use podcasts_data::pipeline;
|
||||
use podcasts_data::utils::checkup;
|
||||
use podcasts_data::Source;
|
||||
use podcasts_downloader::downloader;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use app::Action;
|
||||
|
||||
/// Lazy evaluates and loads widgets to the parent `container` widget.
|
||||
///
|
||||
/// Accepts an `IntoIterator`, `data`, as the source from which each widget
|
||||
/// will be constructed. An `FnMut` function that returns the desired
|
||||
/// widget should be passed as the widget `constructor`. You can also specify
|
||||
/// a `callback` that will be executed when the iteration finish.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # struct Message;
|
||||
/// # struct MessageWidget(gtk::Label);
|
||||
///
|
||||
/// # impl MessageWidget {
|
||||
/// # fn new(_: Message) -> Self {
|
||||
/// # MessageWidget(gtk::Label::new("A message"))
|
||||
/// # }
|
||||
/// # }
|
||||
///
|
||||
/// let messages: Vec<Message> = Vec::new();
|
||||
/// let list = gtk::ListBox::new();
|
||||
/// let constructor = |m| MessageWidget::new(m).0;
|
||||
/// lazy_load(messages, list, constructor, || {});
|
||||
/// ```
|
||||
///
|
||||
/// If you have already constructed the widgets and only want to
|
||||
/// load them to the parent you can pass a closure that returns it's
|
||||
/// own argument to the constructor.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use std::collections::binary_heap::BinaryHeap;
|
||||
/// let widgets: BinaryHeap<gtk::Button> = BinaryHeap::new();
|
||||
/// let list = gtk::ListBox::new();
|
||||
/// lazy_load(widgets, list, |w| w, || {});
|
||||
/// ```
|
||||
pub fn lazy_load<T, C, F, W, U>(data: T, container: C, mut contructor: F, callback: U)
|
||||
where
|
||||
T: IntoIterator + 'static,
|
||||
T::Item: 'static,
|
||||
C: ContainerExt + 'static,
|
||||
F: FnMut(T::Item) -> W + 'static,
|
||||
W: IsA<Widget> + WidgetExt,
|
||||
U: Fn() + 'static,
|
||||
{
|
||||
let func = move |x| {
|
||||
let widget = contructor(x);
|
||||
container.add(&widget);
|
||||
widget.show();
|
||||
};
|
||||
lazy_load_full(data, func, callback);
|
||||
}
|
||||
|
||||
/// Iterate over `data` and execute `func` using a `gtk::idle_add()`,
|
||||
/// when the iteration finishes, it executes `finish_callback`.
|
||||
///
|
||||
/// This is a more flexible version of `lazy_load` with less constrains.
|
||||
/// If you just want to lazy add `widgets` to a `container` check if
|
||||
/// `lazy_load` fits your needs first.
|
||||
#[cfg_attr(feature = "cargo-clippy", allow(redundant_closure))]
|
||||
pub fn lazy_load_full<T, F, U>(data: T, mut func: F, finish_callback: U)
|
||||
where
|
||||
T: IntoIterator + 'static,
|
||||
T::Item: 'static,
|
||||
F: FnMut(T::Item) + 'static,
|
||||
U: Fn() + 'static,
|
||||
{
|
||||
let mut data = data.into_iter();
|
||||
gtk::idle_add(move || {
|
||||
data.next()
|
||||
.map(|x| func(x))
|
||||
.map(|_| glib::Continue(true))
|
||||
.unwrap_or_else(|| {
|
||||
finish_callback();
|
||||
glib::Continue(false)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Kudos to Julian Sparber
|
||||
// https://blogs.gnome.org/jsparber/2018/04/29/animate-a-scrolledwindow/
|
||||
#[cfg_attr(feature = "cargo-clippy", allow(float_cmp))]
|
||||
pub fn smooth_scroll_to(view: >k::ScrolledWindow, target: >k::Adjustment) {
|
||||
if let Some(adj) = view.get_vadjustment() {
|
||||
if let Some(clock) = view.get_frame_clock() {
|
||||
let duration = 200;
|
||||
let start = adj.get_value();
|
||||
let end = target.get_value();
|
||||
let start_time = clock.get_frame_time();
|
||||
let end_time = start_time + 1000 * duration;
|
||||
|
||||
view.add_tick_callback(move |_, clock| {
|
||||
let now = clock.get_frame_time();
|
||||
// FIXME: `adj.get_value != end` is a float comparison...
|
||||
if now < end_time && adj.get_value().abs() != end.abs() {
|
||||
let mut t = (now - start_time) as f64 / (end_time - start_time) as f64;
|
||||
t = ease_out_cubic(t);
|
||||
adj.set_value(start + t * (end - start));
|
||||
glib::Continue(true)
|
||||
} else {
|
||||
adj.set_value(end);
|
||||
glib::Continue(false)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// From clutter-easing.c, based on Robert Penner's
|
||||
// infamous easing equations, MIT license.
|
||||
fn ease_out_cubic(t: f64) -> f64 {
|
||||
let p = t - 1f64;
|
||||
p * p * p + 1f64
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref IGNORESHOWS: Arc<Mutex<HashSet<i32>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
}
|
||||
|
||||
pub fn ignore_show(id: i32) -> Result<bool, Error> {
|
||||
IGNORESHOWS
|
||||
.lock()
|
||||
.map(|mut guard| guard.insert(id))
|
||||
.map_err(|err| format_err!("{}", err))
|
||||
}
|
||||
|
||||
pub fn uningore_show(id: i32) -> Result<bool, Error> {
|
||||
IGNORESHOWS
|
||||
.lock()
|
||||
.map(|mut guard| guard.remove(&id))
|
||||
.map_err(|err| format_err!("{}", err))
|
||||
}
|
||||
|
||||
pub fn get_ignored_shows() -> Result<Vec<i32>, Error> {
|
||||
IGNORESHOWS
|
||||
.lock()
|
||||
.map(|guard| guard.iter().cloned().collect::<Vec<_>>())
|
||||
.map_err(|err| format_err!("{}", err))
|
||||
}
|
||||
|
||||
pub fn cleanup(cleanup_date: DateTime<Utc>) {
|
||||
checkup(cleanup_date)
|
||||
.map_err(|err| error!("Check up failed: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub fn refresh<S>(source: Option<S>, sender: Sender<Action>)
|
||||
where
|
||||
S: IntoIterator<Item = Source> + Send + 'static,
|
||||
{
|
||||
refresh_feed(source, sender)
|
||||
.map_err(|err| error!("Failed to update feeds: {}", err))
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Update the rss feed(s) originating from `source`.
|
||||
/// If `source` is None, Fetches all the `Source` entries in the database and updates them.
|
||||
/// When It's done,it queues up a `RefreshViews` action.
|
||||
fn refresh_feed<S>(source: Option<S>, sender: Sender<Action>) -> Result<(), Error>
|
||||
where
|
||||
S: IntoIterator<Item = Source> + Send + 'static,
|
||||
{
|
||||
sender.send(Action::HeaderBarShowUpdateIndicator);
|
||||
|
||||
rayon::spawn(move || {
|
||||
if let Some(s) = source {
|
||||
// Refresh only specified feeds
|
||||
pipeline::run(s, false)
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Error While trying to update the database."))
|
||||
.ok();
|
||||
} else {
|
||||
// Refresh all the feeds
|
||||
dbqueries::get_sources()
|
||||
.map(|s| s.into_iter())
|
||||
.and_then(|s| pipeline::run(s, false))
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.ok();
|
||||
};
|
||||
|
||||
sender.send(Action::HeaderBarHideUpdateIndicator);
|
||||
sender.send(Action::RefreshAllViews);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref CACHED_PIXBUFS: RwLock<HashMap<(i32, u32), Mutex<SendCell<Pixbuf>>>> =
|
||||
{ RwLock::new(HashMap::new()) };
|
||||
static ref COVER_DL_REGISTRY: RwLock<HashSet<i32>> = RwLock::new(HashSet::new());
|
||||
static ref THREADPOOL: rayon::ThreadPool = rayon::ThreadPoolBuilder::new().build().unwrap();
|
||||
}
|
||||
|
||||
// Since gdk_pixbuf::Pixbuf is refference counted and every episode,
|
||||
// use the cover of the Podcast Feed/Show, We can only create a Pixbuf
|
||||
// cover per show and pass around the Rc pointer.
|
||||
//
|
||||
// GObjects do not implement Send trait, so SendCell is a way around that.
|
||||
// Also lazy_static requires Sync trait, so that's what the mutexes are.
|
||||
// TODO: maybe use something that would just scale to requested size?
|
||||
pub fn set_image_from_path(image: >k::Image, show_id: i32, size: u32) -> Result<(), Error> {
|
||||
// Check if there's an active download about this show cover.
|
||||
// If there is, a callback will be set so this function will be called again.
|
||||
// If the download succedes, there should be a quick return from the pixbuf cache_image
|
||||
// If it fails another download will be scheduled.
|
||||
if let Ok(guard) = COVER_DL_REGISTRY.read() {
|
||||
if guard.contains(&show_id) {
|
||||
let callback = clone!(image => move || {
|
||||
let _ = set_image_from_path(&image, show_id, size);
|
||||
glib::Continue(false)
|
||||
});
|
||||
gtk::timeout_add(250, callback);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(hashmap) = CACHED_PIXBUFS.read() {
|
||||
// Check if the requested (cover + size) is already in the chache
|
||||
// and if so do an early return after that.
|
||||
if let Some(guard) = hashmap.get(&(show_id, size)) {
|
||||
guard
|
||||
.lock()
|
||||
.map_err(|err| format_err!("SendCell Mutex: {}", err))
|
||||
.and_then(|sendcell| {
|
||||
sendcell
|
||||
.try_get()
|
||||
.map(|px| image.set_from_pixbuf(px))
|
||||
.ok_or_else(|| format_err!("Pixbuf was accessed from a different thread"))
|
||||
})?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let (sender, receiver) = unbounded();
|
||||
THREADPOOL.spawn(move || {
|
||||
if let Ok(mut guard) = COVER_DL_REGISTRY.write() {
|
||||
guard.insert(show_id);
|
||||
if let Ok(pd) = dbqueries::get_podcast_cover_from_id(show_id) {
|
||||
sender.send(downloader::cache_image(&pd));
|
||||
}
|
||||
guard.remove(&show_id);
|
||||
}
|
||||
});
|
||||
|
||||
let image = image.clone();
|
||||
let s = size as i32;
|
||||
gtk::timeout_add(25, move || {
|
||||
if let Some(path) = receiver.try_recv() {
|
||||
if let Ok(path) = path {
|
||||
if let Ok(px) = Pixbuf::new_from_file_at_scale(&path, s, s, true) {
|
||||
if let Ok(mut hashmap) = CACHED_PIXBUFS.write() {
|
||||
hashmap.insert((show_id, size), Mutex::new(SendCell::new(px.clone())));
|
||||
image.set_from_pixbuf(&px);
|
||||
}
|
||||
}
|
||||
}
|
||||
glib::Continue(false)
|
||||
} else {
|
||||
glib::Continue(true)
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// FIXME: the signature should be `fn foo(s: Url) -> Result<Url, Error>`
|
||||
pub fn itunes_to_rss(url: &str) -> Result<String, Error> {
|
||||
let id = itunes_id_from_url(url).ok_or_else(|| format_err!("Failed to find an Itunes ID."))?;
|
||||
lookup_id(id)
|
||||
}
|
||||
|
||||
fn itunes_id_from_url(url: &str) -> Option<u32> {
|
||||
lazy_static! {
|
||||
static ref RE: Regex = Regex::new(r"/id([0-9]+)").unwrap();
|
||||
}
|
||||
|
||||
// Get the itunes id from the url
|
||||
let foo = RE.captures_iter(url).nth(0)?.get(1)?.as_str();
|
||||
// Parse it to a u32, this *should* never fail
|
||||
foo.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn lookup_id(id: u32) -> Result<String, Error> {
|
||||
let url = format!("https://itunes.apple.com/lookup?id={}&entity=podcast", id);
|
||||
let req: Value = reqwest::get(&url)?.json()?;
|
||||
let rssurl = || -> Option<&str> { req.get("results")?.get(0)?.get("feedUrl")?.as_str() };
|
||||
rssurl()
|
||||
.map(From::from)
|
||||
.ok_or_else(|| format_err!("Failed to get url from itunes response"))
|
||||
}
|
||||
|
||||
pub fn on_import_clicked(window: >k::ApplicationWindow, sender: &Sender<Action>) {
|
||||
use glib::translate::ToGlib;
|
||||
use gtk::{FileChooserAction, FileChooserNative, FileFilter, ResponseType};
|
||||
|
||||
// let dialog = FileChooserDialog::new(title, Some(&window), FileChooserAction::Open);
|
||||
// TODO: It might be better to use a FileChooserNative widget.
|
||||
// Create the FileChooser Dialog
|
||||
let dialog = FileChooserNative::new(
|
||||
Some("Select the file from which to you want to Import Shows."),
|
||||
Some(window),
|
||||
FileChooserAction::Open,
|
||||
Some("_Import"),
|
||||
None,
|
||||
);
|
||||
|
||||
// Do not show hidden(.thing) files
|
||||
dialog.set_show_hidden(false);
|
||||
|
||||
// Set a filter to show only xml files
|
||||
let filter = FileFilter::new();
|
||||
FileFilterExt::set_name(&filter, Some("OPML file"));
|
||||
filter.add_mime_type("application/xml");
|
||||
filter.add_mime_type("text/xml");
|
||||
dialog.add_filter(&filter);
|
||||
|
||||
dialog.connect_response(clone!(sender => move |dialog, resp| {
|
||||
debug!("Dialong Response {}", resp);
|
||||
if resp == ResponseType::Accept.to_glib() {
|
||||
// TODO: Show an in-app notifictaion if the file can not be accessed
|
||||
if let Some(filename) = dialog.get_filename() {
|
||||
debug!("File selected: {:?}", filename);
|
||||
|
||||
rayon::spawn(clone!(sender => move || {
|
||||
// Parse the file and import the feeds
|
||||
if let Ok(sources) = opml::import_from_file(filename) {
|
||||
// Refresh the succesfully parsed feeds to index them
|
||||
refresh(Some(sources), sender)
|
||||
} else {
|
||||
let text = String::from("Failed to parse the Imported file");
|
||||
sender.send(Action::ErrorNotification(text));
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
let text = String::from("Selected File could not be accessed.");
|
||||
sender.send(Action::ErrorNotification(text));
|
||||
}
|
||||
}
|
||||
|
||||
dialog.destroy();
|
||||
}));
|
||||
|
||||
dialog.run();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// use podcasts_data::Source;
|
||||
// use podcasts_data::dbqueries;
|
||||
|
||||
// #[test]
|
||||
// This test inserts an rss feed to your `XDG_DATA/podcasts/podcasts.db` so we make it explicit
|
||||
// to run it.
|
||||
// #[ignore]
|
||||
// Disabled till https://gitlab.gnome.org/World/hammond/issues/56
|
||||
// fn test_set_image_from_path() {
|
||||
// let url = "https://web.archive.org/web/20180120110727if_/https://rss.acast.com/thetipoff";
|
||||
// Create and index a source
|
||||
// let source = Source::from_url(url).unwrap();
|
||||
// Copy it's id
|
||||
// let sid = source.id();
|
||||
// pipeline::run(vec![source], true).unwrap();
|
||||
|
||||
// Get the Podcast
|
||||
// let img = gtk::Image::new();
|
||||
// let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into();
|
||||
// let pxbuf = set_image_from_path(&img, Arc::new(pd), 256);
|
||||
// assert!(pxbuf.is_ok());
|
||||
// }
|
||||
|
||||
#[test]
|
||||
fn test_itunes_to_rss() {
|
||||
let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
|
||||
let rss_url = String::from("http://feeds.feedburner.com/InterceptedWithJeremyScahill");
|
||||
assert_eq!(rss_url, itunes_to_rss(itunes_url).unwrap());
|
||||
|
||||
let itunes_url = "https://itunes.apple.com/podcast/id000000000000000";
|
||||
assert!(itunes_to_rss(itunes_url).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_itunes_id() {
|
||||
let id = 1195206601;
|
||||
let itunes_url = "https://itunes.apple.com/podcast/id1195206601";
|
||||
assert_eq!(id, itunes_id_from_url(itunes_url).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_itunes_lookup_id() {
|
||||
let id = 1195206601;
|
||||
let rss_url = "http://feeds.feedburner.com/InterceptedWithJeremyScahill";
|
||||
assert_eq!(rss_url, lookup_id(id).unwrap());
|
||||
|
||||
let id = 000000000;
|
||||
assert!(lookup_id(id).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use app::APP_ID;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
// Totally copied it from fractal.
|
||||
// https://gitlab.gnome.org/danigm/fractal/blob/503e311e22b9d7540089d735b92af8e8f93560c5/fractal-gtk/src/app.rs#L1883-1912
|
||||
/// Given a `window` create and attach an `gtk::AboutDialog` to it.
|
||||
pub fn about_dialog(window: >k::ApplicationWindow) {
|
||||
// Feel free to add yourself if you contribured.
|
||||
let authors = &[
|
||||
"Carlos Soriano",
|
||||
"Constantin Nickel",
|
||||
"Gabriele Musco",
|
||||
"Ivan Augusto",
|
||||
"James Wykeham-Martin",
|
||||
"Jordan Petridis",
|
||||
"Julian Sparber",
|
||||
"Rowan Lewis",
|
||||
"Zander Brown",
|
||||
];
|
||||
|
||||
let dialog = gtk::AboutDialog::new();
|
||||
dialog.set_logo_icon_name(APP_ID);
|
||||
dialog.set_comments("Podcast Client for the GNOME Desktop.");
|
||||
dialog.set_copyright("© 2017, 2018 Jordan Petridis");
|
||||
dialog.set_license_type(gtk::License::Gpl30);
|
||||
dialog.set_modal(true);
|
||||
// TODO: make it show it fetches the commit hash from which it was built
|
||||
// and the version number is kept in sync automaticly
|
||||
dialog.set_version("0.4.0");
|
||||
dialog.set_program_name("Podcasts");
|
||||
// TODO: Need a wiki page first.
|
||||
// dialog.set_website("https://wiki.gnome.org/Design/Apps/Potential/Podcasts");
|
||||
// dialog.set_website_label("Learn more about GNOME Podcasts");
|
||||
dialog.set_transient_for(window);
|
||||
|
||||
dialog.set_artists(&["Tobias Bernard", "Sam Hewitt"]);
|
||||
dialog.set_authors(authors);
|
||||
|
||||
dialog.connect_response(|dlg, _| dlg.destroy());
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum UndoState {
|
||||
Shown,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InAppNotification {
|
||||
revealer: gtk::Revealer,
|
||||
text: gtk::Label,
|
||||
undo: gtk::Button,
|
||||
close: gtk::Button,
|
||||
}
|
||||
|
||||
impl Default for InAppNotification {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/inapp_notif.ui");
|
||||
|
||||
let revealer: gtk::Revealer = builder.get_object("revealer").unwrap();
|
||||
let text: gtk::Label = builder.get_object("text").unwrap();
|
||||
let undo: gtk::Button = builder.get_object("undo").unwrap();
|
||||
let close: gtk::Button = builder.get_object("close").unwrap();
|
||||
|
||||
InAppNotification {
|
||||
revealer,
|
||||
text,
|
||||
undo,
|
||||
close,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InAppNotification {
|
||||
pub fn new<F, U>(text: &str, mut callback: F, undo_callback: U, show_undo: UndoState) -> Self
|
||||
where
|
||||
F: FnMut() -> glib::Continue + 'static,
|
||||
U: Fn() + 'static,
|
||||
{
|
||||
let notif = InAppNotification::default();
|
||||
notif.text.set_text(&text);
|
||||
|
||||
let revealer = notif.revealer.clone();
|
||||
let id = timeout_add_seconds(6, move || {
|
||||
revealer.set_reveal_child(false);
|
||||
callback()
|
||||
});
|
||||
let id = Rc::new(RefCell::new(Some(id)));
|
||||
|
||||
// Cancel the callback
|
||||
let revealer = notif.revealer.clone();
|
||||
notif.undo.connect_clicked(move |_| {
|
||||
let foo = id.borrow_mut().take();
|
||||
if let Some(id) = foo {
|
||||
glib::source::source_remove(id);
|
||||
}
|
||||
|
||||
undo_callback();
|
||||
|
||||
// Hide the notification
|
||||
revealer.set_reveal_child(false);
|
||||
});
|
||||
|
||||
// Hide the revealer when the close button is clicked
|
||||
let revealer = notif.revealer.clone();
|
||||
notif.close.connect_clicked(move |_| {
|
||||
revealer.set_reveal_child(false);
|
||||
});
|
||||
|
||||
match show_undo {
|
||||
UndoState::Shown => (),
|
||||
UndoState::Hidden => notif.undo.hide(),
|
||||
}
|
||||
|
||||
notif
|
||||
}
|
||||
|
||||
// This is a seperate method cause in order to get a nice animation
|
||||
// the revealer should be attached to something that displays it.
|
||||
// Previously we where doing it in the constructor, which had the result
|
||||
// of the animation being skipped cause there was no parent widget to display it.
|
||||
pub fn show(&self, overlay: >k::Overlay) {
|
||||
overlay.add_overlay(&self.revealer);
|
||||
// We need to display the notification after the widget is added to the overlay
|
||||
// so there will be a nice animation.
|
||||
self.revealer.set_reveal_child(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use gtk;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmptyView {
|
||||
pub container: gtk::Box,
|
||||
}
|
||||
|
||||
impl Default for EmptyView {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/empty_view.ui");
|
||||
let view: gtk::Box = builder.get_object("empty_view").unwrap();
|
||||
|
||||
EmptyView { container: view }
|
||||
}
|
||||
}
|
||||
|
||||
impl EmptyView {
|
||||
pub fn new() -> EmptyView {
|
||||
EmptyView::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
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 app::Action;
|
||||
use manager;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::{Rc, Weak};
|
||||
use std::sync::{Arc, Mutex, TryLockError};
|
||||
|
||||
lazy_static! {
|
||||
static ref SIZE_OPTS: Arc<size_opts::FileSizeOpts> = {
|
||||
// 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 struct EpisodeWidget {
|
||||
pub 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::ButtonBox,
|
||||
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 duaration 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()
|
||||
.map(|c| c.add_class("dim-label"));
|
||||
} else {
|
||||
self.title
|
||||
.get_style_context()
|
||||
.map(|c| c.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> = 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<i32>) {
|
||||
// If lenght is provided
|
||||
if let Some(s) = seconds {
|
||||
// Convert seconds to minutes
|
||||
let minutes = chrono::Duration::seconds(s.into()).num_minutes();
|
||||
// If the lenght is 1 or more minutes
|
||||
if minutes != 0 {
|
||||
// Set the label and show them.
|
||||
self.duration.set_text(&format!("{} min", minutes));
|
||||
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<i32>) {
|
||||
// Convert the bytes to a String label
|
||||
let size = || -> Option<String> {
|
||||
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 fn new(episode: EpisodeWidgetModel, sender: &Sender<Action>) -> Rc<Self> {
|
||||
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 refference to it at the end of
|
||||
// scope. That's cause we only attach the `self.container`
|
||||
// to the parent.
|
||||
//
|
||||
// So this callback keeps a refference to the Rust Struct
|
||||
// so the compiler won't drop it.
|
||||
//
|
||||
// When the widget is detached from it's parent view this
|
||||
// callback runs freeing the last refference we were holding.
|
||||
let foo = RefCell::new(Some(widget.clone()));
|
||||
widget.container.connect_remove(move |_, _| {
|
||||
foo.borrow_mut().take();
|
||||
});
|
||||
|
||||
widget
|
||||
}
|
||||
|
||||
// fn init(widget: Rc<Self>, sender: &Sender<Action>) {}
|
||||
|
||||
// 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<Self>,
|
||||
episode: &EpisodeWidgetModel,
|
||||
sender: &Sender<Action>,
|
||||
) -> 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<Option<_>, 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: InProggress
|
||||
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!("Donwload started succesfully.");
|
||||
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<Action>) -> 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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_play_bttn_clicked(
|
||||
widget: &Weak<EpisodeWidget>,
|
||||
episode: &mut EpisodeWidgetModel,
|
||||
sender: &Sender<Action>,
|
||||
) -> 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()));
|
||||
// Refresh background views to match the normal/greyout title state
|
||||
sender.send(Action::RefreshEpisodesViewBGR);
|
||||
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<EpisodeWidget>,
|
||||
prog: &Arc<Mutex<manager::Progress>>,
|
||||
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<EpisodeWidget>,
|
||||
prog: &Arc<Mutex<manager::Progress>>,
|
||||
episode_rowid: i32,
|
||||
) -> Result<glib::Continue, Error> {
|
||||
let widget = match widget.upgrade() {
|
||||
Some(w) => w,
|
||||
None => return Ok(glib::Continue(false)),
|
||||
};
|
||||
|
||||
let (fraction, downloaded) = match prog.try_lock() {
|
||||
Ok(guard) => (guard.get_fraction(), guard.get_downloaded()),
|
||||
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 {
|
||||
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<EpisodeWidget>, prog: &Arc<Mutex<manager::Progress>>) {
|
||||
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<EpisodeWidget>,
|
||||
prog: &Arc<Mutex<manager::Progress>>,
|
||||
) -> Result<glib::Continue, Error> {
|
||||
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_total_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(|_| ())
|
||||
// }
|
||||
@@ -0,0 +1,243 @@
|
||||
use chrono::prelude::*;
|
||||
use failure::Error;
|
||||
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use podcasts_data::dbqueries;
|
||||
use podcasts_data::EpisodeWidgetModel;
|
||||
use send_cell::SendCell;
|
||||
|
||||
use app::Action;
|
||||
use utils::{self, lazy_load_full};
|
||||
use widgets::EpisodeWidget;
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref EPISODES_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> =
|
||||
Mutex::new(None);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ListSplit {
|
||||
Today,
|
||||
Yday,
|
||||
Week,
|
||||
Month,
|
||||
Rest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HomeView {
|
||||
pub container: gtk::Box,
|
||||
scrolled_window: gtk::ScrolledWindow,
|
||||
frame_parent: gtk::Box,
|
||||
today_box: gtk::Box,
|
||||
yday_box: gtk::Box,
|
||||
week_box: gtk::Box,
|
||||
month_box: gtk::Box,
|
||||
rest_box: gtk::Box,
|
||||
today_list: gtk::ListBox,
|
||||
yday_list: gtk::ListBox,
|
||||
week_list: gtk::ListBox,
|
||||
month_list: gtk::ListBox,
|
||||
rest_list: gtk::ListBox,
|
||||
}
|
||||
|
||||
impl Default for HomeView {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/episodes_view.ui");
|
||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
||||
let frame_parent: gtk::Box = builder.get_object("frame_parent").unwrap();
|
||||
let today_box: gtk::Box = builder.get_object("today_box").unwrap();
|
||||
let yday_box: gtk::Box = builder.get_object("yday_box").unwrap();
|
||||
let week_box: gtk::Box = builder.get_object("week_box").unwrap();
|
||||
let month_box: gtk::Box = builder.get_object("month_box").unwrap();
|
||||
let rest_box: gtk::Box = builder.get_object("rest_box").unwrap();
|
||||
let today_list: gtk::ListBox = builder.get_object("today_list").unwrap();
|
||||
let yday_list: gtk::ListBox = builder.get_object("yday_list").unwrap();
|
||||
let week_list: gtk::ListBox = builder.get_object("week_list").unwrap();
|
||||
let month_list: gtk::ListBox = builder.get_object("month_list").unwrap();
|
||||
let rest_list: gtk::ListBox = builder.get_object("rest_list").unwrap();
|
||||
|
||||
HomeView {
|
||||
container,
|
||||
scrolled_window,
|
||||
frame_parent,
|
||||
today_box,
|
||||
yday_box,
|
||||
week_box,
|
||||
month_box,
|
||||
rest_box,
|
||||
today_list,
|
||||
yday_list,
|
||||
week_list,
|
||||
month_list,
|
||||
rest_list,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: REFACTOR ME
|
||||
impl HomeView {
|
||||
pub fn new(sender: Sender<Action>) -> Result<Rc<HomeView>, Error> {
|
||||
use self::ListSplit::*;
|
||||
|
||||
let view = Rc::new(HomeView::default());
|
||||
let ignore = utils::get_ignored_shows()?;
|
||||
let episodes = dbqueries::get_episodes_widgets_filter_limit(&ignore, 100)?;
|
||||
let now_utc = Utc::now();
|
||||
|
||||
let view_ = view.clone();
|
||||
let func = move |ep: EpisodeWidgetModel| {
|
||||
let epoch = ep.epoch();
|
||||
let widget = HomeEpisode::new(ep, &sender);
|
||||
|
||||
match split(&now_utc, i64::from(epoch)) {
|
||||
Today => add_to_box(&widget, &view_.today_list, &view_.today_box),
|
||||
Yday => add_to_box(&widget, &view_.yday_list, &view_.yday_box),
|
||||
Week => add_to_box(&widget, &view_.week_list, &view_.week_box),
|
||||
Month => add_to_box(&widget, &view_.month_list, &view_.month_box),
|
||||
Rest => add_to_box(&widget, &view_.rest_list, &view_.rest_box),
|
||||
}
|
||||
};
|
||||
|
||||
let view_ = view.clone();
|
||||
let callback = move || {
|
||||
view_
|
||||
.set_vadjustment()
|
||||
.map_err(|err| format!("{}", err))
|
||||
.ok();
|
||||
};
|
||||
|
||||
lazy_load_full(episodes, func, callback);
|
||||
view.container.show_all();
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// Set scrolled window vertical adjustment.
|
||||
fn set_vadjustment(&self) -> Result<(), Error> {
|
||||
let guard = EPISODES_VIEW_VALIGNMENT
|
||||
.lock()
|
||||
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
|
||||
|
||||
if let Some(ref sendcell) = *guard {
|
||||
// Copy the vertical scrollbar adjustment from the old view into the new one.
|
||||
sendcell
|
||||
.try_get()
|
||||
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save the vertical scrollbar position.
|
||||
pub fn save_alignment(&self) -> Result<(), Error> {
|
||||
if let Ok(mut guard) = EPISODES_VIEW_VALIGNMENT.lock() {
|
||||
let adj = self
|
||||
.scrolled_window
|
||||
.get_vadjustment()
|
||||
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
|
||||
*guard = Some(SendCell::new(adj));
|
||||
info!("Saved episodes_view alignment.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn add_to_box(widget: &HomeEpisode, listbox: >k::ListBox, box_: >k::Box) {
|
||||
listbox.add(&widget.container);
|
||||
box_.show();
|
||||
}
|
||||
|
||||
fn split(now: &DateTime<Utc>, epoch: i64) -> ListSplit {
|
||||
let ep = Utc.timestamp(epoch, 0);
|
||||
|
||||
if now.ordinal() == ep.ordinal() && now.year() == ep.year() {
|
||||
ListSplit::Today
|
||||
} else if now.ordinal() == ep.ordinal() + 1 && now.year() == ep.year() {
|
||||
ListSplit::Yday
|
||||
} else if now.iso_week().week() == ep.iso_week().week() && now.year() == ep.year() {
|
||||
ListSplit::Week
|
||||
} else if now.month() == ep.month() && now.year() == ep.year() {
|
||||
ListSplit::Month
|
||||
} else {
|
||||
ListSplit::Rest
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HomeEpisode {
|
||||
container: gtk::Box,
|
||||
image: gtk::Image,
|
||||
// FIXME: Change it to `EpisodeWidget` instead of a `Box`?
|
||||
episode: gtk::Box,
|
||||
}
|
||||
|
||||
impl Default for HomeEpisode {
|
||||
fn default() -> Self {
|
||||
let builder =
|
||||
gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/episodes_view_widget.ui");
|
||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||
let image: gtk::Image = builder.get_object("cover").unwrap();
|
||||
let ep = EpisodeWidget::default();
|
||||
container.pack_start(&ep.container, true, true, 0);
|
||||
|
||||
HomeEpisode {
|
||||
container,
|
||||
image,
|
||||
episode: ep.container,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HomeEpisode {
|
||||
fn new(episode: EpisodeWidgetModel, sender: &Sender<Action>) -> HomeEpisode {
|
||||
let builder =
|
||||
gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/episodes_view_widget.ui");
|
||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||
let image: gtk::Image = builder.get_object("cover").unwrap();
|
||||
let pid = episode.show_id();
|
||||
let ep = EpisodeWidget::new(episode, sender);
|
||||
|
||||
let view = HomeEpisode {
|
||||
container,
|
||||
image,
|
||||
episode: ep.container.clone(),
|
||||
};
|
||||
|
||||
view.init(pid);
|
||||
view
|
||||
}
|
||||
|
||||
fn init(&self, show_id: i32) {
|
||||
self.set_cover(show_id);
|
||||
self.container.pack_start(&self.episode, true, true, 0);
|
||||
}
|
||||
|
||||
fn set_cover(&self, show_id: i32) {
|
||||
// The closure above is a regular `Fn` closure.
|
||||
// which means we can't mutate stuff inside it easily,
|
||||
// so Cell is used.
|
||||
//
|
||||
// `Option<T>` along with the `.take()` method ensure
|
||||
// that the function will only be run once, during the first execution.
|
||||
let show_id = Cell::new(Some(show_id));
|
||||
|
||||
self.image.connect_draw(move |image, _| {
|
||||
show_id.take().map(|id| {
|
||||
utils::set_image_from_path(image, id, 64)
|
||||
.map_err(|err| error!("Failed to set a cover: {}", err))
|
||||
.ok();
|
||||
});
|
||||
|
||||
gtk::Inhibit(false)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
mod aboutdialog;
|
||||
pub mod appnotif;
|
||||
mod empty;
|
||||
mod episode;
|
||||
mod home_view;
|
||||
pub mod player;
|
||||
mod show;
|
||||
pub mod show_menu;
|
||||
mod shows_view;
|
||||
|
||||
pub use self::aboutdialog::about_dialog;
|
||||
pub use self::empty::EmptyView;
|
||||
pub use self::episode::EpisodeWidget;
|
||||
pub use self::home_view::HomeView;
|
||||
pub use self::show::ShowWidget;
|
||||
pub use self::show_menu::ShowMenu;
|
||||
pub use self::shows_view::ShowsView;
|
||||
@@ -0,0 +1,459 @@
|
||||
use gst::prelude::*;
|
||||
use gst::ClockTime;
|
||||
use gst_player;
|
||||
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use gio::{File, FileExt};
|
||||
use glib::SignalHandlerId;
|
||||
|
||||
use chrono::NaiveTime;
|
||||
use crossbeam_channel::Sender;
|
||||
use failure::Error;
|
||||
use send_cell::SendCell;
|
||||
|
||||
use podcasts_data::{dbqueries, USER_AGENT};
|
||||
use podcasts_data::{EpisodeWidgetModel, ShowCoverModel};
|
||||
|
||||
use app::Action;
|
||||
use utils::set_image_from_path;
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum SeekDirection {
|
||||
Backwards,
|
||||
Forward,
|
||||
}
|
||||
|
||||
trait PlayerExt {
|
||||
fn play(&self);
|
||||
fn pause(&self);
|
||||
fn stop(&self);
|
||||
fn seek(&self, offset: ClockTime, direction: SeekDirection);
|
||||
fn fast_forward(&self);
|
||||
fn rewind(&self);
|
||||
fn set_playback_rate(&self, f64);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlayerInfo {
|
||||
container: gtk::Box,
|
||||
show: gtk::Label,
|
||||
episode: gtk::Label,
|
||||
cover: gtk::Image,
|
||||
}
|
||||
|
||||
impl PlayerInfo {
|
||||
// FIXME: create a Diesel Model of the joined episode and podcast query instead
|
||||
fn init(&self, episode: &EpisodeWidgetModel, podcast: &ShowCoverModel) {
|
||||
self.set_cover_image(podcast);
|
||||
self.set_show_title(podcast);
|
||||
self.set_episode_title(episode);
|
||||
}
|
||||
|
||||
fn set_episode_title(&self, episode: &EpisodeWidgetModel) {
|
||||
self.episode.set_text(episode.title());
|
||||
self.episode.set_tooltip_text(episode.title());
|
||||
}
|
||||
|
||||
fn set_show_title(&self, show: &ShowCoverModel) {
|
||||
self.show.set_text(show.title());
|
||||
self.show.set_tooltip_text(show.title());
|
||||
}
|
||||
|
||||
fn set_cover_image(&self, show: &ShowCoverModel) {
|
||||
set_image_from_path(&self.cover, show.id(), 34)
|
||||
.map_err(|err| error!("Player Cover: {}", err))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlayerTimes {
|
||||
container: gtk::Box,
|
||||
progressed: gtk::Label,
|
||||
duration: gtk::Label,
|
||||
separator: gtk::Label,
|
||||
slider: gtk::Scale,
|
||||
slider_update: Rc<SignalHandlerId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Duration(ClockTime);
|
||||
|
||||
impl Deref for Duration {
|
||||
type Target = ClockTime;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Position(ClockTime);
|
||||
|
||||
impl Deref for Position {
|
||||
type Target = ClockTime;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerTimes {
|
||||
/// Update the duration `gtk::Label` and the max range of the `gtk::SclaeBar`.
|
||||
pub fn on_duration_changed(&self, duration: Duration) {
|
||||
let seconds = duration.seconds().map(|v| v as f64).unwrap_or(0.0);
|
||||
|
||||
self.slider.block_signal(&self.slider_update);
|
||||
self.slider.set_range(0.0, seconds);
|
||||
self.slider.unblock_signal(&self.slider_update);
|
||||
|
||||
self.duration.set_text(&format_duration(seconds as u32));
|
||||
}
|
||||
|
||||
/// Update the `gtk::SclaeBar` when the pipeline position is changed.
|
||||
pub fn on_position_updated(&self, position: Position) {
|
||||
let seconds = position.seconds().map(|v| v as f64).unwrap_or(0.0);
|
||||
|
||||
self.slider.block_signal(&self.slider_update);
|
||||
self.slider.set_value(seconds);
|
||||
self.slider.unblock_signal(&self.slider_update);
|
||||
|
||||
self.progressed.set_text(&format_duration(seconds as u32));
|
||||
}
|
||||
}
|
||||
|
||||
fn format_duration(seconds: u32) -> String {
|
||||
let time = NaiveTime::from_num_seconds_from_midnight(seconds, 0);
|
||||
|
||||
if seconds >= 3600 {
|
||||
time.format("%T").to_string()
|
||||
} else {
|
||||
time.format("%M:%S").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlayerRate {
|
||||
radio150: gtk::RadioButton,
|
||||
radio125: gtk::RadioButton,
|
||||
radio_normal: gtk::RadioButton,
|
||||
popover: gtk::Popover,
|
||||
btn: gtk::MenuButton,
|
||||
label: gtk::Label,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PlayerControls {
|
||||
container: gtk::Box,
|
||||
play: gtk::Button,
|
||||
pause: gtk::Button,
|
||||
forward: gtk::Button,
|
||||
rewind: gtk::Button,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlayerWidget {
|
||||
pub action_bar: gtk::ActionBar,
|
||||
player: gst_player::Player,
|
||||
controls: PlayerControls,
|
||||
timer: PlayerTimes,
|
||||
info: PlayerInfo,
|
||||
rate: PlayerRate,
|
||||
}
|
||||
|
||||
impl Default for PlayerWidget {
|
||||
fn default() -> Self {
|
||||
let dispatcher = gst_player::PlayerGMainContextSignalDispatcher::new(None);
|
||||
let player = gst_player::Player::new(
|
||||
None,
|
||||
// Use the gtk main thread
|
||||
Some(&dispatcher.upcast::<gst_player::PlayerSignalDispatcher>()),
|
||||
);
|
||||
|
||||
let mut config = player.get_config();
|
||||
config.set_user_agent(USER_AGENT);
|
||||
config.set_position_update_interval(250);
|
||||
player.set_config(config).unwrap();
|
||||
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/player_toolbar.ui");
|
||||
let action_bar = builder.get_object("action_bar").unwrap();
|
||||
|
||||
let buttons = builder.get_object("buttons").unwrap();
|
||||
let play = builder.get_object("play_button").unwrap();
|
||||
let pause = builder.get_object("pause_button").unwrap();
|
||||
let forward: gtk::Button = builder.get_object("ff_button").unwrap();
|
||||
let rewind: gtk::Button = builder.get_object("rewind_button").unwrap();
|
||||
|
||||
let controls = PlayerControls {
|
||||
container: buttons,
|
||||
play,
|
||||
pause,
|
||||
forward,
|
||||
rewind,
|
||||
};
|
||||
|
||||
let timer_container = builder.get_object("timer").unwrap();
|
||||
let progressed = builder.get_object("progress_time_label").unwrap();
|
||||
let duration = builder.get_object("total_duration_label").unwrap();
|
||||
let separator = builder.get_object("separator").unwrap();
|
||||
let slider: gtk::Scale = builder.get_object("seek").unwrap();
|
||||
slider.set_range(0.0, 1.0);
|
||||
let slider_update = Rc::new(Self::connect_update_slider(&slider, &player));
|
||||
let timer = PlayerTimes {
|
||||
container: timer_container,
|
||||
progressed,
|
||||
duration,
|
||||
separator,
|
||||
slider,
|
||||
slider_update,
|
||||
};
|
||||
|
||||
let labels = builder.get_object("info").unwrap();
|
||||
let show = builder.get_object("show_label").unwrap();
|
||||
let episode = builder.get_object("episode_label").unwrap();
|
||||
let cover = builder.get_object("show_cover").unwrap();
|
||||
let info = PlayerInfo {
|
||||
container: labels,
|
||||
show,
|
||||
episode,
|
||||
cover,
|
||||
};
|
||||
|
||||
let radio150 = builder.get_object("rate_1_50").unwrap();
|
||||
let radio125 = builder.get_object("rate_1_25").unwrap();
|
||||
let radio_normal = builder.get_object("normal_rate").unwrap();
|
||||
let popover = builder.get_object("rate_popover").unwrap();
|
||||
let btn = builder.get_object("rate_button").unwrap();
|
||||
let label = builder.get_object("rate_label").unwrap();
|
||||
let rate = PlayerRate {
|
||||
radio150,
|
||||
radio125,
|
||||
radio_normal,
|
||||
popover,
|
||||
label,
|
||||
btn,
|
||||
};
|
||||
|
||||
PlayerWidget {
|
||||
player,
|
||||
action_bar,
|
||||
controls,
|
||||
timer,
|
||||
info,
|
||||
rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerWidget {
|
||||
pub fn new(sender: &Sender<Action>) -> Rc<Self> {
|
||||
let w = Rc::new(Self::default());
|
||||
Self::init(&w, sender);
|
||||
w
|
||||
}
|
||||
|
||||
fn init(s: &Rc<Self>, sender: &Sender<Action>) {
|
||||
Self::connect_control_buttons(s);
|
||||
Self::connect_rate_buttons(s);
|
||||
Self::connect_gst_signals(s, sender);
|
||||
}
|
||||
|
||||
/// Connect the `PlayerControls` buttons to the `PlayerExt` methods.
|
||||
fn connect_control_buttons(s: &Rc<Self>) {
|
||||
let weak = Rc::downgrade(s);
|
||||
|
||||
// Connect the play button to the gst Player.
|
||||
s.controls.play.connect_clicked(clone!(weak => move |_| {
|
||||
weak.upgrade().map(|p| p.play());
|
||||
}));
|
||||
|
||||
// Connect the pause button to the gst Player.
|
||||
s.controls.pause.connect_clicked(clone!(weak => move |_| {
|
||||
weak.upgrade().map(|p| p.pause());
|
||||
}));
|
||||
|
||||
// Connect the rewind button to the gst Player.
|
||||
s.controls.rewind.connect_clicked(clone!(weak => move |_| {
|
||||
weak.upgrade().map(|p| p.rewind());
|
||||
}));
|
||||
|
||||
// Connect the fast-forward button to the gst Player.
|
||||
s.controls.forward.connect_clicked(clone!(weak => move |_| {
|
||||
weak.upgrade().map(|p| p.fast_forward());
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg_attr(rustfmt, rustfmt_skip)]
|
||||
fn connect_gst_signals(s: &Rc<Self>, sender: &Sender<Action>) {
|
||||
// Log gst warnings.
|
||||
s.player.connect_warning(move |_, warn| warn!("gst warning: {}", warn));
|
||||
|
||||
// Log gst errors.
|
||||
s.player.connect_error(clone!(sender => move |_, _error| {
|
||||
// sender.send(Action::ErrorNotification(format!("Player Error: {}", error)));
|
||||
let s = "The media player was unable to execute an action.".into();
|
||||
sender.send(Action::ErrorNotification(s));
|
||||
}));
|
||||
|
||||
// The followign callbacks require `Send` but are handled by the gtk main loop
|
||||
let weak = SendCell::new(Rc::downgrade(s));
|
||||
|
||||
// Update the duration label and the slider
|
||||
s.player.connect_duration_changed(clone!(weak => move |_, clock| {
|
||||
weak.borrow()
|
||||
.upgrade()
|
||||
.map(|p| p.timer.on_duration_changed(Duration(clock)));
|
||||
}));
|
||||
|
||||
// Update the position label and the slider
|
||||
s.player.connect_position_updated(clone!(weak => move |_, clock| {
|
||||
weak.borrow()
|
||||
.upgrade()
|
||||
.map(|p| p.timer.on_position_updated(Position(clock)));
|
||||
}));
|
||||
|
||||
// Reset the slider to 0 and show a play button
|
||||
s.player.connect_end_of_stream(clone!(weak => move |_| {
|
||||
weak.borrow()
|
||||
.upgrade()
|
||||
.map(|p| p.stop());
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg_attr(rustfmt, rustfmt_skip)]
|
||||
fn connect_rate_buttons(s: &Rc<Self>) {
|
||||
let weak = Rc::downgrade(s);
|
||||
|
||||
s.rate
|
||||
.radio_normal
|
||||
.connect_toggled(clone!(weak => move |_| {
|
||||
weak.upgrade().map(|p| p.on_rate_changed(1.00));
|
||||
}));
|
||||
|
||||
s.rate
|
||||
.radio125
|
||||
.connect_toggled(clone!(weak => move |_| {
|
||||
weak.upgrade().map(|p| p.on_rate_changed(1.25));
|
||||
}));
|
||||
|
||||
s.rate
|
||||
.radio150
|
||||
.connect_toggled(clone!(weak => move |_| {
|
||||
weak.upgrade().map(|p| p.on_rate_changed(1.50));
|
||||
}));
|
||||
}
|
||||
|
||||
fn on_rate_changed(&self, rate: f64) {
|
||||
self.set_playback_rate(rate);
|
||||
self.rate.label.set_text(&format!("{:.2}x", rate));
|
||||
}
|
||||
|
||||
fn reveal(&self) {
|
||||
self.action_bar.show();
|
||||
}
|
||||
|
||||
pub fn initialize_episode(&self, rowid: i32) -> Result<(), Error> {
|
||||
let ep = dbqueries::get_episode_widget_from_rowid(rowid)?;
|
||||
let pd = dbqueries::get_podcast_cover_from_id(ep.show_id())?;
|
||||
|
||||
self.info.init(&ep, &pd);
|
||||
// Currently that will always be the case since the play button is
|
||||
// only shown if the file is downloaded
|
||||
if let Some(ref path) = ep.local_uri() {
|
||||
if Path::new(path).exists() {
|
||||
// path is an absolute fs path ex. "foo/bar/baz".
|
||||
// Convert it so it will have a "file:///"
|
||||
// FIXME: convert it properly
|
||||
if let Some(uri) = File::new_for_path(path).get_uri() {
|
||||
// play the file
|
||||
self.player.set_uri(&uri);
|
||||
self.play();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// TODO: log an error
|
||||
}
|
||||
|
||||
// FIXME: Stream stuff
|
||||
// unimplemented!()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn connect_update_slider(slider: >k::Scale, player: &gst_player::Player) -> SignalHandlerId {
|
||||
slider.connect_value_changed(clone!(player => move |slider| {
|
||||
let value = slider.get_value() as u64;
|
||||
player.seek(ClockTime::from_seconds(value));
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerExt for PlayerWidget {
|
||||
fn play(&self) {
|
||||
self.reveal();
|
||||
|
||||
self.controls.pause.show();
|
||||
self.controls.play.hide();
|
||||
|
||||
self.player.play();
|
||||
}
|
||||
|
||||
fn pause(&self) {
|
||||
self.controls.pause.hide();
|
||||
self.controls.play.show();
|
||||
|
||||
self.player.pause();
|
||||
|
||||
// Only rewind on pause if the stream position is passed a certain point.
|
||||
if let Some(sec) = self.player.get_position().seconds() {
|
||||
if sec >= 90 {
|
||||
self.seek(ClockTime::from_seconds(5), SeekDirection::Backwards);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(rustfmt, rustfmt_skip)]
|
||||
fn stop(&self) {
|
||||
self.controls.pause.hide();
|
||||
self.controls.play.show();
|
||||
|
||||
self.player.stop();
|
||||
|
||||
// Reset the slider bar to the start
|
||||
self.timer.on_position_updated(Position(ClockTime::from_seconds(0)));
|
||||
}
|
||||
|
||||
// Adapted from https://github.com/philn/glide/blob/b52a65d99daeab0b487f79a0e1ccfad0cd433e22/src/player_context.rs#L219-L245
|
||||
fn seek(&self, offset: ClockTime, direction: SeekDirection) {
|
||||
let position = self.player.get_position();
|
||||
if position.is_none() || offset.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let duration = self.player.get_duration();
|
||||
let destination = match direction {
|
||||
SeekDirection::Backwards if position >= offset => Some(position - offset),
|
||||
SeekDirection::Forward if !duration.is_none() && position + offset <= duration => {
|
||||
Some(position + offset)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
destination.map(|d| self.player.seek(d));
|
||||
}
|
||||
|
||||
fn rewind(&self) {
|
||||
self.seek(ClockTime::from_seconds(10), SeekDirection::Backwards)
|
||||
}
|
||||
|
||||
fn fast_forward(&self) {
|
||||
self.seek(ClockTime::from_seconds(10), SeekDirection::Forward)
|
||||
}
|
||||
|
||||
fn set_playback_rate(&self, rate: f64) {
|
||||
self.player.set_rate(rate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use failure::Error;
|
||||
use html2text;
|
||||
use rayon;
|
||||
use send_cell::SendCell;
|
||||
|
||||
use podcasts_data::dbqueries;
|
||||
use podcasts_data::Show;
|
||||
|
||||
use app::Action;
|
||||
use utils::{self, lazy_load};
|
||||
use widgets::{EpisodeWidget, ShowMenu};
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
lazy_static! {
|
||||
static ref SHOW_WIDGET_VALIGNMENT: Mutex<Option<(i32, SendCell<gtk::Adjustment>)>> =
|
||||
Mutex::new(None);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShowWidget {
|
||||
pub container: gtk::Box,
|
||||
scrolled_window: gtk::ScrolledWindow,
|
||||
cover: gtk::Image,
|
||||
description: gtk::Label,
|
||||
episodes: gtk::ListBox,
|
||||
show_id: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for ShowWidget {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/show_widget.ui");
|
||||
let container: gtk::Box = builder.get_object("container").unwrap();
|
||||
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
||||
let episodes = builder.get_object("episodes").unwrap();
|
||||
|
||||
let cover: gtk::Image = builder.get_object("cover").unwrap();
|
||||
let description: gtk::Label = builder.get_object("description").unwrap();
|
||||
|
||||
ShowWidget {
|
||||
container,
|
||||
scrolled_window,
|
||||
cover,
|
||||
description,
|
||||
episodes,
|
||||
show_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShowWidget {
|
||||
pub fn new(pd: Arc<Show>, sender: Sender<Action>) -> Rc<ShowWidget> {
|
||||
let mut pdw = ShowWidget::default();
|
||||
pdw.init(&pd);
|
||||
|
||||
let menu = ShowMenu::new(&pd, &pdw.episodes, &sender);
|
||||
sender.send(Action::InitShowMenu(SendCell::new(menu)));
|
||||
|
||||
let pdw = Rc::new(pdw);
|
||||
let res = populate_listbox(&pdw, pd.clone(), sender);
|
||||
debug_assert!(res.is_ok());
|
||||
|
||||
pdw
|
||||
}
|
||||
|
||||
pub fn init(&mut self, pd: &Arc<Show>) {
|
||||
self.set_description(pd.description());
|
||||
self.show_id = Some(pd.id());
|
||||
|
||||
let res = self.set_cover(&pd);
|
||||
debug_assert!(res.is_ok());
|
||||
}
|
||||
|
||||
/// Set the show cover.
|
||||
fn set_cover(&self, pd: &Arc<Show>) -> Result<(), Error> {
|
||||
utils::set_image_from_path(&self.cover, pd.id(), 256)
|
||||
}
|
||||
|
||||
/// Set the descripton text.
|
||||
fn set_description(&self, text: &str) {
|
||||
self.description
|
||||
.set_markup(html2text::from_read(text.as_bytes(), 70).trim());
|
||||
}
|
||||
|
||||
/// Save the scrollabar vajustment to the cache.
|
||||
pub fn save_vadjustment(&self, oldid: i32) -> Result<(), Error> {
|
||||
if let Ok(mut guard) = SHOW_WIDGET_VALIGNMENT.lock() {
|
||||
let adj = self
|
||||
.scrolled_window
|
||||
.get_vadjustment()
|
||||
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
|
||||
*guard = Some((oldid, SendCell::new(adj)));
|
||||
debug!("Widget Alignment was saved with ID: {}.", oldid);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set scrolled window vertical adjustment.
|
||||
fn set_vadjustment(&self, pd: &Arc<Show>) -> Result<(), Error> {
|
||||
let guard = SHOW_WIDGET_VALIGNMENT
|
||||
.lock()
|
||||
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
|
||||
|
||||
if let Some((oldid, ref sendcell)) = *guard {
|
||||
// Only copy the old scrollbar if both widget's represent the same podcast.
|
||||
debug!("PID: {}", pd.id());
|
||||
debug!("OLDID: {}", oldid);
|
||||
if pd.id() != oldid {
|
||||
debug!("Early return");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Copy the vertical scrollbar adjustment from the old view into the new one.
|
||||
sendcell
|
||||
.try_get()
|
||||
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show_id(&self) -> Option<i32> {
|
||||
self.show_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate the listbox with the shows episodes.
|
||||
fn populate_listbox(
|
||||
show: &Rc<ShowWidget>,
|
||||
pd: Arc<Show>,
|
||||
sender: Sender<Action>,
|
||||
) -> Result<(), Error> {
|
||||
use crossbeam_channel::bounded;
|
||||
|
||||
let count = dbqueries::get_pd_episodes_count(&pd)?;
|
||||
|
||||
let (sender_, receiver) = bounded(1);
|
||||
rayon::spawn(clone!(pd => move || {
|
||||
if let Ok(episodes) = dbqueries::get_pd_episodeswidgets(&pd) {
|
||||
// The receiver can be dropped if there's an early return
|
||||
// like on show without episodes for example.
|
||||
sender_.send(episodes);
|
||||
}
|
||||
}));
|
||||
|
||||
if count == 0 {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/empty_show.ui");
|
||||
let container: gtk::Box = builder
|
||||
.get_object("empty_show")
|
||||
.ok_or_else(|| format_err!("FOO"))?;
|
||||
show.episodes.add(&container);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let show_ = show.clone();
|
||||
gtk::idle_add(move || {
|
||||
let episodes = match receiver.try_recv() {
|
||||
Some(e) => e,
|
||||
None => return glib::Continue(true),
|
||||
};
|
||||
debug_assert!(episodes.len() as i64 == count);
|
||||
|
||||
let list = show_.episodes.clone();
|
||||
let constructor = clone!(sender => move |ep| {
|
||||
EpisodeWidget::new(ep, &sender).container.clone()
|
||||
});
|
||||
|
||||
let callback = clone!(pd, show_ => move || {
|
||||
let res = show_.set_vadjustment(&pd);
|
||||
debug_assert!(res.is_ok());
|
||||
});
|
||||
|
||||
lazy_load(episodes, list.clone(), constructor, callback);
|
||||
|
||||
glib::Continue(false)
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use glib;
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use failure::Error;
|
||||
use open;
|
||||
use rayon;
|
||||
|
||||
use podcasts_data::dbqueries;
|
||||
use podcasts_data::utils::delete_show;
|
||||
use podcasts_data::Show;
|
||||
|
||||
use app::Action;
|
||||
use utils;
|
||||
use widgets::appnotif::{InAppNotification, UndoState};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShowMenu {
|
||||
pub container: gtk::PopoverMenu,
|
||||
website: gtk::ModelButton,
|
||||
played: gtk::ModelButton,
|
||||
unsub: gtk::ModelButton,
|
||||
}
|
||||
|
||||
impl Default for ShowMenu {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/show_menu.ui");
|
||||
let container = builder.get_object("menu").unwrap();
|
||||
let website = builder.get_object("website").unwrap();
|
||||
let played = builder.get_object("played").unwrap();
|
||||
let unsub = builder.get_object("unsub").unwrap();
|
||||
|
||||
ShowMenu {
|
||||
container,
|
||||
website,
|
||||
played,
|
||||
unsub,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShowMenu {
|
||||
pub fn new(pd: &Arc<Show>, episodes: >k::ListBox, sender: &Sender<Action>) -> Self {
|
||||
let s = Self::default();
|
||||
s.init(pd, episodes, sender);
|
||||
s
|
||||
}
|
||||
|
||||
fn init(&self, pd: &Arc<Show>, episodes: >k::ListBox, sender: &Sender<Action>) {
|
||||
self.connect_website(pd);
|
||||
self.connect_played(pd, episodes, sender);
|
||||
self.connect_unsub(pd, sender)
|
||||
}
|
||||
|
||||
fn connect_website(&self, pd: &Arc<Show>) {
|
||||
self.website.set_tooltip_text(Some(pd.link()));
|
||||
self.website.connect_clicked(clone!(pd => move |_| {
|
||||
let link = pd.link();
|
||||
info!("Opening link: {}", link);
|
||||
let res = open::that(link);
|
||||
debug_assert!(res.is_ok());
|
||||
}));
|
||||
}
|
||||
|
||||
fn connect_played(&self, pd: &Arc<Show>, episodes: >k::ListBox, sender: &Sender<Action>) {
|
||||
self.played
|
||||
.connect_clicked(clone!(pd, episodes, sender => move |_| {
|
||||
let res = dim_titles(&episodes);
|
||||
debug_assert!(res.is_some());
|
||||
|
||||
sender.send(Action::MarkAllPlayerNotification(pd.clone()))
|
||||
}));
|
||||
}
|
||||
|
||||
fn connect_unsub(&self, pd: &Arc<Show>, sender: &Sender<Action>) {
|
||||
self.unsub
|
||||
.connect_clicked(clone!(pd, sender => move |unsub| {
|
||||
// hack to get away without properly checking for none.
|
||||
// if pressed twice would panic.
|
||||
unsub.set_sensitive(false);
|
||||
|
||||
sender.send(Action::RemoveShow(pd.clone()));
|
||||
|
||||
sender.send(Action::HeaderBarNormal);
|
||||
sender.send(Action::ShowShowsAnimated);
|
||||
// Queue a refresh after the switch to avoid blocking the db.
|
||||
sender.send(Action::RefreshShowsView);
|
||||
sender.send(Action::RefreshEpisodesView);
|
||||
|
||||
unsub.set_sensitive(true);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Ideally if we had a custom widget this would have been as simple as:
|
||||
// `for row in listbox { ep = row.get_episode(); ep.dim_title(); }`
|
||||
// But now I can't think of a better way to do it than hardcoding the title
|
||||
// position relative to the EpisodeWidget container gtk::Box.
|
||||
fn dim_titles(episodes: >k::ListBox) -> Option<()> {
|
||||
let children = episodes.get_children();
|
||||
|
||||
for row in children {
|
||||
let row = row.downcast::<gtk::ListBoxRow>().ok()?;
|
||||
let container = row.get_children().remove(0).downcast::<gtk::Box>().ok()?;
|
||||
let foo = container
|
||||
.get_children()
|
||||
.remove(0)
|
||||
.downcast::<gtk::Box>()
|
||||
.ok()?;
|
||||
let bar = foo.get_children().remove(0).downcast::<gtk::Box>().ok()?;
|
||||
let title = bar.get_children().remove(0).downcast::<gtk::Label>().ok()?;
|
||||
|
||||
title.get_style_context().map(|c| c.add_class("dim-label"));
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn mark_all_watched(pd: &Show, sender: &Sender<Action>) -> Result<(), Error> {
|
||||
dbqueries::update_none_to_played_now(pd)?;
|
||||
// Not all widgets migth have been loaded when the mark_all is hit
|
||||
// So we will need to refresh again after it's done.
|
||||
sender.send(Action::RefreshWidgetIfSame(pd.id()));
|
||||
sender.send(Action::RefreshEpisodesView);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_all_notif(pd: Arc<Show>, sender: &Sender<Action>) -> InAppNotification {
|
||||
let id = pd.id();
|
||||
let callback = clone!(sender => move || {
|
||||
let res = mark_all_watched(&pd, &sender);
|
||||
debug_assert!(res.is_ok());
|
||||
glib::Continue(false)
|
||||
});
|
||||
|
||||
let undo_callback = clone!(sender => move || sender.send(Action::RefreshWidgetIfSame(id)));
|
||||
let text = "Marked all episodes as listened";
|
||||
InAppNotification::new(text, callback, undo_callback, UndoState::Shown)
|
||||
}
|
||||
|
||||
pub fn remove_show_notif(pd: Arc<Show>, sender: Sender<Action>) -> InAppNotification {
|
||||
let text = format!("Unsubscribed from {}", pd.title());
|
||||
|
||||
let res = utils::ignore_show(pd.id());
|
||||
debug_assert!(res.is_ok());
|
||||
|
||||
let callback = clone!(pd, sender => move || {
|
||||
let res = utils::uningore_show(pd.id());
|
||||
debug_assert!(res.is_ok());
|
||||
|
||||
// Spawn a thread so it won't block the ui.
|
||||
rayon::spawn(clone!(pd, sender => move || {
|
||||
delete_show(&pd)
|
||||
.map_err(|err| error!("Error: {}", err))
|
||||
.map_err(|_| error!("Failed to delete {}", pd.title()))
|
||||
.ok();
|
||||
|
||||
sender.send(Action::RefreshEpisodesView);
|
||||
}));
|
||||
glib::Continue(false)
|
||||
});
|
||||
|
||||
let undo_callback = move || {
|
||||
let res = utils::uningore_show(pd.id());
|
||||
debug_assert!(res.is_ok());
|
||||
sender.send(Action::RefreshShowsView);
|
||||
sender.send(Action::RefreshEpisodesView);
|
||||
};
|
||||
|
||||
InAppNotification::new(&text, callback, undo_callback, UndoState::Shown)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
use gtk;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crossbeam_channel::Sender;
|
||||
use failure::Error;
|
||||
use send_cell::SendCell;
|
||||
|
||||
use podcasts_data::dbqueries;
|
||||
use podcasts_data::Show;
|
||||
|
||||
use app::Action;
|
||||
use utils::{self, get_ignored_shows, lazy_load, set_image_from_path};
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
lazy_static! {
|
||||
static ref SHOWS_VIEW_VALIGNMENT: Mutex<Option<SendCell<gtk::Adjustment>>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShowsView {
|
||||
pub container: gtk::Box,
|
||||
scrolled_window: gtk::ScrolledWindow,
|
||||
flowbox: gtk::FlowBox,
|
||||
}
|
||||
|
||||
impl Default for ShowsView {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/shows_view.ui");
|
||||
let container: gtk::Box = builder.get_object("fb_parent").unwrap();
|
||||
let scrolled_window: gtk::ScrolledWindow = builder.get_object("scrolled_window").unwrap();
|
||||
let flowbox: gtk::FlowBox = builder.get_object("flowbox").unwrap();
|
||||
|
||||
ShowsView {
|
||||
container,
|
||||
scrolled_window,
|
||||
flowbox,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShowsView {
|
||||
pub fn new(sender: Sender<Action>) -> Rc<Self> {
|
||||
let pop = Rc::new(ShowsView::default());
|
||||
pop.init(sender);
|
||||
// Populate the flowbox with the Shows.
|
||||
let res = populate_flowbox(&pop);
|
||||
debug_assert!(res.is_ok());
|
||||
pop
|
||||
}
|
||||
|
||||
pub fn init(&self, sender: Sender<Action>) {
|
||||
self.flowbox.connect_child_activated(move |_, child| {
|
||||
let res = on_child_activate(child, &sender);
|
||||
debug_assert!(res.is_ok());
|
||||
});
|
||||
}
|
||||
|
||||
/// Set scrolled window vertical adjustment.
|
||||
fn set_vadjustment(&self) -> Result<(), Error> {
|
||||
let guard = SHOWS_VIEW_VALIGNMENT
|
||||
.lock()
|
||||
.map_err(|err| format_err!("Failed to lock widget align mutex: {}", err))?;
|
||||
|
||||
if let Some(ref sendcell) = *guard {
|
||||
// Copy the vertical scrollbar adjustment from the old view into the new one.
|
||||
sendcell
|
||||
.try_get()
|
||||
.map(|x| utils::smooth_scroll_to(&self.scrolled_window, &x));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save the vertical scrollbar position.
|
||||
pub fn save_alignment(&self) -> Result<(), Error> {
|
||||
if let Ok(mut guard) = SHOWS_VIEW_VALIGNMENT.lock() {
|
||||
let adj = self
|
||||
.scrolled_window
|
||||
.get_vadjustment()
|
||||
.ok_or_else(|| format_err!("Could not get the adjustment"))?;
|
||||
*guard = Some(SendCell::new(adj));
|
||||
info!("Saved episodes_view alignment.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_flowbox(shows: &Rc<ShowsView>) -> Result<(), Error> {
|
||||
let ignore = get_ignored_shows()?;
|
||||
let podcasts = dbqueries::get_podcasts_filter(&ignore)?;
|
||||
|
||||
let constructor = move |parent| ShowsChild::new(&parent).child;
|
||||
let callback = clone!(shows => move || {
|
||||
shows.set_vadjustment()
|
||||
.map_err(|err| error!("Failed to set ShowsView Alignment: {}", err))
|
||||
.ok();
|
||||
});
|
||||
|
||||
let flowbox = shows.flowbox.clone();
|
||||
lazy_load(podcasts, flowbox, constructor, callback);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_child_activate(child: >k::FlowBoxChild, sender: &Sender<Action>) -> Result<(), Error> {
|
||||
use gtk::WidgetExt;
|
||||
|
||||
// This is such an ugly hack...
|
||||
let id = WidgetExt::get_name(child)
|
||||
.ok_or_else(|| format_err!("Faild to get \"episodes\" child from the stack."))?
|
||||
.parse::<i32>()?;
|
||||
let pd = Arc::new(dbqueries::get_podcast_from_id(id)?);
|
||||
|
||||
sender.send(Action::HeaderBarShowTile(pd.title().into()));
|
||||
sender.send(Action::ReplaceWidget(pd));
|
||||
sender.send(Action::ShowWidgetAnimated);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShowsChild {
|
||||
container: gtk::Box,
|
||||
cover: gtk::Image,
|
||||
child: gtk::FlowBoxChild,
|
||||
}
|
||||
|
||||
impl Default for ShowsChild {
|
||||
fn default() -> Self {
|
||||
let builder = gtk::Builder::new_from_resource("/org/gnome/Podcasts/gtk/shows_child.ui");
|
||||
|
||||
let container: gtk::Box = builder.get_object("fb_child").unwrap();
|
||||
let cover: gtk::Image = builder.get_object("pd_cover").unwrap();
|
||||
|
||||
let child = gtk::FlowBoxChild::new();
|
||||
child.add(&container);
|
||||
|
||||
ShowsChild {
|
||||
container,
|
||||
cover,
|
||||
child,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShowsChild {
|
||||
pub fn new(pd: &Show) -> ShowsChild {
|
||||
let child = ShowsChild::default();
|
||||
child.init(pd);
|
||||
child
|
||||
}
|
||||
|
||||
fn init(&self, pd: &Show) {
|
||||
self.container.set_tooltip_text(pd.title());
|
||||
WidgetExt::set_name(&self.child, &pd.id().to_string());
|
||||
|
||||
self.set_cover(pd.id())
|
||||
}
|
||||
|
||||
fn set_cover(&self, show_id: i32) {
|
||||
// The closure above is a regular `Fn` closure.
|
||||
// which means we can't mutate stuff inside it easily,
|
||||
// so Cell is used.
|
||||
//
|
||||
// `Option<T>` along with the `.take()` method ensure
|
||||
// that the function will only be run once, during the first execution.
|
||||
let show_id = Cell::new(Some(show_id));
|
||||
|
||||
self.cover.connect_draw(move |cover, _| {
|
||||
show_id.take().map(|id| {
|
||||
set_image_from_path(cover, id, 256)
|
||||
.map_err(|err| error!("Failed to set a cover: {}", err))
|
||||
.ok();
|
||||
});
|
||||
|
||||
gtk::Inhibit(false)
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user