Initial massive renaming.

This commit is contained in:
Jordan Petridis
2018-07-25 03:26:35 +03:00
parent 518ea9c8b5
commit 04c68ba013
112 changed files with 300 additions and 298 deletions
+320
View File
@@ -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: &gtk::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",
&gtk_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);
}
}
+276
View File
@@ -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: &gtk::PopoverMenu) {
self.dots.set_popover(Some(pop));
}
}
+130
View File
@@ -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();
}
+184
View File
@@ -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();
}
}
+86
View File
@@ -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: &gtk::ApplicationWindow) {
self.dialog.set_transient_for(Some(parent));
self.dialog.set_modal(true);
self.dialog.show_all();
}
}
+136
View File
@@ -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: &gtk::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: &gtk::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());
// }
+89
View File
@@ -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()
}
}
+117
View File
@@ -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(())
}
}
+9
View File
@@ -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};
+158
View File
@@ -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;
}
}
}
}
+95
View File
@@ -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(())
}
}
+18
View File
@@ -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(())
}
+426
View File
@@ -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: &gtk::ScrolledWindow, target: &gtk::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: &gtk::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: &gtk::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());
}
}
+43
View File
@@ -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: &gtk::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();
}
+94
View File
@@ -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: &gtk::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);
}
}
+21
View File
@@ -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()
}
}
+596
View File
@@ -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(|_| ())
// }
+243
View File
@@ -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: &gtk::ListBox, box_: &gtk::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)
});
}
}
+17
View File
@@ -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;
+459
View File
@@ -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: &gtk::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);
}
}
+186
View File
@@ -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(())
}
+173
View File
@@ -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: &gtk::ListBox, sender: &Sender<Action>) -> Self {
let s = Self::default();
s.init(pd, episodes, sender);
s
}
fn init(&self, pd: &Arc<Show>, episodes: &gtk::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: &gtk::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: &gtk::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)
}
+182
View File
@@ -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: &gtk::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)
});
}
}