zvidlib 0.5.0

Frame-accurate video and audio I/O for native and WebAssembly applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
//! Native GL rendering example.
//!
//! Demuxes an MP4 (by default the Big Buck Bunny sample checked into
//! `examples/media/BigBuckBunny.mp4`) and drives the real
//! native HEVC decoder and the CPU -> native OpenGL [`execute_transfer`] path with a
//! [`GraphicsAdapter`] backed by a real
//! `winit` window and `glutin` OpenGL context, so the uploaded frames are drawn to an actual
//! window on all platforms. The example prefers an available accelerated backend and falls back
//! to the dependency-free software decoder. Playback loops back to the first frame once the last
//! one is shown, the decoded-frame playback rate is drawn in the top-left corner, and a control
//! legend is drawn above the timeline bar until it fades out (press `H` to show or hide it).
//!
//! Dragging the timeline bar decodes the frame under the pointer on a background thread (see
//! [`scrub`]), so the window keeps drawing while the pointer moves. What it draws until that frame
//! lands comes from [`zvidlib::PreviewIndex`], a background pass that keeps a shrunk picture every
//! half second of the track: the bundled sample is one group of pictures, so the frame at the far
//! end of the bar is a second of hardware decoding away however the walk to it is arranged, and
//! only a picture that was decoded already can answer a drag immediately. Nothing else is drawn
//! while a drag is open - a picture the walk passed is of the start of the movie rather than of
//! the pointer's position, and showing those was issue #458 - and the audio follows the pointer
//! too, by seeking the clock to it a few times a second rather than playing on from where the
//! drag began.
//!
//! Run with:
//!
//! ```console
//! cargo run --example native_gl --features native
//! ```

use std::env;
use std::fs;
use std::future::Future;
use std::num::NonZeroU32;
use std::path::PathBuf;
use std::task::{Context, Poll, Waker};
use std::time::Instant;

use glutin::config::ConfigTemplateBuilder;
use glutin::context::{ContextApi, ContextAttributesBuilder, PossiblyCurrentContext};
use glutin::display::GetGlDisplay;
use glutin::prelude::*;
use glutin::surface::{Surface, SurfaceAttributesBuilder, SwapInterval, WindowSurface};
use glutin_winit::DisplayBuilder;
use raw_window_handle::HasWindowHandle;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{KeyCode, PhysicalKey};
use winit::window::{Window, WindowId};

use zvidlib::io::MemorySource;
use zvidlib::{
    AudioDecoder, AudioSampleReader, Codec, CodecProfile, ColorRange, CpuFrameSource,
    DefaultAudioOutput, Error, ErrorKind, FrameDestination, FrameSource, GraphicsAdapter,
    GraphicsApi, GraphicsResource, HardwarePreference, IndexedPresentationTimeline, Limits,
    Mp4Demuxer, Mp4DemuxerOptions, NativeAacDecoder, NativeAudioOutput, NativeOpusDecoder,
    OPUS_SAMPLE_RATE, Orientation, PixelFormat, PlaybackController, PlaybackOptions, PreviewIndex,
    PreviewOptions, ResourceKind, ResourceOwnership, Result, TrackKind, TransferPolicy,
    VideoDecoderConfig, VideoDecoderFactory, VideoDimensions, execute_transfer,
    native_hevc_video_decoder_factory, opus_preroll_packets,
};

mod gl_window;
mod scrub;

use gl_window::{CONTROL_LEGEND, FpsCounter, GlWindowAdapter, LegendVisibility};
use scrub::{AudioScrubCadence, FrameService, frame_is_at_playhead, target_frame};

const TEXTURE_HANDLE: u64 = 1;

fn main() {
    if let Err(error) = run() {
        eprintln!("error: {}", error.message());
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let input_path = env::args().nth(1).map(PathBuf::from).unwrap_or_else(|| {
        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/media/BigBuckBunny.mp4")
    });

    let bytes = fs::read(&input_path).map_err(|error| {
        invalid(format!(
            "could not read {} ({error}); pass an MP4 path as the first argument to use a different sample",
            input_path.display()
        ))
    })?;
    let source = MemorySource::new(bytes);

    let demuxer = block_on(Mp4Demuxer::open(&source, Mp4DemuxerOptions::default()))?;
    let video = demuxer
        .tracks
        .iter()
        .find(|track| track.kind == TrackKind::Video)
        .ok_or_else(|| invalid("the MP4 does not contain a video track"))?;
    let audio = demuxer
        .tracks
        .iter()
        .find(|track| {
            track.kind == TrackKind::Audio && matches!(track.codec, Codec::Aac | Codec::Opus)
        })
        .ok_or_else(|| invalid("the MP4 does not contain an AAC or Opus audio track"))?;
    let dimensions = video
        .dimensions
        .ok_or_else(|| invalid("the video track does not report dimensions"))?;

    println!(
        "Opened {}: {}x{} {:?}, {} samples ({} in presentation order)",
        input_path.display(),
        dimensions.width,
        dimensions.height,
        video.codec,
        video.samples.len(),
        video.presentation_order.len(),
    );
    let limits = Limits::default();
    let video_samples = block_on(video.to_encoded_video_samples(&source, &limits))?;
    let audio_packets = block_on(audio.to_encoded_audio_samples(&source, &limits))?;
    let audio_timing = audio.audio_timing(demuxer.movie_timescale)?;
    let factory = native_hevc_video_decoder_factory();
    let configuration = VideoDecoderConfig {
        codec: video.codec,
        profile: CodecProfile::HevcMain,
        coded_dimensions: dimensions,
        output_format: PixelFormat::Rgba8,
        color_range: ColorRange::Limited,
        hardware: HardwarePreference::Prefer,
        configuration: video.decoder_config.clone(),
    };
    let support = factory.capability(&configuration);
    // Every frame the window draws comes off this one worker thread, playback's included: a
    // decode on the event-loop thread is what froze the window for seconds after a scrub.
    let frames = FrameService::new(
        &factory,
        configuration.clone(),
        video_samples.clone(),
        limits,
    )?;
    // Issue #374: this sample's 768 frames are one group of pictures, so the frame under a
    // pointer at the far end of the bar is a second of hardware decoding away however the walk to
    // it is arranged. A background pass keeps a shrunk picture every few frames, so the drag has
    // something to draw for any position immediately while that walk runs.
    let frames_per_second = ((video.presentation_order.len() as u128 * u128::from(video.timescale))
        / u128::from(video.duration.max(1)))
    .max(1) as u64;
    let previews = PreviewIndex::new(
        &factory,
        configuration,
        video_samples,
        limits,
        PreviewOptions::for_frame_rate(frames_per_second),
    )?;
    let video_reader = frames.source();
    // The track's codec picks the decoder: an AAC track, as the bundled sample has, or Opus.
    let (audio_decoder, sample_rate, channels, preroll): (Box<dyn AudioDecoder>, u32, u16, usize) =
        match audio.codec {
            Codec::Opus => {
                let head = audio.opus_config()?;
                (
                    Box::new(NativeOpusDecoder::new(&head, Limits::default())?),
                    OPUS_SAMPLE_RATE,
                    u16::from(head.channels),
                    opus_preroll_packets(&audio_packets),
                )
            }
            _ => {
                let config = audio.aac_config()?;
                (
                    Box::new(NativeAacDecoder::new(&config, Limits::default())?),
                    config.sample_rate,
                    config.channels,
                    2,
                )
            }
        };
    let audio_reader = AudioSampleReader::new(
        audio_decoder,
        audio_packets,
        sample_rate,
        channels,
        audio_timing,
        preroll,
        Limits::default(),
    )?;
    let output = NativeAudioOutput(DefaultAudioOutput::open(sample_rate, channels)?);
    let frame_count = video.presentation_order.len().max(1) as u64;
    let frames_per_five_seconds = ((u128::from(frame_count) * u128::from(video.timescale) * 5)
        / u128::from(video.duration.max(1)))
    .max(1)
    .min(u128::from(frame_count)) as u64;
    let timeline = IndexedPresentationTimeline::from_mp4_track(video, sample_rate, &limits)?;
    let playback = PlaybackController::new_with_indexed_timeline(
        video_reader,
        audio_reader,
        output,
        timeline,
        PlaybackOptions::for_sample_rate(sample_rate),
    )?;
    println!("Selected {support:?} HEVC decoding.");
    println!(
        "Playing {:?} video and {:?} audio through zvidlib's synchronized pipeline.",
        video.codec, audio.codec
    );

    println!(
        "Controls: SPACE play/pause, LEFT/RIGHT previous/next frame, J/L rewind/fast-forward 5s, \
         click or drag the timeline bar along the bottom edge to scrub, H show/hide the \
         on-screen legend ({} lines).",
        CONTROL_LEGEND.len()
    );

    let mut app = App::new(
        dimensions,
        playback,
        frame_count,
        frames_per_five_seconds,
        frames,
        previews,
    );
    let event_loop = EventLoop::new()
        .map_err(|error| invalid(format!("could not create the event loop: {error}")))?;
    event_loop.set_control_flow(ControlFlow::Poll);
    event_loop
        .run_app(&mut app)
        .map_err(|error| invalid(format!("windowed event loop failed: {error}")))?;
    app.into_result()
}

/// Drives the `winit` window, owns the `glutin` GL context, and renders looping frames.
struct App<P> {
    dimensions: VideoDimensions,
    /// The size of the picture the texture currently holds, which is the source's for a decoded
    /// frame and the preview's for a scrub preview. The video quad is laid out from this, and a
    /// preview keeps the source's aspect ratio, so a preview fills the same rectangle.
    displayed: VideoDimensions,
    playback: P,
    frame_count: u64,
    frames_per_five_seconds: u64,
    /// A frame is wanted that playback is not going to ask for on its own: the still the window
    /// shows while paused, at the frame a seek or a committed scrub left it on.
    needs_static_frame: bool,
    timeline_hover: Option<f32>,
    frames: FrameService,
    /// The pointer is down on the timeline bar, so previews replace normal presentation.
    dragging: bool,
    /// The exact frame the drag is pointing at, committed when the pointer is released.
    scrub_target: Option<u64>,
    /// Paces how often a drag moves the audio clock to the frame under the pointer.
    audio_scrub: AudioScrubCadence,
    /// Pictures decoded ahead of time, one every few frames, so a drag draws the right part of
    /// the movie before the walk to the exact frame under it finishes.
    previews: PreviewIndex,
    legend: LegendVisibility,
    fps: FpsCounter,
    state: Option<WindowState>,
    error: Option<Error>,
}

struct WindowState {
    window: Window,
    surface: Surface<WindowSurface>,
    context: PossiblyCurrentContext,
    adapter: GlWindowAdapter,
}

impl<V, A, O> App<PlaybackController<V, A, O>>
where
    V: zvidlib::PlaybackVideoSource,
    A: zvidlib::PlaybackAudioSource,
    O: zvidlib::PlaybackAudioOutput,
{
    fn new(
        dimensions: VideoDimensions,
        playback: PlaybackController<V, A, O>,
        frame_count: u64,
        frames_per_five_seconds: u64,
        frames: FrameService,
        previews: PreviewIndex,
    ) -> Self {
        Self {
            dimensions,
            displayed: dimensions,
            playback,
            frame_count,
            frames_per_five_seconds,
            needs_static_frame: false,
            timeline_hover: None,
            frames,
            dragging: false,
            scrub_target: None,
            audio_scrub: AudioScrubCadence::default(),
            previews,
            legend: LegendVisibility::new(Instant::now()),
            fps: FpsCounter::new(),
            state: None,
            error: None,
        }
    }

    fn into_result(self) -> Result<()> {
        match self.error {
            Some(error) => Err(error),
            None => Ok(()),
        }
    }

    fn fail(&mut self, event_loop: &ActiveEventLoop, error: Error) {
        eprintln!("error: {}", error.message());
        self.error = Some(error);
        event_loop.exit();
    }

    fn toggle_playback(&mut self, event_loop: &ActiveEventLoop) {
        let result = if self.playback.is_playing() {
            self.needs_static_frame = true;
            self.playback.pause()
        } else {
            self.needs_static_frame = false;
            self.playback.play()
        };
        if let Err(error) = result {
            self.fail(event_loop, error);
        }
        if let Some(state) = self.state.as_ref() {
            state.window.request_redraw();
        }
    }

    fn seek_by_frames(&mut self, event_loop: &ActiveEventLoop, delta: i64) {
        let Ok(current) = self.playback.current_frame_index() else {
            return;
        };
        let maximum = self.frame_count.saturating_sub(1);
        let target = if delta < 0 {
            current.0.saturating_sub(delta.unsigned_abs())
        } else {
            current.0.saturating_add(delta as u64).min(maximum)
        };
        self.seek_to_frame(event_loop, target);
    }

    fn seek_to_frame(&mut self, event_loop: &ActiveEventLoop, frame: u64) {
        if let Err(error) = self.playback.seek(zvidlib::FrameIndex(frame)) {
            return self.fail(event_loop, error);
        }
        if !self.playback.is_playing() {
            self.needs_static_frame = true;
        }
        if let Some(state) = self.state.as_ref() {
            state.window.request_redraw();
        }
    }

    fn timeline_fraction(&self, x: f64, y: f64) -> Option<f32> {
        let state = self.state.as_ref()?;
        let size = state.window.inner_size();
        if y < f64::from(size.height.saturating_sub(40)) {
            return None;
        }
        Some(self.horizontal_fraction(x))
    }

    /// Where `x` falls across the window, ignoring how far the pointer has drifted off the bar
    /// vertically: a drag that started on the timeline keeps scrubbing until it is released.
    fn horizontal_fraction(&self, x: f64) -> f32 {
        let width = self
            .state
            .as_ref()
            .map_or(1, |state| state.window.inner_size().width);
        (x / f64::from(width.max(1))).clamp(0.0, 1.0) as f32
    }

    /// Records where the drag is pointing, points the worker thread at it, and takes the audio
    /// there.
    ///
    /// Nothing here decodes video. The worker is asked for the frame under the pointer and for no
    /// other: the pictures a walk passes are of the start of the movie on this sample and the
    /// window will not draw them (issue #458), so paying their conversion would only delay the one
    /// it is waiting for. What the window shows until that frame lands is the preview index's
    /// shrunk picture, which is already of wherever the pointer is.
    fn scrub_preview(&mut self, event_loop: &ActiveEventLoop, fraction: f32) {
        let target = target_frame(fraction, self.frame_count);
        self.scrub_target = Some(target);
        // Drawn here rather than left to the worker: this is a lookup and a texture upload over
        // a picture that was decoded minutes ago, so the picture is right for wherever the
        // pointer now is within a frame of the window's, whatever the walk behind it is doing.
        if let Some(preview) = self.previews.nearest(target) {
            let _ = self.upload_frame_at(preview.dimensions, &preview);
        }
        self.frames.request(target);
        self.scrub_audio(event_loop, target);
        if let Some(state) = self.state.as_ref() {
            state.window.request_redraw();
        }
    }

    /// Moves the audio clock to the frame the pointer is on, so a drag hears the part of the
    /// movie it is pointing at.
    ///
    /// Before this, a drag left the clock wherever the press found it and `pump_audio` kept
    /// scheduling from there, so the picture scrubbed while the sound played straight on (issue
    /// #458). The seek is what moves it: it is cheap on this example's video source, whose `reset`
    /// deliberately leaves the worker's decoder alone, so the walk the drag has going is not
    /// disturbed by it. It is paced by [`AudioScrubCadence`] rather than run on every pointer
    /// move, because each seek discards the PCM already queued and a drag delivers a move per
    /// displayed frame.
    fn scrub_audio(&mut self, event_loop: &ActiveEventLoop, target: u64) {
        // Nothing is being scheduled while paused, so there is nothing to keep at the playhead;
        // the commit's seek is what moves a paused clock.
        if !self.playback.is_playing() || !self.audio_scrub.due(Instant::now()) {
            return;
        }
        if let Err(error) = self.playback.seek(zvidlib::FrameIndex(target)) {
            self.fail(event_loop, error);
        }
    }

    /// The frame the window is trying to show: the one under the pointer while a drag is open,
    /// and the one the audio clock is on otherwise.
    fn playhead_frame(&self) -> Option<u64> {
        match self.scrub_target {
            Some(target) if self.dragging => Some(target),
            _ => self
                .playback
                .current_frame_index()
                .ok()
                .map(|frame| frame.0),
        }
    }

    /// How far from the playhead a delivered picture may be and still be drawn, in frames.
    ///
    /// A quarter of a second of this track. A drag retargets faster than a decode finishes, so
    /// the picture that lands is of the frame the pointer was on when it started; that is the
    /// same moment of the movie, while the frames a walk passes are a different one entirely.
    fn playhead_tolerance(&self) -> u64 {
        (self.frames_per_five_seconds / 20).max(1)
    }

    /// Ends a drag by moving playback to the frame the pointer was left on. Only the clock moves
    /// here: the picture arrives from the worker, which the drag has already walked to that
    /// frame, so committing decodes nothing.
    fn commit_scrub(&mut self, event_loop: &ActiveEventLoop) {
        self.dragging = false;
        // The next drag moves the audio on its first pointer move rather than inheriting this
        // one's pacing.
        self.audio_scrub.rearm();
        let Some(target) = self.scrub_target.take() else {
            return;
        };
        // Unconditional, even when the position has not moved: a preview replaced whatever was
        // on screen, so the committed frame still has to be drawn.
        self.seek_to_frame(event_loop, target);
    }

    /// Draws the newest frame the worker has decoded, if one has arrived, without waiting for it.
    fn present_latest_frame(&mut self, event_loop: &ActiveEventLoop) -> bool {
        // `present` would decode the frame the audio clock calls for, which is exactly the work a
        // drag must not do on this thread; the audio queue still needs topping up.
        if let Err(error) = self.playback.pump_audio() {
            self.fail(event_loop, error);
            return false;
        }
        // The index says whether this is the frame the window is waiting for or one a walk
        // passed on the way to it. Only the first is drawn: the pictures between a random-access
        // point and a far target are of the start of the movie, and drawing them was what made a
        // drag show frame 0, then 15, then 30, before the frame under the pointer (issue #458).
        let playhead = self.playhead_frame();
        let tolerance = self.playhead_tolerance();
        match self.frames.take_latest() {
            Some((index, frame))
                if playhead
                    .is_some_and(|playhead| frame_is_at_playhead(index, playhead, tolerance)) =>
            {
                self.upload_frame(event_loop, &frame);
                true
            }
            _ => false,
        }
    }

    fn render(&mut self, event_loop: &ActiveEventLoop) {
        if self.state.is_none() {
            return;
        }

        let now = Instant::now();
        let frame_presented = if self.dragging {
            self.present_latest_frame(event_loop)
        } else if self.playback.is_playing() {
            match self.playback.present() {
                Ok((_, Some(frame))) => {
                    self.upload_frame(event_loop, &frame);
                    true
                }
                Ok((presentation, None)) => {
                    if presentation.finished {
                        if let Err(error) = self.playback.seek(zvidlib::FrameIndex(0)) {
                            return self.fail(event_loop, error);
                        }
                    }
                    false
                }
                // The frame the clock calls for is still decoding. The window keeps the picture
                // it has and asks again on the next redraw rather than waiting for it, which is
                // what lets playback resume immediately after a seek.
                Err(error) if error.kind() == ErrorKind::WouldBlock => false,
                Err(error) => {
                    return self.fail(event_loop, error);
                }
            }
        } else if self.needs_static_frame {
            match self.playback.current_frame() {
                Ok(frame) => {
                    self.needs_static_frame = false;
                    self.upload_frame(event_loop, &frame);
                    true
                }
                // Same again while paused: the still is drawn when the worker has decoded it, and
                // until then whatever the walk to it has passed keeps the window moving.
                Err(error) if error.kind() == ErrorKind::WouldBlock => {
                    self.present_latest_frame(event_loop)
                }
                Err(error) => return self.fail(event_loop, error),
            }
        } else {
            false
        };

        let last_frame = self.frame_count.saturating_sub(1).max(1) as f32;
        // While dragging, the bar follows the pointer rather than the audio clock, which has not
        // moved there yet.
        let progress = match self.scrub_target {
            Some(target) if self.dragging => target as f32 / last_frame,
            _ => self
                .playback
                .current_frame_index()
                .map(|frame| frame.0 as f32 / last_frame)
                .unwrap_or(0.0),
        };
        let fps = self.fps.update(frame_presented, now);
        let legend_opacity = self.legend.opacity(now);
        let state = self.state.as_mut().expect("state exists");
        state.adapter.draw(
            TEXTURE_HANDLE,
            self.displayed,
            fps,
            progress,
            self.timeline_hover,
            legend_opacity,
        );
        if let Err(error) = state.surface.swap_buffers(&state.context) {
            return self.fail(
                event_loop,
                invalid(format!("could not swap GL buffers: {error}")),
            );
        }

        // Keep redrawing while the legend is fading so the fade animates even when paused, while
        // a drag is open so a preview that finishes decoding is picked up, and while a still is
        // outstanding so the frame a seek asked for is drawn when the worker delivers it.
        if self.playback.is_playing()
            || legend_opacity > 0.0
            || self.dragging
            || self.needs_static_frame
        {
            state.window.request_redraw();
        }
    }

    fn upload_frame(&mut self, event_loop: &ActiveEventLoop, frame: &zvidlib::VideoFrame) {
        if let Err(error) = self.upload_frame_at(frame.dimensions, frame) {
            self.fail(event_loop, error);
        }
    }

    /// Uploads `frame` as the video texture at its own size.
    ///
    /// A scrub preview is a quarter of the source on each axis, so the size the texture is
    /// declared at has to come from the picture rather than from the track.
    fn upload_frame_at(
        &mut self,
        dimensions: VideoDimensions,
        frame: &zvidlib::VideoFrame,
    ) -> Result<()> {
        let Some(state) = self.state.as_mut() else {
            return Ok(());
        };
        let resource = GraphicsResource::new(
            GraphicsApi::NativeOpenGl,
            state.adapter.context_identity(),
            state.adapter.execution_owner(),
            ResourceKind::Texture2d,
            TEXTURE_HANDLE,
            dimensions,
            PixelFormat::Rgba8,
            ColorRange::Limited,
            Orientation::TopLeft,
            ResourceOwnership::Caller,
        );
        execute_transfer(
            Some(&mut state.adapter),
            FrameSource::Cpu(CpuFrameSource {
                frame,
                orientation: Orientation::TopLeft,
            }),
            FrameDestination::Graphics(resource),
            TransferPolicy::any(),
        )?;
        self.displayed = dimensions;
        Ok(())
    }
}

impl<V, A, O> ApplicationHandler for App<PlaybackController<V, A, O>>
where
    V: zvidlib::PlaybackVideoSource,
    A: zvidlib::PlaybackAudioSource,
    O: zvidlib::PlaybackAudioOutput,
{
    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
        if self.state.is_some() {
            return;
        }

        let window_attributes = Window::default_attributes()
            .with_title("zvidlib native GL example")
            .with_inner_size(winit::dpi::LogicalSize::new(
                self.dimensions.width.max(1),
                self.dimensions.height.max(1),
            ));
        let template = ConfigTemplateBuilder::new();
        let display_builder = DisplayBuilder::new().with_window_attributes(Some(window_attributes));

        let (window, gl_config) = match display_builder.build(event_loop, template, |configs| {
            configs
                .reduce(|accum, config| {
                    if config.num_samples() > accum.num_samples() {
                        config
                    } else {
                        accum
                    }
                })
                .expect("glutin reported no usable GL configs")
        }) {
            Ok((Some(window), config)) => (window, config),
            Ok((None, _)) => {
                return self.fail(event_loop, invalid("glutin did not create a window"));
            }
            Err(error) => {
                return self.fail(
                    event_loop,
                    invalid(format!("could not create a GL window: {error}")),
                );
            }
        };

        let raw_window_handle = window.window_handle().ok().map(|handle| handle.as_raw());
        let gl_display = gl_config.display();
        let context_attributes = ContextAttributesBuilder::new()
            .with_context_api(ContextApi::OpenGl(None))
            .build(raw_window_handle);
        let not_current_context =
            match unsafe { gl_display.create_context(&gl_config, &context_attributes) } {
                Ok(context) => context,
                Err(error) => {
                    return self.fail(
                        event_loop,
                        invalid(format!("could not create a GL context: {error}")),
                    );
                }
            };

        let size = window.inner_size();
        let width = NonZeroU32::new(size.width.max(1)).expect("non-zero window width");
        let height = NonZeroU32::new(size.height.max(1)).expect("non-zero window height");
        let surface_attributes = SurfaceAttributesBuilder::<WindowSurface>::new().build(
            raw_window_handle.expect("window must provide a raw window handle"),
            width,
            height,
        );
        let surface =
            match unsafe { gl_display.create_window_surface(&gl_config, &surface_attributes) } {
                Ok(surface) => surface,
                Err(error) => {
                    return self.fail(
                        event_loop,
                        invalid(format!("could not create a GL surface: {error}")),
                    );
                }
            };

        let context = match not_current_context.make_current(&surface) {
            Ok(context) => context,
            Err(error) => {
                return self.fail(
                    event_loop,
                    invalid(format!("could not activate the GL context: {error}")),
                );
            }
        };

        if let Err(error) =
            surface.set_swap_interval(&context, SwapInterval::Wait(NonZeroU32::new(1).unwrap()))
        {
            eprintln!("warning: could not enable vsync: {error}");
        }

        let gl = unsafe {
            glow::Context::from_loader_function(|symbol| {
                let symbol = std::ffi::CString::new(symbol).unwrap();
                gl_display.get_proc_address(symbol.as_c_str()).cast()
            })
        };
        let mut adapter = GlWindowAdapter::new(gl);
        adapter.resize(size.width, size.height);

        window.request_redraw();
        self.state = Some(WindowState {
            window,
            surface,
            context,
            adapter,
        });
        if let Err(error) = self.playback.play() {
            self.fail(event_loop, error);
        }
    }

    fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
        let matches_window = self
            .state
            .as_ref()
            .is_some_and(|state| state.window.id() == id);
        if !matches_window {
            return;
        }

        match event {
            WindowEvent::CloseRequested => event_loop.exit(),
            WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
                match event.physical_key {
                    PhysicalKey::Code(KeyCode::Space) => self.toggle_playback(event_loop),
                    PhysicalKey::Code(KeyCode::ArrowLeft) => self.seek_by_frames(event_loop, -1),
                    PhysicalKey::Code(KeyCode::ArrowRight) => self.seek_by_frames(event_loop, 1),
                    PhysicalKey::Code(KeyCode::KeyJ) => {
                        self.seek_by_frames(event_loop, -(self.frames_per_five_seconds as i64))
                    }
                    PhysicalKey::Code(KeyCode::KeyL) => {
                        self.seek_by_frames(event_loop, self.frames_per_five_seconds as i64)
                    }
                    PhysicalKey::Code(KeyCode::KeyH) => {
                        self.legend.toggle(Instant::now());
                        if let Some(state) = self.state.as_ref() {
                            state.window.request_redraw();
                        }
                    }
                    _ => {}
                }
            }
            WindowEvent::CursorMoved { position, .. } => {
                self.timeline_hover = self.timeline_fraction(position.x, position.y);
                // Hovering only moves the bar's marker. Seeking on a bare pointer move is what
                // made a single pass across the bar queue hundreds of decodes (issue #319).
                if self.dragging {
                    let fraction = self.horizontal_fraction(position.x);
                    // A drag keeps the marker under the pointer even once it has slid off the
                    // bar vertically, since it is still scrubbing.
                    self.timeline_hover = Some(fraction);
                    self.scrub_preview(event_loop, fraction);
                } else if let Some(state) = self.state.as_ref() {
                    state.window.request_redraw();
                }
            }
            WindowEvent::MouseInput {
                state: ElementState::Pressed,
                button: MouseButton::Left,
                ..
            } => {
                if let Some(fraction) = self.timeline_hover {
                    self.dragging = true;
                    self.scrub_preview(event_loop, fraction);
                }
            }
            WindowEvent::MouseInput {
                state: ElementState::Released,
                button: MouseButton::Left,
                ..
            } => {
                if self.dragging {
                    self.commit_scrub(event_loop);
                }
            }
            // A drag that leaves the window has no release to wait for.
            WindowEvent::CursorLeft { .. } => {
                self.timeline_hover = None;
                if self.dragging {
                    self.commit_scrub(event_loop);
                }
            }
            WindowEvent::Resized(size) => {
                if let Some(state) = self.state.as_mut() {
                    if let (Some(width), Some(height)) =
                        (NonZeroU32::new(size.width), NonZeroU32::new(size.height))
                    {
                        state.surface.resize(&state.context, width, height);
                        state.adapter.resize(size.width, size.height);
                    }
                }
            }
            WindowEvent::RedrawRequested => self.render(event_loop),
            _ => {}
        }
    }
}

fn invalid(message: impl Into<String>) -> Error {
    Error::new(ErrorKind::InvalidInput, message)
}

/// Drives a future to completion. `Mp4Demuxer::open` never actually suspends against a
/// `MemorySource`, so a single poll always resolves.
fn block_on<F: Future>(future: F) -> F::Output {
    let mut boxed = Box::pin(future);
    let waker = Waker::noop();
    let mut context = Context::from_waker(waker);
    match boxed.as_mut().poll(&mut context) {
        Poll::Ready(value) => value,
        Poll::Pending => panic!("unexpected pending future in a synchronous example"),
    }
}