--- AnyEvent-MPV/MPV.pm 2023/03/20 22:10:37 1.14 +++ AnyEvent-MPV/MPV.pm 2023/03/22 19:36:59 1.17 @@ -6,6 +6,16 @@ use AnyEvent::MPV; + my $videofile = "path/to/file.mkv"; + use AnyEvent; + my $mpv = AnyEvent::MPV->new (trace => 1); + $mpv->start ("--idle=yes"); + $mpv->cmd (loadfile => $mpv->escape_binary ($videofile)); + my $quit = AE::cv; + $mpv->register_event (end_file => $quit); + $quit->recv; + + =head1 DESCRIPTION This module allows you to remote control F (a video player). It also @@ -52,7 +62,7 @@ use AnyEvent; use AnyEvent::MPV; - my $videofile = "./xyzzy.mp4"; + my $videofile = "./xyzzy.mkv"; my $mpv = AnyEvent::MPV->new (trace => 1); @@ -87,7 +97,7 @@ use AnyEvent; use AnyEvent::MPV; - my $videofile = "./xyzzy.mp4"; + my $videofile = "./xyzzy.mkv"; my $mpv = AnyEvent::MPV->new ( trace => 1, @@ -142,7 +152,7 @@ use AnyEvent; use AnyEvent::MPV; - my $videofile = "xyzzy.mp4"; + my $videofile = "xyzzy.mkv"; my $quit = AE::cv; @@ -189,7 +199,7 @@ is expected to be in unicode. To pass something that isn't, you need to escape it using C. -Data received from C<$mpv>, however, is I decoded to unicode, as data +Data received from F, however, is I decoded to unicode, as data returned by F is not generally encoded in unicode, and the encoding is usually unspecified. So if you receive data and expect it to be in unicode, you need to first decode it from UTF-8, but note that this might @@ -213,7 +223,7 @@ use AnyEvent (); use AnyEvent::Util (); -our $VERSION = '0.2'; +our $VERSION = '1.0'; sub OBSID() { 0x10000000000000 } # 2**52 @@ -824,6 +834,482 @@ If you don't want to go to the effort of subclassing this module, you can also specify all event handlers as constructor keys. +=head1 EXAMPLES + +Here are some real-world code snippets, thrown in here mainly to give you +some example code to copy. + +=head2 doomfrontend + +At one point I replaced mythtv-frontend by my own terminal-based video +player (based on rxvt-unicode). I toyed with the diea of using F's +subtitle engine to create the user interface, but that is hard to use +since you don't know how big your letters are. It is also where most of +this modules code has originally been developed in. + +It uses a unified input queue to handle various remote controls, so its +event handling needs are very simple - it simply feeds all events into the +input queue: + + my $mpv = AnyEvent::MPV->new ( + mpv => $MPV, + args => \@MPV_ARGS, + on_event => sub { + input_feed "mpv/$_[1]", $_[2]; + }, + on_key => sub { + input_feed $_[1]; + }, + on_eof => sub { + input_feed "mpv/quit"; + }, + ); + + ... + + $mpv->start ("--idle=yes", "--pause", "--force-window=no"); + +It also doesn't use complicated command line arguments - the file search +options have the most impact, as they prevent F from scanning +directories with tens of thousands of files for subtitles and more: + + --audio-client-name=doomfrontend + --osd-on-seek=msg-bar --osd-bar-align-y=-0.85 --osd-bar-w=95 + --sub-auto=exact --audio-file-auto=exact + +Since it runs on a TV without a desktop environemnt, it tries to keep complications such as dbus +away and the screensaver happy: + + # prevent xscreensaver from doing something stupid, such as starting dbus + $ENV{DBUS_SESSION_BUS_ADDRESS} = "/"; # prevent dbus autostart for sure + $ENV{XDG_CURRENT_DESKTOP} = "generic"; + +It does bind a number of keys to internal (to doomfrontend) commands: + + for ( + List::Util::pairs qw( + ESC return + q return + ENTER enter + SPACE pause + [ steprev + ] stepfwd + j subtitle + BS red + i green + o yellow + b blue + D triangle + UP up + DOWN down + RIGHT right + LEFT left + ), + (map { ("KP$_" => "num$_") } 0..9), + KP_INS => 0, # KP0, but different + ) { + $mpv->bind_key ($_->[0] => $_->[1]); + } + +It also reacts to sponsorblock chapters, so it needs to know when vidoe +chapters change. Preadting C, it handles observers +manually: + + $mpv->cmd (observe_property => 1, "chapter-metadata"); + +It also tries to apply an F profile, if it exists: + + eval { + # the profile is optional + $mpv->cmd ("apply-profile" => "doomfrontend"); + }; + +Most of the complicated parts deal with saving and restoring per-video +data, such as bookmarks, playing position, selected audio and subtitle +tracks and so on. However, since it uses L, it can conveniently +block and wait for replies, which is n ot possible in purely event based +programs, as you are not allowed to block inside event callbacks in most +event loops. This simplifies the code quite a bit. + +When the file to be played is a Tv recording done by mythtv, it uses the +C protocol and deinterlacing: + + if (is_myth $mpv_path) { + $mpv_path = "appending://$mpv_path"; + $initial_deinterlace = 1; + } + +Otherwise, it sets some defaults and loads the file (I forgot what the +C argument is for, but I am sure it is needed by some F +version): + + $mpv->cmd ("script-message", "osc-visibility", "never", "dummy"); + $mpv->cmd ("set", "vid", "auto"); + $mpv->cmd ("set", "aid", "auto"); + $mpv->cmd ("set", "sid", "no"); + $mpv->cmd ("set", "file-local-options/chapters-file", $mpv->escape_binary ("$mpv_path.chapters")); + $mpv->cmd ("loadfile", $mpv->escape_binary ($mpv_path)); + $mpv->cmd ("script-message", "osc-visibility", "auto", "dummy"); + +Handling events makes the main bulk of video playback code. For example, +various ways of ending playback: + + if ($INPUT eq "mpv/quit") { # should not happen, but allows user to kill etc. without consequence + $status = 1; + mpv_init; # try reinit + last; + + } elsif ($INPUT eq "mpv/idle") { # normal end-of-file + last; + + } elsif ($INPUT eq "return") { + $status = 1; + last; + +Or the code that actually starts playback, once the file is loaded: + + our %SAVE_PROPERTY = (aid => 1, sid => 1, "audio-delay" => 1); + + ... + + my $oid = 100; + + } elsif ($INPUT eq "mpv/file-loaded") { # start playing, configure video + $mpv->cmd ("seek", $playback_start, "absolute+exact") if $playback_start > 0; + + my $target_fps = eval { $mpv->cmd_recv ("get_property", "container-fps") } || 60; + $target_fps *= play_video_speed_mult; + set_fps $target_fps; + + unless (eval { $mpv->cmd_recv ("get_property", "video-format") }) { + $mpv->cmd ("set", "file-local-options/lavfi-complex", "[aid1] asplit [ao], showcqt=..., format=yuv420p [vo]"); + }; + + for my $prop (keys %SAVE_PROPERTY) { + if (exists $PLAYING_STATE->{"mpv_$prop"}) { + $mpv->cmd ("set", "$prop", $PLAYING_STATE->{"mpv_$prop"} . ""); + } + + $mpv->cmd ("observe_property", ++$oid, $prop); + } + + play_video_set_speed; + $mpv->cmd ("set", "osd-level", "$OSD_LEVEL"); + $mpv->cmd ("observe_property", ++$oid, "osd-level"); + $mpv->cmd ("set", "pause", "no"); + + $mpv->cmd ("set_property", "deinterlace", "yes") + if $initial_deinterlace; + +There is a lot going on here. First it seeks to the actual playback +position, if it is not at the start of the file (it would probaby be more +efficient to set the starting position before loading the file, though, +but this is good enough). + +Then it plays with the display fps, to set it to something harmonious +w.r.t. the video framerate. + +If the file does not have a video part, it assumes it is an audio file and +sets a visualizer. + +Also, a number of properties are not global, but per-file. At the moment, +this is C, and the current audio/subtitle track, which it +sets, and also creates an observer. Again, this doesn'T use the observe +functionality of this module, but handles it itself, assigning obsevrer +ids 100+ to temporary/per-file observers. + +Lastly, it sets some global (or per-youtube-uploader) parameters, such as +speed, and unpauses. Property changes are handled like other input events: + + } elsif ($INPUT eq "mpv/property-change") { + my $prop = $INPUT_DATA->{name}; + + if ($prop eq "chapter-metadata") { + if ($INPUT_DATA->{data}{TITLE} =~ /^\[SponsorBlock\]: (.*)/) { + my $section = $1; + my $skip; + + $skip ||= $SPONSOR_SKIP{$_} + for split /\s*,\s*/, $section; + + if (defined $skip) { + if ($skip) { + # delay a bit, in case we get two metadata changes in quick succession, e.g. + # because we have a skip at file load time. + $skip_delay = AE::timer 2/50, 0, sub { + $mpv->cmd ("no-osd", "add", "chapter", 1); + $mpv->cmd ("show-text", "skipped sponsorblock section \"$section\"", 3000); + }; + } else { + undef $skip_delay; + $mpv->cmd ("show-text", "NOT skipping sponsorblock section \"$section\"", 3000); + } + } else { + $mpv->cmd ("show-text", "UNRECOGNIZED sponsorblock section \"$section\"", 60000); + } + } else { + # cancel a queued skip + undef $skip_delay; + } + + } elsif (exists $SAVE_PROPERTY{$prop}) { + $PLAYING_STATE->{"mpv_$prop"} = $INPUT_DATA->{data}; + ::state_save; + } + +This saves back the per-file properties, and also handles chapter changes +in a hacky way. + +Most of the handlers are very simple, though. For example: + + } elsif ($INPUT eq "pause") { + $mpv->cmd ("cycle", "pause"); + $PLAYING_STATE->{curpos} = $mpv->cmd_recv ("get_property", "playback-time"); + } elsif ($INPUT eq "right") { + $mpv->cmd ("osd-msg-bar", "seek", 30, "relative+exact"); + } elsif ($INPUT eq "left") { + $mpv->cmd ("osd-msg-bar", "seek", -5, "relative+exact"); + } elsif ($INPUT eq "up") { + $mpv->cmd ("osd-msg-bar", "seek", +600, "relative+exact"); + } elsif ($INPUT eq "down") { + $mpv->cmd ("osd-msg-bar", "seek", -600, "relative+exact"); + } elsif ($INPUT eq "select") { + $mpv->cmd ("osd-msg-bar", "add", "audio-delay", "-0.100"); + } elsif ($INPUT eq "start") { + $mpv->cmd ("osd-msg-bar", "add", "audio-delay", "0.100"); + } elsif ($INPUT eq "intfwd") { + $mpv->cmd ("no-osd", "frame-step"); + } elsif ($INPUT eq "audio") { + $mpv->cmd ("osd-auto", "cycle", "audio"); + } elsif ($INPUT eq "subtitle") { + $mpv->cmd ("osd-auto", "cycle", "sub"); + } elsif ($INPUT eq "triangle") { + $mpv->cmd ("osd-auto", "cycle", "deinterlace"); + +Once a file has finished playing (or the user strops playback), it pauses, +unobserves the per-file observers, and saves the current position for to +be able to resume: + + $mpv->cmd ("set", "pause", "yes"); + + while ($oid > 100) { + $mpv->cmd ("unobserve_property", $oid--); + } + + $PLAYING_STATE->{curpos} = $mpv->cmd_recv ("get_property", "playback-time"); + +And thats most of the F-related code. + +=head2 F + +F is low-feature image viewer that I use many times daily +because it can handle directories with millions of files without falling +over. It also had the ability to play videos for ages, but it used an +older, crappier protocol to talk to F and used F before +playing each file instead of letting F handle format/size detection. + +After writing this module, I decided to upgprade Gtk2::CV by making use +of it, with the goal of getting rid of F and being ablew to +reuse F processes, which would have a multitude of speed benefits +(for example, fork+exec of F caused the kernel to close all file +descriptors, which could take minutes if a large file was being copied via +NFS, as the kernel waited for thr buffers to be flushed on close - not +having to start F gets rid of this issue). + +Setting up is only complicated by the fact that F needs to be +embedded into an existing window. To keep control of all inputs, +F puts an eventbox in front of F, so F receives no +input events: + + $self->{mpv} = AnyEvent::MPV->new ( + trace => $ENV{CV_MPV_TRACE}, + ); + + # create an eventbox, so we receive all input events + my $box = $self->{mpv_eventbox} = new Gtk2::EventBox; + $box->set_above_child (1); + $box->set_visible_window (0); + $box->set_events ([]); + $box->can_focus (0); + + # create a drawingarea that mpv can display into + my $window = $self->{mpv_window} = new Gtk2::DrawingArea; + $box->add ($window); + + # put the drawingarea intot he eventbox, and the eventbox into our display window + $self->add ($box); + + # we need to pass the window id to F, which means we need to realise + # the drawingarea, so an X window is allocated for it. + $self->show_all; + $window->realize; + my $xid = $window->window->get_xid; + +Then it starts F using this setup: + + local $ENV{LC_ALL} = "POSIX"; + $self->{mpv}->start ( + "--no-terminal", + "--no-input-terminal", + "--no-input-default-bindings", + "--no-input-cursor", + "--input-conf=/dev/null", + "--input-vo-keyboard=no", + + "--loop-file=inf", + "--force-window=yes", + "--idle=yes", + + "--audio-client-name=CV", + + "--osc=yes", # --osc=no displays fading play/pause buttons instead + + "--wid=$xid", + ); + + $self->{mpv}->cmd ("script-message" => "osc-visibility" => "never", "dummy"); + $self->{mpv}->cmd ("osc-idlescreen" => "no"); + +It also prepares a hack to force a ConfigureNotify event on every vidoe +reconfig: + + # force a configurenotify on every video-reconfig + $self->{mpv_reconfig} = $self->{mpv}->register_event (video_reconfig => sub { + my ($mpv, $event, $data) = @_; + + $self->mpv_window_update; + }); + +The way this is done is by doing a "dummy" resize to 1x1 and back: + + $self->{mpv_window}->window->resize (1, 1), + $self->{mpv_window}->window->resize ($self->{w}, $self->{h}); + +Without this, F often doesn't "get" the correct window size. Doing +it this way is not nice, but I didn't fine a nicer way to do it. + +When no file is being played, F is hidden and prepared: + + $self->{mpv_eventbox}->hide; + + $self->{mpv}->cmd (set_property => "pause" => "yes"); + $self->{mpv}->cmd ("playlist_remove", "current"); + $self->{mpv}->cmd (set_property => "video-rotate" => 0); + $self->{mpv}->cmd (set_property => "lavfi-complex" => ""); + +Loading a file is a bit more complicated, as bluray and DVD rips are +supported: + + if ($moviedir) { + if ($moviedir eq "br") { + $mpv->cmd (set => "bluray-device" => $path); + $mpv->cmd (loadfile => "bd://"); + } elsif ($moviedir eq "dvd") { + $mpv->cmd (set => "dvd-device" => $path); + $mpv->cmd (loadfile => "dvd://"); + } + } elsif ($type eq "video/iso-bluray") { + $mpv->cmd (set => "bluray-device" => $path); + $mpv->cmd (loadfile => "bd://"); + } else { + $mpv->cmd (loadfile => $mpv->escape_binary ($path)); + } + +After this, C waits for the file to be loaded, video to be +configured, and then queries the video size (to resize its own window) +and video format (to decide whether an audio visualizer is needed for +audio playback). The problematic word here is "wait", as this needs to be +imploemented using callbacks. + +This made the code much harder to write, as the whole setup is very +asynchronous (C talks to the command interface in F, which +talks to the decode and playback parts, all of which run asynchronously +w.r.t. each other. In practise, this can mean that C waits for +a file to be loaded by F while the command interface of F still +deals with the previous file and the decoder still handles an even older +file). Adding to this fact is that Gtk2::CV is bound by the glib event +loop, which means we cannot wait for replies form F anywhere, so +everything has to be chained callbacks. + +The way this is handled is by creating a new empty hash ref that is unique +for each loaded file, and use it to detect whether the event is old or +not, and also store C guard objects in it: + + # every time we loaded a file, we create a new hash + my $guards = $self->{mpv_guards} = { }; + +Then, when we wait for an event to occur, delete the handler, and, if the +C object has changed, we ignore it. Something like this: + + $guards->{file_loaded} = $mpv->register_event (file_loaded => sub { + delete $guards->{file_loaded}; + return if $guards != $self->{mpv_guards}; + +Commands do not have guards since they cnanot be cancelled, so we don't +have to do this for commands. But what prevents us form misinterpreting +an old event? Since F (by default) handles commands synchronously, +we can queue a dummy command, whose only purpose is to tell us when all +previous commands are done. We use C for this. + +The simplified code looks like this: + + Scalar::Util::weaken $self; + + $mpv->cmd ("get_version")->cb (sub { + + $guards->{file_loaded} = $mpv->register_event (file_loaded => sub { + delete $guards->{file_loaded}; + return if $guards != $self->{mpv_guards}; + + $mpv->cmd (get_property => "video-format")->cb (sub { + return if $guards != $self->{mpv_guards}; + + # video-format handling + return if eval { $_[0]->recv; 1 }; + + # no video? assume audio and visualize, cpu usage be damned + $mpv->cmd (set => "lavfi-complex" => ..."); + }); + + $guards->{show} = $mpv->register_event (video_reconfig => sub { + delete $guards->{show}; + return if $guards != $self->{mpv_guards}; + + $self->{mpv_eventbox}->show_all; + + $w = $mpv->cmd (get_property => "dwidth"); + $h = $mpv->cmd (get_property => "dheight"); + + $h->cb (sub { + $w = eval { $w->recv }; + $h = eval { $h->recv }; + + $mpv->cmd (set_property => "pause" => "no"); + + if ($w && $h) { + # resize our window + } + + }); + }); + + }); + + }); + +Most of the rest of the code is much simpler and just deals with forwarding user commands: + + } elsif ($key == $Gtk2::Gdk::Keysyms{Right}) { $mpv->cmd ("osd-msg-bar" => seek => "+10"); + } elsif ($key == $Gtk2::Gdk::Keysyms{Left} ) { $mpv->cmd ("osd-msg-bar" => seek => "-10"); + } elsif ($key == $Gtk2::Gdk::Keysyms{Up} ) { $mpv->cmd ("osd-msg-bar" => seek => "+60"); + } elsif ($key == $Gtk2::Gdk::Keysyms{Down} ) { $mpv->cmd ("osd-msg-bar" => seek => "-60"); + } elsif ($key == $Gtk2::Gdk::Keysyms{a}) ) { $mpv->cmd ("osd-msg-msg" => cycle => "audio"); + } elsif ($key == $Gtk2::Gdk::Keysyms{j} ) { $mpv->cmd ("osd-msg-msg" => cycle => "sub"); + } elsif ($key == $Gtk2::Gdk::Keysyms{o} ) { $mpv->cmd ("no-osd" => "cycle-values", "osd-level", "2", "3", "0", "2"); + } elsif ($key == $Gtk2::Gdk::Keysyms{p} ) { $mpv->cmd ("no-osd" => cycle => "pause"); + } elsif ($key == $Gtk2::Gdk::Keysyms{9} ) { $mpv->cmd ("osd-msg-bar" => add => "ao-volume", "-2"); + } elsif ($key == $Gtk2::Gdk::Keysyms{0} ) { $mpv->cmd ("osd-msg-bar" => add => "ao-volume", "+2"); + =head1 SEE ALSO L, L.