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