diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8726f9e..3374923 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -18,12 +18,19 @@ before_script: stage: test script: - rustc --version && cargo --version + # Force regeneration of gresources regardless of artifacts chage + - cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../ - cargo build - cargo test --verbose -- --test-threads=1 + cache: + paths: + - target/ + - cargo/ variables: # RUSTFLAGS: "-C link-dead-code" RUST_BACKTRACE: "FULL" + CARGO_HOME: $CI_PROJECT_DIR/cargo stable:test: # https://hub.docker.com/_/rust/ @@ -44,7 +51,7 @@ rustfmt: CFG_RELEASE_CHANNEL: "nightly" script: - rustc --version && cargo --version - - cargo install rustfmt-nightly + - cargo install rustfmt-nightly --force - cargo fmt --all -- --write-mode=diff # Configure and run clippy on nightly @@ -54,5 +61,7 @@ clippy: stage: lint script: - rustc --version && cargo --version - - cargo install clippy + - cargo install clippy --force + # Force regeneration of gresources regardless of artifacts chage + - cd hammond-gtk/resources/ && glib-compile-resources --generate resources.xml && cd ../../ - cargo clippy --all diff --git a/Cargo.lock b/Cargo.lock index 500afb9..cdba1e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -611,11 +611,14 @@ dependencies = [ "gtk 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "hammond-data 0.1.0", "hammond-downloader 0.1.0", + "humansize 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", + "lazy_static 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", "loggerv 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", "open 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "rayon 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "regex 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", + "send-cell 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -635,6 +638,11 @@ name = "httparse" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "humansize" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "hyper" version = "0.11.9" @@ -1280,6 +1288,11 @@ dependencies = [ "libc 0.2.34 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "send-cell" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "serde" version = "1.0.24" @@ -1673,6 +1686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum gtk-sys 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "905fcfbaaad1b44ec0b4bba9e4d527d728284c62bc2ba41fccedace2b096766f" "checksum html5ever 0.21.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ba3a1fd1857a714d410c191364c5d7bf8a6487c0ab5575146d37dd7eb17ef523" "checksum httparse 1.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "af2f2dd97457e8fb1ae7c5a420db346af389926e36f43768b96f101546b04a07" +"checksum humansize 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d99804bdb0790b0c312a5a1115f83804b821f1a96d80759fbb57ce796d1f3778" "checksum hyper 0.11.9 (registry+https://github.com/rust-lang/crates.io-index)" = "e0594792d2109069d0caffd176f674d770a84adf024c5bb48e686b1ee5ac7659" "checksum hyper-tls 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "9c81fa95203e2a6087242c38691a0210f23e9f3f8f944350bd676522132e2985" "checksum idna 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "014b298351066f1512874135335d62a789ffe78a9974f94b43ed5621951eaf7d" @@ -1746,6 +1760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" "checksum secur32-sys 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "3f412dfa83308d893101dd59c10d6fda8283465976c28c287c5c855bf8d216bc" "checksum security-framework 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)" = "dfa44ee9c54ce5eecc9de7d5acbad112ee58755239381f687e564004ba4a2332" "checksum security-framework-sys 0.1.16 (registry+https://github.com/rust-lang/crates.io-index)" = "5421621e836278a0b139268f36eee0dc7e389b784dc3f79d8f11aabadf41bead" +"checksum send-cell 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "1c620dd7e056b468b9d374a9f51cfa6bb4bf17a8ca4ee62e5efa0d99aaff2c41" "checksum serde 1.0.24 (registry+https://github.com/rust-lang/crates.io-index)" = "1c57ab4ec5fa85d08aaf8ed9245899d9bbdd66768945b21113b84d5f595cb6a1" "checksum serde_json 1.0.8 (registry+https://github.com/rust-lang/crates.io-index)" = "7cf5b0b5b4bd22eeecb7e01ac2e1225c7ef5e4272b79ee28a8392a8c8489c839" "checksum serde_urlencoded 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ce0fd303af908732989354c6f02e05e2e6d597152870f2c6990efb0577137480" diff --git a/TODO.md b/TODO.md index 1ed5678..6106680 100644 --- a/TODO.md +++ b/TODO.md @@ -1,20 +1,13 @@ -## TODOs: +# TODOs ## Planned Features -## Priorities: +## Priorities - [ ] Unplayed Only and Downloaded only view. -- [ ] Auto-updater - [ ] OPML import/export // Probably need to create a crate. -**Proper Desing Mockups for the Gtk Client:** - -- [ ] Re-design EpisodeWidget. -- [ ] Re-design PodcastWidget. -- [ ] Polish the flowbox_child banner. - -## Second: +## Second - [ ] Make use of file metadas, [This](https://github.com/GuillaumeGomez/audio-video-metadata) might be helpfull. - [ ] Notifications @@ -23,10 +16,9 @@ - [ ] MPRIS integration - [ ] Search Implementation +## Third -## Third: - -- [ ] Download Queue +- [ ] Download Queue - [ ] Ability to Stream content on demand - [ ] soundcloud and itunes feeds // [This](http://getrssfeed.com) seems intresting. - [ ] Integrate with Itunes API for various crap @@ -37,8 +29,7 @@ **Would be nice:** - [ ] Make Podcast cover fetchng and loading not block the execution of the program at startup. -- [ ] Lazy evaluate episode loading based on the podcast_widget's view scrolling. -- [ ] Headerbar back button and stack switching +- [ ] Lazy evaluate episode loading based on the show_widget's scrolling. **FIXME:** diff --git a/hammond-data/migrations/2017-12-22-145740_add_duration_column/down.sql b/hammond-data/migrations/2017-12-22-145740_add_duration_column/down.sql new file mode 100644 index 0000000..7f1bbcb --- /dev/null +++ b/hammond-data/migrations/2017-12-22-145740_add_duration_column/down.sql @@ -0,0 +1,22 @@ +ALTER TABLE episode RENAME TO old_table; + +CREATE TABLE episode ( + title TEXT NOT NULL, + uri TEXT, + local_uri TEXT, + description TEXT, + published_date TEXT, + epoch INTEGER NOT NULL DEFAULT 0, + length INTEGER, + guid TEXT, + played INTEGER, + podcast_id INTEGER NOT NULL, + favorite INTEGER DEFAULT 0, + archive INTEGER DEFAULT 0, + PRIMARY KEY (title, podcast_id) +); + +INSERT INTO episode (title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id) +SELECT title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id +FROM old_table; +Drop table old_table; diff --git a/hammond-data/migrations/2017-12-22-145740_add_duration_column/up.sql b/hammond-data/migrations/2017-12-22-145740_add_duration_column/up.sql new file mode 100644 index 0000000..f2dcf10 --- /dev/null +++ b/hammond-data/migrations/2017-12-22-145740_add_duration_column/up.sql @@ -0,0 +1,23 @@ +ALTER TABLE episode RENAME TO old_table; + +CREATE TABLE episode ( + title TEXT NOT NULL, + uri TEXT, + local_uri TEXT, + description TEXT, + published_date TEXT, + epoch INTEGER NOT NULL DEFAULT 0, + length INTEGER, + duration INTEGER, + guid TEXT, + played INTEGER, + podcast_id INTEGER NOT NULL, + favorite INTEGER DEFAULT 0, + archive INTEGER DEFAULT 0, + PRIMARY KEY (title, podcast_id) +); + +INSERT INTO episode (title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id) +SELECT title, uri, local_uri, description, published_date, epoch, length, guid, played, favorite, archive, podcast_id +FROM old_table; +Drop table old_table; \ No newline at end of file diff --git a/hammond-data/src/dbqueries.rs b/hammond-data/src/dbqueries.rs index 7adb91c..65236d6 100644 --- a/hammond-data/src/dbqueries.rs +++ b/hammond-data/src/dbqueries.rs @@ -2,7 +2,8 @@ use diesel::prelude::*; use diesel; -use models::queryables::{Episode, EpisodeWidgetQuery, Podcast, Source}; +use models::queryables::{Episode, EpisodeCleanerQuery, EpisodeWidgetQuery, Podcast, + PodcastCoverQuery, Source}; use chrono::prelude::*; use errors::*; @@ -32,14 +33,15 @@ pub fn get_episodes() -> Result> { Ok(episode.order(epoch.desc()).load::(&*con)?) } -pub fn get_downloaded_episodes() -> Result> { +pub(crate) fn get_downloaded_episodes() -> Result> { use schema::episode::dsl::*; let db = connection(); let con = db.get()?; Ok(episode + .select((rowid, local_uri, played)) .filter(local_uri.is_not_null()) - .load::(&*con)?) + .load::(&*con)?) } pub fn get_played_episodes() -> Result> { @@ -50,6 +52,17 @@ pub fn get_played_episodes() -> Result> { Ok(episode.filter(played.is_not_null()).load::(&*con)?) } +pub fn get_played_cleaner_episodes() -> Result> { + use schema::episode::dsl::*; + + let db = connection(); + let con = db.get()?; + Ok(episode + .select((rowid, local_uri, played)) + .filter(played.is_not_null()) + .load::(&*con)?) +} + pub fn get_episode_from_rowid(ep_id: i32) -> Result { use schema::episode::dsl::*; @@ -84,6 +97,29 @@ pub fn get_episodes_with_limit(limit: u32) -> Result> { .load::(&*con)?) } +pub fn get_episodes_widgets_with_limit(limit: u32) -> Result> { + use schema::episode; + + let db = connection(); + let con = db.get()?; + + Ok(episode::table + .select(( + episode::rowid, + episode::title, + episode::uri, + episode::local_uri, + episode::epoch, + episode::length, + episode::duration, + episode::played, + episode::podcast_id, + )) + .order(episode::epoch.desc()) + .limit(i64::from(limit)) + .load::(&*con)?) +} + pub fn get_podcast_from_id(pid: i32) -> Result { use schema::podcast::dsl::*; @@ -92,6 +128,17 @@ pub fn get_podcast_from_id(pid: i32) -> Result { Ok(podcast.filter(id.eq(pid)).get_result::(&*con)?) } +pub fn get_podcast_cover_from_id(pid: i32) -> Result { + use schema::podcast::dsl::*; + + let db = connection(); + let con = db.get()?; + Ok(podcast + .select((id, title, image_uri)) + .filter(id.eq(pid)) + .get_result::(&*con)?) +} + pub fn get_pd_episodes(parent: &Podcast) -> Result> { use schema::episode::dsl::*; @@ -110,7 +157,7 @@ pub fn get_pd_episodeswidgets(parent: &Podcast) -> Result { - if foo.source_id() != self.source_id { - error!("NSPD sid: {}, SPD sid: {}", self.source_id, foo.source_id()); - }; - if (foo.link() != self.link) || (foo.title() != self.title) || (foo.image_uri() != self.image_uri.as_ref().map(|x| x.as_str())) { @@ -166,6 +162,7 @@ pub(crate) struct NewEpisode { description: Option, published_date: Option, length: Option, + duration: Option, guid: Option, epoch: i32, podcast_id: i32, @@ -211,6 +208,7 @@ impl NewEpisode { if foo.title() != self.title.as_str() || foo.epoch() != self.epoch || foo.uri() != self.uri.as_ref().map(|s| s.as_str()) + || foo.duration() != self.duration { self.update(con, foo.rowid())?; } diff --git a/hammond-data/src/models/queryables.rs b/hammond-data/src/models/queryables.rs index 4290979..d4409cd 100644 --- a/hammond-data/src/models/queryables.rs +++ b/hammond-data/src/models/queryables.rs @@ -33,6 +33,7 @@ pub struct Episode { published_date: Option, epoch: i32, length: Option, + duration: Option, guid: Option, played: Option, favorite: bool, @@ -125,6 +126,8 @@ impl Episode { } /// Get the `length`. + /// + /// The number represents the size of the file in bytes. pub fn length(&self) -> Option { self.length } @@ -134,6 +137,18 @@ impl Episode { self.length = value; } + /// Get the `duration` value. + /// + /// The number represents the duration of the item/episode in seconds. + pub fn duration(&self) -> Option { + self.duration + } + + /// Set the `duration`. + pub fn set_duration(&mut self, value: Option) { + self.duration = value; + } + /// Epoch representation of the last time the episode was played. /// /// None/Null for unplayed. @@ -204,6 +219,7 @@ pub struct EpisodeWidgetQuery { local_uri: Option, epoch: i32, length: Option, + duration: Option, played: Option, // favorite: bool, // archive: bool, @@ -250,6 +266,8 @@ impl EpisodeWidgetQuery { } /// Get the `length`. + /// + /// The number represents the size of the file in bytes. pub fn length(&self) -> Option { self.length } @@ -259,6 +277,18 @@ impl EpisodeWidgetQuery { self.length = value; } + /// Get the `duration` value. + /// + /// The number represents the duration of the item/episode in seconds. + pub fn duration(&self) -> Option { + self.duration + } + + /// Set the `duration`. + pub fn set_duration(&mut self, value: Option) { + self.duration = value; + } + /// Epoch representation of the last time the episode was played. /// /// None/Null for unplayed. @@ -320,6 +350,72 @@ impl EpisodeWidgetQuery { } } +#[derive(Queryable, AsChangeset, PartialEq)] +#[table_name = "episode"] +#[changeset_options(treat_none_as_null = "true")] +#[primary_key(title, podcast_id)] +#[derive(Debug, Clone)] +/// Diesel Model to be used internal with the `utils::checkup` function. +pub struct EpisodeCleanerQuery { + rowid: i32, + local_uri: Option, + played: Option, +} + +impl From for EpisodeCleanerQuery { + fn from(e: Episode) -> EpisodeCleanerQuery { + EpisodeCleanerQuery { + rowid: e.rowid(), + local_uri: e.local_uri, + played: e.played, + } + } +} + +impl EpisodeCleanerQuery { + /// Get the value of the sqlite's `ROW_ID` + pub fn rowid(&self) -> i32 { + self.rowid + } + + /// Get the value of the `local_uri`. + /// + /// Represents the local uri,usually filesystem path, + /// that the media file will be located at. + pub fn local_uri(&self) -> Option<&str> { + self.local_uri.as_ref().map(|s| s.as_str()) + } + + /// Set the `local_uri`. + pub fn set_local_uri(&mut self, value: Option<&str>) { + self.local_uri = value.map(|x| x.to_string()); + } + + /// Epoch representation of the last time the episode was played. + /// + /// None/Null for unplayed. + pub fn played(&self) -> Option { + self.played + } + + /// Set the `played` value. + pub fn set_played(&mut self, value: Option) { + self.played = value; + } + + /// Helper method to easily save/"sync" current state of self to the Database. + pub fn save(&self) -> Result { + use schema::episode::dsl::*; + + let db = connection(); + let tempdb = db.get()?; + + Ok(diesel::update(episode.filter(rowid.eq(self.rowid()))) + .set(self) + .execute(&*tempdb)?) + } +} + #[derive(Queryable, Identifiable, AsChangeset, Associations, PartialEq)] #[belongs_to(Source, foreign_key = "source_id")] #[changeset_options(treat_none_as_null = "true")] @@ -427,6 +523,44 @@ impl Podcast { } } +#[derive(Queryable, Debug, Clone)] +/// Diesel Model of the podcast cover query. +/// Used for fetching information about a Podcast's cover. +pub struct PodcastCoverQuery { + id: i32, + title: String, + image_uri: Option, +} + +impl From for PodcastCoverQuery { + fn from(p: Podcast) -> PodcastCoverQuery { + PodcastCoverQuery { + id: *p.id(), + title: p.title, + image_uri: p.image_uri, + } + } +} + +impl PodcastCoverQuery { + /// Get the Feed `id`. + pub fn id(&self) -> i32 { + self.id + } + + /// Get the Feed `title`. + pub fn title(&self) -> &str { + &self.title + } + + /// Get the `image_uri`. + /// + /// Represents the uri(url usually) that the Feed cover image is located at. + pub fn image_uri(&self) -> Option<&str> { + self.image_uri.as_ref().map(|s| s.as_str()) + } +} + #[derive(Queryable, Identifiable, AsChangeset, PartialEq)] #[table_name = "source"] #[changeset_options(treat_none_as_null = "true")] diff --git a/hammond-data/src/parser.rs b/hammond-data/src/parser.rs index 566b06f..352d273 100644 --- a/hammond-data/src/parser.rs +++ b/hammond-data/src/parser.rs @@ -17,9 +17,9 @@ pub(crate) fn new_podcast(chan: &Channel, source_id: i32) -> NewPodcast { let link = url_cleaner(chan.link()); let x = chan.itunes_ext().map(|s| s.image()); let image_uri = if let Some(img) = x { - img.map(|s| url_cleaner(s)) + img.map(|s| s.to_owned()) } else { - chan.image().map(|foo| url_cleaner(foo.url())) + chan.image().map(|foo| foo.url().to_owned()) }; NewPodcastBuilder::default() @@ -43,10 +43,6 @@ pub(crate) fn new_episode(item: &Item, parent_id: i32) -> Result { .map(|s| replace_extra_spaces(&ammonia::clean(s))); let guid = item.guid().map(|s| s.value().trim().to_owned()); - // Its kinda weird this being an Option type. - // Rss 2.0 specified that it's optional. - // Though the db scema has a requirment of episode uri being Unique && Not Null. - // TODO: Restructure let x = item.enclosure().map(|s| url_cleaner(s.url())); // FIXME: refactor let uri = if x.is_some() { @@ -67,13 +63,15 @@ pub(crate) fn new_episode(item: &Item, parent_id: i32) -> Result { let pub_date = date.map(|x| x.to_rfc2822()).ok(); let epoch = date.map(|x| x.timestamp() as i32).unwrap_or(0); - let length = item.enclosure().map(|x| x.length().parse().unwrap_or(0)); + let length = || -> Option { item.enclosure().map(|x| x.length().parse().ok())? }(); + let duration = parse_itunes_duration(item); Ok(NewEpisodeBuilder::default() .title(title) .uri(uri) .description(description) .length(length) + .duration(duration) .published_date(pub_date) .epoch(epoch) .guid(guid) @@ -82,6 +80,36 @@ pub(crate) fn new_episode(item: &Item, parent_id: i32) -> Result { .unwrap()) } +/// Parses an Item Itunes extension and returns it's duration value in seconds. +// FIXME: Rafactor +// TODO: Write tests +#[allow(non_snake_case)] +fn parse_itunes_duration(item: &Item) -> Option { + let duration = item.itunes_ext().map(|s| s.duration())??; + + // FOR SOME FUCKING REASON, IN THE APPLE EXTENSION SPEC + // THE DURATION CAN BE EITHER AN INT OF SECONDS OR + // A STRING OF THE FOLLOWING FORMATS: + // HH:MM:SS, H:MM:SS, MM:SS, M:SS + // LIKE WHO THE FUCK THOUGH THAT WOULD BE A GOOD IDEA. + if let Ok(NO_FUCKING_LOGIC) = duration.parse::() { + return Some(NO_FUCKING_LOGIC); + }; + + let mut seconds = 0; + let fk_apple = duration.split(':').collect::>(); + if fk_apple.len() == 3 { + seconds += fk_apple[0].parse::().unwrap_or(0) * 3600; + seconds += fk_apple[1].parse::().unwrap_or(0) * 60; + seconds += fk_apple[2].parse::().unwrap_or(0); + } else if fk_apple.len() == 2 { + seconds += fk_apple[0].parse::().unwrap_or(0) * 60; + seconds += fk_apple[1].parse::().unwrap_or(0); + } + + Some(seconds) +} + #[cfg(test)] mod tests { use std::fs::File; diff --git a/hammond-data/src/schema.rs b/hammond-data/src/schema.rs index 7b43171..6389143 100644 --- a/hammond-data/src/schema.rs +++ b/hammond-data/src/schema.rs @@ -8,6 +8,7 @@ table! { published_date -> Nullable, epoch -> Integer, length -> Nullable, + duration -> Nullable, guid -> Nullable, played -> Nullable, favorite -> Bool, diff --git a/hammond-data/src/utils.rs b/hammond-data/src/utils.rs index fb9fe1e..07dc34d 100644 --- a/hammond-data/src/utils.rs +++ b/hammond-data/src/utils.rs @@ -8,7 +8,7 @@ use itertools::Itertools; use errors::*; use dbqueries; -use models::queryables::Episode; +use models::queryables::EpisodeCleanerQuery; use std::path::Path; use std::fs; @@ -23,7 +23,7 @@ fn download_checker() -> Result<()> { Ok(()) } -fn checker_helper(ep: &mut Episode) { +fn checker_helper(ep: &mut EpisodeCleanerQuery) { if !Path::new(ep.local_uri().unwrap()).exists() { ep.set_local_uri(None); let res = ep.save(); @@ -35,7 +35,7 @@ fn checker_helper(ep: &mut Episode) { } fn played_cleaner() -> Result<()> { - let episodes = dbqueries::get_played_episodes()?; + let episodes = dbqueries::get_played_cleaner_episodes()?; let now_utc = Utc::now().timestamp() as i32; episodes.into_par_iter().for_each(|mut ep| { @@ -50,7 +50,7 @@ fn played_cleaner() -> Result<()> { error!("Error while trying to delete file: {:?}", ep.local_uri()); error!("Error: {}", err); } else { - info!("Episode {:?} was deleted succesfully.", ep.title()); + info!("Episode {:?} was deleted succesfully.", ep.local_uri()); }; } } @@ -59,7 +59,7 @@ fn played_cleaner() -> Result<()> { } /// Check `ep.local_uri` field and delete the file it points to. -pub fn delete_local_content(ep: &mut Episode) -> Result<()> { +pub fn delete_local_content(ep: &mut EpisodeCleanerQuery) -> Result<()> { if ep.local_uri().is_some() { let uri = ep.local_uri().unwrap().to_owned(); if Path::new(&uri).exists() { @@ -106,7 +106,7 @@ pub fn url_cleaner(s: &str) -> String { } } -/// Helper functions that strips extra spaces and newlines and all the tabs. +/// Helper functions that strips extra spaces and newlines and ignores the tabs. #[allow(match_same_arms)] pub fn replace_extra_spaces(s: &str) -> String { s.trim() @@ -176,12 +176,16 @@ mod tests { #[test] fn test_download_checker() { - let _tmp_dir = helper_db(); + let tmp_dir = helper_db(); download_checker().unwrap(); let episodes = dbqueries::get_downloaded_episodes().unwrap(); + let valid_path = tmp_dir.path().join("virtual_dl.mp3"); assert_eq!(episodes.len(), 1); - assert_eq!("foo_bar", episodes.first().unwrap().title()); + assert_eq!( + Some(valid_path.to_str().unwrap()), + episodes.first().unwrap().local_uri() + ); } #[test] @@ -190,7 +194,9 @@ mod tests { let mut episode = { let db = connection(); let con = db.get().unwrap(); - dbqueries::get_episode_from_pk(&con, "bar_baz", 1).unwrap() + dbqueries::get_episode_from_pk(&con, "bar_baz", 1) + .unwrap() + .into() }; checker_helper(&mut episode); @@ -200,10 +206,12 @@ mod tests { #[test] fn test_download_cleaner() { let _tmp_dir = helper_db(); - let mut episode = { + let mut episode: EpisodeCleanerQuery = { let db = connection(); let con = db.get().unwrap(); - dbqueries::get_episode_from_pk(&con, "foo_bar", 0).unwrap() + dbqueries::get_episode_from_pk(&con, "foo_bar", 0) + .unwrap() + .into() }; let valid_path = episode.local_uri().unwrap().to_owned(); diff --git a/hammond-downloader/src/downloader.rs b/hammond-downloader/src/downloader.rs index e3a4a0c..685986f 100644 --- a/hammond-downloader/src/downloader.rs +++ b/hammond-downloader/src/downloader.rs @@ -6,9 +6,10 @@ use mime_guess; use std::fs::{rename, DirBuilder, File}; use std::io::{BufWriter, Read, Write}; use std::path::Path; +use std::fs; use errors::*; -use hammond_data::{EpisodeWidgetQuery, Podcast}; +use hammond_data::{EpisodeWidgetQuery, PodcastCoverQuery}; use hammond_data::xdg_dirs::{DL_DIR, HAMMOND_CACHE}; // TODO: Replace path that are of type &str with std::path. @@ -123,6 +124,13 @@ pub fn get_episode(ep: &mut EpisodeWidgetQuery, download_folder: &str) -> Result if let Ok(path) = res { // If download succedes set episode local_uri to dlpath. ep.set_local_uri(Some(&path)); + + // Over-write episode lenght + let size = fs::metadata(path); + if let Ok(s) = size { + ep.set_length(Some(s.len() as i32)) + }; + ep.save()?; Ok(()) } else { @@ -131,7 +139,7 @@ pub fn get_episode(ep: &mut EpisodeWidgetQuery, download_folder: &str) -> Result } } -pub fn cache_image(pd: &Podcast) -> Option { +pub fn cache_image(pd: &PodcastCoverQuery) -> Option { let url = pd.image_uri()?.to_owned(); if url == "" { return None; @@ -207,7 +215,7 @@ mod tests { index(vec![feed]); // Get the Podcast - let pd = dbqueries::get_podcast_from_source_id(sid).unwrap(); + let pd = dbqueries::get_podcast_from_source_id(sid).unwrap().into(); let img_path = cache_image(&pd); let foo_ = format!( diff --git a/hammond-gtk/Cargo.toml b/hammond-gtk/Cargo.toml index b1cf01e..ceb387d 100644 --- a/hammond-gtk/Cargo.toml +++ b/hammond-gtk/Cargo.toml @@ -12,11 +12,14 @@ gdk = "0.7.0" gdk-pixbuf = "0.3.0" gio = "0.3.0" glib = "0.4.0" +humansize = "1.0.2" +lazy_static = "1.0.0" log = "0.3.8" loggerv = "0.6.0" open = "1.2.1" rayon = "0.9.0" regex = "0.2.3" +send-cell = "0.1.2" [dependencies.diesel] features = ["sqlite"] diff --git a/hammond-gtk/resources/gtk/episode_widget.ui b/hammond-gtk/resources/gtk/episode_widget.ui index dbf6a43..ff4a249 100644 --- a/hammond-gtk/resources/gtk/episode_widget.ui +++ b/hammond-gtk/resources/gtk/episode_widget.ui @@ -10,13 +10,14 @@ True False - 5 + 6 True False + center vertical - 5 + 6 True @@ -50,7 +51,7 @@ True False - 5 + 6 True @@ -103,8 +104,8 @@ - True False + True ยท False False @@ -145,27 +171,6 @@ 0 - - - True - False - start - center - Show description - True - word-char - 55 - - - - - - True - True - end - 1 - - True @@ -176,7 +181,7 @@ False - True + False 0 @@ -189,7 +194,6 @@ False True - 25 0 diff --git a/hammond-gtk/resources/gtk/shows_view.ui b/hammond-gtk/resources/gtk/shows_view.ui index 7981cb9..5050ca0 100644 --- a/hammond-gtk/resources/gtk/shows_view.ui +++ b/hammond-gtk/resources/gtk/shows_view.ui @@ -21,9 +21,11 @@ False center start + 24 + 24 True - 5 - 5 + 12 + 12 20 none diff --git a/hammond-gtk/resources/gtk/style.css b/hammond-gtk/resources/gtk/style.css new file mode 100644 index 0000000..39bb770 --- /dev/null +++ b/hammond-gtk/resources/gtk/style.css @@ -0,0 +1,7 @@ +row { + border-bottom: solid 1px rgba(0,0,0, 0.1); +} + +row:last-child { + border-bottom: none; +} diff --git a/hammond-gtk/resources/resources.xml b/hammond-gtk/resources/resources.xml index cb87ca4..366f71c 100644 --- a/hammond-gtk/resources/resources.xml +++ b/hammond-gtk/resources/resources.xml @@ -4,8 +4,11 @@ gtk/episode_widget.ui gtk/show_widget.ui gtk/empty_view.ui + gtk/episodes_view.ui + gtk/episodes_view_widget.ui gtk/shows_view.ui gtk/shows_child.ui gtk/headerbar.ui + gtk/style.css diff --git a/hammond-gtk/src/content.rs b/hammond-gtk/src/content.rs index adc4cb6..7722ae0 100644 --- a/hammond-gtk/src/content.rs +++ b/hammond-gtk/src/content.rs @@ -6,6 +6,7 @@ use hammond_data::dbqueries; use views::shows::ShowsPopulated; use views::empty::EmptyView; +use views::episodes::EpisodesView; use widgets::show::ShowWidget; use headerbar::Header; @@ -22,8 +23,8 @@ pub struct Content { impl Content { pub fn new(header: Rc
) -> Rc { let stack = gtk::Stack::new(); - let shows = ShowStack::new(header); let episodes = EpisodeStack::new(); + let shows = ShowStack::new(header, episodes.clone()); stack.add_titled(&episodes.stack, "episodes", "Episodes"); stack.add_titled(&shows.stack, "shows", "Shows"); @@ -49,15 +50,17 @@ impl Content { pub struct ShowStack { pub stack: gtk::Stack, header: Rc
, + epstack: Rc, } impl ShowStack { - fn new(header: Rc
) -> Rc { + fn new(header: Rc
, epstack: Rc) -> Rc { let stack = gtk::Stack::new(); let show = Rc::new(ShowStack { stack, header: header.clone(), + epstack, }); let pop = ShowsPopulated::new(show.clone(), header); @@ -109,7 +112,12 @@ impl ShowStack { pub fn replace_widget(&self, pd: &Podcast) { let old = self.stack.get_child_by_name("widget").unwrap(); - let new = ShowWidget::new(Rc::new(self.clone()), self.header.clone(), pd); + let new = ShowWidget::new( + Rc::new(self.clone()), + self.epstack.clone(), + self.header.clone(), + pd, + ); self.stack.remove(&old); self.stack.add_named(&new.container, "widget"); @@ -144,10 +152,7 @@ impl ShowStack { } #[derive(Debug, Clone)] -struct RecentEpisodes; - -#[derive(Debug, Clone)] -struct EpisodeStack { +pub struct EpisodeStack { // populated: RecentEpisodes, // empty: EmptyView, stack: gtk::Stack, @@ -155,14 +160,18 @@ struct EpisodeStack { impl EpisodeStack { fn new() -> Rc { - let _pop = RecentEpisodes {}; + let episodes = EpisodesView::new(); let empty = EmptyView::new(); let stack = gtk::Stack::new(); - // stack.add_named(&pop.container, "populated"); + stack.add_named(&episodes.container, "episodes"); stack.add_named(&empty.container, "empty"); - // FIXME: - stack.set_visible_child_name("empty"); + + if episodes.is_empty() { + stack.set_visible_child_name("empty"); + } else { + stack.set_visible_child_name("episodes"); + } Rc::new(EpisodeStack { // empty, @@ -171,7 +180,19 @@ impl EpisodeStack { }) } - fn update(&self) { - // unimplemented!() + pub fn update(&self) { + let old = self.stack.get_child_by_name("episodes").unwrap(); + let eps = EpisodesView::new(); + + self.stack.remove(&old); + self.stack.add_named(&eps.container, "episodes"); + + if eps.is_empty() { + self.stack.set_visible_child_name("empty"); + } else { + self.stack.set_visible_child_name("episodes"); + } + + old.destroy(); } } diff --git a/hammond-gtk/src/main.rs b/hammond-gtk/src/main.rs index d565154..89c6eb1 100644 --- a/hammond-gtk/src/main.rs +++ b/hammond-gtk/src/main.rs @@ -1,4 +1,4 @@ -#![cfg_attr(feature = "cargo-clippy", allow(clone_on_ref_ptr))] +#![cfg_attr(feature = "cargo-clippy", allow(clone_on_ref_ptr, needless_pass_by_value))] extern crate gdk; extern crate gdk_pixbuf; @@ -11,11 +11,15 @@ extern crate diesel; extern crate dissolve; extern crate hammond_data; extern crate hammond_downloader; +extern crate humansize; +#[macro_use] +extern crate lazy_static; #[macro_use] extern crate log; extern crate loggerv; extern crate open; extern crate regex; +extern crate send_cell; // extern crate rayon; // use rayon::prelude::*; @@ -23,7 +27,7 @@ use log::LogLevel; use hammond_data::utils::checkup; use gtk::prelude::*; -use gio::{ActionMapExt, ApplicationExt, MenuExt, SimpleActionExt}; +use gio::ApplicationExt; use std::rc::Rc; // http://gtk-rs.org/tuto/closures @@ -54,15 +58,9 @@ mod utils; mod static_resource; fn build_ui(app: >k::Application) { - let menu = gio::Menu::new(); - menu.append("Quit", "app.quit"); - menu.append("Checkup", "app.check"); - menu.append("Update feeds", "app.update"); - app.set_app_menu(&menu); - // Get the main window let window = gtk::ApplicationWindow::new(app); - window.set_default_size(1150, 650); + window.set_default_size(860, 640); // Get the headerbar let header = Rc::new(headerbar::Header::default()); @@ -76,28 +74,6 @@ fn build_ui(app: >k::Application) { Inhibit(false) }); - // Setup quit in the app menu since default is overwritten. - let quit = gio::SimpleAction::new("quit", None); - let window2 = window.clone(); - quit.connect_activate(move |_, _| { - window2.destroy(); - }); - app.add_action(&quit); - - // Setup the checkup in the app menu. - let check = gio::SimpleAction::new("check", None); - check.connect_activate(move |_, _| { - let _ = checkup(); - }); - app.add_action(&check); - - let update = gio::SimpleAction::new("update", None); - let ct_clone = ct.clone(); - update.connect_activate(move |_, _| { - utils::refresh_feed(ct_clone.clone(), None); - }); - app.add_action(&update); - // Update on startup gtk::timeout_add_seconds( 30, @@ -106,7 +82,6 @@ fn build_ui(app: >k::Application) { glib::Continue(false) }), ); - // Auto-updater, runs every hour. // TODO: expose the interval in which it run to a user setting. // TODO: show notifications. @@ -138,6 +113,15 @@ fn main() { let application = gtk::Application::new("org.gnome.Hammond", gio::ApplicationFlags::empty()) .expect("Initialization failed..."); + // Add custom style + let provider = gtk::CssProvider::new(); + gtk::CssProvider::load_from_resource(&provider, "/org/gnome/hammond/gtk/style.css"); + gtk::StyleContext::add_provider_for_screen( + &gdk::Screen::get_default().unwrap(), + &provider, + 600, + ); + application.connect_startup(move |app| { build_ui(app); }); diff --git a/hammond-gtk/src/utils.rs b/hammond-gtk/src/utils.rs index c2f484d..b88a6dc 100644 --- a/hammond-gtk/src/utils.rs +++ b/hammond-gtk/src/utils.rs @@ -1,14 +1,17 @@ +use send_cell::SendCell; use glib; use gdk_pixbuf::Pixbuf; use hammond_data::feed; -use hammond_data::{Podcast, Source}; +use hammond_data::{PodcastCoverQuery, Source}; use hammond_downloader::downloader; use std::thread; use std::cell::RefCell; use std::sync::mpsc::{channel, Receiver}; +use std::sync::Mutex; use std::rc::Rc; +use std::collections::HashMap; use content::Content; @@ -59,14 +62,36 @@ fn refresh_podcasts_view() -> glib::Continue { glib::Continue(false) } -pub fn get_pixbuf_from_path(pd: &Podcast) -> Option { - let img_path = downloader::cache_image(pd)?; - Pixbuf::new_from_file_at_scale(&img_path, 256, 256, true).ok() +lazy_static! { + static ref CACHED_PIXBUFS: Mutex>>> = { + Mutex::new(HashMap::new()) + }; } -pub fn get_pixbuf_from_path_128(pd: &Podcast) -> Option { +// 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 get_pixbuf_from_path(pd: &PodcastCoverQuery, size: u32) -> Option { + let mut hashmap = CACHED_PIXBUFS.lock().unwrap(); + { + let res = hashmap.get(&(pd.id(), size)); + if let Some(px) = res { + let m = px.lock().unwrap(); + return Some(m.clone().into_inner()); + } + } + let img_path = downloader::cache_image(pd)?; - Pixbuf::new_from_file_at_scale(&img_path, 128, 128, true).ok() + let px = Pixbuf::new_from_file_at_scale(&img_path, size as i32, size as i32, true).ok(); + if let Some(px) = px { + hashmap.insert((pd.id(), size), Mutex::new(SendCell::new(px.clone()))); + return Some(px); + } + None } #[cfg(test)] @@ -92,7 +117,7 @@ mod tests { // Get the Podcast let pd = dbqueries::get_podcast_from_source_id(sid).unwrap(); - let pxbuf = get_pixbuf_from_path(&pd); + let pxbuf = get_pixbuf_from_path(&pd.into(), 256); assert!(pxbuf.is_some()); } } diff --git a/hammond-gtk/src/views/episodes.rs b/hammond-gtk/src/views/episodes.rs index 8b13789..2312d15 100644 --- a/hammond-gtk/src/views/episodes.rs +++ b/hammond-gtk/src/views/episodes.rs @@ -1 +1,213 @@ +use gtk; +use gtk::prelude::*; +use chrono::prelude::*; +use hammond_data::dbqueries; +use hammond_data::EpisodeWidgetQuery; + +use widgets::episode::EpisodeWidget; +use utils::get_pixbuf_from_path; + +use std::rc::Rc; + +#[derive(Debug, Clone)] +enum ListSplit { + Today, + Yday, + Week, + Month, + Rest, +} + +#[derive(Debug, Clone)] +pub struct EpisodesView { + pub container: gtk::Box, + 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 EpisodesView { + fn default() -> Self { + let builder = gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view.ui"); + let container: gtk::Box = builder.get_object("container").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(); + + EpisodesView { + container, + frame_parent, + today_box, + yday_box, + week_box, + month_box, + rest_box, + today_list, + yday_list, + week_list, + month_list, + rest_list, + } + } +} + +impl EpisodesView { + pub fn new() -> Rc { + let view = EpisodesView::default(); + let episodes = dbqueries::get_episodes_widgets_with_limit(100).unwrap(); + let now_utc = Utc::now(); + + episodes.into_iter().for_each(|mut ep| { + let viewep = EpisodesViewWidget::new(&mut ep); + + let t = split(&now_utc, i64::from(ep.epoch())); + match t { + ListSplit::Today => { + view.today_list.add(&viewep.container); + } + ListSplit::Yday => { + view.yday_list.add(&viewep.container); + } + ListSplit::Week => { + view.week_list.add(&viewep.container); + } + ListSplit::Month => { + view.month_list.add(&viewep.container); + } + ListSplit::Rest => { + view.rest_list.add(&viewep.container); + } + } + }); + + if view.today_list.get_children().is_empty() { + view.today_box.hide(); + } + + if view.yday_list.get_children().is_empty() { + view.yday_box.hide(); + } + + if view.week_list.get_children().is_empty() { + view.week_box.hide(); + } + + if view.month_list.get_children().is_empty() { + view.month_box.hide(); + } + + if view.rest_list.get_children().is_empty() { + view.rest_box.hide(); + } + + view.container.show_all(); + Rc::new(view) + } + + pub fn is_empty(&self) -> bool { + if !self.today_list.get_children().is_empty() { + return false; + } + + if !self.yday_list.get_children().is_empty() { + return false; + } + + if !self.week_list.get_children().is_empty() { + return false; + } + + if !self.month_list.get_children().is_empty() { + return false; + } + + if !self.rest_list.get_children().is_empty() { + return false; + } + + true + } +} + +fn split(now: &DateTime, 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 EpisodesViewWidget { + container: gtk::Box, + image: gtk::Image, + episode: gtk::Box, +} + +impl Default for EpisodesViewWidget { + fn default() -> Self { + let builder = + gtk::Builder::new_from_resource("/org/gnome/hammond/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, 6); + + EpisodesViewWidget { + container, + image, + episode: ep.container, + } + } +} + +impl EpisodesViewWidget { + fn new(episode: &mut EpisodeWidgetQuery) -> EpisodesViewWidget { + let builder = + gtk::Builder::new_from_resource("/org/gnome/hammond/gtk/episodes_view_widget.ui"); + let container: gtk::Box = builder.get_object("container").unwrap(); + let image: gtk::Image = builder.get_object("cover").unwrap(); + + // FIXME: + if let Ok(pd) = dbqueries::get_podcast_cover_from_id(episode.podcast_id()) { + let img = get_pixbuf_from_path(&pd, 64); + if let Some(i) = img { + image.set_from_pixbuf(&i); + } + } + + let ep = EpisodeWidget::new(episode); + container.pack_start(&ep.container, true, true, 6); + + EpisodesViewWidget { + container, + image, + episode: ep.container, + } + } +} diff --git a/hammond-gtk/src/views/shows.rs b/hammond-gtk/src/views/shows.rs index d12dc58..acd1022 100644 --- a/hammond-gtk/src/views/shows.rs +++ b/hammond-gtk/src/views/shows.rs @@ -111,7 +111,7 @@ impl ShowsChild { fn init(&self, pd: &Podcast) { self.container.set_tooltip_text(pd.title()); - let cover = get_pixbuf_from_path(pd); + let cover = get_pixbuf_from_path(&pd.clone().into(), 256); if let Some(img) = cover { self.cover.set_from_pixbuf(&img); }; diff --git a/hammond-gtk/src/widgets/episode.rs b/hammond-gtk/src/widgets/episode.rs index fd9888d..6c26449 100644 --- a/hammond-gtk/src/widgets/episode.rs +++ b/hammond-gtk/src/widgets/episode.rs @@ -5,6 +5,7 @@ use gtk::prelude::*; use chrono::prelude::*; use open; +use humansize::{file_size_opts as size_opts, FileSize}; use hammond_data::dbqueries; use hammond_data::{EpisodeWidgetQuery, Podcast}; @@ -32,9 +33,9 @@ type Foo = RefCell< thread_local!(static GLOBAL: Foo = RefCell::new(None)); -#[derive(Debug)] -struct EpisodeWidget { - container: gtk::Box, +#[derive(Debug, Clone)] +pub struct EpisodeWidget { + pub container: gtk::Box, play: gtk::Button, delete: gtk::Button, download: gtk::Button, @@ -45,6 +46,8 @@ struct EpisodeWidget { size: gtk::Label, progress: gtk::ProgressBar, progress_label: gtk::Label, + separator1: gtk::Label, + separator2: gtk::Label, } impl Default for EpisodeWidget { @@ -65,6 +68,9 @@ impl Default for EpisodeWidget { let size: gtk::Label = builder.get_object("size_label").unwrap(); let progress_label: gtk::Label = builder.get_object("progress_label").unwrap(); + let separator1: gtk::Label = builder.get_object("separator1").unwrap(); + let separator2: gtk::Label = builder.get_object("separator2").unwrap(); + EpisodeWidget { container, progress, @@ -77,21 +83,23 @@ impl Default for EpisodeWidget { size, date, progress_label, + separator1, + separator2, } } } impl EpisodeWidget { - pub fn new(episode: &mut EpisodeWidgetQuery, pd: &Podcast) -> EpisodeWidget { + pub fn new(episode: &mut EpisodeWidgetQuery) -> EpisodeWidget { let widget = EpisodeWidget::default(); - widget.init(episode, pd); + widget.init(episode); widget } // TODO: calculate lenght. // TODO: wire the progress_bar to the downloader. // TODO: wire the cancel button. - fn init(&self, episode: &mut EpisodeWidgetQuery, pd: &Podcast) { + fn init(&self, episode: &mut EpisodeWidgetQuery) { self.title.set_xalign(0.0); self.title.set_text(episode.title()); @@ -101,21 +109,43 @@ impl EpisodeWidget { .map(|c| c.add_class("dim-label")); } - let progress = self.progress.clone(); - timeout_add(200, move || { - progress.pulse(); - glib::Continue(true) - }); - - if let Some(size) = episode.length() { - let megabytes = size / 1024 / 1024; // episode.length represents bytes - self.size.set_text(&format!("{} MB", megabytes)) + // Declare a custom humansize option struct + // See: https://docs.rs/humansize/1.0.2/humansize/file_size_opts/struct.FileSizeOpts.html + let custom_options = 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: "", }; - let date = Utc.timestamp(i64::from(episode.epoch()), 0) - .format("%b %e") - .to_string(); - self.date.set_text(&date); + if let Some(size) = episode.length() { + if size != 0 { + let s = size.file_size(custom_options); + if let Ok(s) = s { + self.size.set_text(&s); + self.size.show(); + self.separator2.show(); + } + } + }; + + if let Some(secs) = episode.duration() { + self.duration.set_text(&format!("{} min", secs / 60)); + self.duration.show(); + self.separator1.show(); + }; + + let now = Utc::now(); + let date = Utc.timestamp(i64::from(episode.epoch()), 0); + if now.year() == date.year() { + self.date.set_text(&date.format("%e %b").to_string()); + } else { + self.date.set_text(&date.format("%e %b %Y").to_string()); + }; // Show or hide the play/delete/download buttons upon widget initialization. let local_uri = episode.local_uri(); @@ -147,21 +177,19 @@ impl EpisodeWidget { download.show(); })); - let pd_title = pd.title().to_owned(); let play = &self.play; let delete = &self.delete; let cancel = &self.cancel; - let progress = &self.progress; + let progress = self.progress.clone(); self.download.connect_clicked( clone!(play, delete, episode, cancel, progress => move |dl| { on_download_clicked( - &pd_title, &mut episode.clone(), dl, &play, &delete, &cancel, - &progress + progress.clone() ); }), ); @@ -170,34 +198,42 @@ impl EpisodeWidget { // TODO: show notification when dl is finished. fn on_download_clicked( - pd_title: &str, ep: &mut EpisodeWidgetQuery, download_bttn: >k::Button, play_bttn: >k::Button, del_bttn: >k::Button, cancel_bttn: >k::Button, - progress_bar: >k::ProgressBar, + progress_bar: gtk::ProgressBar, ) { + let progress = progress_bar.clone(); + + // Start the proggress_bar pulse. + timeout_add(200, move || { + progress_bar.pulse(); + glib::Continue(true) + }); + // Create a async channel. let (sender, receiver) = channel(); // Pass the desired arguments into the Local Thread Storage. GLOBAL.with( - clone!(download_bttn, play_bttn, del_bttn, cancel_bttn, progress_bar => move |global| { + clone!(download_bttn, play_bttn, del_bttn, cancel_bttn, progress => move |global| { *global.borrow_mut() = Some(( download_bttn, play_bttn, del_bttn, cancel_bttn, - progress_bar, + progress, receiver)); }), ); - let pd_title = pd_title.to_owned(); + let pd = dbqueries::get_podcast_from_id(ep.podcast_id()).unwrap(); + let pd_title = pd.title().to_owned(); let mut ep = ep.clone(); cancel_bttn.show(); - progress_bar.show(); + progress.show(); download_bttn.hide(); thread::spawn(move || { let download_fold = downloader::get_download_folder(&pd_title).unwrap(); @@ -232,7 +268,9 @@ fn on_play_bttn_clicked(episode_id: i32) { } fn on_delete_bttn_clicked(episode_id: i32) { - let mut ep = dbqueries::get_episode_from_rowid(episode_id).unwrap(); + let mut ep = dbqueries::get_episode_from_rowid(episode_id) + .unwrap() + .into(); let e = delete_local_content(&mut ep); if let Err(err) = e { @@ -270,15 +308,8 @@ pub fn episodes_listbox(pd: &Podcast) -> Result { let list = gtk::ListBox::new(); episodes.into_iter().for_each(|mut ep| { - let widget = EpisodeWidget::new(&mut ep, pd); + let widget = EpisodeWidget::new(&mut ep); list.add(&widget.container); - - let sep = gtk::Separator::new(gtk::Orientation::Vertical); - sep.set_sensitive(false); - sep.set_can_focus(false); - - list.add(&sep); - sep.show() }); list.set_vexpand(false); diff --git a/hammond-gtk/src/widgets/show.rs b/hammond-gtk/src/widgets/show.rs index c232661..66da950 100644 --- a/hammond-gtk/src/widgets/show.rs +++ b/hammond-gtk/src/widgets/show.rs @@ -10,8 +10,8 @@ use hammond_data::utils::replace_extra_spaces; use hammond_downloader::downloader; use widgets::episode::episodes_listbox; -use utils::get_pixbuf_from_path_128; -use content::ShowStack; +use utils::get_pixbuf_from_path; +use content::{EpisodeStack, ShowStack}; use headerbar::Header; use std::rc::Rc; @@ -40,10 +40,6 @@ impl Default for ShowWidget { let link: gtk::Button = builder.get_object("link_button").unwrap(); let settings: gtk::MenuButton = builder.get_object("settings_button").unwrap(); - unsub - .get_style_context() - .map(|c| c.add_class("destructive-action")); - ShowWidget { container, cover, @@ -57,18 +53,30 @@ impl Default for ShowWidget { } impl ShowWidget { - pub fn new(shows: Rc, header: Rc
, pd: &Podcast) -> ShowWidget { + pub fn new( + shows: Rc, + epstack: Rc, + header: Rc
, + pd: &Podcast, + ) -> ShowWidget { let pdw = ShowWidget::default(); - pdw.init(shows, header, pd); + pdw.init(shows, epstack, header, pd); pdw } - pub fn init(&self, shows: Rc, header: Rc
, pd: &Podcast) { + pub fn init( + &self, + shows: Rc, + epstack: Rc, + header: Rc
, + pd: &Podcast, + ) { WidgetExt::set_name(&self.container, &pd.id().to_string()); // TODO: should spawn a thread to avoid locking the UI probably. - self.unsub.connect_clicked(clone!(shows, pd => move |bttn| { - on_unsub_button_clicked(shows.clone(), &pd, bttn); + self.unsub + .connect_clicked(clone!(shows, epstack, pd => move |bttn| { + on_unsub_button_clicked(shows.clone(), epstack.clone(), &pd, bttn); header.switch_to_normal(); })); @@ -81,7 +89,7 @@ impl ShowWidget { let desc = dissolve::strip_html_tags(pd.description()).join(" "); self.description.set_text(&replace_extra_spaces(&desc)); - let img = get_pixbuf_from_path_128(pd); + let img = get_pixbuf_from_path(&pd.clone().into(), 128); if let Some(i) = img { self.cover.set_from_pixbuf(&i); } @@ -98,7 +106,12 @@ impl ShowWidget { } } -fn on_unsub_button_clicked(shows: Rc, pd: &Podcast, unsub_button: >k::Button) { +fn on_unsub_button_clicked( + shows: Rc, + epstack: Rc, + pd: &Podcast, + unsub_button: >k::Button, +) { let res = dbqueries::remove_feed(pd); if res.is_ok() { info!("{} was removed succesfully.", pd.title()); @@ -116,6 +129,7 @@ fn on_unsub_button_clicked(shows: Rc, pd: &Podcast, unsub_button: > } shows.switch_podcasts_animated(); shows.update_podcasts(); + epstack.update(); } #[allow(dead_code)]