Create our own media player based on the vlc lib from zero point

Create our own media player based on the vlc lib from zero point

Greetings ladies, gentlemen, revived mechanisms, xenos and others, my name is Antares Kana and today we will create our own media player based on ready-made libraries from the vlc player (https://www.videolan.org/vlc/).

To do this, we need some compiler (the library is ported to all normal programming languages), and a little perseverance to read this article. Or to copy-paste (I will add a link to all the sources with the finished project at the end).

Let start. I will use lazarus (a cross-platform freepascal development environment), because is my favorite complier, but I will explain the logic outside of a specific language, for your system and language, the source code is here (https://www.videolan.org/vlc/libvlc.html ). For java (https://github.com/caprica/vlcj) and C++(https://code.videolan.org/videolan/libvlcpp). For Pascal, the resources will be in my project, but the link is also (https://wiki.videolan.org/Using_libvlc_with_Delphi/) here.

We create a new desktop application project and add forms, in my case I added 5 forms at once (ideally even 6 or more), for the video drawing area, for the playlist, control buttons, menus, and etc. Because my interface will contain all sorts of trinkets.

Альтернативный текст для этого изображения не предоставлен

In fact, all these forms will be composite components of one window for me, I do this for a simpler perception of the code and a transparency effect with a smooth transition without inventing a bicycle.

I remove the standard border from all windows (any "village" player has own, this is a canon), and add 6 panels and a timer to the form, I placed and bind the panels like a window border with a title, but only 1 pixel.

// auxiliary variable for getting cursor coordinate
help_last_resize_position:TPoint;

// one more, to get the size of the player window and its position
help_last_resize_position2:TPoint;

// and this one is for the timer to determine what manipulations to do
rsz_move_type:string;s        

Now we need to implement their functionality to change the position of the player window on the screen and its size. To do this, I create 3 global variables (two for coordinates, one auxiliary for determining the direction and how to resize, I will put all the code in a timer).

For all panels, I create two standard events, a mouse down event, and a mouse release event. When you click somewhere on the hone of the panel, we will turn on the timer and send a conditional text to the global variable, by which we will determine what and where to change.

And in fact, I have one procedure \ function \ events for all key release events, which I simply created for one panel, and then assigned this procedure as a release event for all, since it simply turns off the timer.

For each specific panel, we need our own data, namely the mouse coordinates at the time the event is triggered (they are given by the procedure itself, but you can use the GetCursorPos() function of Windows; where the parameter is a variable for obtaining coordinates, with type coordinates).

In principle, we can also collect all the information at once and copy-paste the code. And only change the contents of our helper variable for the timer to determine the type of resize.

// procedure for handling simple window movement on the scree
// type click on the window title
procedure TMP.BRDTRMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin

  // get into an auxiliary variable
  // coordinates of the mouse click along the X axis
  help_last_resize_position.X:=X;

  // get into an auxiliary variable
  // coordinates of the mouse click along the Y axis
  help_last_resize_position.Y:=Y;

  // get into another auxiliary variable
  // indent to the left (indent of the player window, from the left
  // edge of the screen, in pixels), to which we add
  // player window width
  help_last_resize_position2.X:=MP.Left+MP.Width;

  // get into another auxiliary variable
  // indent from above (indent of the player window, from the top
  // edge of the screen, in pixels), to which we add
  // player window height
  help_last_resize_position2.Y:=MP.Top+MP.Height;

  // get the auxiliary variable type
  // manipulations with the program window, in my case
  // by the word top I will determine that it is simple
  // move the program window
  rsz_move_type:='top';

  // turn on our timer, which was originally turned off
  Rsz.Enabled:=true;
  
end;        

This is just an example of one panel, but in the source code there will be everything, we perform other operations using poor mathematics, and moving the program window, for example

ProgramWindowWidth = ProgramWindowWidth + (CurrentMouseCoordinatesAxisX - (ProgramWindowIndentFromScreenLeftEdge + ProgramWindowWidth));

Or an example for resizing on the right, where we change the position of the window padding on the left and the width

IndentProgramWindowFromLeftEdgeofScreen = CurrentMouseCoordinatesX - Axis -MouseCoordinatesAtStartResize;

ProgramWindowWidth = ProgramWindowOffsetFromLeftEdgeOfScreen + ProgramWindowWidth + (MouseCoordinatesAtTheStartOfResizing - CurrentMouseCoordinatesXAxis );

Something like this. For the sake of brevity, here is my code.

// magic code that does not require comment
if rsz_move_type = 'move' then
begin
  MP.Left:=Mouse.CursorPos.X-help_last_resize_position.X;
  MP.Top:=Mouse.CursorPos.Y-help_last_resize_position.Y;
end;

if rsz_move_type = 'right_bottom' then
begin
  MP.Width:=MP.Width + (Mouse.CursorPos.X - (MP.Left+MP.Width));
  MP.Height:=MP.Height + (Mouse.CursorPos.Y - (MP.Top+MP.Height));
end;

if rsz_move_type = 'right' then
begin
  MP.Width:=MP.Width + (Mouse.CursorPos.X - (MP.Left+MP.Width));
end;

if rsz_move_type = 'bottom' then
begin
  MP.Height:=MP.Height + (Mouse.CursorPos.Y - (MP.Top+MP.Height));
end;

if rsz_move_type = 'left' then
begin
  MP.Left:=Mouse.CursorPos.X-help_last_resize_position.X;
  MP.Width:=help_last_resize_position2.X+(help_last_resize_position.X-   Mouse.CursorPos.X);
end;

if rsz_move_type = 'top' then
begin
  MP.Top:=Mouse.CursorPos.Y-help_last_resize_position.Y;
  MP.Height:=help_last_resize_position2.Y+(help_last_resize_position.Y-Mouse.CursorPos.Y);
end;

// in addition to changing the size and position of the player window
// drag after it and set new sizes for
// playlists, player button bar, etc
TopMenu.Left:=MP.Left+1;
TopMenu.Top:=MP.Top+1;
TopMenu.Width:=MP.Width-2;
TopMenu.Visible:=true;
TopMenu.BringToFront;

PlayerControls.Left:=MP.Left+1;
PlayerControls.Top:=MP.Top+(MP.Height-51);
PlayerControls.Width:=MP.Width-2;
PlayerControls.Visible:=true;
PlayerControls.BringToFront;

if PMenu.Visible = true then
begin
  PMenu.Left:=MP.Left;
  PMenu.Top:=MP.Top+51;
  PMenu.Height:=MP.Height-102;
  PMenu.Width:=400;
end;

if Playlist.Visible = true then
begin
  Playlist.Visible:=true;
  Playlist.Left:=(MP.Left+MP.Width)-Playlist.Width;
  Playlist.Top:=MP.Top+51;
  Playlist.Height:=MP.Height-102;
end;        

Now that we have a player window, let's bring pictures with sounds to the screen by connecting the lib_vlc.dll library. All functions from this library are intuitive understandable and the same for all languages. A complete list of functions from the libraries can be found on the website (https://videolan.videolan.me/vlc/group__libvlc.html).

We connect the sources linking the library with the described header functions (for Pascal, this is uses PasLibVlcUnit). We create two global variables, the first variable of the libvlc_instance_t_ptr type, the second variable of the libvlc_media_player_t_ptr type, for the library instance and the player, the name can be anything, I have them called from the p_li and p_mi examples (unfortunate abbreviations from librarians instance and media instance).

We create a function in which we load and initialize a library instance, and from it we already call a function that will create an instance of the finished player for us.


function INITVLC:boolean
begin

// load the library and link the headers
// for this function to work correctly
// computer must have vlc media player
// I use it because I have a crooked assembly
// Windows, in which dlls are not registered
libvlc_dynamic_dll_init();

// load the library from somewhere
// as a function argument I pass
// contents of my global app_path variable,
// in which I put the path to my program,
// to which I add the name of the library with the player
//libvlc_dynamic_dll_init_with_path(app_path+'libvlc.dll');
// create a condition if the contents of the variable
// libvlc_dynamic_dll_error from PasLibVlcUnit
// contains something more than nothing, call
// standard system dialog, and display
// error code from libvlc_dynamic_dll_error
if (libvlc_dynamic_dll_error <> '') then
begin
  // call dialog
  MessageDlg(libvlc_dynamic_dll_error, mtError, [mbOK], 0);

  // destroy the program instance
  // and exit the application
  Application.Terminate();
  // urgently interrupt the execution of further code
  exit;
end;

// with PasLibVlcUnit library object
// create a new variable instance, in
// which I put additional parameters
// to load the library
with TArgcArgs.Create([
  WideString(libvlc_dynamic_dll_path),
    '--intf=dummy',
    '--ignore-config',
    '--quiet',
    '--no-video-title-show',
    '--no-video-on-top'
]) do
begin

  // call a function from the PasLibVlcUnit library,
  // which creates a new instance of the conditional
  // hidden player vlc with arguments and associate
  // it with p_li variable
  p_li := libvlc_new(ARGC, ARGS);

  // destroy the TArgcArgs object since it is larger
  // not needed
  free;
end;

// create condition if instance variable p_li
// contains something but nothing continue actions
if (p_li <> NIL) then
begin

  // associate with the p_mi variable the result of the execution
  // libvlc_media_player_new functions, in which I pass
  // as argument variable with loaded
  // an instance of the lib_vlc.dll library
  p_mi := libvlc_media_player_new(p_li);

end;

// return the result of the function as true
result:=true;

end;        

Imagine that everything is clear and everything worked out the first time. And let's also assume that we placed all the bells and whistles on the form as we wanted and they work correctly. Let's immediately describe the functions for opening a file, playing, pausing, and stopping.

Everything is simpler than it seems, we just use simple functions from our linked library, and as an argument for these functions we pass our global variable with the player, or library, depending on the action.

function StopVideo:boolean
begin

  // create a condition if the content
  // p_mi variable with media player
  // doesn't equal anything, execute content
  // conditions
  if (p_mi <> NIL) then
  begin
    // calling a function from the library
    // PasLibVlcUnit that pauses
    // I specify as an argument
    // variable associated with
    // player instance
    libvlc_media_player_pause(p_mi);

    // I redraw the screen area
    // this is for my gimmicks, this code is not
    // required
    MP.Monitor1.Repaint;
    MP.Monitor1.ParentBackground:=false;
    MP.Monitor1.ParentBackground:=true;
  end;

  // return the result of the function execution as true
  result:=true;

end;        

Function of loading

function PlayVideo(filename:WideString):boolean
var

// create a media object type variable
// from PasLibVlcUnit library
p_md : libvlc_media_t_ptr;

// slzdaem auxiliary variable
// to convert filename
// type string ansi
a_st : AnsiString;

// and another character type
p_st : PAnsiChar;

begin
  // convert the contents of the argument
  // playvideo functions in foot8
  a_st := UTF8Encode(fileName);

  // get the first character from the path,
  // which I convert to ansi format,
  // not entirely clear what this is for
  p_st := PAnsiChar(@a_st[1]);

  // create a condition if the content
  // variable associated with the library
  // lib_vlc.dll is more than nothing, continue
  if (p_li <> NIL) then
  begin

    // calling a function from the PasLibVlcUnit library
    // which receives information about the file, its type,
    // size, etc., as a parameter I pass
    // contents of auxiliary variables with which
    // I associated converted to utf8 format
    // path to the file with the name, the result of execution
    // I put the functions in another auxiliary variable p_md
    p_md := libvlc_media_new_path(p_li, p_st);

    // create a condition if the content
    // variable p_md is greater than nothing,
    // continue actions
    if (p_md <> NIL) then
    begin

      // create a condition if the content
      // variable with media player instance
      // more than nothing, continue
      if (p_mi <> NIL) then
      begin

        // call a function from the PasLibVlcUnit library,
        // which allows you to intercept from the program,
        // to the library processing keyboard keystrokes,
        // as parameters I pass a variable with a media player
        // and pointer 0, so how to handle keys
        // will be my program
        libvlc_video_set_key_input(p_mi, 0);

        // perform a similar operation for the mouse
        libvlc_video_set_mouse_input(p_mi, 0);

        // call a function from the PasLibVlcUnit library,
        // which allows you to assign some object,
        // which has an area in its content for
        // drawing as screen for video, as
        // parameters I pass a variable with an instance of the player,
        // and the index of the object with the drawing area in the format
        // system, I have a panel called Monitor1
        libvlc_media_player_set_display_window(p_mi, MP.Monitor1.Handle);

        // call a function from the PasLibVlcUnit library,
        // which allows you to associate a media object with
        // an instance of the player, as a parameter I pass
        // media player instance from global variable and
        // contents of the auxiliary variable with information
        // about the media file
        libvlc_media_player_set_media(p_mi, p_md);

      end;

      // call a function from the PasLibVlcUnit library,
      // which allows you to determine and decode the file
      libvlc_media_release(p_md);
    end;
  end;

  // I return the result of the function execution true
  result:=true;

end;        

And finally the function to start playback. For me, this function combines the ability to play / pause, because the player can have several states (file loaded, file not found, file being played, etc.), you need to process them all. I will describe for the pause state, because another clear.


procedure TPlayerControls.Image1Click(Sender: TObject);
begin

  // create a condition if the execution result
  // functions from the PasLibVlcUnit library called
  // libvlc_media_player_get_state returned the value
  // which matches the conditional keyword
  // from the libvlc_Paused library, perform the actions of the condition
  if libvlc_media_player_get_state(p_mi) = libvlc_Paused then
  begin
    // call the play function
    libvlc_media_player_play(p_mi);

    // these are trinkets, changing the button icon
    // play from play to pause
    Self.Image1.Picture:=Self.src_img_pause.Picture;

    // suddenly interrupt the execution of the procedure
    exit;
  end;

  if libvlc_media_player_get_state(p_mi) = libvlc_Playing then
  begin
    libvlc_media_player_pause(p_mi);
    Self.Image1.Picture:=Self.src_img_play.Picture;
    exit;
  end;

  if libvlc_media_player_get_state(p_mi) = libvlc_Ended then
  begin
    libvlc_media_player_play(p_mi);
    Self.Image1.Picture:=Self.src_img_pause.Picture;
    exit;
  end;

  if libvlc_media_player_get_state(p_mi) = libvlc_Stopped then
  begin
    libvlc_media_player_play(p_mi);
    Self.Image1.Picture:=Self.src_img_pause.Picture;
    exit;
  end;
end;        

And now we already have a ready-made prototype of the program that can play video, it remains to create a button and place a file open dialog box, inside the open button event, call our function to open a media file, and pass it the result of the dialog box for selecting a file.

Альтернативный текст для этого изображения не предоставлен

Now let's add a timer strip and a playlist. For the timer strip, I use two more panels, one above the other, in different colors, as time progresses, I will resize one strip that will be located above the other, the other will represent the entire timeline.

I will place the playlist in a separate window and take for it the standard blanks from the TListBox system libraries (in C ++ cli this is ListBox, and JList in jdk). I will also add a new timer that will receive the current time position of the file being played, and update the progress of the panel over time.

Let's describe the function of the timer, as it will run every second all the time and check the state of the player, based on which it will recalculate the progress of the timeline.

First, I have a helper function that converts the seconds to the usual format, since I also have the time of the video displayed. It is not obligatory, but I will simply add the source to.

function ConvertMsToNormalTime(i:TCaption):TCaption
var a:string;
  b:real;
  c,d:integer;
  min,sec,hours:integer;
begin

  a:='';
  c:=0;
  d:=round(StrToFloat(i));
  min:=0;
  sec:=0;
  hours:=0;

  while c < d do
  begin
    inc(sec);
    if sec = 60 then
    begin
      inc(min);
      sec:=0;
    end;

    if min = 60 then
    begin
      inc(hours);
      min:=0;
    end;

    inc(c);
  end;

  if hours < 10 then
  begin
    a:=a+'0'+IntToStr(hours)+':';
  end else
  begin
    a:=a+IntToStr(hours)+':';
  end;

  if min < 10 then
  begin
    a:=a+'0'+IntToStr(min)+':';
  end else
  begin
    a:=a+IntToStr(min)+':';
  end;

  if sec < 10 then
  begin
    a:=a+'0'+IntToStr(sec);
  end else
  begin
    a:=a+IntToStr(sec);
  end;

result:=a;

end;        

Now the timeline progress updater function itself.

procedure TMP.MoveTimerTimer(Sender: TObject)

// create auxiliary type variables
// real number (for bydlocoders I will explain that
// this is a floating point number)
var a,s1,s2,s3:real;

// create auxiliary variables of type number
// (integer)
varb,c:integer;

begin

  // create a condition if with a variable
  // instance associated with what
  // nothing else, I execute the code
  // conditions
  if (p_mi <> NIL) then
  begin

    // create a condition if the function
    // libvlc_media_player_get_state for
    // get player status from
    // libraries PasLibVlcUnit returns
    // value that matches
    // described in the PasLibVlcUnit library
    // libvlc_Playing value, execute condition code
    if (libvlc_media_player_get_state(p_mi) = libvlc_Playing) then
    begin
      // associate with helper
      // value variable 0
      b:=0;

      // start loop with condition
      // repeat until
      // value of variable b is less than 100
      while b < 100 do
      begin

        // with auxiliary variable
        // associate the result of the function execution
        // libvlc_media_player_get_position which
        // allows you to get the current position in milliseconds
        // video being played, from the PasLibVlcUnit library,
        // passing a global variable as a parameter
        // with which the player instance is associated, and converting
        // built-in pascal function Real I cast the data to the type
        // floating point number, which I round up to seconds
        s1:=RoundTo(Real(libvlc_media_player_get_position(p_mi)),-3);

        // create a condition if 1 is divided
        // 100 times content
        // auxiliary variable b is greater
        // values of s1 fulfill the condition, otherwise
        // perform alternative action
        // (function libvlc_media_player_get_position
        // returns a value in the range from 0.0 to
        // 1.0)
        if ((1 / 100) * Real(b) >= s1) then
        begin

          // transfer the value of the variable b
          // to another auxiliary variable c
          // and stop the loop
          c:=b;
          b:=100;
        end ELSE
        begin
          c:=100;
        end;

        inc(b);
      end;

      // for the progress bar I set the width,
      // which is equal to the rounded percentage
      // ratio of the entire timeline,
      // which I have presented in another
      // socket
      PlayerControls.Time2.Width:=Round((PlayerControls.Time1.Width / 100) * c);

      // in the inscription (or Textlabel object) I put the result
      // executing a helper function for converting
      // seconds into the usual format, into which I pass (converting
      // into the text the floating point value) that I get
      // from the PasLibVlcUnit library function, which returns
      // current playback time in milliseconds
PlayerControls.Label1.Caption:=ConvertMsToNormalTime(FloatToStr(libvlc_media_player_get_time(p_mi) / 1000));

      // I do a similar operation for another inscription,
      // which contains the total time of the video
PlayerControls.Label2.Caption:=ConvertMsToNormalTime(FloatToStr(Real(libvlc_media_player_get_length(p_mi)) / 1000))+' ('+IntToStr(c)+'%)';

    end;

    // create a condition if the result of the function execution
    // libvlc_media_player_get_state returns the appropriate value
    // libvlc_Ended from the PasLibVlcUnit library, doing the condition
    if libvlc_media_player_get_state(p_mi) = libvlc_Ended then
    begin

      // set the width of the progress bar equal to the width
      // the entire panel of time
      PlayerControls.Time2.Width:=PlayerControls.Time1.Width;

      // every time I get the current playback time
      // video (playback position)
PlayerControls.Label1.Caption:=ConvertMsToNormalTime(FloatToStr(libvlc_media_player_get_time(p_mi) / 1000));

      // turn off the timers of my gadgets
      // this code is optional
      MP.MoveTimer.Enabled:=false;
      MP.Player_Next.Enabled:=true;
    end;

  end;
end;        

Well, in addition, the code for the poke event on the progress bar. For the panel, which is responsible for the entire timeline, we create a poke event, after which we associate the same function with a similar event of the second panel, which is responsible for the current time.

procedure TPlayerControls.Time1Click(Sender: TObject)
// create auxiliary variables
// type integer
varb,c:integer;

begin

  // associate with an auxiliary variable
  // cursor coordinates along the X axis, of which
  // subtract all padding from the left edge of the screen,
  // to our progress panel
  c:=Mouse.CursorPos.X - (1+MP.Left+Time1.left);

  // start loop to get percentage
  // playbar values
  b:=0;
  while b <= 99 do
  begin

    // create a condition if converted
    // to a floating point value
    // panel width of the current time divided by
    // by 100 and multiplied by the value of variable b
    // greater than or equal to the value of variable c
    // perform the actions of the condition
    if Real(Time1.Width / 100) * b >= c then
    begin

      // call the function from the PasLibVlcUnit library for
      // change the playback time, as a parameter
      // specify the variable with which the instance is associated
      // player and new time in floating point format,
      // which is obtained by multiplying 1 by the value of the variable b
      libvlc_media_player_set_position(p_mi, StrToFloat(FloatToStr(Real(1) / Real(99) * b)));

      // call the function to play
      // video, from the PasLibVlcUnit library, which
      // starts playing the video, as a parameter
      // specify the variable with which the instance is associated
      libvlc_media_player_play(p_mi);

      // b = 100
      b:=100;
    end;

    inc(b);
  end;
end;        

Here we already have a working player, and it even has a timeline. Let me tell you a little more about the playlist and, in principle, the alpha version of the player will be ready. Since my player was planned mainly as a video player, as an alternative to the same vlc media player, with functionality more close to YouTube and a web player interface in general, I don’t have extended information about media in the program.

This is not so difficult to obtain, especially since vlc has already done everything itself, you just need to take it by writing the appropriate request function (wiki on vlc lib to help). My playlist will contain information about the file size, file name and location.

At the same time, during the download of the first file, like WMP, my player looks for all other playable formats in the same folder and puts them in the playlist. As far as I know vlc works with all major media formats.

In my program, a combined mode with viewing a photo was planned, so my list is a little different.

*.mp4;*.mp3;*.mp2;*.wmv;*.wma;*.avi;*.asf;*.mov;*.mkv;*.wav;*.ogg;*.ogm;*.flac;*.flv;*.mxf;*.smf;*.mid;*.3gp;*.jpg;*.jpeg;*.png;*.gif;*.bmp;*.ico;*.tif;*.tiff;

In lazarus, there is a FindAllFiles () function for this, you may have another one (most likely a standard interface with a resulting sheet, or a FindFirst, FindNext function). I simply put all the full paths to the files in the list of the sheet, which I redraw in the draw event of the TListBox list items.

procedure TPlaylist.ListBox1DrawItem(Control: TWinControl; Index: Integer
ARect: TRect; state: TOwnerDrawState);
// create a numeric variable
varb:integer;
//a:TLCLTextMetric;
//varf:file;
begin

  //AssignFile(f, ListBox1.Items[Index]);
  //Draw(0,0,Playlist.img_icon_music.Picture.Graphic);
  // with the canvas object of the ListBox I start the actions
  with ListBox1.Canvas do
  begin

    // create a condition if the value
    // passed argument with current
    // index in the sheet, which, divided by
    // has a remainder of 0, I fulfill the conditions,
    // otherwise go to alternative condition
    if (Index) mod 2 = 0 then
    begin

      // for canvas object of listbox1
      // properties of the background color of the fill set
      // from a global auxiliary variable,
      // which contains the color code
      Brush.Color := style_base_color;

      // similar for rendering color
      Font.Color := style_text_color;

      // fill with background color
      // entire drawing area
      FillRect(ARect);

      // for text rendering
      // set font size to 10
      Font.Size:=10;

      // draw text in coordinates,
      // which were received as an argument,
      // render the filename obtained from
      // value of the list string that I'm skipping
      // through a function that cuts off from the name
      // extension and path (that is, leave only the name)
      TextOut(ARect.Left+4, ARect.Top, GetFileNameFromFullPath(ListBox1.Items[Index]));

      // for text rendering
      // set font size to 6
      Font.Size:=6;

      // draw in shifted coordinates, relative to
      // and inside the render area, render full
      // path to the file named
      TextOut(ARect.Left+4, ARect.Top+16, ListBox1.Items[Index]);

    end else
    begin
      // Alternative condition,
      // I do everything myself, too,
      // just invert render colors
      // and fills
      Brush.Color := style_text_color;
      FillRect(ARect);
      Font.Color := style_base_color;
      Font.Size:=10;
      TextOut(ARect.Left+4, ARect.Top, GetFileNameFromFullPath(ListBox1.Items[Index]));
      Font.Size:=6;
      TextOut(ARect.Left+4, ARect.Top+16, ListBox1.Items[Index]);
    end;
  end;
end;        

Well, as a bonus, the now fashionable RGB tint, which I have implemented only for the header with the file name, as it looks like a collective farm, and for cutting out suitable colors for transfusion, as on coolers, too lazy to do. I have it turned off, so I need to turn on the PlayerRGB timer.

Link to resources (https://1drv.ms/u/s!Att7piQftCNFgSAEPOUhgXc0jR5R?e=F2CALy)

Альтернативный текст для этого изображения не предоставлен

In general, I think this can be finished, since the alpha of the player is essentially described, but something extra for wimps. I hope this article will be useful, share your opinion and if there are any errors and suggestions, you can always add me in the comments, thanks for your attention^^

要查看或添加评论,请登录

Antares Kana的更多文章

社区洞察

其他会员也浏览了