]> git.sesse.net Git - nageru/blob - mainwindow.cpp
Allow symlinked frame files. Useful for testing.
[nageru] / mainwindow.cpp
1 #include "mainwindow.h"
2
3 #include "clip_list.h"
4 #include "disk_space_estimator.h"
5 #include "flags.h"
6 #include "frame_on_disk.h"
7 #include "player.h"
8 #include "post_to_main_thread.h"
9 #include "timebase.h"
10 #include "ui_mainwindow.h"
11
12 #include <QMouseEvent>
13 #include <QShortcut>
14 #include <QTimer>
15 #include <QWheelEvent>
16 #include <future>
17 #include <sqlite3.h>
18 #include <string>
19 #include <vector>
20
21 using namespace std;
22 using namespace std::placeholders;
23
24 MainWindow *global_mainwindow = nullptr;
25 static ClipList *cliplist_clips;
26 static PlayList *playlist_clips;
27
28 extern int64_t current_pts;
29
30 MainWindow::MainWindow()
31         : ui(new Ui::MainWindow),
32           db(global_flags.working_directory + "/futatabi.db")
33 {
34         global_mainwindow = this;
35         ui->setupUi(this);
36
37         // The menus.
38         connect(ui->exit_action, &QAction::triggered, this, &MainWindow::exit_triggered);
39
40         global_disk_space_estimator = new DiskSpaceEstimator(bind(&MainWindow::report_disk_space, this, _1, _2));
41         disk_free_label = new QLabel(this);
42         disk_free_label->setStyleSheet("QLabel {padding-right: 5px;}");
43         ui->menuBar->setCornerWidget(disk_free_label);
44
45         StateProto state = db.get_state();
46
47         cliplist_clips = new ClipList(state.clip_list());
48         ui->clip_list->setModel(cliplist_clips);
49         connect(cliplist_clips, &ClipList::any_content_changed, this, &MainWindow::content_changed);
50
51         playlist_clips = new PlayList(state.play_list());
52         ui->playlist->setModel(playlist_clips);
53         connect(playlist_clips, &PlayList::any_content_changed, this, &MainWindow::content_changed);
54
55         // For un-highlighting when we lose focus.
56         ui->clip_list->installEventFilter(this);
57
58         // For scrubbing in the pts columns.
59         ui->clip_list->viewport()->installEventFilter(this);
60         ui->playlist->viewport()->installEventFilter(this);
61
62         QShortcut *cue_in = new QShortcut(QKeySequence(Qt::Key_A), this);
63         connect(cue_in, &QShortcut::activated, ui->cue_in_btn, &QPushButton::click);
64         connect(ui->cue_in_btn, &QPushButton::clicked, this, &MainWindow::cue_in_clicked);
65
66         QShortcut *cue_out = new QShortcut(QKeySequence(Qt::Key_S), this);
67         connect(cue_out, &QShortcut::activated, ui->cue_out_btn, &QPushButton::click);
68         connect(ui->cue_out_btn, &QPushButton::clicked, this, &MainWindow::cue_out_clicked);
69
70         QShortcut *queue = new QShortcut(QKeySequence(Qt::Key_Q), this);
71         connect(queue, &QShortcut::activated, ui->queue_btn, &QPushButton::click);
72         connect(ui->queue_btn, &QPushButton::clicked, this, &MainWindow::queue_clicked);
73
74         QShortcut *preview = new QShortcut(QKeySequence(Qt::Key_W), this);
75         connect(preview, &QShortcut::activated, ui->preview_btn, &QPushButton::click);
76         connect(ui->preview_btn, &QPushButton::clicked, this, &MainWindow::preview_clicked);
77
78         QShortcut *play = new QShortcut(QKeySequence(Qt::Key_Space), this);
79         connect(play, &QShortcut::activated, ui->play_btn, &QPushButton::click);
80         connect(ui->play_btn, &QPushButton::clicked, this, &MainWindow::play_clicked);
81
82         QShortcut *preview_1 = new QShortcut(QKeySequence(Qt::Key_1), this);
83         connect(preview_1, &QShortcut::activated, ui->preview_1_btn, &QPushButton::click);
84         connect(ui->input1_display, &JPEGFrameView::clicked, ui->preview_1_btn, &QPushButton::click);
85         connect(ui->preview_1_btn, &QPushButton::clicked, [this]{ preview_angle_clicked(0); });
86         ui->input1_display->set_overlay("1");
87
88         QShortcut *preview_2 = new QShortcut(QKeySequence(Qt::Key_2), this);
89         connect(preview_2, &QShortcut::activated, ui->preview_2_btn, &QPushButton::click);
90         connect(ui->input2_display, &JPEGFrameView::clicked, ui->preview_2_btn, &QPushButton::click);
91         connect(ui->preview_2_btn, &QPushButton::clicked, [this]{ preview_angle_clicked(1); });
92         ui->input2_display->set_overlay("2");
93
94         QShortcut *preview_3 = new QShortcut(QKeySequence(Qt::Key_3), this);
95         connect(preview_3, &QShortcut::activated, ui->preview_3_btn, &QPushButton::click);
96         connect(ui->input3_display, &JPEGFrameView::clicked, ui->preview_3_btn, &QPushButton::click);
97         connect(ui->preview_3_btn, &QPushButton::clicked, [this]{ preview_angle_clicked(2); });
98         ui->input3_display->set_overlay("3");
99
100         QShortcut *preview_4 = new QShortcut(QKeySequence(Qt::Key_4), this);
101         connect(preview_4, &QShortcut::activated, ui->preview_4_btn, &QPushButton::click);
102         connect(ui->input4_display, &JPEGFrameView::clicked, ui->preview_4_btn, &QPushButton::click);
103         connect(ui->preview_4_btn, &QPushButton::clicked, [this]{ preview_angle_clicked(3); });
104         ui->input4_display->set_overlay("4");
105
106         connect(ui->playlist_duplicate_btn, &QPushButton::clicked, this, &MainWindow::playlist_duplicate);
107
108         connect(ui->playlist_remove_btn, &QPushButton::clicked, this, &MainWindow::playlist_remove);
109         QShortcut *delete_key = new QShortcut(QKeySequence(Qt::Key_Delete), ui->playlist);
110         connect(delete_key, &QShortcut::activated, [this] {
111                 if (ui->playlist->hasFocus()) {
112                         playlist_remove();
113                 }
114         });
115
116         // TODO: support drag-and-drop.
117         connect(ui->playlist_move_up_btn, &QPushButton::clicked, [this]{ playlist_move(-1); });
118         connect(ui->playlist_move_down_btn, &QPushButton::clicked, [this]{ playlist_move(1); });
119
120         connect(ui->playlist->selectionModel(), &QItemSelectionModel::selectionChanged,
121                 this, &MainWindow::playlist_selection_changed);
122         playlist_selection_changed();  // First time set-up.
123
124         preview_player = new Player(ui->preview_display, /*also_output_to_stream=*/false);
125         live_player = new Player(ui->live_display, /*also_output_to_stream=*/true);
126         live_player->set_done_callback([this]{
127                 post_to_main_thread([this]{
128                         live_player_clip_done();
129                 });
130         });
131         live_player->set_next_clip_callback(bind(&MainWindow::live_player_get_next_clip, this));
132         live_player->set_progress_callback([this](const map<size_t, double> &progress) {
133                 post_to_main_thread([this, progress] {
134                         live_player_clip_progress(progress);
135                 });
136         });
137         set_output_status("paused");
138
139         defer_timeout = new QTimer(this);
140         defer_timeout->setSingleShot(true);
141         connect(defer_timeout, &QTimer::timeout, this, &MainWindow::defer_timer_expired);
142
143         connect(ui->clip_list->selectionModel(), &QItemSelectionModel::currentChanged,
144                 this, &MainWindow::clip_list_selection_changed);
145 }
146
147 void MainWindow::cue_in_clicked()
148 {
149         if (!cliplist_clips->empty() && cliplist_clips->back()->pts_out < 0) {
150                 cliplist_clips->mutable_back()->pts_in = current_pts;
151                 return;
152         }
153         Clip clip;
154         clip.pts_in = current_pts;
155         cliplist_clips->add_clip(clip);
156         playlist_selection_changed();
157 }
158
159 void MainWindow::cue_out_clicked()
160 {
161         if (!cliplist_clips->empty()) {
162                 cliplist_clips->mutable_back()->pts_out = current_pts;
163                 // TODO: select the row in the clip list?
164         }
165 }
166
167 void MainWindow::queue_clicked()
168 {
169         if (cliplist_clips->empty()) {
170                 return;
171         }
172
173         QItemSelectionModel *selected = ui->clip_list->selectionModel();
174         if (!selected->hasSelection()) {
175                 Clip clip = *cliplist_clips->back();
176                 clip.stream_idx = 0;
177                 if (clip.pts_out != -1) {
178                         playlist_clips->add_clip(clip);
179                         playlist_selection_changed();
180                 }
181                 return;
182         }
183
184         QModelIndex index = selected->currentIndex();
185         Clip clip = *cliplist_clips->clip(index.row());
186         if (index.column() >= int(ClipList::Column::CAMERA_1) &&
187             index.column() <= int(ClipList::Column::CAMERA_4)) {
188                 clip.stream_idx = index.column() - int(ClipList::Column::CAMERA_1);
189         } else {
190                 clip.stream_idx = ui->preview_display->get_stream_idx();
191         }
192
193         if (clip.pts_out != -1) {
194                 playlist_clips->add_clip(clip);
195                 playlist_selection_changed();
196         }
197 }
198
199 void MainWindow::preview_clicked()
200 {
201         if (ui->playlist->hasFocus()) {
202                 // Allow the playlist as preview iff it has focus and something is selected.
203                 QItemSelectionModel *selected = ui->playlist->selectionModel();
204                 if (selected->hasSelection()) {
205                         QModelIndex index = selected->currentIndex();
206                         const Clip &clip = *playlist_clips->clip(index.row());
207                         preview_player->play_clip(clip, index.row(), clip.stream_idx);
208                         return;
209                 }
210         }
211
212         if (cliplist_clips->empty())
213                 return;
214
215         QItemSelectionModel *selected = ui->clip_list->selectionModel();
216         if (!selected->hasSelection()) {
217                 preview_player->play_clip(*cliplist_clips->back(), cliplist_clips->size() - 1, 0);
218                 return;
219         }
220
221         QModelIndex index = selected->currentIndex();
222         unsigned stream_idx;
223         if (index.column() >= int(ClipList::Column::CAMERA_1) &&
224             index.column() <= int(ClipList::Column::CAMERA_4)) {
225                 stream_idx = index.column() - int(ClipList::Column::CAMERA_1);
226         } else {
227                 stream_idx = ui->preview_display->get_stream_idx();
228         }
229         preview_player->play_clip(*cliplist_clips->clip(index.row()), index.row(), stream_idx);
230 }
231
232 void MainWindow::preview_angle_clicked(unsigned stream_idx)
233 {
234         preview_player->override_angle(stream_idx);
235
236         // Change the selection if we were previewing a clip from the clip list.
237         // (The only other thing we could be showing is a pts scrub, and if so,
238         // that would be selected.)
239         QItemSelectionModel *selected = ui->clip_list->selectionModel();
240         if (selected->hasSelection()) {
241                 QModelIndex cell = selected->selectedIndexes()[0];
242                 int column = int(ClipList::Column::CAMERA_1) + stream_idx;
243                 selected->setCurrentIndex(cell.sibling(cell.row(), column), QItemSelectionModel::ClearAndSelect);
244         }
245 }
246
247 void MainWindow::playlist_duplicate()
248 {
249         QItemSelectionModel *selected = ui->playlist->selectionModel();
250         if (!selected->hasSelection()) {
251                 // Should have been grayed out, but OK.
252                 return;
253         }
254         QModelIndexList rows = selected->selectedRows();
255         int first = rows.front().row(), last = rows.back().row();
256         playlist_clips->duplicate_clips(first, last);
257         playlist_selection_changed();
258 }
259
260 void MainWindow::playlist_remove()
261 {
262         QItemSelectionModel *selected = ui->playlist->selectionModel();
263         if (!selected->hasSelection()) {
264                 // Should have been grayed out, but OK.
265                 return;
266         }
267         QModelIndexList rows = selected->selectedRows();
268         int first = rows.front().row(), last = rows.back().row();
269         playlist_clips->erase_clips(first, last);
270
271         // TODO: select the next one in the list?
272
273         playlist_selection_changed();
274 }
275
276 void MainWindow::playlist_move(int delta)
277 {
278         QItemSelectionModel *selected = ui->playlist->selectionModel();
279         if (!selected->hasSelection()) {
280                 // Should have been grayed out, but OK.
281                 return;
282         }
283
284         QModelIndexList rows = selected->selectedRows();
285         int first = rows.front().row(), last = rows.back().row();
286         if ((delta == -1 && first == 0) ||
287             (delta == 1 && size_t(last) == playlist_clips->size() - 1)) {
288                 // Should have been grayed out, but OK.
289                 return;
290         }
291
292         playlist_clips->move_clips(first, last, delta);
293         playlist_selection_changed();
294 }
295
296 void MainWindow::defer_timer_expired()
297 {
298         state_changed(deferred_state);
299 }
300
301 void MainWindow::content_changed()
302 {
303         if (defer_timeout->isActive() &&
304             (!currently_deferring_model_changes || deferred_change_id != current_change_id)) {
305                 // There's some deferred event waiting, but this event is unrelated.
306                 // So it's time to short-circuit that timer and do the work it wanted to do.
307                 defer_timeout->stop();
308                 state_changed(deferred_state);
309         }
310         StateProto state;
311         *state.mutable_clip_list() = cliplist_clips->serialize();
312         *state.mutable_play_list() = playlist_clips->serialize();
313         if (currently_deferring_model_changes) {
314                 deferred_change_id = current_change_id;
315                 deferred_state = std::move(state);
316                 defer_timeout->start(200);
317                 return;
318         }
319         state_changed(state);
320 }
321
322 void MainWindow::state_changed(const StateProto &state)
323 {
324         db.store_state(state);
325 }
326
327 void MainWindow::play_clicked()
328 {
329         if (playlist_clips->empty())
330                 return;
331
332         QItemSelectionModel *selected = ui->playlist->selectionModel();
333         int row;
334         if (!selected->hasSelection()) {
335                 row = 0;
336         } else {
337                 row = selected->selectedRows(0)[0].row();
338         }
339
340         const Clip &clip = *playlist_clips->clip(row);
341         live_player->play_clip(clip, row, clip.stream_idx);
342         playlist_clips->set_progress({{ row, 0.0f }});
343         playlist_clips->set_currently_playing(row, 0.0f);
344         playlist_selection_changed();
345 }
346
347 void MainWindow::live_player_clip_done()
348 {
349         int row = playlist_clips->get_currently_playing();
350         if (row == -1 || row == int(playlist_clips->size()) - 1) {
351                 set_output_status("paused");
352                 playlist_clips->set_progress({});
353                 playlist_clips->set_currently_playing(-1, 0.0f);
354         } else {
355                 playlist_clips->set_progress({{ row + 1, 0.0f }});
356                 playlist_clips->set_currently_playing(row + 1, 0.0f);
357         }
358 }
359
360 pair<Clip, size_t> MainWindow::live_player_get_next_clip()
361 {
362         // playlist_clips can only be accessed on the main thread.
363         // Hopefully, we won't have to wait too long for this to come back.
364         promise<pair<Clip, size_t>> clip_promise;
365         future<pair<Clip, size_t>> clip = clip_promise.get_future();
366         post_to_main_thread([this, &clip_promise] {
367                 int row = playlist_clips->get_currently_playing();
368                 if (row != -1 && row < int(playlist_clips->size()) - 1) {
369                         clip_promise.set_value(make_pair(*playlist_clips->clip(row + 1), row + 1));
370                 } else {
371                         clip_promise.set_value(make_pair(Clip(), 0));
372                 }
373         });
374         return clip.get();
375 }
376
377 static string format_duration(double t)
378 {
379         int t_ms = lrint(t * 1e3);
380
381         int ms = t_ms % 1000;
382         t_ms /= 1000;
383         int s = t_ms % 60;
384         t_ms /= 60;
385         int m = t_ms;
386
387         char buf[256];
388         snprintf(buf, sizeof(buf), "%d:%02d.%03d", m, s, ms);
389         return buf;
390 }
391
392 void MainWindow::live_player_clip_progress(const map<size_t, double> &progress)
393 {
394         playlist_clips->set_progress(progress);
395
396         // Look at the last clip and then start counting from there.
397         assert(!progress.empty());
398         auto last_it = progress.end();
399         --last_it;
400         double remaining = 0.0;
401         double last_fade_time_seconds = 0.0;
402         for (size_t row = last_it->first; row < playlist_clips->size(); ++row) {
403                 const Clip clip = *playlist_clips->clip(row);
404                 double clip_length = double(clip.pts_out - clip.pts_in) / TIMEBASE / 0.5;  // FIXME: stop hardcoding speed.
405                 if (row == last_it->first) {
406                         // A clip we're playing: Subtract the part we've already played.
407                         remaining = clip_length * (1.0 - last_it->second);
408                 } else {
409                         // A clip we haven't played yet: Subtract the part that's overlapping
410                         // with a previous clip (due to fade).
411                         remaining += max(clip_length - last_fade_time_seconds, 0.0);
412                 }
413                 last_fade_time_seconds = min(clip_length, clip.fade_time_seconds);
414         }
415         set_output_status(format_duration(remaining) + " left");
416 }
417
418 void MainWindow::resizeEvent(QResizeEvent *event)
419 {
420         QMainWindow::resizeEvent(event);
421
422         // Ask for a relayout, but only after the event loop is done doing relayout
423         // on everything else.
424         QMetaObject::invokeMethod(this, "relayout", Qt::QueuedConnection);
425 }
426
427 void MainWindow::relayout()
428 {
429         ui->live_display->setMinimumWidth(ui->live_display->height() * 16 / 9);
430         ui->preview_display->setMinimumWidth(ui->preview_display->height() * 16 / 9);
431 }
432
433 void set_pts_in(int64_t pts, int64_t current_pts, ClipProxy &clip)
434 {
435         pts = std::max<int64_t>(pts, 0);
436         if (clip->pts_out == -1) {
437                 pts = std::min(pts, current_pts);
438         } else {
439                 pts = std::min(pts, clip->pts_out);
440         }
441         clip->pts_in = pts;
442 }
443
444 bool MainWindow::eventFilter(QObject *watched, QEvent *event)
445 {
446         constexpr int dead_zone_pixels = 3;  // To avoid that simple clicks get misinterpreted.
447         constexpr int scrub_sensitivity = 100;  // pts units per pixel.
448         constexpr int wheel_sensitivity = 100;  // pts units per degree.
449         constexpr int camera_degrees_per_pixel = 15;  // One click of most mice.
450
451         unsigned stream_idx = ui->preview_display->get_stream_idx();
452
453         if (watched == ui->clip_list) {
454                 if (event->type() == QEvent::FocusOut) {
455                         highlight_camera_input(-1);
456                 }
457                 return false;
458         }
459
460         if (event->type() != QEvent::Wheel) {
461                 last_mousewheel_camera_row = -1;
462         }
463
464         if (event->type() == QEvent::MouseButtonPress) {
465                 QMouseEvent *mouse = (QMouseEvent *)event;
466
467                 QTableView *destination;
468                 ScrubType type;
469
470                 if (watched == ui->clip_list->viewport()) {
471                         destination = ui->clip_list;
472                         type = SCRUBBING_CLIP_LIST;
473                 } else if (watched == ui->playlist->viewport()) {
474                         destination = ui->playlist;
475                         type = SCRUBBING_PLAYLIST;
476                 } else {
477                         return false;
478                 }
479                 int column = destination->columnAt(mouse->x());
480                 int row = destination->rowAt(mouse->y());
481                 if (column == -1 || row == -1)
482                         return false;
483
484                 if (type == SCRUBBING_CLIP_LIST) {
485                         if (ClipList::Column(column) == ClipList::Column::IN) {
486                                 scrub_pts_origin = cliplist_clips->clip(row)->pts_in;
487                                 preview_single_frame(scrub_pts_origin, stream_idx, FIRST_AT_OR_AFTER);
488                         } else if (ClipList::Column(column) == ClipList::Column::OUT) {
489                                 scrub_pts_origin = cliplist_clips->clip(row)->pts_out;
490                                 preview_single_frame(scrub_pts_origin, stream_idx, LAST_BEFORE);
491                         } else {
492                                 return false;
493                         }
494                 } else {
495                         if (PlayList::Column(column) == PlayList::Column::IN) {
496                                 scrub_pts_origin = playlist_clips->clip(row)->pts_in;
497                                 preview_single_frame(scrub_pts_origin, stream_idx, FIRST_AT_OR_AFTER);
498                         } else if (PlayList::Column(column) == PlayList::Column::OUT) {
499                                 scrub_pts_origin = playlist_clips->clip(row)->pts_out;
500                                 preview_single_frame(scrub_pts_origin, stream_idx, LAST_BEFORE);
501                         } else {
502                                 return false;
503                         }
504                 }
505
506                 scrubbing = true;
507                 scrub_row = row;
508                 scrub_column = column;
509                 scrub_x_origin = mouse->x();
510                 scrub_type = type;
511         } else if (event->type() == QEvent::MouseMove) {
512                 if (scrubbing) {
513                         QMouseEvent *mouse = (QMouseEvent *)event;
514                         int offset = mouse->x() - scrub_x_origin;
515                         int adjusted_offset;
516                         if (offset >= dead_zone_pixels) {
517                                 adjusted_offset = offset - dead_zone_pixels;
518                         } else if (offset < -dead_zone_pixels) {
519                                 adjusted_offset = offset + dead_zone_pixels;
520                         } else {
521                                 adjusted_offset = 0;
522                         }
523
524                         int64_t pts = scrub_pts_origin + adjusted_offset * scrub_sensitivity;
525                         currently_deferring_model_changes = true;
526                         if (scrub_type == SCRUBBING_CLIP_LIST) {
527                                 ClipProxy clip = cliplist_clips->mutable_clip(scrub_row);
528                                 if (scrub_column == int(ClipList::Column::IN)) {
529                                         current_change_id = "cliplist:in:" + to_string(scrub_row);
530                                         set_pts_in(pts, current_pts, clip);
531                                         preview_single_frame(pts, stream_idx, FIRST_AT_OR_AFTER);
532                                 } else {
533                                         current_change_id = "cliplist:out" + to_string(scrub_row);
534                                         pts = std::max(pts, clip->pts_in);
535                                         pts = std::min(pts, current_pts);
536                                         clip->pts_out = pts;
537                                         preview_single_frame(pts, stream_idx, LAST_BEFORE);
538                                 }
539                         } else {
540                                 ClipProxy clip = playlist_clips->mutable_clip(scrub_row);
541                                 if (scrub_column == int(PlayList::Column::IN)) {
542                                         current_change_id = "playlist:in:" + to_string(scrub_row);
543                                         set_pts_in(pts, current_pts, clip);
544                                         preview_single_frame(pts, clip->stream_idx, FIRST_AT_OR_AFTER);
545                                 } else {
546                                         current_change_id = "playlist:out:" + to_string(scrub_row);
547                                         pts = std::max(pts, clip->pts_in);
548                                         pts = std::min(pts, current_pts);
549                                         clip->pts_out = pts;
550                                         preview_single_frame(pts, clip->stream_idx, LAST_BEFORE);
551                                 }
552                         }
553                         currently_deferring_model_changes = false;
554
555                         return true;  // Don't use this mouse movement for selecting things.
556                 }
557         } else if (event->type() == QEvent::Wheel) {
558                 QWheelEvent *wheel = (QWheelEvent *)event;
559
560                 QTableView *destination;
561                 int in_column, out_column, camera_column;
562                 if (watched == ui->clip_list->viewport()) {
563                         destination = ui->clip_list;
564                         in_column = int(ClipList::Column::IN);
565                         out_column = int(ClipList::Column::OUT);
566                         camera_column = -1;
567                         last_mousewheel_camera_row = -1;
568                 } else if (watched == ui->playlist->viewport()) {
569                         destination = ui->playlist;
570                         in_column = int(PlayList::Column::IN);
571                         out_column = int(PlayList::Column::OUT);
572                         camera_column = int(PlayList::Column::CAMERA);
573                 } else {
574                         last_mousewheel_camera_row = -1;
575                         return false;
576                 }
577                 int column = destination->columnAt(wheel->x());
578                 int row = destination->rowAt(wheel->y());
579                 if (column == -1 || row == -1) return false;
580
581                 // Only adjust pts with the wheel if the given row is selected.
582                 if (!destination->hasFocus() ||
583                     row != destination->selectionModel()->currentIndex().row()) {
584                         return false;
585                 }
586
587                 currently_deferring_model_changes = true;
588                 {
589                         current_change_id = (watched == ui->clip_list->viewport()) ? "cliplist:" : "playlist:";
590                         ClipProxy clip = (watched == ui->clip_list->viewport()) ?
591                                 cliplist_clips->mutable_clip(row) : playlist_clips->mutable_clip(row);
592                         if (watched == ui->playlist->viewport()) {
593                                 stream_idx = clip->stream_idx;
594                         }
595
596                         if (column != camera_column) {
597                                 last_mousewheel_camera_row = -1;
598                         }
599                         if (column == in_column) {
600                                 current_change_id += "in:" + to_string(row);
601                                 int64_t pts = clip->pts_in + wheel->angleDelta().y() * wheel_sensitivity;
602                                 set_pts_in(pts, current_pts, clip);
603                                 preview_single_frame(pts, stream_idx, FIRST_AT_OR_AFTER);
604                         } else if (column == out_column) {
605                                 current_change_id += "out:" + to_string(row);
606                                 int64_t pts = clip->pts_out + wheel->angleDelta().y() * wheel_sensitivity;
607                                 pts = std::max(pts, clip->pts_in);
608                                 pts = std::min(pts, current_pts);
609                                 clip->pts_out = pts;
610                                 preview_single_frame(pts, stream_idx, LAST_BEFORE);
611                         } else if (column == camera_column) {
612                                 current_change_id += "camera:" + to_string(row);
613                                 int angle_degrees = wheel->angleDelta().y();
614                                 if (last_mousewheel_camera_row == row) {
615                                         angle_degrees += leftover_angle_degrees;
616                                 }
617
618                                 int stream_idx = clip->stream_idx + angle_degrees / camera_degrees_per_pixel;
619                                 stream_idx = std::max(stream_idx, 0);
620                                 stream_idx = std::min(stream_idx, NUM_CAMERAS - 1);
621                                 clip->stream_idx = stream_idx;
622
623                                 last_mousewheel_camera_row = row;
624                                 leftover_angle_degrees = angle_degrees % camera_degrees_per_pixel;
625
626                                 // Don't update the live view, that's rarely what the operator wants.
627                         }
628                 }
629                 currently_deferring_model_changes = false;
630                 return true;  // Don't scroll.
631         } else if (event->type() == QEvent::MouseButtonRelease) {
632                 scrubbing = false;
633         }
634         return false;
635 }
636
637 void MainWindow::preview_single_frame(int64_t pts, unsigned stream_idx, MainWindow::Rounding rounding)
638 {
639         if (rounding == LAST_BEFORE) {
640                 lock_guard<mutex> lock(frame_mu);
641                 if (frames[stream_idx].empty())
642                         return;
643                 auto it = lower_bound(frames[stream_idx].begin(), frames[stream_idx].end(), pts,
644                         [](const FrameOnDisk &frame, int64_t pts) { return frame.pts < pts; });
645                 if (it != frames[stream_idx].end()) {
646                         pts = it->pts;
647                 }
648         } else {
649                 assert(rounding == FIRST_AT_OR_AFTER);
650                 lock_guard<mutex> lock(frame_mu);
651                 if (frames[stream_idx].empty())
652                         return;
653                 auto it = upper_bound(frames[stream_idx].begin(), frames[stream_idx].end(), pts - 1,
654                         [](int64_t pts, const FrameOnDisk &frame) { return pts < frame.pts; });
655                 if (it != frames[stream_idx].end()) {
656                         pts = it->pts;
657                 }
658         }
659
660         Clip fake_clip;
661         fake_clip.pts_in = pts;
662         fake_clip.pts_out = pts + 1;
663         preview_player->play_clip(fake_clip, 0, stream_idx);
664 }
665
666 void MainWindow::playlist_selection_changed()
667 {
668         QItemSelectionModel *selected = ui->playlist->selectionModel();
669         bool any_selected = selected->hasSelection();
670         ui->playlist_duplicate_btn->setEnabled(any_selected);
671         ui->playlist_remove_btn->setEnabled(any_selected);
672         ui->playlist_move_up_btn->setEnabled(
673                 any_selected && selected->selectedRows().front().row() > 0);
674         ui->playlist_move_down_btn->setEnabled(
675                 any_selected && selected->selectedRows().back().row() < int(playlist_clips->size()) - 1);
676         ui->play_btn->setEnabled(!playlist_clips->empty());
677
678         if (!any_selected) {
679                 set_output_status("paused");
680         } else {
681                 double remaining = 0.0;
682                 for (int row = selected->selectedRows().front().row(); row < int(playlist_clips->size()); ++row) {
683                         const Clip clip = *playlist_clips->clip(row);
684                         remaining += double(clip.pts_out - clip.pts_in) / TIMEBASE / 0.5;  // FIXME: stop hardcoding speed.
685                 }
686                 set_output_status(format_duration(remaining) + " ready");
687         }
688 }
689
690 void MainWindow::clip_list_selection_changed(const QModelIndex &current, const QModelIndex &)
691 {
692         int camera_selected = -1;
693         if (current.column() >= int(ClipList::Column::CAMERA_1) &&
694             current.column() <= int(ClipList::Column::CAMERA_4)) {
695                 camera_selected = current.column() - int(ClipList::Column::CAMERA_1);
696         }
697         highlight_camera_input(camera_selected);
698 }
699
700 void MainWindow::report_disk_space(off_t free_bytes, double estimated_seconds_left)
701 {
702         char time_str[256];
703         if (estimated_seconds_left < 60.0) {
704                 strcpy(time_str, "<font color=\"red\">Less than a minute</font>");
705         } else if (estimated_seconds_left < 1800.0) {  // Less than half an hour: Xm Ys (red).
706                 int s = lrintf(estimated_seconds_left);
707                 int m = s / 60;
708                 s %= 60;
709                 snprintf(time_str, sizeof(time_str), "<font color=\"red\">%dm %ds</font>", m, s);
710         } else if (estimated_seconds_left < 3600.0) {  // Less than an hour: Xm.
711                 int m = lrintf(estimated_seconds_left / 60.0);
712                 snprintf(time_str, sizeof(time_str), "%dm", m);
713         } else if (estimated_seconds_left < 36000.0) {  // Less than ten hours: Xh Ym.
714                 int m = lrintf(estimated_seconds_left / 60.0);
715                 int h = m / 60;
716                 m %= 60;
717                 snprintf(time_str, sizeof(time_str), "%dh %dm", h, m);
718         } else {  // More than ten hours: Xh.
719                 int h = lrintf(estimated_seconds_left / 3600.0);
720                 snprintf(time_str, sizeof(time_str), "%dh", h);
721         }
722         char buf[256];
723         snprintf(buf, sizeof(buf), "Disk free: %'.0f MB (approx. %s)", free_bytes / 1048576.0, time_str);
724
725         std::string label = buf;
726
727         post_to_main_thread([this, label] {
728                 disk_free_label->setText(QString::fromStdString(label));
729                 ui->menuBar->setCornerWidget(disk_free_label);  // Need to set this again for the sizing to get right.
730         });
731 }
732
733 void MainWindow::exit_triggered()
734 {
735         close();
736 }
737
738 void MainWindow::highlight_camera_input(int stream_idx)
739 {
740         if (stream_idx == 0) {
741                 ui->input1_frame->setStyleSheet("background: rgb(0,255,0)");
742         } else {
743                 ui->input1_frame->setStyleSheet("");
744         }
745         if (stream_idx == 1) {
746                 ui->input2_frame->setStyleSheet("background: rgb(0,255,0)");
747         } else {
748                 ui->input2_frame->setStyleSheet("");
749         }
750         if (stream_idx == 2) {
751                 ui->input3_frame->setStyleSheet("background: rgb(0,255,0)");
752         } else {
753                 ui->input3_frame->setStyleSheet("");
754         }
755         if (stream_idx == 3) {
756                 ui->input4_frame->setStyleSheet("background: rgb(0,255,0)");
757         } else {
758                 ui->input4_frame->setStyleSheet("");
759         }
760 }
761
762 void MainWindow::set_output_status(const string &status)
763 {
764         ui->live_label->setText(QString::fromStdString("Current output (" + status + ")"));
765
766         lock_guard<mutex> lock(queue_status_mu);
767         queue_status = status;
768 }
769
770 pair<string, string> MainWindow::get_queue_status() const {
771         lock_guard<mutex> lock(queue_status_mu);
772         return {queue_status, "text/plain"};
773 }