Construya un reproductor con lista de reproducción de YouTube personalizado

Construya un reproductor con lista de reproducción de YouTube personalizado

Extienda el reproductor integrado básico para que incluya las mismas funciones que el reproductor de YouTube.com nativo
Lanzado en el 2005, YouTube ha evolucionado hacia el sitio de web dominante para compartir videos. Las listas de reproducción son una de las funciones más ampliamente utilizadas de YouTube. Puede desarrollar listas de reproducción propias y los videos subidos de otros usuarios y compartir sus listas de reproducción en YouTube. También puede compartir sus listas de reproducción integrándolas en su sitio de web, blog o página de medios sociales. Sin embargo, al reproductor de listas de reproducción integrado carece de la plena funcionalidad del reproductor nativo en YouTube.com.
La Figura 1 muestra el reproductor de la lista de reproducción nativa de youtube.com.
Figura 1. Reproductor de lista de reproducción nativo
Screenshot shows the native YouTube playlist player
La Figura 2 muestra el reproductor integrado predeterminado.
Figura 2. Reproductor integrado de listas de reproducción
Screenshot shows the default embedded YouTube playlist player
En la lista de reproducción integrada:
  • La lista de videos incluidos está oculta de manera predeterminada y sólo aparece como una forma sobrepuesta, no a un lado del reproductor como lo hace en YouTube.com.
  • Se elimina la habilidad para que la lista de reproducción sea aleatoria.
  • Se eliminan las notas agregadas a la lista de reproducción por este autor.
Yo encontré estas limitaciones cuando decidí integrar una lista de reproducción conteniendo algunos de los mejores goles en los juegos de calificación para la Copa Mundial FIFA 2014 en mi blog. Con la ayuda de YouTube API, jQuery, máquinas de plantillas JsRender y el framework de front-end de Bootstrap, extendí y mejoré el reproductor integrado predeterminado para crear una versión equivalente al reproductor nativo. Yo los llevaré a través del mismo proceso en este artículo. Construirá una lista de reproducción en YouTube, agregará notas con los tiempos de los momentos sobresalientes (como los goles), después utilizará las mismas herramientas para construir un reproductor integrado que restaure la funcionalidad faltante y mejore la experiencia del usuario.
El código completo del proyecto se guarda en DevOps Services. He desplegado la aplicación a IBM Cloud™ para que pueda verlo en operación. Puede usar cualquier sitio de host, incluyendo a IBM Cloud, para desplegar su código.
Nota: Para realizar la bifurcación del código para el proyecto de este artículo, haga un clic en el botón EDIT CODE en la esquina superior derecha (introduzca sus credenciales DevOps Services si no ha iniciado la sesión todavía) y haga un clic en el botón FORK del menú para crear un nuevo proyecto.
El primer paso es el de obtener una llave para acceder a su API de YouTube.

Obtenga una llave del API de YouTube

Para acceder al API para cualquier servicio de Google, incluyendo a YouTube, primero deberá registrar un proyecto en Google Developers Console y crear una llave API. El acceso a los diferentes APIs es gratuito hasta para un cierto número de solicitudes por día, que varían de servicio en servicio y estará disponible a cualquiera con una cuenta de Google.
Inicie una sesión en Google Developers Console con sus credenciales de Google y haga un clic en Create Project. De manera predeterminada, los cuadros de texto del nombre del proyecto y de la identificación del proyecto contienen valores aleatorios. Introduzca el nombre de su proyecto e identificación en su lugar y haga un clic en Create.
Figura 3. Creando un nuevo proyecto de API de Google
Screenshot of the Google Developer Console's New Project dialog box
En el tablero del proyecto, haga un clic en APIs & auth para abrir la lista de APIs disponibles. Desplácese hacia abajo a YouTube Data API v3 y haga un clic en el botón asociado etiquetado como Off para permitir el acceso para su proyecto. Seleccione APIs & Auth > Credentials y seleccione Create New Key bajo Public API Access. Elija Browser key.
En el diálogo Create a browser key and configure allowed referers, se puede restringir el acceso a la llave de API a las solicitudes de ciertos dominios, tales como el propio sitio o IBM Cloud.net. A no ser que se restrinja el acceso, su llave de API estará visible a todos en la fuente de HTML de su aplicación. Durante el desarrollo este no es un tema, así que deje el diálogo en blanco y haga clic en Create. Pero cuando se despliegue el proyecto, regrese a este paso y restrinja el acceso a las solicitudes del dominio de sus aplicaciones para que los terceros no puedan usar la llave en otras aplicaciones.
Si se clonara el proyecto de DevOps Services, puede insertar la llave ahora donde se indique en el archivo index.html para permitir que el código opere exitosamente.

Configure la biblioteca del cliente Google JavaScript para acceder al API de YouTube

Google provee bibliotecas del cliente para varios idiomas, incluyendo JavaScript. Importe el cliente de JavaScript a su documento HTML incluyendo la etiqueta del HTML en la etiqueta del documento <body> (no en la etiqueta <head>):
1
<script src="https://apis.google.com/js/client.js?onload=function"></script>
La mejor práctica recomendada es colocar esta etiqueta <script> al final de la etiqueta <body>.
Después de que es cargue la biblioteca del cliente, el objeto gapi (Google API) está disponible en el alcance de la ventana en su página. El parámetro onload en la etiqueta <script> que acaba de agregar se refiere a una función callback que se solicitó inmediatamente después de las cargas de la biblioteca. La definición de esa función deberá preceder la etiqueta <script> que carga el cliente. La función llama a el método gapi.client.load(api nameversioncallback function) para cargar los APIs que usará el cliente. En este caso, cargue el API de YouTube con gapi.client.load('youtube', 'v3', onYouTubeApiLoad)onYouTubeApiLoad que es una función de una línea que llama al método setAPIKey. En setAPIKey, coloque la llave en el valor de su llave de navegador Google.
El cliente provee los métodos para acceder a las varias llamadas de API para los servicios de Google. Para obtener la lista de partidas en una lista de reproducción, usted podrá tratar la respuesta del método de playlistItems.list en el API de YouTube en una función asíncrona y guardar los atributos relevantes en una instancia de un objeto de JavaScript denominado YouTubePlayList. Usted desarrollará el objeto YouTubePlaylist a través de este artículo. El constructor del objeto se define en la función de JavaScript que se muestra abajo.
Listado 1. YouTubePlaylist.js
1
function YouTubePlaylist(id, entries) { this.id = id; this.entries = entries; this.currently_playing = 0; this.randomizer = false; }
  • id que es la ID de la lista de reproducción.
  • entries es un arreglo JSON de los videos en la lista de reproducción.
  • currently_playing es el índice del video actualmente en reproducción en el arreglo entries.
  • randomizer indica si la reproducción es aleatoria.

Cree un objeto JSON con parámetros de respuesta

Ahora cree un objeto JSON con los parámetros necesarios en la respuesta. Sólo necesita un pequeño subconjunto de la lista completa de parámetros disponibles.
El parámetro part es una lista de atributos de valores separados por comas (CSV) que devolverá la llamada. Use los atributos contentDetails y snippet. El atributo snippet contiene información básica sobre cada video. contentDetails contiene la ID de video y cualesquiera notas que agregue el autor de la lista de reproducción. contentDetails será importante después cuando identifique los puntos sobresalientes en el video. El parámetro playListId es la ID de la lista de reproducción que usará. Para propósitos de este artículo, coloqué una lista de reproducción con los puntos sobresalientes cuya ID es PLLzJfby7cTLTbusOgXca-yIpVOImC1mWe. En la lista de reproducción, note que se agregan las horas de los goles como notas. El objeto JSON requestOptions ahora se ve así:
1
var requestOptions = { playlistId: playlist_id, part: 'contentDetails,snippet' };
Al recurrir al método gapi.client.youtube.playlistItems.list() con este objeto JSON como parámetro regresa un objeto con dos métodos: execute y subscribe. Recurra al método execute con una función asíncrona, con la respuesta como un parámetro.
1
request.execute(function(response) {});
El arreglo items en la respuesta contiene la lista de videos en la lista de reproducción. Usará el método jQuery each() para iterar a través de las partidas. Guardará la ID de video, la miniatura mediana del video, el título y la nota en un objeto JSON, después agregue eso a un arreglo.
Listado 2. Agregando el objeto JSON al arreglo entries
1
var entries = []; $.each( response.items, function( key, val ) { var entry = {}; entry.video_id = val.snippet.resourceId.videoId; entry.image_src = val.snippet.thumbnails.medium.url; entry.title = val.snippet.title; entry.note = val.contentDetails.note; entries.push(entry); });

Cree el objeto YouTubePlayLlist

Cree el nuevo objeto YouTubePlaylist llamando al constructor (consulte Listado 1) con la ID de la lista de reproducción y el arreglo entries y guarde el objeto en el alcance de la ventana como una nueva variable designada con la ID de la lista de reproducción.
1
window[playlist_id] = new YouTubePlaylist(playlistId, entries);
Ahora puede acceder al objeto YouTubePlaylist usando window[playlist_id]. Posteriormente, usará window[playlist_id] para llamar la funcionalidad adicional del objeto YouTubePlaylist.

Use una plantilla para formatear el reproductor con JsRender

El esquema de su reproductor será parecido al de la Figura 4, con la miniatura, título y lista de notas a la derecha conformada por registros en su lista de reproducción.
Figura 4. Esquema del nuevo reproductor
Illustration shows the structure of the new playlist player
De manera predeterminada, el conjunto de caracteres HTML no incluye iconos similares a los iconos siguientes, previos y aleatorios en el reproductor nativo de YouTube. Su aplicación usará los iconos suministrados por el framework front-end Bootstrap. Para importar la hoja del estilo Bootstrap, agregue el siguiente snippet a la etiqueta <head>.
1
<link rel="stylesheet" type="text/css" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css">
Entregará el objeto YouTubePlaylist usando el motor de la plantilla JsRender. Se define una plantilla JsRender en una etiqueta <script> con el conjunto de atributos type en text/x-jsrender. Genere el HTML recurriendo al método render() de la plantilla con el objeto x-jsrender que se entregará. Se entregan variables en la plantilla usando la anotación de corchetes angulares dobles {{:}}. Por ejemplo, {{:id}} entrega el atributo id del objeto pasado a la plantilla.
La plantilla que se muestra en el Listado 3 integra un reproductor YouTube en una página web.
Listado 3. Plantilla para integrar un reproductor YouTube
1
<object width="640" height="360" data="http://www.youtube.com/v/video id?version=3&enablejsapi=1&playerapiid=id" id="player id" type="application/x-shockwave-flash"> <param value="always" name="allowScriptAccess"> <param value="true" name="allowFullScreen"> </object>
En el Listado 3, video id está la ID del video que se reproducirá, y player id es la ID del propio objeto del reproductor. En la inicialización se hará la cola del primer video en el arreglo de entries, así que introduzca {{:entries[0].video_id}} como la ID de video en la plantilla del reproductor integrado. Para la ID del reproductor, use la ID de la lista de reproducción, digamos {{:id}}.
A la inicialización de un objeto del reproductor de YouTube, el reproductor llamará automáticamente la función onYouTubePlayerReady() con la ID del reproductor como un parámetro para personalizar su conducta cuando cambie su estado. Los estados del reproductor son no iniciado, terminado, reproduciendo, en pausa, en memoria intermedia y en cola de video; estas se enumeraron como -1, 0, 1, 2, 3, y 4. En la versión actual del API, no puede personalizarse la función callback. Por ahora, definirá una función en el alcance de la ventana para descargar el siguiente video en la lista de reproducción (definirá esta función en el objeto YouTubePlaylist posteriormente) y agregarlo como un evento para escuchar en el reproductor.
Listado 4. La función para cargar el siguiente video en la lista de reproducción
1
function onYouTubePlayerReady(playerApiId) { var player = document.getElementById(playerApiId); window["onStateChange" + playerApiId] = function(state) { switch(state) { case 0: var video_player = document.getElementById(player_id); video_player.loadVideoById(window[player_id].getNextVideo(), 0, "large"); <br> break; } }; player.addEventListener("onStateChange", "onStateChange" + playerApiId); }
Para iterar a través del arreglo de videos, usará la etiqueta JsRender {{for}}. Creará cada registro de lista de reproducción a la derecha de la Figura 4 con la plantilla en el Listado 5.
Listado 5. Plantilla para crear cada registro en la lista de reproducción en la lista
1
{{for entries}} &lt;div class="playListEntry {{if #index == 0}}nowPlaying{{/if}}" id="{{:video_id}}"&gt; &lt;div class="playListEntryThumbnail"&gt; &lt;img src="{{:image_src}}"/&gt; &lt;/div&gt; &lt;div class="playListEntryDescription"&gt; &lt;div class="playListEntryTitle"&gt;{{:title}}&lt;/div&gt; &lt;div class="playListEntryNote"&gt;{{:note}}&lt;/div&gt; &lt;/div&gt; &lt;/div&gt; {{/for}}
En el Listado 5, entries está el atributo entries en el objeto YouTubePlaylist. El índice del objeto en el arreglo se guarda en la variable #index. Dado que el primer registro en la lista es el video que se carga en el reproductor a su creación, aplicará la clase CSS nowPlaying a la primera clase playListEntry usando la etiqueta {{if}} para identificarlo en el bucle for.
Los controles en la parte inferior del reproductor se crean aplicando la clase Bootstrap glyphicon a un span, usando las clases glyphicon-backwardglyphicon-forward y glyphicon-random para los iconos.
1
&lt;div class="playListControls"&gt; &lt;span class="playListControl disabled glyphicon glyphicon-backward"/&gt; &lt;span class="playListControl glyphicon glyphicon-forward"/&gt; &lt;span class="playListControl glyphicon glyphicon-random"/&gt; &lt;/div&gt;
Note que inicialmente se deshabilita el icono "previous" porque cuando se carga primero el reproductor, se preestablece al primer registro en la lista de reproducción, por lo cual no hay un video previo por reproducir.
Agregará el HTML entregado a un div vacío en la página con la ID playlist con este snippet de código (recordando que window[player_id] se refiere al objeto que se creó en la sección "Create the YouTubePlaylist".
1
$('#' + player_id).html($('#playListPlayerTemplate').render(window[player_id]));
Para hacer este código reutilizable, muévalo a una función con la firma addPlaylistToElement(playlist_id, element_id) Después puede llamarse con addPlaylistToElement('PLLzJfby7cTLTbusOgXca-yIpVOImC1mWe', 'playlist').

Agregue controles

Regrese el objeto YouTubePlaylist y empiece a agregar funcionalidad. Posteriormente, usará esta versión mejorada del objeto para completar el reproductor en la página web.
Agregue seis funciones al objeto: previous()next()getCurrentlyPlaying()setCurrentlyPlaying()randomize() y isRandomized(). Las funciones previous y next se mueven a los videos relevantes en la lista de reproducción y regresan true si la acción tiene éxito o “false” si no lo hacen (eso es, si el usuario hace clic en "previous" en el primer registro en la lista de reproducción o "next" en el último registro). getCurrentlyPlaying() regresa la ID del video que se encuentra actualmente en reproducción en la lista de reproducción. randomize() establece o deja de establecer el atributo random en el objeto, y isRandomizer() regresa el valor del atributo aleatorio.
El listado 6 muestra la función Next().
Listado 6. La función Next()
1
next: function() { var retVal = false; if(this.randomizer) { retVal = true; this.currently_playing = Math.floor((Math.random() * this.entries.length)); } else if(this.currently_playing &lt;= this.entries.length) { retVal = true; this.currently_playing++; } return retVal;
En la función Next(), verifique primero si está establecido el atributo random y si lo está, establezca el índice currently_playing a un valor aleatorio en el arreglo entries. Si no se establece el atributo random y el índice currently_playing es inferior al número de videos en el arreglo (eso es, no ha pasado el último video en la lista de reproducción), incremente el valor del índice en uno para moverse al siguiente video y regresar true para indicar que la operación tuvo éxito. Si no tuvo éxito, regrese false.
El listado 7 muestra la función previous(). Si el índice currently_playing es mayor a cero (eso es, el usuario está viendo cualquier video a excepción del primer video en la lista de reproducción), disminuya el índice en uno y regrese true para indicar que la operación tuvo éxito; de lo contrario utilice false.
Listado 7. La función previous()
1
previous: function() { var retVal = false; if(this.currently_playing &gt; 0) { retVal = true; this.currently_playing--; } return retVal; }
En la función getCurrentlyPlaying(), utilice la ID de video del índice que se esté reproduciendo actualmente en el arreglo de registros.
1
getCurrentlyPlaying: function() { return this.entries[this.currently_playing].video_id; }
El listado 8 muestra la función setCurrentlyPlaying(). Una vez que obtenga la video_id de la lista de reproducción actual, establezca currently_playing al índice del elemento en el arreglo entries array con ese valor.
Listado 8. La función setCurrentlyPlaying()
1
setCurrentlyPlaying: function(video_id) { for(var index = 0; index &lt; this.entries.length; index++) { if (this.entries[index].video_id === video_id) { this.currently_playing = index; break; } } }
En la función randomize(), invierta el valor del atributorandomizer— de verdadero a falso y viceversa — e introduzca el nuevo valor.
1
randomize: function() { this.randomizer = !(this.randomizer); return this.randomizer; }
La función isRandomized() da el valor del atributo randomizer de la lista de reproducción — eso es, si la lista de reproducción esta en reproducción aleatoria:
1
isRandomized: function() { return this.randomizer; }

Use la funcionalidad agregada

Ahora agregue funciones para usar la funcionalidad agregada del objeto JavaScript.
Primero, agregue la función del asistente para arreglar los controles de una lista de reproducción. Si el reproductor está en reproducción aleatoria:
  • Se resalta el icono "random".
  • Siempre se deshabilita el icono "previous" porque ya no se está grabando el video reproducido anteriormente en ninguna parte.
  • Siempre está habilitado el icono "next" porque la lista de reproducción nunca puede reproducir el video "last" de manera aleatoria. (Piénselo: Cuando su reproductor MP3 esté en aleatorio ¿dejará de reproducirse alguna vez?)
Si la lista de reproducción no está en reproducción aleatoria:
  • El icono "previous" sólo si se está reproduciendo el primer registro en la lista de reproducción.
  • Sólo se deshabilita el icono "next" si se está reproduciendo el último registro en la lista de reproducción.
  • Se deshabilita el icono "random" hasta volver a hacer un nuevo clic.
El listado 9 muestra la función del asistente.
Listado 9. La función del asistente para arreglar los controles de la lista de reproducción
1
function arrangePlayerControls(player_id) { var playListPlayer = $('#' + player_id + 'playListPlayer'); if(window[player_id].isRandomized()) { $('#' + player_id + 'Backward').addClass('disabled'); $('#' + player_id + 'Forward').removeClass('disabled'); $('#' + player_id + 'Random').addClass('randomizeActive'); } else { $('#' + player_id + 'Random').removeClass('randomizeActive'); var playListEntries = $('#' + player_id + 'playListEntries'); if(playListEntries.children(":first").hasClass('nowPlaying')) { $('#' + player_id + 'Backward').addClass('disabled'); } else { $('#' + player_id + 'Backward').removeClass('disabled'); } if(playListEntries.children(":last").hasClass('nowPlaying')) { $('#' + player_id + 'Forward').addClass('disabled'); } else { $('#' + player_id + 'Forward').removeClass('disabled'); } } }
Después, agregue una función para cargar un video al reproductor para una ID de lista de reproducción dada y el índice de tiempo para empezarlo. Recuerde quitar la clase nowPlaying del div para el video que se está reproduciendo actualmente y agregarlo al div para el nuevo video. Después llame la función del asistente en el Listado 9 para arreglar los iconos de la lista de reproducción. El Listado 10 muestra una función de cargado de video.
Listado 10. Función para cargar un video al reproductor
1
function loadVideoForPlayer(currently_playing_video_id, player_id, time) { time = time || 0; var video_id = window[player_id].getCurrentlyPlaying(); $('#' + currently_playing_video_id).removeClass('nowPlaying') $('#' + video_id).addClass('nowPlaying'); $('#' + player_id + 'playListEntries').scrollTop($('#' + video_id).index() * 80); document.getElementById(player_id).loadVideoById(video_id, time, "large"); arrangePlayerControls(player_id); }
Finalmente, agregue una función para cargar el siguiente video en una lista de reproducción dada, pero sólo si hay otro video en la lista de reproducción (eso es, no está en reproducción aleatoria y el video actual no es el último video).
Listado 11. Función para cargar el video si existe el siguiente video
1
function loadNextVideo(player_id) { var currently_playing_video_id = window[player_id].getCurrentlyPlaying(); if(window[player_id].next()) { loadVideoForPlayer(currently_playing_video_id, player_id); } }
Esta función es similar a la función anónima que declaró en onYouTubePlayerReady() (consulte el Listado 4), así que refactorice el bloque case 0 en onYouTubePlayerReady() para mejor utilizar loadNextVideo().
Puede haberse dado cuenta de que agregué los tiempos de los goles en cada video en la lista de reproducción. Con estas funciones, puede usar los tiempos de los goles como zonas de vínculos para saltar directamente al gol en el video, en lugar de necesitar ver absolutamente todo. En el bucle de $.each() en addPlaylistToElement(), guarde el valor de la nota para cada objeto playlistItem en una variable local, después use la función JavaScript match() para obtener un arreglo de horas a partir de la nota, usando la expresión regular /[0-9]*:[0-5][0-9]/g para encontrar cada tiempo. Después se podrá hacer un bucle en este arreglo y reemplazar el valor de cada tiempo en la variable con un vínculo para llamar la función cueThisVideo() con la ID del reproductor, el video que se reproducirá y el índice de tiempo donde empezará. Recuerde que la llamada del API de YouTube loadVideoById() tome el tiempo en segundos, así que divida el tiempo en un arreglo, usando dos puntos (:) en la cadena como delimitador. Multiplique el valor en el primer índice (los minutos) por 60 para convertirlo en segundos y después súmelo a los segundos en el índice de segundos para obtener el número total de segundos. Por ejemplo, 1:30 se convierte en un arreglo de [1, 30] (1 * 60) + 30 = 90 segundos. Finalmente, reemplace el tiempo en la nota con el nuevo vínculo. Al haberse procesado cada hora en la nota, guarde la cadena concluida como una nota para el registro en el arreglo entries, como se muestra en el Listado 12.
Listado 12. Reemplazando las horas en la nota con un vínculo
1
var note = val.contentDetails.note; var times = note.match(/[0-9]*:[0-5][0-9]/g); times.forEach(function(value, index, array) { var time = value.split(":"); var seconds = parseInt(time[0]) * 60; seconds += parseInt(time[1]); note = note.replace(value, "&lt;span class='timeLink' onclick='cueThisVideo(\"" + player_id + "\", \"" + video_id + "\", " + seconds + ");'&gt;" + value + "&lt;/span&gt;"); }); entry.note = note;
Todo lo que resta es revisitar la plantilla y agregar las llamadas a estas nuevas funciones. Quiere que el usuario sea capaz de crear una cola de video haciendo clic en el título o en la miniatura, hacer una cola en los videos previos o siguientes en la lista de reproducción y hacer que la lista de reproducción sea aleatoria con los botones relevantes en el panel de control. El Listado 13 muestra los cambios concluidos en la plantilla para usar la funcionalidad agregada.
Listado 13. Código refactorizado de la plantilla
1
&lt;div onclick="cueThisVideo('{{:~player_id}}', '{{:video_id}}');" class="playListEntryThumbnail"&gt; &lt;img src="{{:image_src}}"/&gt; &lt;/div&gt; &lt;div onclick="cueThisVideo('{{:~player_id}}', '{{:video_id}}');" class="playListEntryTitle"&gt; {{:title}} &lt;/div&gt; &lt;span id="{{:id}}Backward" class="playListControl disabled glyphicon glyphicon-backward" onclick="if(!$(this).hasClass('disabled'))    { loadPreviousVideo('{{:id}}') }"&gt; &lt;/span&gt; &lt;span id="{{:id}}Forward" class="playListControl glyphicon glyphicon-forward" onclick="if(!$(this).hasClass('disabled')) { loadNextVideo('{{:id}}') }"&gt; &lt;/span&gt; &lt;span id="{{:id}}Random" class="playListControl glyphicon glyphicon-random" onclick="window['{{:id}}'].randomize();arrangePlayerControls('{{:id}}');"&gt; &lt;/span&gt;
¡Ahora su reproductor a la medida está listo para el rock!

Conclusión

Este artículo ha demostrado la forma de usar el API de YouTube API y cierto JavaScript simple y la estilización para obtener una lista de reproducción de YouTube integrada con una funcionalidad equivalente a la lista de reproducción nativa en YouTube.com. Consulte el archivo README en mi página DevOps Services para ver el proyecto y para obtener algunas sugerencias para mejorar aún más el reproductor. Siéntase en la libertad de bifurcar el código del proyecto para implementar cualesquiera o todas esas sugerencias.

Recursos para Descargar

Temas relacionados


SHARE

Oscar perez

Arquitecto especialista en gestion de proyectos si necesitas desarrollar algun proyecto en Bogota contactame en el 3006825874 o visita mi pagina en www.arquitectobogota.tk

  • Image
  • Image
  • Image
  • Image
  • Image
    Blogger Comment
    Facebook Comment

0 comentarios:

Publicar un comentario