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