Merge pull request #16 from floreal/formating-and-clippy
Fixed: Clippy & Formatting
This commit is contained in:
commit
4c876c0a9d
189
src/api.rs
189
src/api.rs
|
@ -1,171 +1,170 @@
|
||||||
use serde_derive::*;
|
|
||||||
use lazy_static::*;
|
use lazy_static::*;
|
||||||
use workerpool::Worker;
|
use serde_derive::*;
|
||||||
use std::{
|
use std::sync::{
|
||||||
sync::{
|
mpsc::{channel, Receiver},
|
||||||
Arc,
|
Arc, Mutex,
|
||||||
mpsc::{channel, Receiver},
|
|
||||||
Mutex,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
use workerpool::Worker;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref API: Arc<Mutex<Option<RequestContext>>>
|
pub static ref API: Arc<Mutex<Option<RequestContext>>> = Arc::new(Mutex::new(None));
|
||||||
= Arc::new(Mutex::new(None));
|
static ref JOBS: workerpool::Pool<Req> = workerpool::Pool::new(5);
|
||||||
|
|
||||||
static ref JOBS: workerpool::Pool<Req>
|
|
||||||
= workerpool::Pool::new(5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn execute(req: reqwest::RequestBuilder) -> Receiver<reqwest::Response> {
|
pub fn execute(req: reqwest::RequestBuilder) -> Receiver<reqwest::Response> {
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
JOBS.execute_to(tx, req);
|
JOBS.execute_to(tx, req);
|
||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RequestContext {
|
pub struct RequestContext {
|
||||||
token: String,
|
token: String,
|
||||||
instance: String,
|
instance: String,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestContext {
|
impl RequestContext {
|
||||||
pub fn new(instance: String) -> Self {
|
pub fn new(instance: String) -> Self {
|
||||||
RequestContext {
|
RequestContext {
|
||||||
token: String::new(),
|
token: String::new(),
|
||||||
instance: instance.clone(),
|
instance,
|
||||||
client: reqwest::Client::new()
|
client: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn auth(&mut self, token: String) {
|
pub fn auth(&mut self, token: String) {
|
||||||
self.token = token.clone();
|
self.token = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get<S: AsRef<str>>(&self, url: S) -> reqwest::RequestBuilder {
|
pub fn get<S: AsRef<str>>(&self, url: S) -> reqwest::RequestBuilder {
|
||||||
self.client
|
self
|
||||||
.get(&format!("{}{}", self.instance, url.as_ref()))
|
.client
|
||||||
.header(reqwest::header::AUTHORIZATION, format!("JWT {}", self.token))
|
.get(&format!("{}{}", self.instance, url.as_ref()))
|
||||||
}
|
.header(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
format!("JWT {}", self.token),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Warning: no authentication, since it is only used for login
|
/// Warning: no authentication, since it is only used for login
|
||||||
pub fn post<S: AsRef<str>>(&self, url: S) -> reqwest::RequestBuilder {
|
pub fn post<S: AsRef<str>>(&self, url: S) -> reqwest::RequestBuilder {
|
||||||
self.client
|
self
|
||||||
.post(&format!("{}{}", self.instance, url.as_ref()))
|
.client
|
||||||
}
|
.post(&format!("{}{}", self.instance, url.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn to_json(&self) -> serde_json::Value {
|
pub fn to_json(&self) -> serde_json::Value {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"token": self.token,
|
"token": self.token,
|
||||||
"instance": self.instance,
|
"instance": self.instance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Req;
|
pub struct Req;
|
||||||
|
|
||||||
impl Worker for Req {
|
impl Worker for Req {
|
||||||
type Input = reqwest::RequestBuilder;
|
type Input = reqwest::RequestBuilder;
|
||||||
type Output = reqwest::Response;
|
type Output = reqwest::Response;
|
||||||
|
|
||||||
fn execute(&mut self, req: Self::Input) -> Self::Output {
|
fn execute(&mut self, req: Self::Input) -> Self::Output {
|
||||||
req.send().expect("Error while sending request")
|
req.send().expect("Error while sending request")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct LoginData {
|
pub struct LoginData {
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct LoginInfo {
|
pub struct LoginInfo {
|
||||||
pub token: String
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct UserInfo {
|
pub struct UserInfo {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub avatar: Image,
|
pub avatar: Image,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct Image {
|
pub struct Image {
|
||||||
pub medium_square_crop: Option<String>,
|
pub medium_square_crop: Option<String>,
|
||||||
pub small_square_crop: Option<String>,
|
pub small_square_crop: Option<String>,
|
||||||
pub original: Option<String>,
|
pub original: Option<String>,
|
||||||
pub square_crop: Option<String>,
|
pub square_crop: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct SearchQuery {
|
pub struct SearchQuery {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct SearchResult {
|
pub struct SearchResult {
|
||||||
pub artists: Vec<Artist>,
|
pub artists: Vec<Artist>,
|
||||||
pub albums: Vec<Album>,
|
pub albums: Vec<Album>,
|
||||||
pub tracks: Vec<Track>,
|
pub tracks: Vec<Track>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct Artist {
|
pub struct Artist {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub albums: Option<Vec<ArtistAlbum>>,
|
pub albums: Option<Vec<ArtistAlbum>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct Album {
|
pub struct Album {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub artist: ArtistPreview,
|
pub artist: ArtistPreview,
|
||||||
pub tracks: Option<Vec<AlbumTrack>>,
|
pub tracks: Option<Vec<AlbumTrack>>,
|
||||||
pub cover: Image,
|
pub cover: Image,
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct ArtistAlbum {
|
pub struct ArtistAlbum {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub tracks_count: i32,
|
pub tracks_count: i32,
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub cover: Image,
|
pub cover: Image,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct Track {
|
pub struct Track {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub album: Album,
|
pub album: Album,
|
||||||
pub artist: ArtistPreview,
|
pub artist: ArtistPreview,
|
||||||
pub listen_url: String,
|
pub listen_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct ArtistPreview {
|
pub struct ArtistPreview {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct AlbumTrack {
|
pub struct AlbumTrack {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub artist: ArtistPreview,
|
pub artist: ArtistPreview,
|
||||||
pub listen_url: String,
|
pub listen_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AlbumTrack {
|
impl AlbumTrack {
|
||||||
pub fn into_full(self, album: &Album) -> Track {
|
pub fn into_full(self, album: &Album) -> Track {
|
||||||
let mut album = album.clone();
|
let mut album = album.clone();
|
||||||
album.tracks = None;
|
album.tracks = None;
|
||||||
Track {
|
Track {
|
||||||
album: album,
|
album,
|
||||||
id: self.id,
|
id: self.id,
|
||||||
title: self.title,
|
title: self.title,
|
||||||
artist: self.artist,
|
artist: self.artist,
|
||||||
listen_url: self.listen_url,
|
listen_url: self.listen_url,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
372
src/main.rs
372
src/main.rs
|
@ -1,11 +1,11 @@
|
||||||
use gtk::{self, prelude::*, *};
|
use gtk::{self, prelude::*, *};
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
cell::RefCell,
|
||||||
cell::RefCell,
|
collections::HashMap,
|
||||||
rc::Rc,
|
fs,
|
||||||
sync::{Arc, Mutex},
|
path::PathBuf,
|
||||||
fs,
|
rc::Rc,
|
||||||
path::PathBuf,
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
macro_rules! clone {
|
macro_rules! clone {
|
||||||
|
@ -35,34 +35,32 @@ macro_rules! rc {
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! wait {
|
macro_rules! wait {
|
||||||
($exp:expr => | const $res:ident | $then:block) => {
|
($exp:expr => | const $res:ident | $then:block) => {
|
||||||
let rx = $exp;
|
let rx = $exp;
|
||||||
gtk::idle_add(move || {
|
gtk::idle_add(move || match rx.try_recv() {
|
||||||
match rx.try_recv() {
|
Err(_) => glib::Continue(true),
|
||||||
Err(_) => glib::Continue(true),
|
Ok($res) => {
|
||||||
Ok($res) => {
|
$then;
|
||||||
$then;
|
glib::Continue(false)
|
||||||
glib::Continue(false)
|
}
|
||||||
},
|
})
|
||||||
}
|
};
|
||||||
})
|
($exp:expr => | $res:ident | $then:block) => {
|
||||||
};
|
let rx = $exp;
|
||||||
($exp:expr => | $res:ident | $then:block) => {
|
gtk::idle_add(move || match rx.try_recv() {
|
||||||
let rx = $exp;
|
Err(_) => glib::Continue(true),
|
||||||
gtk::idle_add(move || {
|
Ok(mut $res) => {
|
||||||
match rx.try_recv() {
|
$then;
|
||||||
Err(_) => glib::Continue(true),
|
glib::Continue(false)
|
||||||
Ok(mut $res) => {
|
}
|
||||||
$then;
|
})
|
||||||
glib::Continue(false)
|
};
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! client {
|
macro_rules! client {
|
||||||
() => (crate::api::API.lock().unwrap().as_ref().unwrap())
|
() => {
|
||||||
|
crate::api::API.lock().unwrap().as_ref().unwrap()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
|
@ -70,194 +68,230 @@ mod ui;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
window: Rc<RefCell<Window>>,
|
window: Rc<RefCell<Window>>,
|
||||||
stack: Stack,
|
stack: Stack,
|
||||||
error: InfoBar,
|
error: InfoBar,
|
||||||
header: HeaderBar,
|
header: HeaderBar,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type State = Rc<RefCell<AppState>>;
|
pub type State = Rc<RefCell<AppState>>;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum DlStatus {
|
pub enum DlStatus {
|
||||||
Planned,
|
Planned,
|
||||||
Started,
|
Started,
|
||||||
Done,
|
Done,
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Download {
|
pub struct Download {
|
||||||
url: String,
|
url: String,
|
||||||
status: DlStatus,
|
status: DlStatus,
|
||||||
output: PathBuf,
|
output: PathBuf,
|
||||||
track: api::Track,
|
track: api::Track,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Download {
|
impl Download {
|
||||||
pub fn ended(&mut self, out: PathBuf) {
|
pub fn ended(&mut self, out: PathBuf) {
|
||||||
self.status = DlStatus::Done;
|
self.status = DlStatus::Done;
|
||||||
self.output = out;
|
self.output = out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref DOWNLOADS: Arc<Mutex<HashMap<i32, Download>>> = Arc::new(Mutex::new(HashMap::new()));
|
static ref DOWNLOADS: Arc<Mutex<HashMap<i32, Download>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
static ref DL_JOBS: workerpool::Pool<TrackDl> = workerpool::Pool::new(5);
|
static ref DL_JOBS: workerpool::Pool<TrackDl> = workerpool::Pool::new(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct TrackDl;
|
struct TrackDl;
|
||||||
|
|
||||||
impl workerpool::Worker for TrackDl {
|
impl workerpool::Worker for TrackDl {
|
||||||
type Input = Download;
|
type Input = Download;
|
||||||
type Output = ();
|
type Output = ();
|
||||||
|
|
||||||
fn execute(&mut self, dl: Self::Input) -> Self::Output {
|
fn execute(&mut self, dl: Self::Input) -> Self::Output {
|
||||||
if dl.status == DlStatus::Cancelled {
|
if dl.status == DlStatus::Cancelled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut dls = DOWNLOADS.lock().unwrap();
|
let mut dls = DOWNLOADS.lock().unwrap();
|
||||||
let mut dl = dls.get_mut(&dl.track.id).unwrap();
|
let mut dl = dls.get_mut(&dl.track.id).unwrap();
|
||||||
dl.status = DlStatus::Started;
|
dl.status = DlStatus::Started;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut res = client!().get(&dl.url).send().unwrap();
|
let mut res = client!().get(&dl.url).send().unwrap();
|
||||||
|
|
||||||
let ext = res.headers()
|
let ext = res
|
||||||
.get(reqwest::header::CONTENT_DISPOSITION).and_then(|h| h.to_str().ok())
|
.headers()
|
||||||
.unwrap_or(".mp3")
|
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||||
.rsplitn(2, ".").next().unwrap_or("mp3");
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.unwrap_or(".mp3")
|
||||||
|
.rsplitn(2, '.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("mp3");
|
||||||
|
|
||||||
fs::create_dir_all(dl.output.clone().parent().unwrap()).unwrap();
|
fs::create_dir_all(dl.output.parent().unwrap()).unwrap();
|
||||||
let mut out = dl.output.clone();
|
let mut out = dl.output.clone();
|
||||||
out.set_extension(ext);
|
out.set_extension(ext);
|
||||||
let mut file = fs::File::create(out.clone()).unwrap();
|
let mut file = fs::File::create(out.clone()).unwrap();
|
||||||
|
|
||||||
res.copy_to(&mut file).unwrap();
|
res.copy_to(&mut file).unwrap();
|
||||||
|
|
||||||
let mut dls = DOWNLOADS.lock().unwrap();
|
let mut dls = DOWNLOADS.lock().unwrap();
|
||||||
if let Some(dl) = dls.get_mut(&dl.track.id) {
|
if let Some(dl) = dls.get_mut(&dl.track.id) {
|
||||||
dl.ended(out);
|
dl.ended(out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
if gtk::init().is_err() {
|
if gtk::init().is_err() {
|
||||||
println!("Failed to initialize GTK.");
|
println!("Failed to initialize GTK.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let window = Window::new(WindowType::Toplevel);
|
let window = Window::new(WindowType::Toplevel);
|
||||||
window.set_icon_from_file("icons/128.svg").ok();
|
window.set_icon_from_file("icons/128.svg").ok();
|
||||||
window.set_title("Mobydick");
|
window.set_title("Mobydick");
|
||||||
window.set_default_size(1080, 720);
|
window.set_default_size(1080, 720);
|
||||||
|
|
||||||
window.connect_delete_event(move |_, _| {
|
window.connect_delete_event(move |_, _| {
|
||||||
gtk::main_quit();
|
gtk::main_quit();
|
||||||
|
|
||||||
fs::create_dir_all(dirs::config_dir().unwrap().join("mobydick")).unwrap();
|
fs::create_dir_all(dirs::config_dir().unwrap().join("mobydick")).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
dirs::config_dir().unwrap().join("mobydick").join("data.json"),
|
dirs::config_dir()
|
||||||
serde_json::to_string(&client!().to_json()).unwrap()
|
.unwrap()
|
||||||
).unwrap();
|
.join("mobydick")
|
||||||
|
.join("data.json"),
|
||||||
|
serde_json::to_string(&client!().to_json()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
Inhibit(false)
|
Inhibit(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
init(Rc::new(RefCell::new(window)));
|
init(Rc::new(RefCell::new(window)));
|
||||||
|
|
||||||
gtk::main();
|
gtk::main();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init(window: Rc<RefCell<Window>>) {
|
fn init(window: Rc<RefCell<Window>>) {
|
||||||
let connected = fs::read(dirs::config_dir().unwrap().join("mobydick").join("data.json")).ok().and_then(|f| {
|
let connected = fs::read(
|
||||||
let json: serde_json::Value = serde_json::from_slice(&f).ok()?;
|
dirs::config_dir()
|
||||||
let mut api_ctx = crate::api::API.lock().ok()?;
|
.unwrap()
|
||||||
let mut ctx = api::RequestContext::new(json["instance"].as_str()?.to_string());
|
.join("mobydick")
|
||||||
ctx.auth(json["token"].as_str()?.to_string());
|
.join("data.json"),
|
||||||
*api_ctx = Some(ctx);
|
)
|
||||||
|
.ok()
|
||||||
|
.and_then(|f| {
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&f).ok()?;
|
||||||
|
let mut api_ctx = crate::api::API.lock().ok()?;
|
||||||
|
let mut ctx = api::RequestContext::new(json["instance"].as_str()?.to_string());
|
||||||
|
ctx.auth(json["token"].as_str()?.to_string());
|
||||||
|
*api_ctx = Some(ctx);
|
||||||
|
|
||||||
Some(())
|
Some(())
|
||||||
}).is_some();
|
})
|
||||||
|
.is_some();
|
||||||
|
|
||||||
let state = Rc::new(RefCell::new(AppState {
|
let state = Rc::new(RefCell::new(AppState {
|
||||||
window: window.clone(),
|
window: window.clone(),
|
||||||
stack: {
|
stack: {
|
||||||
let s = Stack::new();
|
let s = Stack::new();
|
||||||
s.set_vexpand(true);
|
s.set_vexpand(true);
|
||||||
s
|
s
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
let error = InfoBar::new();
|
let error = InfoBar::new();
|
||||||
error.set_revealed(false);
|
error.set_revealed(false);
|
||||||
error.set_message_type(MessageType::Error);
|
error.set_message_type(MessageType::Error);
|
||||||
error.get_content_area().unwrap().downcast::<gtk::Box>().unwrap().add(&Label::new("Test test"));
|
error
|
||||||
error.set_show_close_button(true);
|
.get_content_area()
|
||||||
error.connect_close(|e| e.set_revealed(false));
|
.unwrap()
|
||||||
error.connect_response(|e, _| e.set_revealed(false));
|
.downcast::<gtk::Box>()
|
||||||
error
|
.unwrap()
|
||||||
},
|
.add(&Label::new("Test test"));
|
||||||
header: {
|
error.set_show_close_button(true);
|
||||||
let h = HeaderBar::new();
|
error.connect_close(|e| e.set_revealed(false));
|
||||||
h.set_show_close_button(true);
|
error.connect_response(|e, _| e.set_revealed(false));
|
||||||
h.set_title("Mobydick");
|
error
|
||||||
h
|
},
|
||||||
},
|
header: {
|
||||||
}));
|
let h = HeaderBar::new();
|
||||||
|
h.set_show_close_button(true);
|
||||||
|
h.set_title("Mobydick");
|
||||||
|
h
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
let main_box = gtk::Box::new(Orientation::Vertical, 0);
|
let main_box = gtk::Box::new(Orientation::Vertical, 0);
|
||||||
main_box.add(&state.borrow().error);
|
main_box.add(&state.borrow().error);
|
||||||
main_box.add(&state.borrow().stack);
|
main_box.add(&state.borrow().stack);
|
||||||
|
|
||||||
let scrolled = ScrolledWindow::new(None, None);
|
let scrolled = ScrolledWindow::new(None, None);
|
||||||
scrolled.add(&main_box);
|
scrolled.add(&main_box);
|
||||||
window.borrow().add(&scrolled);
|
window.borrow().add(&scrolled);
|
||||||
window.borrow().set_titlebar(&state.borrow().header);
|
window.borrow().set_titlebar(&state.borrow().header);
|
||||||
window.borrow().show_all();
|
window.borrow().show_all();
|
||||||
|
|
||||||
if connected {
|
if connected {
|
||||||
let main_page = ui::main_page::render(
|
let main_page =
|
||||||
state.borrow().window.clone(),
|
ui::main_page::render(state.borrow().window.clone(), &state.borrow().header, &{
|
||||||
&state.borrow().header,
|
let s = StackSwitcher::new();
|
||||||
&{
|
s.set_stack(&state.borrow().stack);
|
||||||
let s = StackSwitcher::new();
|
s
|
||||||
s.set_stack(&state.borrow().stack);
|
});
|
||||||
s
|
state
|
||||||
}
|
.borrow()
|
||||||
);
|
.stack
|
||||||
state.borrow().stack.add_titled(&main_page, "main", "Search Music");
|
.add_titled(&main_page, "main", "Search Music");
|
||||||
state.borrow().stack.add_titled(&*ui::dl_list::render().borrow(), "downloads", "Downloads");
|
state
|
||||||
state.borrow().stack.set_visible_child_name("main");
|
.borrow()
|
||||||
} else {
|
.stack
|
||||||
let login_page = ui::login_page::render(state.clone());
|
.add_titled(&*ui::dl_list::render().borrow(), "downloads", "Downloads");
|
||||||
state.borrow().stack.add_named(&login_page, "login");
|
state.borrow().stack.set_visible_child_name("main");
|
||||||
}
|
} else {
|
||||||
|
let login_page = ui::login_page::render(state.clone());
|
||||||
|
state.borrow().stack.add_named(&login_page, "login");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_error(state: State, msg: &str) {
|
fn show_error(state: State, msg: &str) {
|
||||||
let b = state.borrow().error.get_content_area().unwrap().downcast::<gtk::Box>().unwrap();
|
let b = state
|
||||||
for ch in b.get_children() {
|
.borrow()
|
||||||
b.remove(&ch);
|
.error
|
||||||
}
|
.get_content_area()
|
||||||
b.add(&Label::new(msg));
|
.unwrap()
|
||||||
state.borrow().error.show_all();
|
.downcast::<gtk::Box>()
|
||||||
state.borrow().error.set_revealed(true);
|
.unwrap();
|
||||||
|
for ch in b.get_children() {
|
||||||
|
b.remove(&ch);
|
||||||
|
}
|
||||||
|
b.add(&Label::new(msg));
|
||||||
|
state.borrow().error.show_all();
|
||||||
|
state.borrow().error.set_revealed(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn logout(window: Rc<RefCell<Window>>) {
|
fn logout(window: Rc<RefCell<Window>>) {
|
||||||
fs::remove_file(dirs::config_dir().unwrap().join("mobydick").join("data.json")).ok();
|
fs::remove_file(
|
||||||
*api::API.lock().unwrap() = None;
|
dirs::config_dir()
|
||||||
*DOWNLOADS.lock().unwrap() = HashMap::new();
|
.unwrap()
|
||||||
{
|
.join("mobydick")
|
||||||
let window = window.borrow();
|
.join("data.json"),
|
||||||
for ch in window.get_children() {
|
)
|
||||||
window.remove(&ch);
|
.ok();
|
||||||
}
|
*api::API.lock().unwrap() = None;
|
||||||
}
|
*DOWNLOADS.lock().unwrap() = HashMap::new();
|
||||||
init(window)
|
{
|
||||||
}
|
let window = window.borrow();
|
||||||
|
for ch in window.get_children() {
|
||||||
|
window.remove(&ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init(window)
|
||||||
|
}
|
||||||
|
|
426
src/ui/card.rs
426
src/ui/card.rs
|
@ -1,240 +1,270 @@
|
||||||
|
use crate::{api, ui::network_image::NetworkImage, DlStatus, Download};
|
||||||
use gtk::*;
|
use gtk::*;
|
||||||
use std::{thread, sync::mpsc::channel, rc::Rc, cell::RefCell};
|
use std::{cell::RefCell, rc::Rc, sync::mpsc::channel, thread};
|
||||||
use crate::{Download, DlStatus, api, ui::network_image::NetworkImage};
|
|
||||||
|
|
||||||
pub fn render<T>(model: T) -> Rc<RefCell<Grid>> where T: CardModel + 'static {
|
pub fn render<T>(model: T) -> Rc<RefCell<Grid>>
|
||||||
let card = Grid::new();
|
where
|
||||||
card.set_column_spacing(12);
|
T: CardModel + 'static,
|
||||||
card.set_valign(Align::Start);
|
{
|
||||||
|
let card = Grid::new();
|
||||||
|
card.set_column_spacing(12);
|
||||||
|
card.set_valign(Align::Start);
|
||||||
|
|
||||||
if let Some(url) = model.image_url() {
|
if let Some(url) = model.image_url() {
|
||||||
let img = NetworkImage::new(url);
|
let img = NetworkImage::new(url);
|
||||||
card.attach(&*img.img.borrow(), 0, 0, 1, 2);
|
card.attach(&*img.img.borrow(), 0, 0, 1, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
let main_text = Label::new(model.text().as_ref());
|
let main_text = Label::new(model.text().as_ref());
|
||||||
main_text.get_style_context().map(|c| c.add_class("h3"));
|
if let Some(c) = main_text.get_style_context() {
|
||||||
main_text.set_hexpand(true);
|
c.add_class("h3")
|
||||||
main_text.set_halign(Align::Start);
|
}
|
||||||
let sub_text = Label::new(model.subtext().as_ref());
|
main_text.set_hexpand(true);
|
||||||
sub_text.get_style_context().map(|c| c.add_class("dim-label"));
|
main_text.set_halign(Align::Start);
|
||||||
sub_text.set_hexpand(true);
|
let sub_text = Label::new(model.subtext().as_ref());
|
||||||
sub_text.set_halign(Align::Start);
|
if let Some(c) = sub_text.get_style_context() {
|
||||||
|
c.add_class("dim-label")
|
||||||
|
}
|
||||||
|
sub_text.set_hexpand(true);
|
||||||
|
sub_text.set_halign(Align::Start);
|
||||||
|
|
||||||
rc!(card);
|
rc!(card);
|
||||||
if let Some(dl) = model.download_status() {
|
if let Some(dl) = model.download_status() {
|
||||||
match dl.status {
|
match dl.status {
|
||||||
DlStatus::Done => {
|
DlStatus::Done => {
|
||||||
let open_bt = Button::new_with_label("Play");
|
let open_bt = Button::new_with_label("Play");
|
||||||
open_bt.set_valign(Align::Center);
|
open_bt.set_valign(Align::Center);
|
||||||
open_bt.set_vexpand(true);
|
open_bt.set_vexpand(true);
|
||||||
open_bt.get_style_context().map(|c| c.add_class("suggested-action"));
|
if let Some(c) = open_bt.get_style_context() {
|
||||||
|
c.add_class("suggested-action")
|
||||||
|
}
|
||||||
|
|
||||||
let out = dl.output.clone();
|
let out = dl.output.clone();
|
||||||
open_bt.connect_clicked(move |_| {
|
open_bt.connect_clicked(move |_| {
|
||||||
open::that(out.clone()).unwrap();
|
open::that(out.clone()).unwrap();
|
||||||
println!("opened file");
|
println!("opened file");
|
||||||
});
|
});
|
||||||
card.borrow().attach(&open_bt, 3, 0, 1, 2);
|
card.borrow().attach(&open_bt, 3, 0, 1, 2);
|
||||||
|
|
||||||
let open_bt = Button::new_with_label("View File");
|
let open_bt = Button::new_with_label("View File");
|
||||||
open_bt.set_valign(Align::Center);
|
open_bt.set_valign(Align::Center);
|
||||||
open_bt.set_vexpand(true);
|
open_bt.set_vexpand(true);
|
||||||
|
|
||||||
let out = dl.output.clone();
|
let out = dl.output;
|
||||||
open_bt.connect_clicked(move |_| {
|
open_bt.connect_clicked(move |_| {
|
||||||
open::that(out.parent().unwrap().clone()).unwrap();
|
open::that(&(*out.parent().unwrap())).unwrap();
|
||||||
println!("opened folder");
|
println!("opened folder");
|
||||||
});
|
});
|
||||||
card.borrow().attach(&open_bt, 2, 0, 1, 2);
|
card.borrow().attach(&open_bt, 2, 0, 1, 2);
|
||||||
},
|
}
|
||||||
DlStatus::Planned | DlStatus::Started => {
|
DlStatus::Planned | DlStatus::Started => {
|
||||||
let cancel_bt = Button::new_with_label("Cancel");
|
let cancel_bt = Button::new_with_label("Cancel");
|
||||||
cancel_bt.set_valign(Align::Center);
|
cancel_bt.set_valign(Align::Center);
|
||||||
cancel_bt.set_vexpand(true);
|
cancel_bt.set_vexpand(true);
|
||||||
cancel_bt.get_style_context().map(|c| c.add_class("destructive-action"));
|
if let Some(c) = cancel_bt.get_style_context() {
|
||||||
|
c.add_class("destructive-action")
|
||||||
|
}
|
||||||
|
|
||||||
let track_id = dl.track.id;
|
let track_id = dl.track.id;
|
||||||
cancel_bt.connect_clicked(move |_| {
|
cancel_bt.connect_clicked(move |_| {
|
||||||
let mut dls = crate::DOWNLOADS.lock().unwrap();
|
let mut dls = crate::DOWNLOADS.lock().unwrap();
|
||||||
let mut dl = dls.get_mut(&track_id).unwrap();
|
let mut dl = dls.get_mut(&track_id).unwrap();
|
||||||
dl.status = DlStatus::Cancelled;
|
dl.status = DlStatus::Cancelled;
|
||||||
println!("Cancelled");
|
println!("Cancelled");
|
||||||
});
|
});
|
||||||
card.borrow().attach(&cancel_bt, 3, 0, 1, 2);
|
card.borrow().attach(&cancel_bt, 3, 0, 1, 2);
|
||||||
|
|
||||||
if dl.status == DlStatus::Planned {
|
if dl.status == DlStatus::Planned {
|
||||||
sub_text.set_text(format!("{} — Waiting to download", model.subtext()).as_ref());
|
sub_text.set_text(format!("{} — Waiting to download", model.subtext()).as_ref());
|
||||||
} else {
|
} else {
|
||||||
sub_text.set_text(format!("{} — Download in progress", model.subtext()).as_ref());
|
sub_text.set_text(format!("{} — Download in progress", model.subtext()).as_ref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DlStatus::Cancelled => {
|
DlStatus::Cancelled => {
|
||||||
sub_text.set_text(format!("{} — Cancelled", model.subtext()).as_ref());
|
sub_text.set_text(format!("{} — Cancelled", model.subtext()).as_ref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let dl_bt = Button::new_with_label("Download");
|
let dl_bt = Button::new_with_label("Download");
|
||||||
dl_bt.set_valign(Align::Center);
|
dl_bt.set_valign(Align::Center);
|
||||||
dl_bt.set_vexpand(true);
|
dl_bt.set_vexpand(true);
|
||||||
dl_bt.get_style_context().map(|c| c.add_class("suggested-action"));
|
if let Some(c) = dl_bt.get_style_context() {
|
||||||
|
c.add_class("suggested-action")
|
||||||
|
}
|
||||||
|
|
||||||
rc!(dl_bt);
|
rc!(dl_bt);
|
||||||
{
|
{
|
||||||
clone!(dl_bt, card);
|
clone!(dl_bt, card);
|
||||||
wait!({ // Fetch the list of files to download
|
wait!({ // Fetch the list of files to download
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let dl_list = model.downloads();
|
let dl_list = model.downloads();
|
||||||
tx.send(dl_list).unwrap();
|
tx.send(dl_list).unwrap();
|
||||||
});
|
});
|
||||||
rx
|
rx
|
||||||
} => | const dl_list | {
|
} => | const dl_list | {
|
||||||
let dl_bt = dl_bt.borrow();
|
let dl_bt = dl_bt.borrow();
|
||||||
if dl_list.is_empty() { // Nothing to download
|
if dl_list.is_empty() { // Nothing to download
|
||||||
dl_bt.set_label("Not available");
|
dl_bt.set_label("Not available");
|
||||||
dl_bt.set_sensitive(false);
|
dl_bt.set_sensitive(false);
|
||||||
} else {
|
} else {
|
||||||
clone!(dl_list);
|
clone!(dl_list);
|
||||||
dl_bt.connect_clicked(move |_| {
|
dl_bt.connect_clicked(move |_| {
|
||||||
for dl in dl_list.clone() {
|
for dl in dl_list.clone() {
|
||||||
let mut dls = crate::DOWNLOADS.lock().unwrap();
|
let mut dls = crate::DOWNLOADS.lock().unwrap();
|
||||||
dls.insert(dl.track.id, dl.clone());
|
dls.insert(dl.track.id, dl.clone());
|
||||||
|
|
||||||
crate::DL_JOBS.execute(dl);
|
crate::DL_JOBS.execute(dl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if dl_list.len() > 1 { // Not only one song
|
if dl_list.len() > 1 { // Not only one song
|
||||||
let more_bt = Button::new_with_label("Details");
|
let more_bt = Button::new_with_label("Details");
|
||||||
more_bt.set_valign(Align::Center);
|
more_bt.set_valign(Align::Center);
|
||||||
more_bt.set_vexpand(true);
|
more_bt.set_vexpand(true);
|
||||||
card.borrow().attach(&more_bt, 2, 0, 1, 2);
|
card.borrow().attach(&more_bt, 2, 0, 1, 2);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
card.borrow().attach(&*dl_bt.borrow(), 3, 0, 1, 2);
|
card.borrow().attach(&*dl_bt.borrow(), 3, 0, 1, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let card = card.borrow();
|
let card = card.borrow();
|
||||||
card.attach(&main_text, 1, 0, 1, 1);
|
card.attach(&main_text, 1, 0, 1, 1);
|
||||||
card.attach(&sub_text, 1, 1, 1, 1);
|
card.attach(&sub_text, 1, 1, 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
card
|
card
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait CardModel: Clone + Send + Sync {
|
pub trait CardModel: Clone + Send + Sync {
|
||||||
fn text(&self) -> String;
|
fn text(&self) -> String;
|
||||||
fn subtext(&self) -> String {
|
fn subtext(&self) -> String {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
fn image_url(&self) -> Option<String> {
|
fn image_url(&self) -> Option<String> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn downloads(&self) -> Vec<Download>;
|
fn downloads(&self) -> Vec<Download>;
|
||||||
|
|
||||||
fn download_status(&self) -> Option<Download> {
|
fn download_status(&self) -> Option<Download> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CardModel for api::Artist {
|
impl CardModel for api::Artist {
|
||||||
fn text(&self) -> String {
|
fn text(&self) -> String {
|
||||||
self.name.clone()
|
self.name.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subtext(&self) -> String {
|
fn subtext(&self) -> String {
|
||||||
format!("{} albums", self.albums.clone().unwrap().len())
|
format!("{} albums", self.albums.clone().unwrap().len())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn image_url(&self) -> Option<String> {
|
fn image_url(&self) -> Option<String> {
|
||||||
self.albums.clone()?.iter()
|
self
|
||||||
.next()
|
.albums
|
||||||
.and_then(|album| album.cover.medium_square_crop.clone())
|
.clone()?
|
||||||
}
|
.iter()
|
||||||
|
.next()
|
||||||
|
.and_then(|album| album.cover.medium_square_crop.clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn downloads(&self) -> Vec<Download> {
|
fn downloads(&self) -> Vec<Download> {
|
||||||
let mut dls = vec![];
|
let mut dls = vec![];
|
||||||
for album in self.albums.clone().unwrap_or_default() {
|
for album in self.albums.clone().unwrap_or_default() {
|
||||||
let album: api::Album = client!().get(&format!("/api/v1/albums/{}/", album.id))
|
let album: api::Album = client!()
|
||||||
.send().unwrap()
|
.get(&format!("/api/v1/albums/{}/", album.id))
|
||||||
.json().unwrap();
|
.send()
|
||||||
|
.unwrap()
|
||||||
|
.json()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
for track in album.clone().tracks.unwrap_or_default() {
|
for track in album.clone().tracks.unwrap_or_default() {
|
||||||
dls.push(Download {
|
dls.push(Download {
|
||||||
url: track.listen_url.clone(),
|
url: track.listen_url.clone(),
|
||||||
output: dirs::audio_dir().unwrap()
|
output: dirs::audio_dir()
|
||||||
.join(self.name.clone())
|
.unwrap()
|
||||||
.join(album.title.clone())
|
.join(self.name.clone())
|
||||||
.join(format!("{}.mp3", track.title.clone())),
|
.join(album.title.clone())
|
||||||
status: DlStatus::Planned,
|
.join(format!("{}.mp3", track.title.clone())),
|
||||||
track: track.clone().into_full(&album),
|
status: DlStatus::Planned,
|
||||||
});
|
track: track.clone().into_full(&album),
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
dls
|
}
|
||||||
}
|
dls
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CardModel for api::Album {
|
impl CardModel for api::Album {
|
||||||
fn text(&self) -> String {
|
fn text(&self) -> String {
|
||||||
self.title.clone()
|
self.title.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subtext(&self) -> String {
|
fn subtext(&self) -> String {
|
||||||
format!("{} tracks, by {}", self.tracks.clone().map(|t| t.len()).unwrap_or_default(), self.artist.name)
|
format!(
|
||||||
}
|
"{} tracks, by {}",
|
||||||
|
self.tracks.clone().map(|t| t.len()).unwrap_or_default(),
|
||||||
|
self.artist.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn image_url(&self) -> Option<String> {
|
fn image_url(&self) -> Option<String> {
|
||||||
self.cover.medium_square_crop.clone()
|
self.cover.medium_square_crop.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn downloads(&self) -> Vec<Download> {
|
fn downloads(&self) -> Vec<Download> {
|
||||||
self.tracks.clone().unwrap_or_default().iter().map(|track|
|
self
|
||||||
Download {
|
.tracks
|
||||||
url: track.listen_url.clone(),
|
.clone()
|
||||||
output: dirs::audio_dir().unwrap()
|
.unwrap_or_default()
|
||||||
.join(self.artist.name.clone())
|
.iter()
|
||||||
.join(self.title.clone())
|
.map(|track| Download {
|
||||||
.join(format!("{}.mp3", track.title.clone())),
|
url: track.listen_url.clone(),
|
||||||
status: DlStatus::Planned,
|
output: dirs::audio_dir()
|
||||||
track: track.clone().into_full(&self),
|
.unwrap()
|
||||||
}
|
.join(self.artist.name.clone())
|
||||||
).collect()
|
.join(self.title.clone())
|
||||||
}
|
.join(format!("{}.mp3", track.title.clone())),
|
||||||
|
status: DlStatus::Planned,
|
||||||
|
track: track.clone().into_full(&self),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CardModel for api::Track {
|
impl CardModel for api::Track {
|
||||||
fn text(&self) -> String {
|
fn text(&self) -> String {
|
||||||
self.title.clone()
|
self.title.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subtext(&self) -> String {
|
fn subtext(&self) -> String {
|
||||||
format!("By {}, in {}", self.artist.name, self.album.title)
|
format!("By {}, in {}", self.artist.name, self.album.title)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn image_url(&self) -> Option<String> {
|
fn image_url(&self) -> Option<String> {
|
||||||
self.album.cover.medium_square_crop.clone()
|
self.album.cover.medium_square_crop.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn downloads(&self) -> Vec<Download> {
|
fn downloads(&self) -> Vec<Download> {
|
||||||
vec![Download {
|
vec![Download {
|
||||||
url: self.listen_url.clone(),
|
url: self.listen_url.clone(),
|
||||||
output: dirs::audio_dir().unwrap()
|
output: dirs::audio_dir()
|
||||||
.join(self.artist.name.clone())
|
.unwrap()
|
||||||
.join(self.album.title.clone())
|
.join(self.artist.name.clone())
|
||||||
.join(format!("{}.mp3", self.title.clone())),
|
.join(self.album.title.clone())
|
||||||
status: DlStatus::Planned,
|
.join(format!("{}.mp3", self.title.clone())),
|
||||||
track: self.clone(),
|
status: DlStatus::Planned,
|
||||||
}]
|
track: self.clone(),
|
||||||
}
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
fn download_status(&self) -> Option<Download> {
|
fn download_status(&self) -> Option<Download> {
|
||||||
crate::DOWNLOADS.lock().ok()?.get(&self.id).map(|x| x.clone())
|
crate::DOWNLOADS.lock().ok()?.get(&self.id).cloned()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,37 +1,37 @@
|
||||||
|
use crate::ui::card;
|
||||||
use gtk::{prelude::*, *};
|
use gtk::{prelude::*, *};
|
||||||
use std::{cell::RefCell, rc::Rc};
|
use std::{cell::RefCell, rc::Rc};
|
||||||
use crate::{ui::card};
|
|
||||||
|
|
||||||
pub fn render() -> Rc<RefCell<gtk::Box>> {
|
pub fn render() -> Rc<RefCell<gtk::Box>> {
|
||||||
let cont = gtk::Box::new(Orientation::Vertical, 12);
|
let cont = gtk::Box::new(Orientation::Vertical, 12);
|
||||||
cont.set_valign(Align::Start);
|
cont.set_valign(Align::Start);
|
||||||
cont.set_margin_top(48);
|
cont.set_margin_top(48);
|
||||||
cont.set_margin_bottom(48);
|
cont.set_margin_bottom(48);
|
||||||
cont.set_margin_start(96);
|
cont.set_margin_start(96);
|
||||||
cont.set_margin_end(96);
|
cont.set_margin_end(96);
|
||||||
|
|
||||||
let active = crate::DL_JOBS.active_count();
|
let active = crate::DL_JOBS.active_count();
|
||||||
rc!(cont, active);
|
rc!(cont, active);
|
||||||
gtk::idle_add(clone!(cont => move || {
|
gtk::idle_add(clone!(cont => move || {
|
||||||
let active_now = crate::DL_JOBS.active_count();
|
let active_now = crate::DL_JOBS.active_count();
|
||||||
if active_now != *active.borrow() {
|
if active_now != *active.borrow() {
|
||||||
*active.borrow_mut() = active_now;
|
*active.borrow_mut() = active_now;
|
||||||
|
|
||||||
let cont = cont.borrow();
|
let cont = cont.borrow();
|
||||||
for ch in cont.get_children() {
|
for ch in cont.get_children() {
|
||||||
cont.remove(&ch);
|
cont.remove(&ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dl_list = {
|
let dl_list = {
|
||||||
crate::DOWNLOADS.lock().unwrap().clone()
|
crate::DOWNLOADS.lock().unwrap().clone()
|
||||||
};
|
};
|
||||||
for (_, dl) in dl_list {
|
for (_, dl) in dl_list {
|
||||||
cont.add(&*card::render(dl.track).borrow());
|
cont.add(&*card::render(dl.track).borrow());
|
||||||
}
|
}
|
||||||
cont.show_all();
|
cont.show_all();
|
||||||
}
|
}
|
||||||
glib::Continue(true)
|
glib::Continue(true)
|
||||||
}));
|
}));
|
||||||
cont.borrow().show_all();
|
cont.borrow().show_all();
|
||||||
cont
|
cont
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,29 +1,25 @@
|
||||||
|
use crate::{api::*, ui::title, State};
|
||||||
use gtk::*;
|
use gtk::*;
|
||||||
use std::{
|
use std::{cell::RefCell, rc::Rc};
|
||||||
rc::Rc,
|
|
||||||
cell::RefCell,
|
|
||||||
};
|
|
||||||
use crate::{State, api::*, ui::title};
|
|
||||||
|
|
||||||
pub fn render(state: State) -> gtk::Box {
|
pub fn render(state: State) -> gtk::Box {
|
||||||
let cont = gtk::Box::new(Orientation::Vertical, 24);
|
let cont = gtk::Box::new(Orientation::Vertical, 24);
|
||||||
cont.set_halign(Align::Center);
|
cont.set_halign(Align::Center);
|
||||||
cont.set_valign(Align::Center);
|
cont.set_valign(Align::Center);
|
||||||
cont.set_size_request(300, -1);
|
cont.set_size_request(300, -1);
|
||||||
let title = title("Login");
|
let title = title("Login");
|
||||||
|
|
||||||
let instance = Input::new("Instance URL")
|
let instance = Input::new("Instance URL").with_placeholder("demo.funkwhale.audio");
|
||||||
.with_placeholder("demo.funkwhale.audio");
|
let username = Input::new("Username");
|
||||||
let username = Input::new("Username");
|
let password = Input::new_password("Password");
|
||||||
let password = Input::new_password("Password");
|
|
||||||
|
|
||||||
let login_bt = Button::new_with_label("Login");
|
let login_bt = Button::new_with_label("Login");
|
||||||
login_bt.get_style_context().map(|c| c.add_class("suggested-action"));
|
if let Some(c) = login_bt.get_style_context() {
|
||||||
login_bt.set_margin_bottom(48);
|
c.add_class("suggested-action")
|
||||||
let widgets = Rc::new(RefCell::new((
|
}
|
||||||
instance, username, password
|
login_bt.set_margin_bottom(48);
|
||||||
)));
|
let widgets = Rc::new(RefCell::new((instance, username, password)));
|
||||||
login_bt.connect_clicked(clone!(state, widgets => move |_| {
|
login_bt.connect_clicked(clone!(state, widgets => move |_| {
|
||||||
let mut api_ctx = crate::api::API.lock().unwrap();
|
let mut api_ctx = crate::api::API.lock().unwrap();
|
||||||
let mut instance_url = widgets.borrow().0.get_text().unwrap().trim_end_matches('/').to_string();
|
let mut instance_url = widgets.borrow().0.get_text().unwrap().trim_end_matches('/').to_string();
|
||||||
if !(instance_url.starts_with("http://") || instance_url.starts_with("https://")) {
|
if !(instance_url.starts_with("http://") || instance_url.starts_with("https://")) {
|
||||||
|
@ -35,8 +31,8 @@ pub fn render(state: State) -> gtk::Box {
|
||||||
|
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
wait!(execute(api_ctx.as_ref().unwrap().post("/api/v1/token/").json(&LoginData {
|
wait!(execute(api_ctx.as_ref().unwrap().post("/api/v1/token/").json(&LoginData {
|
||||||
username: widgets.borrow().1.get_text().clone().unwrap(),
|
username: widgets.borrow().1.get_text().unwrap(),
|
||||||
password: widgets.borrow().2.get_text().clone().unwrap(),
|
password: widgets.borrow().2.get_text().unwrap(),
|
||||||
})) => |res| {
|
})) => |res| {
|
||||||
let res: Result<LoginInfo, _> = res.json();
|
let res: Result<LoginInfo, _> = res.json();
|
||||||
|
|
||||||
|
@ -44,7 +40,7 @@ pub fn render(state: State) -> gtk::Box {
|
||||||
Err(_) => crate::show_error(state.clone(), "Somehting went wrong, check your username and password, and the URL of your instance."),
|
Err(_) => crate::show_error(state.clone(), "Somehting went wrong, check your username and password, and the URL of your instance."),
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
if let Some(ref mut client) = *crate::api::API.lock().unwrap() {
|
if let Some(ref mut client) = *crate::api::API.lock().unwrap() {
|
||||||
client.auth(res.token.clone());
|
client.auth(res.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = state.borrow();
|
let state = state.borrow();
|
||||||
|
@ -68,66 +64,70 @@ pub fn render(state: State) -> gtk::Box {
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
{
|
{
|
||||||
|
let (ref instance, ref username, ref password) = *widgets.borrow();
|
||||||
|
cont.add(&title);
|
||||||
|
cont.add(&instance.render());
|
||||||
|
cont.add(&username.render());
|
||||||
|
cont.add(&password.render());
|
||||||
|
cont.add(&login_bt);
|
||||||
|
}
|
||||||
|
|
||||||
let (ref instance, ref username, ref password) = *widgets.borrow();
|
widgets
|
||||||
cont.add(&title);
|
.borrow()
|
||||||
cont.add(&instance.render());
|
.0
|
||||||
cont.add(&username.render());
|
.entry
|
||||||
cont.add(&password.render());
|
.connect_activate(clone!(widgets => move |_| {
|
||||||
cont.add(&login_bt);
|
widgets.borrow().1.entry.grab_focus();
|
||||||
}
|
}));
|
||||||
|
widgets
|
||||||
|
.borrow()
|
||||||
|
.1
|
||||||
|
.entry
|
||||||
|
.connect_activate(clone!(widgets => move |_| {
|
||||||
|
widgets.borrow().2.entry.grab_focus();
|
||||||
|
}));
|
||||||
|
widgets.borrow().2.entry.connect_activate(move |_| {
|
||||||
|
login_bt.clicked();
|
||||||
|
});
|
||||||
|
|
||||||
widgets.borrow().0.entry.connect_activate(clone!(widgets => move |_| {
|
cont.show_all();
|
||||||
widgets.borrow().1.entry.grab_focus();
|
cont
|
||||||
}));
|
|
||||||
widgets.borrow().1.entry.connect_activate(clone!(widgets => move |_| {
|
|
||||||
widgets.borrow().2.entry.grab_focus();
|
|
||||||
}));
|
|
||||||
widgets.borrow().2.entry.connect_activate(move |_| {
|
|
||||||
login_bt.clicked();
|
|
||||||
});
|
|
||||||
|
|
||||||
cont.show_all();
|
|
||||||
cont
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Input<'a> {
|
struct Input<'a> {
|
||||||
label: &'a str,
|
label: &'a str,
|
||||||
entry: gtk::Entry,
|
entry: gtk::Entry,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Input<'a> {
|
impl<'a> Input<'a> {
|
||||||
fn new(text: &'a str) -> Input {
|
fn new(text: &'a str) -> Input {
|
||||||
let entry = gtk::Entry::new();
|
let entry = gtk::Entry::new();
|
||||||
Input {
|
Input { label: text, entry }
|
||||||
label: text,
|
}
|
||||||
entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new_password(text: &'a str) -> Input {
|
fn new_password(text: &'a str) -> Input {
|
||||||
let input = Input::new(text);
|
let input = Input::new(text);
|
||||||
input.entry.set_visibility(false);
|
input.entry.set_visibility(false);
|
||||||
input
|
input
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_placeholder(self, ph: &'a str) -> Input {
|
fn with_placeholder(self, ph: &'a str) -> Input {
|
||||||
self.entry.set_placeholder_text(ph);
|
self.entry.set_placeholder_text(ph);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_text(&self) -> Option<String> {
|
fn get_text(&self) -> Option<String> {
|
||||||
self.entry.get_text()
|
self.entry.get_text()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self) -> gtk::Box {
|
fn render(&self) -> gtk::Box {
|
||||||
let label = gtk::Label::new(self.label);
|
let label = gtk::Label::new(self.label);
|
||||||
label.set_halign(Align::Start);
|
label.set_halign(Align::Start);
|
||||||
|
|
||||||
let cont = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
let cont = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||||
cont.add(&label);
|
cont.add(&label);
|
||||||
cont.add(&self.entry);
|
cont.add(&self.entry);
|
||||||
cont
|
cont
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,32 @@
|
||||||
|
use crate::{
|
||||||
|
api::{self, execute},
|
||||||
|
ui::{card, title},
|
||||||
|
};
|
||||||
use gdk::ContextExt;
|
use gdk::ContextExt;
|
||||||
use gdk_pixbuf::PixbufExt;
|
use gdk_pixbuf::PixbufExt;
|
||||||
use gtk::*;
|
use gtk::*;
|
||||||
use std::{
|
use std::{cell::RefCell, fs, rc::Rc};
|
||||||
cell::RefCell,
|
|
||||||
fs,
|
|
||||||
rc::Rc,
|
|
||||||
};
|
|
||||||
use crate::{api::{self, execute}, ui::{title, card}};
|
|
||||||
|
|
||||||
pub fn render(window: Rc<RefCell<Window>>, header: &HeaderBar, switcher: &StackSwitcher) -> gtk::Box {
|
pub fn render(
|
||||||
let cont = gtk::Box::new(Orientation::Vertical, 12);
|
window: Rc<RefCell<Window>>,
|
||||||
cont.set_margin_top(48);
|
header: &HeaderBar,
|
||||||
cont.set_margin_bottom(48);
|
switcher: &StackSwitcher,
|
||||||
cont.set_margin_start(96);
|
) -> gtk::Box {
|
||||||
cont.set_margin_end(96);
|
let cont = gtk::Box::new(Orientation::Vertical, 12);
|
||||||
|
cont.set_margin_top(48);
|
||||||
|
cont.set_margin_bottom(48);
|
||||||
|
cont.set_margin_start(96);
|
||||||
|
cont.set_margin_end(96);
|
||||||
|
|
||||||
let avatar_path = dirs::cache_dir().unwrap().join("mobydick").join("avatar.png");
|
let avatar_path = dirs::cache_dir()
|
||||||
|
.unwrap()
|
||||||
|
.join("mobydick")
|
||||||
|
.join("avatar.png");
|
||||||
|
|
||||||
let avatar = DrawingArea::new();
|
let avatar = DrawingArea::new();
|
||||||
avatar.set_size_request(32, 32);
|
avatar.set_size_request(32, 32);
|
||||||
avatar.set_halign(Align::Center);
|
avatar.set_halign(Align::Center);
|
||||||
avatar.connect_draw(clone!(avatar_path => move |da, g| { // More or less stolen from Fractal (https://gitlab.gnome.org/GNOME/fractal/blob/master/fractal-gtk/src/widgets/avatar.rs)
|
avatar.connect_draw(clone!(avatar_path => move |da, g| { // More or less stolen from Fractal (https://gitlab.gnome.org/GNOME/fractal/blob/master/fractal-gtk/src/widgets/avatar.rs)
|
||||||
use std::f64::consts::PI;
|
use std::f64::consts::PI;
|
||||||
let width = 32.0f64;
|
let width = 32.0f64;
|
||||||
let height = 32.0f64;
|
let height = 32.0f64;
|
||||||
|
@ -50,89 +56,88 @@ pub fn render(window: Rc<RefCell<Window>>, header: &HeaderBar, switcher: &StackS
|
||||||
|
|
||||||
Inhibit(false)
|
Inhibit(false)
|
||||||
}));
|
}));
|
||||||
header.pack_start(&avatar);
|
header.pack_start(&avatar);
|
||||||
header.set_custom_title(&*switcher);
|
header.set_custom_title(&*switcher);
|
||||||
|
|
||||||
let logout_bt = Button::new_from_icon_name("system-log-out", IconSize::LargeToolbar.into());
|
let logout_bt = Button::new_from_icon_name("system-log-out", IconSize::LargeToolbar.into());
|
||||||
logout_bt.connect_clicked(clone!(window => move |_| {
|
logout_bt.connect_clicked(clone!(window => move |_| {
|
||||||
crate::logout(window.clone());
|
crate::logout(window.clone());
|
||||||
}));
|
}));
|
||||||
header.pack_end(&logout_bt);
|
header.pack_end(&logout_bt);
|
||||||
header.show_all();
|
header.show_all();
|
||||||
|
|
||||||
let search = SearchEntry::new();
|
let search = SearchEntry::new();
|
||||||
search.set_placeholder_text("Search");
|
search.set_placeholder_text("Search");
|
||||||
cont.add(&search);
|
cont.add(&search);
|
||||||
|
|
||||||
let results = gtk::Box::new(Orientation::Vertical, 12);
|
let results = gtk::Box::new(Orientation::Vertical, 12);
|
||||||
results.set_valign(Align::Start);
|
results.set_valign(Align::Start);
|
||||||
cont.add(&results);
|
cont.add(&results);
|
||||||
|
|
||||||
rc!(avatar, results);
|
rc!(avatar, results);
|
||||||
clone!(avatar, results, avatar_path);
|
clone!(avatar, results, avatar_path);
|
||||||
wait!(execute(client!().get("/api/v1/users/users/me")) => |res| {
|
wait!(execute(client!().get("/api/v1/users/users/me")) => |res| {
|
||||||
let res: Result<api::UserInfo, _> = res.json();
|
let res: Result<api::UserInfo, _> = res.json();
|
||||||
match res {
|
match res {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
avatar.borrow().set_tooltip_text(format!("Connected as {}.", res.username).as_ref());
|
avatar.borrow().set_tooltip_text(format!("Connected as {}.", res.username).as_ref());
|
||||||
|
|
||||||
clone!(avatar_path, avatar);
|
clone!(avatar_path, avatar);
|
||||||
wait!(execute(client!().get(&res.avatar.medium_square_crop.unwrap_or_default())) => |avatar_dl| {
|
wait!(execute(client!().get(&res.avatar.medium_square_crop.unwrap_or_default())) => |avatar_dl| {
|
||||||
fs::create_dir_all(avatar_path.parent().unwrap()).unwrap();
|
fs::create_dir_all(avatar_path.parent().unwrap()).unwrap();
|
||||||
let mut avatar_file = fs::File::create(avatar_path.clone()).unwrap();
|
let mut avatar_file = fs::File::create(avatar_path.clone()).unwrap();
|
||||||
avatar_dl.copy_to(&mut avatar_file).unwrap();
|
avatar_dl.copy_to(&mut avatar_file).unwrap();
|
||||||
avatar.borrow().queue_draw();
|
avatar.borrow().queue_draw();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
crate::logout(window.clone());
|
crate::logout(window.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
search.connect_activate(move |s| {
|
search.connect_activate(move |s| {
|
||||||
let results = results.clone();
|
let results = results.clone();
|
||||||
wait!(execute(client!().get("/api/v1/search").query(&api::SearchQuery {
|
wait!(execute(client!().get("/api/v1/search").query(&api::SearchQuery {
|
||||||
query: s.get_text().unwrap_or_default()
|
query: s.get_text().unwrap_or_default()
|
||||||
})) => |res| {
|
})) => |res| {
|
||||||
update_results(res.json().unwrap(), &results.borrow());
|
update_results(res.json().unwrap(), &results.borrow());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
cont.show_all();
|
cont.show_all();
|
||||||
cont
|
cont
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_results(res: api::SearchResult, cont: >k::Box) {
|
fn update_results(res: api::SearchResult, cont: >k::Box) {
|
||||||
for ch in cont.get_children() {
|
for ch in cont.get_children() {
|
||||||
cont.remove(&ch);
|
cont.remove(&ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
if res.artists.is_empty() && res.albums.is_empty() && res.tracks.is_empty() {
|
if res.artists.is_empty() && res.albums.is_empty() && res.tracks.is_empty() {
|
||||||
cont.add(&Label::new("No results. Try something else."));
|
cont.add(&Label::new("No results. Try something else."));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !res.artists.is_empty() {
|
if !res.artists.is_empty() {
|
||||||
cont.add(&title("Artists"));
|
cont.add(&title("Artists"));
|
||||||
for artist in res.artists.clone() {
|
for artist in res.artists.clone() {
|
||||||
cont.add(&*card::render(artist).borrow());
|
cont.add(&*card::render(artist).borrow());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !res.albums.is_empty() {
|
if !res.albums.is_empty() {
|
||||||
cont.add(&title("Albums"));
|
cont.add(&title("Albums"));
|
||||||
for album in res.albums.clone() {
|
for album in res.albums.clone() {
|
||||||
cont.add(&*card::render(album).borrow());
|
cont.add(&*card::render(album).borrow());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !res.tracks.is_empty() {
|
if !res.tracks.is_empty() {
|
||||||
cont.add(&title("Songs"));
|
cont.add(&title("Songs"));
|
||||||
for track in res.tracks.clone() {
|
for track in res.tracks.clone() {
|
||||||
cont.add(&*card::render(track).borrow());
|
cont.add(&*card::render(track).borrow());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cont.show_all();
|
cont.show_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,9 @@ pub mod main_page;
|
||||||
pub mod network_image;
|
pub mod network_image;
|
||||||
|
|
||||||
fn title(text: &str) -> gtk::Label {
|
fn title(text: &str) -> gtk::Label {
|
||||||
let lbl = gtk::Label::new(text);
|
let lbl = gtk::Label::new(text);
|
||||||
lbl.get_style_context().map(|c| c.add_class("h2"));
|
if let Some(c) = lbl.get_style_context() {
|
||||||
lbl
|
c.add_class("h2")
|
||||||
|
}
|
||||||
|
lbl
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,39 +1,37 @@
|
||||||
use gtk::*;
|
|
||||||
use std::{
|
|
||||||
cell::RefCell,
|
|
||||||
fs, rc::Rc,
|
|
||||||
};
|
|
||||||
use crate::api::execute;
|
use crate::api::execute;
|
||||||
|
use gtk::{Image, ImageExt};
|
||||||
|
use std::{cell::RefCell, fs, rc::Rc};
|
||||||
|
|
||||||
pub struct NetworkImage {
|
pub struct NetworkImage {
|
||||||
pub img: Rc<RefCell<Image>>,
|
pub img: Rc<RefCell<Image>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NetworkImage {
|
impl NetworkImage {
|
||||||
pub fn new(url: String) -> NetworkImage {
|
pub fn new(url: String) -> NetworkImage {
|
||||||
let image = Image::new_from_icon_name("image-loading", 4);
|
let image = Image::new_from_icon_name("image-loading", 4);
|
||||||
rc!(image);
|
rc!(image);
|
||||||
|
|
||||||
let dest_file = url.split("/media/").last().unwrap().replace('/', "-");
|
let dest_file = url.split("/media/").last().unwrap().replace('/', "-");
|
||||||
let dest = dirs::cache_dir().unwrap().join(env!("CARGO_PKG_NAME")).join(dest_file.to_string());
|
let dest = dirs::cache_dir()
|
||||||
|
.unwrap()
|
||||||
|
.join(env!("CARGO_PKG_NAME"))
|
||||||
|
.join(dest_file);
|
||||||
|
|
||||||
if dest.exists() {
|
if dest.exists() {
|
||||||
let pb = gdk_pixbuf::Pixbuf::new_from_file_at_scale(dest, 64, 64, true).unwrap();
|
let pb = gdk_pixbuf::Pixbuf::new_from_file_at_scale(dest, 64, 64, true).unwrap();
|
||||||
image.borrow().set_from_pixbuf(&pb);
|
image.borrow().set_from_pixbuf(&pb);
|
||||||
} else {
|
} else {
|
||||||
clone!(image);
|
clone!(image);
|
||||||
wait!(execute(client!().get(&url)) => |res| {
|
wait!(execute(client!().get(&url)) => |res| {
|
||||||
fs::create_dir_all(dest.parent().unwrap()).unwrap();
|
fs::create_dir_all(dest.parent().unwrap()).unwrap();
|
||||||
let mut file = fs::File::create(dest.clone()).unwrap();
|
let mut file = fs::File::create(dest.clone()).unwrap();
|
||||||
res.copy_to(&mut file).unwrap();
|
res.copy_to(&mut file).unwrap();
|
||||||
|
|
||||||
let pb = gdk_pixbuf::Pixbuf::new_from_file_at_scale(dest.clone(), 64, 64, true).unwrap();
|
let pb = gdk_pixbuf::Pixbuf::new_from_file_at_scale(dest.clone(), 64, 64, true).unwrap();
|
||||||
image.borrow().set_from_pixbuf(&pb);
|
image.borrow().set_from_pixbuf(&pb);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkImage {
|
NetworkImage { img: image }
|
||||||
img: image.clone(),
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue