2017-02-05 19:58:41 -08:00
|
|
|
// This file is part of Moonfire NVR, a security camera digital video recorder.
|
|
|
|
// Copyright (C) 2017 Scott Lamb <slamb@slamb.org>
|
|
|
|
//
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
|
|
// it under the terms of the GNU General Public License as published by
|
|
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
|
|
// (at your option) any later version.
|
|
|
|
//
|
|
|
|
// In addition, as a special exception, the copyright holders give
|
|
|
|
// permission to link the code of portions of this program with the
|
|
|
|
// OpenSSL library under certain conditions as described in each
|
|
|
|
// individual source file, and distribute linked combinations including
|
|
|
|
// the two.
|
|
|
|
//
|
|
|
|
// You must obey the GNU General Public License in all respects for all
|
|
|
|
// of the code used other than OpenSSL. If you modify file(s) with this
|
|
|
|
// exception, you may extend this exception to your version of the
|
|
|
|
// file(s), but you are not obligated to do so. If you do not wish to do
|
|
|
|
// so, delete this exception statement from your version. If you delete
|
|
|
|
// this exception statement from all source files in the program, then
|
|
|
|
// also delete it here.
|
|
|
|
//
|
|
|
|
// This program is distributed in the hope that it will be useful,
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
// GNU General Public License for more details.
|
|
|
|
//
|
|
|
|
// You should have received a copy of the GNU General Public License
|
|
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
extern crate cursive;
|
|
|
|
|
|
|
|
use self::cursive::Cursive;
|
|
|
|
use self::cursive::traits::{Boxable, Identifiable, Finder};
|
|
|
|
use self::cursive::views;
|
2018-03-04 12:24:24 -08:00
|
|
|
use db::{self, writer};
|
2018-02-20 22:46:14 -08:00
|
|
|
use failure::Error;
|
2018-02-11 22:45:51 -08:00
|
|
|
use std::collections::BTreeMap;
|
2018-02-22 16:35:34 -08:00
|
|
|
use std::str::FromStr;
|
2017-02-05 19:58:41 -08:00
|
|
|
use std::sync::Arc;
|
|
|
|
use stream::{self, Opener, Stream};
|
|
|
|
use super::{decode_size, encode_size};
|
|
|
|
|
|
|
|
/// Builds a `CameraChange` from an active `edit_camera_dialog`.
|
|
|
|
fn get_change(siv: &mut Cursive) -> db::CameraChange {
|
2017-07-02 22:03:16 -07:00
|
|
|
// Note: these find_id calls are separate statements, which seems to be important:
|
|
|
|
// https://github.com/gyscos/Cursive/issues/144
|
|
|
|
let sn = siv.find_id::<views::EditView>("short_name").unwrap().get_content().as_str().into();
|
|
|
|
let d = siv.find_id::<views::TextArea>("description").unwrap().get_content().into();
|
|
|
|
let h = siv.find_id::<views::EditView>("host").unwrap().get_content().as_str().into();
|
|
|
|
let u = siv.find_id::<views::EditView>("username").unwrap().get_content().as_str().into();
|
|
|
|
let p = siv.find_id::<views::EditView>("password").unwrap().get_content().as_str().into();
|
2018-02-11 22:45:51 -08:00
|
|
|
let mut c = db::CameraChange {
|
2017-07-02 22:03:16 -07:00
|
|
|
short_name: sn,
|
|
|
|
description: d,
|
|
|
|
host: h,
|
|
|
|
username: u,
|
|
|
|
password: p,
|
2018-02-11 22:45:51 -08:00
|
|
|
streams: Default::default(),
|
|
|
|
};
|
|
|
|
for &t in &db::ALL_STREAM_TYPES {
|
|
|
|
let p = siv.find_id::<views::EditView>(&format!("{}_rtsp_path", t.as_str()))
|
|
|
|
.unwrap().get_content().as_str().into();
|
|
|
|
let r = siv.find_id::<views::Checkbox>(&format!("{}_record", t.as_str()))
|
|
|
|
.unwrap().is_checked();
|
2018-02-22 16:35:34 -08:00
|
|
|
let f = i64::from_str(siv.find_id::<views::EditView>(
|
|
|
|
&format!("{}_flush_if_sec", t.as_str())).unwrap().get_content().as_str())
|
|
|
|
.unwrap_or(0);
|
2018-02-11 22:45:51 -08:00
|
|
|
let d = *siv.find_id::<views::SelectView<Option<i32>>>(
|
|
|
|
&format!("{}_sample_file_dir", t.as_str()))
|
|
|
|
.unwrap().selection();
|
|
|
|
c.streams[t.index()] = db::StreamChange {
|
|
|
|
rtsp_path: p,
|
|
|
|
sample_file_dir_id: d,
|
|
|
|
record: r,
|
2018-02-22 16:35:34 -08:00
|
|
|
flush_if_sec: f,
|
2018-02-11 22:45:51 -08:00
|
|
|
};
|
2017-02-05 19:58:41 -08:00
|
|
|
}
|
2018-02-11 22:45:51 -08:00
|
|
|
c
|
2017-02-05 19:58:41 -08:00
|
|
|
}
|
|
|
|
|
2018-02-11 22:45:51 -08:00
|
|
|
fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) {
|
2017-02-05 19:58:41 -08:00
|
|
|
let change = get_change(siv);
|
|
|
|
|
|
|
|
let result = {
|
|
|
|
let mut l = db.lock();
|
|
|
|
if let Some(id) = id {
|
|
|
|
l.update_camera(id, change)
|
|
|
|
} else {
|
|
|
|
l.add_camera(change).map(|_| ())
|
|
|
|
}
|
|
|
|
};
|
|
|
|
if let Err(e) = result {
|
|
|
|
siv.add_layer(views::Dialog::text(format!("Unable to add camera: {}", e))
|
|
|
|
.title("Error")
|
|
|
|
.dismiss_button("Abort"));
|
|
|
|
} else {
|
2018-02-11 22:45:51 -08:00
|
|
|
siv.pop_layer(); // get rid of the add/edit camera dialog.
|
|
|
|
|
2017-02-05 19:58:41 -08:00
|
|
|
// Recreate the "Edit cameras" dialog from scratch; it's easier than adding the new entry.
|
|
|
|
siv.pop_layer();
|
2018-02-11 22:45:51 -08:00
|
|
|
top_dialog(db, siv);
|
2017-02-05 19:58:41 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
fn press_test_inner(url: &str) -> Result<String, Error> {
|
|
|
|
let stream = stream::FFMPEG.open(stream::Source::Rtsp(url))?;
|
|
|
|
let extra_data = stream.get_extra_data()?;
|
|
|
|
Ok(format!("{}x{} video stream", extra_data.width, extra_data.height))
|
|
|
|
}
|
|
|
|
|
2018-02-11 22:45:51 -08:00
|
|
|
fn press_test(siv: &mut Cursive, t: db::StreamType) {
|
|
|
|
let c = get_change(siv);
|
|
|
|
let url = format!("rtsp://{}:{}@{}{}", c.username, c.password, c.host,
|
|
|
|
c.streams[t.index()].rtsp_path);
|
|
|
|
siv.add_layer(views::Dialog::text(format!("Testing {} stream at {}. This may take a while \
|
|
|
|
on timeout or if you have a long key frame interval",
|
|
|
|
t.as_str(), url))
|
|
|
|
.title("Testing"));
|
|
|
|
|
|
|
|
// Let siv have this thread for its event loop; do the work in a background thread.
|
|
|
|
// siv.cb_sink doesn't actually wake up the event loop. Tell siv to poll, as a workaround.
|
|
|
|
siv.set_fps(5);
|
|
|
|
let sink = siv.cb_sink().clone();
|
|
|
|
::std::thread::spawn(move || {
|
|
|
|
let r = press_test_inner(&url);
|
|
|
|
sink.send(Box::new(move |siv| {
|
|
|
|
// Polling is no longer necessary.
|
|
|
|
siv.set_fps(0);
|
|
|
|
siv.pop_layer();
|
|
|
|
let description = match r {
|
|
|
|
Err(ref e) => {
|
|
|
|
siv.add_layer(
|
|
|
|
views::Dialog::text(format!("{} stream at {}:\n\n{}", t.as_str(), url, e))
|
|
|
|
.title("Stream test failed")
|
|
|
|
.dismiss_button("Back"));
|
|
|
|
return;
|
|
|
|
},
|
|
|
|
Ok(ref d) => d,
|
|
|
|
};
|
|
|
|
siv.add_layer(views::Dialog::text(
|
|
|
|
format!("{} stream at {}:\n\n{}", t.as_str(), url, description))
|
|
|
|
.title("Stream test succeeded")
|
|
|
|
.dismiss_button("Back"));
|
|
|
|
})).unwrap();
|
|
|
|
});
|
2017-02-05 19:58:41 -08:00
|
|
|
}
|
|
|
|
|
2018-02-11 22:45:51 -08:00
|
|
|
fn press_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32, name: String, to_delete: i64) {
|
2017-02-05 19:58:41 -08:00
|
|
|
let dialog = if to_delete > 0 {
|
|
|
|
let prompt = format!("Camera {} has recorded video. Please confirm the amount \
|
|
|
|
of data to delete by typing it back:\n\n{}", name,
|
|
|
|
encode_size(to_delete));
|
|
|
|
views::Dialog::around(
|
|
|
|
views::LinearLayout::vertical()
|
|
|
|
.child(views::TextView::new(prompt))
|
|
|
|
.child(views::DummyView)
|
|
|
|
.child(views::EditView::new().on_submit({
|
|
|
|
let db = db.clone();
|
2018-02-11 22:45:51 -08:00
|
|
|
move |siv, _| confirm_deletion(siv, &db, id, to_delete)
|
2017-02-05 19:58:41 -08:00
|
|
|
}).with_id("confirm")))
|
|
|
|
.button("Delete", {
|
|
|
|
let db = db.clone();
|
2018-02-11 22:45:51 -08:00
|
|
|
move |siv| confirm_deletion(siv, &db, id, to_delete)
|
2017-02-05 19:58:41 -08:00
|
|
|
})
|
|
|
|
} else {
|
|
|
|
views::Dialog::text(format!("Delete camera {}? This camera has no recorded video.", name))
|
|
|
|
.button("Delete", {
|
|
|
|
let db = db.clone();
|
2018-02-11 22:45:51 -08:00
|
|
|
move |s| actually_delete(s, &db, id)
|
2017-02-05 19:58:41 -08:00
|
|
|
})
|
|
|
|
}.title("Delete camera").dismiss_button("Cancel");
|
|
|
|
siv.add_layer(dialog);
|
|
|
|
}
|
|
|
|
|
2018-02-11 22:45:51 -08:00
|
|
|
fn confirm_deletion(siv: &mut Cursive, db: &Arc<db::Database>, id: i32, to_delete: i64) {
|
2017-02-05 19:58:41 -08:00
|
|
|
let typed = siv.find_id::<views::EditView>("confirm").unwrap().get_content();
|
|
|
|
if decode_size(typed.as_str()).ok() == Some(to_delete) {
|
|
|
|
siv.pop_layer(); // deletion confirmation dialog
|
2018-01-23 11:05:07 -08:00
|
|
|
|
2018-02-11 22:45:51 -08:00
|
|
|
let mut zero_limits = BTreeMap::new();
|
2018-01-23 11:05:07 -08:00
|
|
|
{
|
|
|
|
let l = db.lock();
|
|
|
|
for (&stream_id, stream) in l.streams_by_id() {
|
|
|
|
if stream.camera_id == id {
|
2018-02-11 22:45:51 -08:00
|
|
|
let dir_id = match stream.sample_file_dir_id {
|
|
|
|
Some(d) => d,
|
|
|
|
None => continue,
|
|
|
|
};
|
|
|
|
let l = zero_limits.entry(dir_id).or_insert_with(|| Vec::with_capacity(2));
|
2018-03-04 12:24:24 -08:00
|
|
|
l.push(writer::NewLimit {
|
2018-01-23 11:05:07 -08:00
|
|
|
stream_id,
|
|
|
|
limit: 0,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2018-02-11 22:45:51 -08:00
|
|
|
if let Err(e) = lower_retention(db, zero_limits) {
|
2017-02-05 19:58:41 -08:00
|
|
|
siv.add_layer(views::Dialog::text(format!("Unable to delete recordings: {}", e))
|
|
|
|
.title("Error")
|
|
|
|
.dismiss_button("Abort"));
|
|
|
|
return;
|
|
|
|
}
|
2018-02-11 22:45:51 -08:00
|
|
|
actually_delete(siv, db, id);
|
2017-02-05 19:58:41 -08:00
|
|
|
} else {
|
|
|
|
siv.add_layer(views::Dialog::text("Please confirm amount.")
|
|
|
|
.title("Try again")
|
|
|
|
.dismiss_button("Back"));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-03-04 12:24:24 -08:00
|
|
|
fn lower_retention(db: &Arc<db::Database>, zero_limits: BTreeMap<i32, Vec<writer::NewLimit>>)
|
2018-02-11 22:45:51 -08:00
|
|
|
-> Result<(), Error> {
|
2018-02-14 23:10:10 -08:00
|
|
|
let dirs_to_open: Vec<_> = zero_limits.keys().map(|id| *id).collect();
|
|
|
|
db.lock().open_sample_file_dirs(&dirs_to_open[..])?;
|
2018-02-20 10:11:10 -08:00
|
|
|
for (&dir_id, l) in &zero_limits {
|
2018-03-04 12:24:24 -08:00
|
|
|
writer::lower_retention(db.clone(), dir_id, &l)?;
|
2018-02-11 22:45:51 -08:00
|
|
|
}
|
|
|
|
Ok(())
|
|
|
|
}
|
|
|
|
|
|
|
|
fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32) {
|
2017-02-05 19:58:41 -08:00
|
|
|
siv.pop_layer(); // get rid of the add/edit camera dialog.
|
|
|
|
let result = {
|
|
|
|
let mut l = db.lock();
|
|
|
|
l.delete_camera(id)
|
|
|
|
};
|
|
|
|
if let Err(e) = result {
|
|
|
|
siv.add_layer(views::Dialog::text(format!("Unable to delete camera: {}", e))
|
|
|
|
.title("Error")
|
|
|
|
.dismiss_button("Abort"));
|
|
|
|
} else {
|
|
|
|
// Recreate the "Edit cameras" dialog from scratch; it's easier than adding the new entry.
|
|
|
|
siv.pop_layer();
|
2018-02-11 22:45:51 -08:00
|
|
|
top_dialog(db, siv);
|
2017-02-05 19:58:41 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Adds or updates a camera.
|
|
|
|
/// (The former if `item` is None; the latter otherwise.)
|
2018-02-11 22:45:51 -08:00
|
|
|
fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i32>) {
|
|
|
|
let camera_list = views::ListView::new()
|
2017-02-05 19:58:41 -08:00
|
|
|
.child("id", views::TextView::new(match *item {
|
|
|
|
None => "<new>".to_string(),
|
|
|
|
Some(id) => id.to_string(),
|
|
|
|
}))
|
|
|
|
.child("uuid", views::TextView::new("<new>").with_id("uuid"))
|
|
|
|
.child("short name", views::EditView::new().with_id("short_name"))
|
|
|
|
.child("host", views::EditView::new().with_id("host"))
|
|
|
|
.child("username", views::EditView::new().with_id("username"))
|
|
|
|
.child("password", views::EditView::new().with_id("password"))
|
2018-02-11 22:45:51 -08:00
|
|
|
.min_height(6);
|
|
|
|
let mut layout = views::LinearLayout::vertical()
|
|
|
|
.child(camera_list)
|
2017-02-05 19:58:41 -08:00
|
|
|
.child(views::TextView::new("description"))
|
2018-02-11 22:45:51 -08:00
|
|
|
.child(views::TextArea::new().with_id("description").min_height(3));
|
|
|
|
|
|
|
|
let dirs: Vec<_> = ::std::iter::once(("<none>".to_owned(), None))
|
|
|
|
.chain(db.lock()
|
|
|
|
.sample_file_dirs_by_id()
|
|
|
|
.iter()
|
|
|
|
.map(|(&id, d)| (d.path.as_str().to_owned(), Some(id))))
|
|
|
|
.collect();
|
|
|
|
for &type_ in &db::ALL_STREAM_TYPES {
|
|
|
|
let list = views::ListView::new()
|
|
|
|
.child("rtsp path", views::LinearLayout::horizontal()
|
|
|
|
.child(views::EditView::new()
|
|
|
|
.with_id(format!("{}_rtsp_path", type_.as_str()))
|
|
|
|
.full_width())
|
|
|
|
.child(views::DummyView)
|
|
|
|
.child(views::Button::new("Test", move |siv| press_test(siv, type_))))
|
|
|
|
.child("sample file dir",
|
|
|
|
views::SelectView::<Option<i32>>::new()
|
|
|
|
.with_all(dirs.iter().map(|d| d.clone()))
|
|
|
|
.popup()
|
|
|
|
.with_id(format!("{}_sample_file_dir", type_.as_str())))
|
|
|
|
.child("record", views::Checkbox::new().with_id(format!("{}_record", type_.as_str())))
|
2018-02-22 16:35:34 -08:00
|
|
|
.child("flush_if_sec", views::EditView::new()
|
|
|
|
.with_id(format!("{}_flush_if_sec", type_.as_str())))
|
2018-02-11 22:45:51 -08:00
|
|
|
.child("usage/capacity",
|
|
|
|
views::TextView::new("").with_id(format!("{}_usage_cap", type_.as_str())))
|
2018-02-22 16:35:34 -08:00
|
|
|
.min_height(5);
|
2018-02-11 22:45:51 -08:00
|
|
|
layout.add_child(views::DummyView);
|
|
|
|
layout.add_child(views::TextView::new(format!("{} stream", type_.as_str())));
|
|
|
|
layout.add_child(list);
|
|
|
|
}
|
|
|
|
|
2017-02-05 19:58:41 -08:00
|
|
|
let mut dialog = views::Dialog::around(layout);
|
2018-01-23 11:05:07 -08:00
|
|
|
let dialog = if let Some(camera_id) = *item {
|
2017-02-05 19:58:41 -08:00
|
|
|
let l = db.lock();
|
2018-01-23 11:05:07 -08:00
|
|
|
let camera = l.cameras_by_id().get(&camera_id).expect("missing camera");
|
2017-06-11 12:57:55 -07:00
|
|
|
dialog.find_id("uuid", |v: &mut views::TextView| v.set_content(camera.uuid.to_string()))
|
|
|
|
.expect("missing TextView");
|
2018-02-11 22:45:51 -08:00
|
|
|
|
2018-01-23 11:05:07 -08:00
|
|
|
let mut bytes = 0;
|
2018-02-11 22:45:51 -08:00
|
|
|
for (i, sid) in camera.streams.iter().enumerate() {
|
|
|
|
let t = db::StreamType::from_index(i).unwrap();
|
|
|
|
|
|
|
|
// Find the index into dirs of the stored sample file dir.
|
|
|
|
let mut selected_dir = 0;
|
|
|
|
if let Some(s) = sid.map(|sid| l.streams_by_id().get(&sid).unwrap()) {
|
|
|
|
if let Some(id) = s.sample_file_dir_id {
|
|
|
|
for (i, &(_, d_id)) in dirs.iter().skip(1).enumerate() {
|
|
|
|
if Some(id) == d_id {
|
|
|
|
selected_dir = i + 1;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
bytes += s.sample_file_bytes;
|
|
|
|
let u = if s.retain_bytes == 0 {
|
|
|
|
"0 / 0 (0.0%)".to_owned()
|
|
|
|
} else {
|
|
|
|
format!("{} / {} ({:.1}%)", s.sample_file_bytes, s.retain_bytes,
|
|
|
|
100. * s.sample_file_bytes as f32 / s.retain_bytes as f32)
|
|
|
|
};
|
|
|
|
dialog.find_id(&format!("{}_rtsp_path", t.as_str()),
|
|
|
|
|v: &mut views::EditView| v.set_content(s.rtsp_path.to_owned()));
|
|
|
|
dialog.find_id(&format!("{}_usage_cap", t.as_str()),
|
|
|
|
|v: &mut views::TextView| v.set_content(u));
|
|
|
|
dialog.find_id(&format!("{}_record", t.as_str()),
|
|
|
|
|v: &mut views::Checkbox| v.set_checked(s.record));
|
2018-02-22 16:35:34 -08:00
|
|
|
dialog.find_id(&format!("{}_flush_if_sec", t.as_str()),
|
|
|
|
|v: &mut views::EditView| v.set_content(s.flush_if_sec.to_string()));
|
2018-02-11 22:45:51 -08:00
|
|
|
}
|
|
|
|
dialog.find_id(&format!("{}_sample_file_dir", t.as_str()),
|
|
|
|
|v: &mut views::SelectView<Option<i32>>| v.set_selection(selected_dir));
|
2018-01-23 11:05:07 -08:00
|
|
|
}
|
2017-02-05 19:58:41 -08:00
|
|
|
let name = camera.short_name.clone();
|
2018-01-23 11:05:07 -08:00
|
|
|
for &(view_id, content) in &[("short_name", &*camera.short_name),
|
|
|
|
("host", &*camera.host),
|
|
|
|
("username", &*camera.username),
|
2018-02-11 22:45:51 -08:00
|
|
|
("password", &*camera.password)] {
|
2017-06-11 12:57:55 -07:00
|
|
|
dialog.find_id(view_id, |v: &mut views::EditView| v.set_content(content.to_string()))
|
|
|
|
.expect("missing EditView");
|
2017-02-05 19:58:41 -08:00
|
|
|
}
|
2017-06-11 12:57:55 -07:00
|
|
|
dialog.find_id("description",
|
|
|
|
|v: &mut views::TextArea| v.set_content(camera.description.to_string()))
|
|
|
|
.expect("missing TextArea");
|
2017-02-05 19:58:41 -08:00
|
|
|
dialog.title("Edit camera")
|
|
|
|
.button("Edit", {
|
|
|
|
let db = db.clone();
|
2018-02-11 22:45:51 -08:00
|
|
|
move |s| press_edit(s, &db, Some(camera_id))
|
2017-02-05 19:58:41 -08:00
|
|
|
})
|
|
|
|
.button("Delete", {
|
|
|
|
let db = db.clone();
|
2018-02-11 22:45:51 -08:00
|
|
|
move |s| press_delete(s, &db, camera_id, name.clone(), bytes)
|
2017-02-05 19:58:41 -08:00
|
|
|
})
|
|
|
|
} else {
|
2018-02-11 22:45:51 -08:00
|
|
|
for t in &db::ALL_STREAM_TYPES {
|
|
|
|
dialog.find_id(&format!("{}_usage_cap", t.as_str()),
|
|
|
|
|v: &mut views::TextView| v.set_content("<new>"));
|
|
|
|
}
|
2017-02-05 19:58:41 -08:00
|
|
|
dialog.title("Add camera")
|
|
|
|
.button("Add", {
|
|
|
|
let db = db.clone();
|
2018-02-11 22:45:51 -08:00
|
|
|
move |s| press_edit(s, &db, None)
|
2017-02-05 19:58:41 -08:00
|
|
|
})
|
|
|
|
};
|
|
|
|
siv.add_layer(dialog.dismiss_button("Cancel"));
|
|
|
|
}
|
|
|
|
|
2018-02-11 22:45:51 -08:00
|
|
|
pub fn top_dialog(db: &Arc<db::Database>, siv: &mut Cursive) {
|
2017-02-05 19:58:41 -08:00
|
|
|
siv.add_layer(views::Dialog::around(
|
|
|
|
views::SelectView::new()
|
|
|
|
.on_submit({
|
|
|
|
let db = db.clone();
|
2018-02-11 22:45:51 -08:00
|
|
|
move |siv, item| edit_camera_dialog(&db, siv, item)
|
2017-02-05 19:58:41 -08:00
|
|
|
})
|
|
|
|
.item("<new camera>".to_string(), None)
|
|
|
|
.with_all(db.lock()
|
|
|
|
.cameras_by_id()
|
|
|
|
.iter()
|
|
|
|
.map(|(&id, camera)| (format!("{}: {}", id, camera.short_name), Some(id))))
|
|
|
|
.full_width())
|
|
|
|
.dismiss_button("Done")
|
|
|
|
.title("Edit cameras"));
|
|
|
|
}
|