]> git.sesse.net Git - nageru/blob - futatabi/mainwindow.cpp
Implement undo/redo.
[nageru] / futatabi / mainwindow.cpp
1 #include "mainwindow.h"
2
3 #include "shared/aboutdialog.h"
4 #include "clip_list.h"
5 #include "export.h"
6 #include "shared/disk_space_estimator.h"
7 #include "flags.h"
8 #include "frame_on_disk.h"
9 #include "player.h"
10 #include "shared/post_to_main_thread.h"
11 #include "shared/timebase.h"
12 #include "ui_mainwindow.h"
13
14 #include <QDesktopServices>
15 #include <QFileDialog>
16 #include <QMessageBox>
17 #include <QMouseEvent>
18 #include <QShortcut>
19 #include <QTimer>
20 #include <QWheelEvent>
21 #include <future>
22 #include <sqlite3.h>
23 #include <string>
24 #include <vector>
25
26 using namespace std;
27 using namespace std::placeholders;
28
29 MainWindow *global_mainwindow = nullptr;
30 static ClipList *cliplist_clips;
31 static PlayList *playlist_clips;
32
33 extern int64_t current_pts;
34
35 template <class Model>
36 void replace_model(QTableView *view, Model **model, Model *new_model, MainWindow *main_window)
37 {
38         QItemSelectionModel *old_selection_model = view->selectionModel();
39         view->setModel(new_model);
40         delete *model;
41         delete old_selection_model;
42         *model = new_model;
43         main_window->connect(new_model, &Model::any_content_changed, main_window, &MainWindow::content_changed);
44 }
45
46 MainWindow::MainWindow()
47         : ui(new Ui::MainWindow),
48           db(global_flags.working_directory + "/futatabi.db")
49 {
50         global_mainwindow = this;
51         ui->setupUi(this);
52
53         // The menus.
54         connect(ui->exit_action, &QAction::triggered, this, &MainWindow::exit_triggered);
55         connect(ui->export_cliplist_clip_multitrack_action, &QAction::triggered, this, &MainWindow::export_cliplist_clip_multitrack_triggered);
56         connect(ui->export_playlist_clip_interpolated_action, &QAction::triggered, this, &MainWindow::export_playlist_clip_interpolated_triggered);
57         connect(ui->manual_action, &QAction::triggered, this, &MainWindow::manual_triggered);
58         connect(ui->about_action, &QAction::triggered, this, &MainWindow::about_triggered);
59         connect(ui->undo_action, &QAction::triggered, this, &MainWindow::undo_triggered);
60         connect(ui->redo_action, &QAction::triggered, this, &MainWindow::redo_triggered);
61         ui->undo_action->setEnabled(false);
62         ui->redo_action->setEnabled(false);
63
64         global_disk_space_estimator = new DiskSpaceEstimator(bind(&MainWindow::report_disk_space, this, _1, _2));
65         disk_free_label = new QLabel(this);
66         disk_free_label->setStyleSheet("QLabel {padding-right: 5px;}");
67         ui->menuBar->setCornerWidget(disk_free_label);
68
69         StateProto state = db.get_state();
70         undo_stack.push_back(state);  // The undo stack always has the current state on top.
71
72         cliplist_clips = new ClipList(state.clip_list());
73         ui->clip_list->setModel(cliplist_clips);
74         connect(cliplist_clips, &ClipList::any_content_changed, this, &MainWindow::content_changed);
75
76         playlist_clips = new PlayList(state.play_list());
77         ui->playlist->setModel(playlist_clips);
78         connect(playlist_clips, &PlayList::any_content_changed, this, &MainWindow::content_changed);
79
80         // For un-highlighting when we lose focus.
81         ui->clip_list->installEventFilter(this);
82
83         // For scrubbing in the pts columns.
84         ui->clip_list->viewport()->installEventFilter(this);
85         ui->playlist->viewport()->installEventFilter(this);
86
87         QShortcut *cue_in = new QShortcut(QKeySequence(Qt::Key_A), this);
88         connect(cue_in, &QShortcut::activated, ui->cue_in_btn, &QPushButton::click);
89         connect(ui->cue_in_btn, &QPushButton::clicked, this, &MainWindow::cue_in_clicked);
90
91         QShortcut *cue_out = new QShortcut(QKeySequence(Qt::Key_S), this);
92         connect(cue_out, &QShortcut::activated, ui->cue_out_btn, &QPushButton::click);
93         connect(ui->cue_out_btn, &QPushButton::clicked, this, &MainWindow::cue_out_clicked);
94
95         QShortcut *queue = new QShortcut(QKeySequence(Qt::Key_Q), this);
96         connect(queue, &QShortcut::activated, ui->queue_btn, &QPushButton::click);
97         connect(ui->queue_btn, &QPushButton::clicked, this, &MainWindow::queue_clicked);
98
99         QShortcut *preview = new QShortcut(QKeySequence(Qt::Key_W), this);
100         connect(preview, &QShortcut::activated, ui->preview_btn, &QPushButton::click);
101         connect(ui->preview_btn, &QPushButton::clicked, this, &MainWindow::preview_clicked);
102
103         QShortcut *play = new QShortcut(QKeySequence(Qt::Key_Space), this);
104         connect(play, &QShortcut::activated, ui->play_btn, &QPushButton::click);
105         connect(ui->play_btn, &QPushButton::clicked, this, &MainWindow::play_clicked);
106
107         QShortcut *preview_1 = new QShortcut(QKeySequence(Qt::Key_1), this);
108         connect(preview_1, &QShortcut::activated, ui->preview_1_btn, &QPushButton::click);
109         connect(ui->input1_display, &JPEGFrameView::clicked, ui->preview_1_btn, &QPushButton::click);
110         connect(ui->preview_1_btn, &QPushButton::clicked, [this]{ preview_angle_clicked(0); });
111         ui->input1_display->set_overlay("1");
112
113         QShortcut *preview_2 = new QShortcut(QKeySequence(Qt::Key_2), this);
114         connect(preview_2, &QShortcut::activated, ui->preview_2_btn, &QPushButton::click);
115         connect(ui->input2_display, &JPEGFrameView::clicked, ui->preview_2_btn, &QPushButton::click);
116         connect(ui->preview_2_btn, &QPushButton::clicked, [this]{ preview_angle_clicked(1); });
117         ui->input2_display->set_overlay("2");
118
119         QShortcut *preview_3 = new QShortcut(QKeySequence(Qt::Key_3), this);
120         connect(preview_3, &QShortcut::activated, ui->preview_3_btn, &QPushButton::click);
121         connect(ui->input3_display, &JPEGFrameView::clicked, ui->preview_3_btn, &QPushButton::click);
122         connect(ui->preview_3_btn, &QPushButton::clicked, [this]{ preview_angle_clicked(2); });
123         ui->input3_display->set_overlay("3");
124
125         QShortcut *preview_4 = new QShortcut(QKeySequence(Qt::Key_4), this);
126         connect(preview_4, &QShortcut::activated, ui->preview_4_btn, &QPushButton::click);
127         connect(ui->input4_display, &JPEGFrameView::clicked, ui->preview_4_btn, &QPushButton::click);
128         connect(ui->preview_4_btn, &QPushButton::clicked, [this]{ preview_angle_clicked(3); });
129         ui->input4_display->set_overlay("4");
130
131         connect(ui->playlist_duplicate_btn, &QPushButton::clicked, this, &MainWindow::playlist_duplicate);
132
133         connect(ui->playlist_remove_btn, &QPushButton::clicked, this, &MainWindow::playlist_remove);
134         QShortcut *delete_key = new QShortcut(QKeySequence(Qt::Key_Delete), ui->playlist);
135         connect(delete_key, &QShortcut::activated, [this] {
136                 if (ui->playlist->hasFocus()) {
137                         playlist_remove();
138                 }
139         });
140
141         // TODO: support drag-and-drop.
142         connect(ui->playlist_move_up_btn, &QPushButton::clicked, [this]{ playlist_move(-1); });
143         connect(ui->playlist_move_down_btn, &QPushButton::clicked, [this]{ playlist_move(1); });
144
145         connect(ui->playlist->selectionModel(), &QItemSelectionModel::selectionChanged,
146                 this, &MainWindow::playlist_selection_changed);
147         playlist_selection_changed();  // First time set-up.
148
149         preview_player.reset(new Player(ui->preview_display, Player::NO_STREAM_OUTPUT));
150         live_player.reset(new Player(ui->live_display, Player::HTTPD_STREAM_OUTPUT));
151         live_player->set_done_callback([this]{
152                 post_to_main_thread([this]{
153                         live_player_clip_done();
154                 });
155         });
156         live_player->set_next_clip_callback(bind(&MainWindow::live_player_get_next_clip, this));
157         live_player->set_progress_callback([this](const map<size_t, double> &progress) {
158                 post_to_main_thread([this, progress] {
159                         live_player_clip_progress(progress);
160                 });
161         });
162         set_output_status("paused");
163
164         defer_timeout = new QTimer(this);
165         defer_timeout->setSingleShot(true);
166         connect(defer_timeout, &QTimer::timeout, this, &MainWindow::defer_timer_expired);
167         ui->undo_action->setEnabled(true);
168
169         connect(ui->clip_list->selectionModel(), &QItemSelectionModel::currentChanged,
170                 this, &MainWindow::clip_list_selection_changed);
171 }
172
173 MainWindow::~MainWindow()
174 {
175         // Empty so that we can forward-declare Player in the .h file.
176 }
177
178 void MainWindow::cue_in_clicked()
179 {
180         if (!cliplist_clips->empty() && cliplist_clips->back()->pts_out < 0) {
181                 cliplist_clips->mutable_back()->pts_in = current_pts;
182                 return;
183         }
184         Clip clip;
185         clip.pts_in = current_pts;
186         cliplist_clips->add_clip(clip);
187         playlist_selection_changed();
188         ui->clip_list->scrollToBottom();
189 }
190
191 void MainWindow::cue_out_clicked()
192 {
193         if (!cliplist_clips->empty()) {
194                 cliplist_clips->mutable_back()->pts_out = current_pts;
195                 // TODO: select the row in the clip list?
196         }
197 }
198
199 void MainWindow::queue_clicked()
200 {
201         if (cliplist_clips->empty()) {
202                 return;
203         }
204
205         QItemSelectionModel *selected = ui->clip_list->selectionModel();
206         if (!selected->hasSelection()) {
207                 Clip clip = *cliplist_clips->back();
208                 clip.stream_idx = 0;
209                 if (clip.pts_out != -1) {
210                         playlist_clips->add_clip(clip);
211                         playlist_selection_changed();
212                         ui->playlist->scrollToBottom();
213                 }
214                 return;
215         }
216
217         QModelIndex index = selected->currentIndex();
218         Clip clip = *cliplist_clips->clip(index.row());
219         if (index.column() >= int(ClipList::Column::CAMERA_1) &&
220             index.column() <= int(ClipList::Column::CAMERA_4)) {
221                 clip.stream_idx = index.column() - int(ClipList::Column::CAMERA_1);
222         } else {
223                 clip.stream_idx = ui->preview_display->get_stream_idx();
224         }
225
226         if (clip.pts_out != -1) {
227                 playlist_clips->add_clip(clip);
228                 playlist_selection_changed();
229                 ui->playlist->scrollToBottom();
230                 if (!ui->playlist->selectionModel()->hasSelection()) {
231                         // TODO: Figure out why this doesn't always seem to actually select the row.
232                         QModelIndex bottom = playlist_clips->index(playlist_clips->size() - 1, 0);
233                         ui->playlist->setCurrentIndex(bottom);
234                 }
235         }
236 }
237
238 void MainWindow::preview_clicked()
239 {
240         if (ui->playlist->hasFocus()) {
241                 // Allow the playlist as preview iff it has focus and something is selected.
242                 QItemSelectionModel *selected = ui->playlist->selectionModel();
243                 if (selected->hasSelection()) {
244                         QModelIndex index = selected->currentIndex();
245                         const Clip &clip = *playlist_clips->clip(index.row());
246                         preview_player->play_clip(clip, index.row(), clip.stream_idx);
247                         return;
248                 }
249         }
250
251         if (cliplist_clips->empty())
252                 return;
253
254         QItemSelectionModel *selected = ui->clip_list->selectionModel();
255         if (!selected->hasSelection()) {
256                 preview_player->play_clip(*cliplist_clips->back(), cliplist_clips->size() - 1, 0);
257                 return;
258         }
259
260         QModelIndex index = selected->currentIndex();
261         unsigned stream_idx;
262         if (index.column() >= int(ClipList::Column::CAMERA_1) &&
263             index.column() <= int(ClipList::Column::CAMERA_4)) {
264                 stream_idx = index.column() - int(ClipList::Column::CAMERA_1);
265         } else {
266                 stream_idx = ui->preview_display->get_stream_idx();
267         }
268         preview_player->play_clip(*cliplist_clips->clip(index.row()), index.row(), stream_idx);
269 }
270
271 void MainWindow::preview_angle_clicked(unsigned stream_idx)
272 {
273         preview_player->override_angle(stream_idx);
274
275         // Change the selection if we were previewing a clip from the clip list.
276         // (The only other thing we could be showing is a pts scrub, and if so,
277         // that would be selected.)
278         QItemSelectionModel *selected = ui->clip_list->selectionModel();
279         if (selected->hasSelection()) {
280                 QModelIndex cell = selected->selectedIndexes()[0];
281                 int column = int(ClipList::Column::CAMERA_1) + stream_idx;
282                 selected->setCurrentIndex(cell.sibling(cell.row(), column), QItemSelectionModel::ClearAndSelect);
283         }
284 }
285
286 void MainWindow::playlist_duplicate()
287 {
288         QItemSelectionModel *selected = ui->playlist->selectionModel();
289         if (!selected->hasSelection()) {
290                 // Should have been grayed out, but OK.
291                 return;
292         }
293         QModelIndexList rows = selected->selectedRows();
294         int first = rows.front().row(), last = rows.back().row();
295         playlist_clips->duplicate_clips(first, last);
296         playlist_selection_changed();
297 }
298
299 void MainWindow::playlist_remove()
300 {
301         QItemSelectionModel *selected = ui->playlist->selectionModel();
302         if (!selected->hasSelection()) {
303                 // Should have been grayed out, but OK.
304                 return;
305         }
306         QModelIndexList rows = selected->selectedRows();
307         int first = rows.front().row(), last = rows.back().row();
308         playlist_clips->erase_clips(first, last);
309
310         // TODO: select the next one in the list?
311
312         playlist_selection_changed();
313 }
314
315 void MainWindow::playlist_move(int delta)
316 {
317         QItemSelectionModel *selected = ui->playlist->selectionModel();
318         if (!selected->hasSelection()) {
319                 // Should have been grayed out, but OK.
320                 return;
321         }
322
323         QModelIndexList rows = selected->selectedRows();
324         int first = rows.front().row(), last = rows.back().row();
325         if ((delta == -1 && first == 0) ||
326             (delta == 1 && size_t(last) == playlist_clips->size() - 1)) {
327                 // Should have been grayed out, but OK.
328                 return;
329         }
330
331         playlist_clips->move_clips(first, last, delta);
332         playlist_selection_changed();
333 }
334
335 void MainWindow::defer_timer_expired()
336 {
337         state_changed(deferred_state);
338 }
339
340 void MainWindow::content_changed()
341 {
342         if (defer_timeout->isActive() &&
343             (!currently_deferring_model_changes || deferred_change_id != current_change_id)) {
344                 // There's some deferred event waiting, but this event is unrelated.
345                 // So it's time to short-circuit that timer and do the work it wanted to do.
346                 defer_timeout->stop();
347                 state_changed(deferred_state);
348         }
349         StateProto state;
350         *state.mutable_clip_list() = cliplist_clips->serialize();
351         *state.mutable_play_list() = playlist_clips->serialize();
352         if (currently_deferring_model_changes) {
353                 deferred_change_id = current_change_id;
354                 deferred_state = std::move(state);
355                 defer_timeout->start(200);
356                 return;
357         }
358         state_changed(state);
359 }
360
361 void MainWindow::state_changed(const StateProto &state)
362 {
363         db.store_state(state);
364
365         redo_stack.clear();
366         ui->redo_action->setEnabled(false);
367
368         undo_stack.push_back(state);
369         ui->undo_action->setEnabled(undo_stack.size() > 1);
370
371         // Make sure it doesn't grow without bounds.
372         while (undo_stack.size() >= 100) {
373                 undo_stack.pop_front();
374         }
375 }
376
377 void MainWindow::play_clicked()
378 {
379         if (playlist_clips->empty())
380                 return;
381
382         QItemSelectionModel *selected = ui->playlist->selectionModel();
383         int row;
384         if (!selected->hasSelection()) {
385                 row = 0;
386         } else {
387                 row = selected->selectedRows(0)[0].row();
388         }
389
390         const Clip &clip = *playlist_clips->clip(row);
391         live_player->play_clip(clip, row, clip.stream_idx);
392         playlist_clips->set_progress({{ row, 0.0f }});
393         playlist_clips->set_currently_playing(row, 0.0f);
394         playlist_selection_changed();
395 }
396
397 void MainWindow::live_player_clip_done()
398 {
399         int row = playlist_clips->get_currently_playing();
400         if (row == -1 || row == int(playlist_clips->size()) - 1) {
401                 set_output_status("paused");
402                 playlist_clips->set_progress({});
403                 playlist_clips->set_currently_playing(-1, 0.0f);
404         } else {
405                 playlist_clips->set_progress({{ row + 1, 0.0f }});
406                 playlist_clips->set_currently_playing(row + 1, 0.0f);
407         }
408 }
409
410 pair<Clip, size_t> MainWindow::live_player_get_next_clip()
411 {
412         // playlist_clips can only be accessed on the main thread.
413         // Hopefully, we won't have to wait too long for this to come back.
414         //
415         // TODO: If MainWindow is in the process of being destroyed and waiting
416         // for Player to shut down, we could have a deadlock here.
417         promise<pair<Clip, size_t>> clip_promise;
418         future<pair<Clip, size_t>> clip = clip_promise.get_future();
419         post_to_main_thread([this, &clip_promise] {
420                 int row = playlist_clips->get_currently_playing();
421                 if (row != -1 && row < int(playlist_clips->size()) - 1) {
422                         clip_promise.set_value(make_pair(*playlist_clips->clip(row + 1), row + 1));
423                 } else {
424                         clip_promise.set_value(make_pair(Clip(), 0));
425                 }
426         });
427         return clip.get();
428 }
429
430 static string format_duration(double t)
431 {
432         int t_ms = lrint(t * 1e3);
433
434         int ms = t_ms % 1000;
435         t_ms /= 1000;
436         int s = t_ms % 60;
437         t_ms /= 60;
438         int m = t_ms;
439
440         char buf[256];
441         snprintf(buf, sizeof(buf), "%d:%02d.%03d", m, s, ms);
442         return buf;
443 }
444
445 void MainWindow::live_player_clip_progress(const map<size_t, double> &progress)
446 {
447         playlist_clips->set_progress(progress);
448
449         vector<Clip> clips;
450         for (size_t row = 0; row < playlist_clips->size(); ++row) {
451                 clips.push_back(*playlist_clips->clip(row));
452         }
453         double remaining = compute_time_left(clips, progress);
454         set_output_status(format_duration(remaining) + " left");
455 }
456
457 void MainWindow::resizeEvent(QResizeEvent *event)
458 {
459         QMainWindow::resizeEvent(event);
460
461         // Ask for a relayout, but only after the event loop is done doing relayout
462         // on everything else.
463         QMetaObject::invokeMethod(this, "relayout", Qt::QueuedConnection);
464 }
465
466 void MainWindow::relayout()
467 {
468         ui->live_display->setMinimumWidth(ui->live_display->height() * 16 / 9);
469         ui->preview_display->setMinimumWidth(ui->preview_display->height() * 16 / 9);
470 }
471
472 void set_pts_in(int64_t pts, int64_t current_pts, ClipProxy &clip)
473 {
474         pts = std::max<int64_t>(pts, 0);
475         if (clip->pts_out == -1) {
476                 pts = std::min(pts, current_pts);
477         } else {
478                 pts = std::min(pts, clip->pts_out);
479         }
480         clip->pts_in = pts;
481 }
482
483 bool MainWindow::eventFilter(QObject *watched, QEvent *event)
484 {
485         constexpr int dead_zone_pixels = 3;  // To avoid that simple clicks get misinterpreted.
486         constexpr int camera_degrees_per_pixel = 15;  // One click of most mice.
487         int scrub_sensitivity = 100;  // pts units per pixel.
488         int wheel_sensitivity = 100;  // pts units per degree.
489
490         unsigned stream_idx = ui->preview_display->get_stream_idx();
491
492         if (watched == ui->clip_list) {
493                 if (event->type() == QEvent::FocusOut) {
494                         highlight_camera_input(-1);
495                 }
496                 return false;
497         }
498
499         if (event->type() != QEvent::Wheel) {
500                 last_mousewheel_camera_row = -1;
501         }
502
503         if (event->type() == QEvent::MouseButtonPress) {
504                 QMouseEvent *mouse = (QMouseEvent *)event;
505
506                 QTableView *destination;
507                 ScrubType type;
508
509                 if (watched == ui->clip_list->viewport()) {
510                         destination = ui->clip_list;
511                         type = SCRUBBING_CLIP_LIST;
512                 } else if (watched == ui->playlist->viewport()) {
513                         destination = ui->playlist;
514                         type = SCRUBBING_PLAYLIST;
515                 } else {
516                         return false;
517                 }
518                 int column = destination->columnAt(mouse->x());
519                 int row = destination->rowAt(mouse->y());
520                 if (column == -1 || row == -1)
521                         return false;
522
523                 if (type == SCRUBBING_CLIP_LIST) {
524                         if (ClipList::Column(column) == ClipList::Column::IN) {
525                                 scrub_pts_origin = cliplist_clips->clip(row)->pts_in;
526                                 preview_single_frame(scrub_pts_origin, stream_idx, FIRST_AT_OR_AFTER);
527                         } else if (ClipList::Column(column) == ClipList::Column::OUT) {
528                                 scrub_pts_origin = cliplist_clips->clip(row)->pts_out;
529                                 preview_single_frame(scrub_pts_origin, stream_idx, LAST_BEFORE);
530                         } else {
531                                 return false;
532                         }
533                 } else {
534                         if (PlayList::Column(column) == PlayList::Column::IN) {
535                                 scrub_pts_origin = playlist_clips->clip(row)->pts_in;
536                                 preview_single_frame(scrub_pts_origin, stream_idx, FIRST_AT_OR_AFTER);
537                         } else if (PlayList::Column(column) == PlayList::Column::OUT) {
538                                 scrub_pts_origin = playlist_clips->clip(row)->pts_out;
539                                 preview_single_frame(scrub_pts_origin, stream_idx, LAST_BEFORE);
540                         } else {
541                                 return false;
542                         }
543                 }
544
545                 scrubbing = true;
546                 scrub_row = row;
547                 scrub_column = column;
548                 scrub_x_origin = mouse->x();
549                 scrub_type = type;
550         } else if (event->type() == QEvent::MouseMove) {
551                 QMouseEvent *mouse = (QMouseEvent *)event;
552                 if (mouse->modifiers() & Qt::KeyboardModifier::ShiftModifier) {
553                         scrub_sensitivity *= 10;
554                         wheel_sensitivity *= 10;
555                 }
556                 if (mouse->modifiers() & Qt::KeyboardModifier::AltModifier) {  // Note: Shift + Alt cancel each other out.
557                         scrub_sensitivity /= 10;
558                         wheel_sensitivity /= 10;
559                 }
560                 if (scrubbing) {
561                         int offset = mouse->x() - scrub_x_origin;
562                         int adjusted_offset;
563                         if (offset >= dead_zone_pixels) {
564                                 adjusted_offset = offset - dead_zone_pixels;
565                         } else if (offset < -dead_zone_pixels) {
566                                 adjusted_offset = offset + dead_zone_pixels;
567                         } else {
568                                 adjusted_offset = 0;
569                         }
570
571                         int64_t pts = scrub_pts_origin + adjusted_offset * scrub_sensitivity;
572                         currently_deferring_model_changes = true;
573                         if (scrub_type == SCRUBBING_CLIP_LIST) {
574                                 ClipProxy clip = cliplist_clips->mutable_clip(scrub_row);
575                                 if (scrub_column == int(ClipList::Column::IN)) {
576                                         current_change_id = "cliplist:in:" + to_string(scrub_row);
577                                         set_pts_in(pts, current_pts, clip);
578                                         preview_single_frame(pts, stream_idx, FIRST_AT_OR_AFTER);
579                                 } else {
580                                         current_change_id = "cliplist:out" + to_string(scrub_row);
581                                         pts = std::max(pts, clip->pts_in);
582                                         pts = std::min(pts, current_pts);
583                                         clip->pts_out = pts;
584                                         preview_single_frame(pts, stream_idx, LAST_BEFORE);
585                                 }
586                         } else {
587                                 ClipProxy clip = playlist_clips->mutable_clip(scrub_row);
588                                 if (scrub_column == int(PlayList::Column::IN)) {
589                                         current_change_id = "playlist:in:" + to_string(scrub_row);
590                                         set_pts_in(pts, current_pts, clip);
591                                         preview_single_frame(pts, clip->stream_idx, FIRST_AT_OR_AFTER);
592                                 } else {
593                                         current_change_id = "playlist:out:" + to_string(scrub_row);
594                                         pts = std::max(pts, clip->pts_in);
595                                         pts = std::min(pts, current_pts);
596                                         clip->pts_out = pts;
597                                         preview_single_frame(pts, clip->stream_idx, LAST_BEFORE);
598                                 }
599                         }
600                         currently_deferring_model_changes = false;
601
602                         return true;  // Don't use this mouse movement for selecting things.
603                 }
604         } else if (event->type() == QEvent::Wheel) {
605                 QWheelEvent *wheel = (QWheelEvent *)event;
606                 int angle_delta = wheel->angleDelta().y();
607                 if (wheel->modifiers() & Qt::KeyboardModifier::ShiftModifier) {
608                         scrub_sensitivity *= 10;
609                         wheel_sensitivity *= 10;
610                 }
611                 if (wheel->modifiers() & Qt::KeyboardModifier::AltModifier) {  // Note: Shift + Alt cancel each other out.
612                         scrub_sensitivity /= 10;
613                         wheel_sensitivity /= 10;
614                         angle_delta = wheel->angleDelta().x();  // Qt ickiness.
615                 }
616
617                 QTableView *destination;
618                 int in_column, out_column, camera_column;
619                 if (watched == ui->clip_list->viewport()) {
620                         destination = ui->clip_list;
621                         in_column = int(ClipList::Column::IN);
622                         out_column = int(ClipList::Column::OUT);
623                         camera_column = -1;
624                         last_mousewheel_camera_row = -1;
625                 } else if (watched == ui->playlist->viewport()) {
626                         destination = ui->playlist;
627                         in_column = int(PlayList::Column::IN);
628                         out_column = int(PlayList::Column::OUT);
629                         camera_column = int(PlayList::Column::CAMERA);
630                 } else {
631                         last_mousewheel_camera_row = -1;
632                         return false;
633                 }
634                 int column = destination->columnAt(wheel->x());
635                 int row = destination->rowAt(wheel->y());
636                 if (column == -1 || row == -1) return false;
637
638                 // Only adjust pts with the wheel if the given row is selected.
639                 if (!destination->hasFocus() ||
640                     row != destination->selectionModel()->currentIndex().row()) {
641                         return false;
642                 }
643
644                 currently_deferring_model_changes = true;
645                 {
646                         current_change_id = (watched == ui->clip_list->viewport()) ? "cliplist:" : "playlist:";
647                         ClipProxy clip = (watched == ui->clip_list->viewport()) ?
648                                 cliplist_clips->mutable_clip(row) : playlist_clips->mutable_clip(row);
649                         if (watched == ui->playlist->viewport()) {
650                                 stream_idx = clip->stream_idx;
651                         }
652
653                         if (column != camera_column) {
654                                 last_mousewheel_camera_row = -1;
655                         }
656                         if (column == in_column) {
657                                 current_change_id += "in:" + to_string(row);
658                                 int64_t pts = clip->pts_in + angle_delta * wheel_sensitivity;
659                                 set_pts_in(pts, current_pts, clip);
660                                 preview_single_frame(pts, stream_idx, FIRST_AT_OR_AFTER);
661                         } else if (column == out_column) {
662                                 current_change_id += "out:" + to_string(row);
663                                 int64_t pts = clip->pts_out + angle_delta * wheel_sensitivity;
664                                 pts = std::max(pts, clip->pts_in);
665                                 pts = std::min(pts, current_pts);
666                                 clip->pts_out = pts;
667                                 preview_single_frame(pts, stream_idx, LAST_BEFORE);
668                         } else if (column == camera_column) {
669                                 current_change_id += "camera:" + to_string(row);
670                                 int angle_degrees = angle_delta;
671                                 if (last_mousewheel_camera_row == row) {
672                                         angle_degrees += leftover_angle_degrees;
673                                 }
674
675                                 int stream_idx = clip->stream_idx + angle_degrees / camera_degrees_per_pixel;
676                                 stream_idx = std::max(stream_idx, 0);
677                                 stream_idx = std::min(stream_idx, NUM_CAMERAS - 1);
678                                 clip->stream_idx = stream_idx;
679
680                                 last_mousewheel_camera_row = row;
681                                 leftover_angle_degrees = angle_degrees % camera_degrees_per_pixel;
682
683                                 // Don't update the live view, that's rarely what the operator wants.
684                         }
685                 }
686                 currently_deferring_model_changes = false;
687                 return true;  // Don't scroll.
688         } else if (event->type() == QEvent::MouseButtonRelease) {
689                 scrubbing = false;
690         }
691         return false;
692 }
693
694 void MainWindow::preview_single_frame(int64_t pts, unsigned stream_idx, MainWindow::Rounding rounding)
695 {
696         if (rounding == LAST_BEFORE) {
697                 lock_guard<mutex> lock(frame_mu);
698                 if (frames[stream_idx].empty())
699                         return;
700                 auto it = find_last_frame_before(frames[stream_idx], pts);
701                 if (it != frames[stream_idx].end()) {
702                         pts = it->pts;
703                 }
704         } else {
705                 assert(rounding == FIRST_AT_OR_AFTER);
706                 lock_guard<mutex> lock(frame_mu);
707                 if (frames[stream_idx].empty())
708                         return;
709                 auto it = find_first_frame_at_or_after(frames[stream_idx], pts);
710                 if (it != frames[stream_idx].end()) {
711                         pts = it->pts;
712                 }
713         }
714
715         Clip fake_clip;
716         fake_clip.pts_in = pts;
717         fake_clip.pts_out = pts + 1;
718         preview_player->play_clip(fake_clip, 0, stream_idx);
719 }
720
721 void MainWindow::playlist_selection_changed()
722 {
723         QItemSelectionModel *selected = ui->playlist->selectionModel();
724         bool any_selected = selected->hasSelection();
725         ui->playlist_duplicate_btn->setEnabled(any_selected);
726         ui->playlist_remove_btn->setEnabled(any_selected);
727         ui->playlist_move_up_btn->setEnabled(
728                 any_selected && selected->selectedRows().front().row() > 0);
729         ui->playlist_move_down_btn->setEnabled(
730                 any_selected && selected->selectedRows().back().row() < int(playlist_clips->size()) - 1);
731         ui->play_btn->setEnabled(!playlist_clips->empty());
732
733         if (!any_selected) {
734                 set_output_status("paused");
735         } else {
736                 double remaining = 0.0;
737                 for (int row = selected->selectedRows().front().row(); row < int(playlist_clips->size()); ++row) {
738                         const Clip clip = *playlist_clips->clip(row);
739                         remaining += double(clip.pts_out - clip.pts_in) / TIMEBASE / 0.5;  // FIXME: stop hardcoding speed.
740                 }
741                 set_output_status(format_duration(remaining) + " ready");
742         }
743 }
744
745 void MainWindow::clip_list_selection_changed(const QModelIndex &current, const QModelIndex &)
746 {
747         int camera_selected = -1;
748         if (current.column() >= int(ClipList::Column::CAMERA_1) &&
749             current.column() <= int(ClipList::Column::CAMERA_4)) {
750                 camera_selected = current.column() - int(ClipList::Column::CAMERA_1);
751         }
752         highlight_camera_input(camera_selected);
753 }
754
755 void MainWindow::report_disk_space(off_t free_bytes, double estimated_seconds_left)
756 {
757         char time_str[256];
758         if (estimated_seconds_left < 60.0) {
759                 strcpy(time_str, "<font color=\"red\">Less than a minute</font>");
760         } else if (estimated_seconds_left < 1800.0) {  // Less than half an hour: Xm Ys (red).
761                 int s = lrintf(estimated_seconds_left);
762                 int m = s / 60;
763                 s %= 60;
764                 snprintf(time_str, sizeof(time_str), "<font color=\"red\">%dm %ds</font>", m, s);
765         } else if (estimated_seconds_left < 3600.0) {  // Less than an hour: Xm.
766                 int m = lrintf(estimated_seconds_left / 60.0);
767                 snprintf(time_str, sizeof(time_str), "%dm", m);
768         } else if (estimated_seconds_left < 36000.0) {  // Less than ten hours: Xh Ym.
769                 int m = lrintf(estimated_seconds_left / 60.0);
770                 int h = m / 60;
771                 m %= 60;
772                 snprintf(time_str, sizeof(time_str), "%dh %dm", h, m);
773         } else {  // More than ten hours: Xh.
774                 int h = lrintf(estimated_seconds_left / 3600.0);
775                 snprintf(time_str, sizeof(time_str), "%dh", h);
776         }
777         char buf[256];
778         snprintf(buf, sizeof(buf), "Disk free: %'.0f MB (approx. %s)", free_bytes / 1048576.0, time_str);
779
780         std::string label = buf;
781
782         post_to_main_thread([this, label] {
783                 disk_free_label->setText(QString::fromStdString(label));
784                 ui->menuBar->setCornerWidget(disk_free_label);  // Need to set this again for the sizing to get right.
785         });
786 }
787
788 void MainWindow::exit_triggered()
789 {
790         close();
791 }
792
793 void MainWindow::export_cliplist_clip_multitrack_triggered()
794 {
795         QItemSelectionModel *selected = ui->clip_list->selectionModel();
796         if (!selected->hasSelection()) {
797                 QMessageBox msgbox;
798                 msgbox.setText("No clip selected in the clip list. Select one and try exporting again.");
799                 msgbox.exec();
800                 return;
801         }
802
803         QModelIndex index = selected->currentIndex();
804         Clip clip = *cliplist_clips->clip(index.row());
805         QString filename = QFileDialog::getSaveFileName(this,
806                 "Export multitrack clip", QString(), tr("Matroska video files (*.mkv)"));
807         if (filename.isNull()) {
808                 // Cancel.
809                 return;
810         }
811         if (!filename.endsWith(".mkv")) {
812                 filename += ".mkv";
813         }
814         export_multitrack_clip(filename.toStdString(), clip);
815 }
816
817 void MainWindow::export_playlist_clip_interpolated_triggered()
818 {
819         QItemSelectionModel *selected = ui->playlist->selectionModel();
820         if (!selected->hasSelection()) {
821                 QMessageBox msgbox;
822                 msgbox.setText("No clip selected in the playlist. Select one and try exporting again.");
823                 msgbox.exec();
824                 return;
825         }
826
827         QString filename = QFileDialog::getSaveFileName(this,
828                 "Export interpolated clip", QString(), tr("Matroska video files (*.mkv)"));
829         if (filename.isNull()) {
830                 // Cancel.
831                 return;
832         }
833         if (!filename.endsWith(".mkv")) {
834                 filename += ".mkv";
835         }
836
837         vector<Clip> clips;
838         QModelIndexList rows = selected->selectedRows();
839         for (QModelIndex index : rows) {
840                 clips.push_back(*playlist_clips->clip(index.row()));
841         }
842         export_interpolated_clip(filename.toStdString(), clips);
843 }
844
845 void MainWindow::manual_triggered()
846 {
847         if (!QDesktopServices::openUrl(QUrl("https://nageru.sesse.net/doc/"))) {
848                 QMessageBox msgbox;
849                 msgbox.setText("Could not launch manual in web browser.\nPlease see https://nageru.sesse.net/doc/ manually.");
850                 msgbox.exec();
851         }
852 }
853
854 void MainWindow::about_triggered()
855 {
856         AboutDialog("Futatabi", "Multicamera slow motion video server").exec();
857 }
858
859 void MainWindow::undo_triggered()
860 {
861         // Finish any deferred action.
862         if (defer_timeout->isActive()) {
863                 defer_timeout->stop();
864                 state_changed(deferred_state);
865         }
866
867         StateProto redo_state;
868         *redo_state.mutable_clip_list() = cliplist_clips->serialize();
869         *redo_state.mutable_play_list() = playlist_clips->serialize();
870         redo_stack.push_back(std::move(redo_state));
871         ui->redo_action->setEnabled(true);
872
873         assert(undo_stack.size() > 1);
874
875         // Pop off the current state, which is always at the top of the stack.
876         undo_stack.pop_back();
877
878         StateProto state = undo_stack.back();
879         ui->undo_action->setEnabled(undo_stack.size() > 1);
880
881         replace_model(ui->clip_list, &cliplist_clips, new ClipList(state.clip_list()), this);
882         replace_model(ui->playlist, &playlist_clips, new PlayList(state.play_list()), this);
883
884         db.store_state(state);
885 }
886
887 void MainWindow::redo_triggered()
888 {
889         assert(!redo_stack.empty());
890
891         ui->undo_action->setEnabled(true);
892         ui->redo_action->setEnabled(true);
893
894         undo_stack.push_back(std::move(redo_stack.back()));
895         redo_stack.pop_back();
896         ui->undo_action->setEnabled(true);
897         ui->redo_action->setEnabled(!redo_stack.empty());
898
899         const StateProto &state = undo_stack.back();
900         replace_model(ui->clip_list, &cliplist_clips, new ClipList(state.clip_list()), this);
901         replace_model(ui->playlist, &playlist_clips, new PlayList(state.play_list()), this);
902
903         db.store_state(state);
904 }
905
906 void MainWindow::highlight_camera_input(int stream_idx)
907 {
908         if (stream_idx == 0) {
909                 ui->input1_frame->setStyleSheet("background: rgb(0,255,0)");
910         } else {
911                 ui->input1_frame->setStyleSheet("");
912         }
913         if (stream_idx == 1) {
914                 ui->input2_frame->setStyleSheet("background: rgb(0,255,0)");
915         } else {
916                 ui->input2_frame->setStyleSheet("");
917         }
918         if (stream_idx == 2) {
919                 ui->input3_frame->setStyleSheet("background: rgb(0,255,0)");
920         } else {
921                 ui->input3_frame->setStyleSheet("");
922         }
923         if (stream_idx == 3) {
924                 ui->input4_frame->setStyleSheet("background: rgb(0,255,0)");
925         } else {
926                 ui->input4_frame->setStyleSheet("");
927         }
928 }
929
930 void MainWindow::set_output_status(const string &status)
931 {
932         ui->live_label->setText(QString::fromStdString("Current output (" + status + ")"));
933
934         lock_guard<mutex> lock(queue_status_mu);
935         queue_status = status;
936 }
937
938 pair<string, string> MainWindow::get_queue_status() const {
939         lock_guard<mutex> lock(queue_status_mu);
940         return {queue_status, "text/plain"};
941 }