YouTubeのフロントサイドを作ってみた話
世界的なアプリYouTube。Pythonを使って作られていることはPythonエンジニアならば大抵ご存知のことかと思いますが、実際に作ろうと考えた場合には中身の構造ってどうなってるか気になりますよね?アプリを開いたりサイトへとアクセスしたらずらずらと各ユーザーにおすすめの動画がピックアップされ、スクロールしていくとアイキャッチ画像から動画本編へと切り替わって動画が再生される仕様。ユーザーを飽きさせない機械学習だけでも十分すごいですけれども、ユーザーが気になった動画までスクロールしたとたん動画を再生して見せてくれる仕様ってのもどうなってるか気になりますよね?GitHubを探すとYouTubeのクローンはあったりしますけれども機械学習とかフロントサイドの再現まで丁寧にやってくれているものはなかったりします。今回はそのYouTubeのフロントサイドを可能な限り再現しましたのでその話を記事にしたいと思います。
1.バックエンド構築
まずはバックエンドから。といっても今回の記事の主題はフロントエンドですのでそこまでがっつりとは書きません。
##1-1.models.py
models.pyにてモデル構築から始めましょう。目的は動画を一覧表示してページをスクロールしていくとアイキャッチ画像から動画本編に切り替わるあの仕様を再現することですのでmodelの定義も必要最小限です。
from django.contrib.auth.models import User
from django.db import models
class Video(models.Model):
user = models.ForeignKey(User, verbose_name='ユーザー', on_delete=models.CASCADE)
title = models.CharField(verbose_name='タイトル', max_length=40)
content = models.TextField(verbose_name='動画説明文')
video = models.FileField(verbose_name='投稿動画')
image = models.ImageField(verbose_name='アイキャッチ画像')
views = models.IntegerField(default=0) #表示回数記録用
created_at = models.DateTimeField(verbose_name='投稿日時', auto_now_add=True)
動画を一覧表示するのが目的ですので今回はこれだけあれば十分です。本格的に再現するならいいね!用のクラスとかコメント用のクラス、通報用のクラスなんかも別途作成の必要があるわけですが、今回は割愛します。
##1-2.views.py
実際にアプリとして運用したりすることを考えたらforms.py等々関連ファイルをいじる必要がありますが、そういったコンテンツの作成は管理サイトでやることを前提にviews.pyとurls.pyの説明だけにとどめさせていただきます。views.pyはこうしておきます。機械学習とか組み込むのでしたらこれとはまた別の書き方になるんでしょうけれども一覧表示をするのが目的ならこれで十分です。
from django.views import generic
from .models import Video
class IndexView(generic.ListView):
model = Video
context_object_name = 'video_list'
template_name = 'index.html'
def get_queryset(self):
videos = Video.objects.all().order_by('-created_at')
return videos
##1-3.urls.py
urls.pyの編集もコンテンツを一覧表示するだけの必要最低限しか書きません。トップページにいきなり一覧表示しますのでこちらも書くコードはこれだけです。
from django.urls import path
from . import views
app_name = 'アプリ名'
urlpatterns =[
path('', views.IndexView.as_view(), name="index"),
]
バックエンドのコードは以上になります。
#2.フロントエンド構築
次に、本題となる箇所以外のページを作ります。作成するのは基礎部分となるbase.htmlとindex.html、サイト装飾用のmystyle.cssです。
##2-1.base.html
特筆することはありません。Djangoを触ったことのある方でしたら基本的にこう書くでしょうね。見栄えのためにBootStrapを読み込ませたりしてますが使うか使わないかはお任せします。
{% load static %}
<html lang='ja'>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<!-- Bootstrap core CSS -->
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet">
<title>{% block title %}{% endblock %}</title>
{% block head %}{% endblock %}
</head>
<body>
<div id="wrapper">
{% block header %}{% endblock %}
{% block contents %}{% endblock %}
<footer class="py-5 bg-black">
<div class="container">
<p class="m-0 text-center text-white small">Copyright © hogehoge 2021</p>
</div>
</footer>
<!-- Bootstrap core JavaScript -->
<script src="{% static 'vendor/jquery/jquery.min.js' %}"></script>
<script src="{% static 'vendor/bootstrap/js/bootstrap.bundle.min.js' %}"></script>
</div>
</body>
</html>
必要最低限の要素だけ追加しました。ナビゲーションバーをつけるか付けないかはお好みで。そもそもトップページにコンテンツを一覧表示するのが目的ですのでいらないでしょうけれども…。
##2-2.index.html
index.htmlも特に派手に飾り付けることはしません。コンテンツを一覧表示することだけを目的とします。
{% extends 'base.html' %}
{% load static %}
{% block title %}YouTubeのフロントサイドを再現します{% endblock %}
{% block contents %}
<div class="container">
<div class="row">
<div class="mt-2 w-100">
<div class="col-lg-8 col-md-10 mx-auto">
<h1>hogehoge</h1>
{% for items in video_list %}
<div class="post-preview">
<a href="#"> <!--動画詳細移動(省略)-->
<img class="float-right" src="{{ items.photo.url }}" width="240" height="130">
<video class="float-right is_hide" src="{{ items.video.url }}" width="240" height="130" controls muted autoplay playinline loop></video>
<p class="post-meta text-black">
投稿者:{{ items.user }}
</p>
<h4 class="post-subtitle text-black">
タイトル:{{ items.title }}
</h4>
<h4 class="post-subtitle text-black">
説明:{{ items.content|truncatechars:20 }}
</h4>
</a>
<p class="post-meta mb-1">{{ items.created_at }}</p>
<small class="text-info">
コメント={{items.number_of_comments}}<br>
表示回数={{items.views}}
</small>
</div>
<hr>
{% empty %}
<p>投稿がありません。</p>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
##2-3.mystyle.css
最低限の装飾を行います。リンクを黒字にし、ホバー時の下線を消去することだけ書いておきました。その他、.is_hideというクラスについてもこちらで定義していますが、詳細については後述します。
.text-black {
color: #000000 !important;
}
a:hover {
text-decoration: none;
}
.is_hide {
display: none;
}
#3.本題
とりあえず上記まででトップページに動画を一覧表示することはできるようになりました。あとは、リストアップされたコンテンツについてスクロール中はアイキャッチ画像を表示し、スクロールをやめたとたん動画が再生されるような仕様があればYouTubeのフロントサイドをかなり再現できそうですね。となると、出番はやはりJavaScriptです。あれこれやり方を考える過程でsetTimeoutやsetIntervalとスクロールイベントを組み合わせる手段なんかも試しましたが、それでやろうとするとスクロールをやめたとたんリストアップされた動画が一斉に再生されてしまうというもはや笑うしかない結末になりました。そんな壁に当たりつつもあれこれ手段を考えてたらあれ?これだったらスクロール地点に応じて発動するタイプの簡単なDOM操作で解決できるんじゃね?と思い立ち、それを軸にコードを書いてみることにしました。
その過程で行きついたのがこちらのサイト(https://techmemo.biz/javascript/vanilla-js-scroll-animation/)です。下にスクロールしていけばいくほど隠してあったコンテンツがふわっという動きとともに表示されるアニメーションについて紹介していました。
スクロールのトリガーをこのサイトを参考に作ったうえで、特定箇所までスクロールするたびにDOM操作を行うようにJavaScriptのコードを考えてみました。それが以下のコードになります。
let scrollElem = document.querySelectorAll('.js-trigger');
let scrollElem2 = document.querySelectorAll('.js-trigger2');
let scrollAnimation = function() {
for(let i = 0; i < scrollElem.length; i++) {
let trigger = 200;
if (window.innerHeight > scrollElem[i].getBoundingClientRect().top + trigger) {
if (scrollElem[i].getBoundingClientRect().top + trigger + 300 > window.innerHeight) {
scrollElem[i].classList.add('is_hide');
scrollElem2[i].classList.remove('is_hide');
} else {
scrollElem[i].classList.remove('is_hide');
scrollElem2[i].classList.add('is_hide');
}
}
}
}
window.addEventListener('load', scrollAnimation);
window.addEventListener('scroll', scrollAnimation);
このコードが意図することはトリガーとしてのクラスが設置された箇所までスクロールする度に要素の非表示を行うためのis_hideクラスを追加したり削除したりする処理を行うことによってアイキャッチ画像と動画とを切り替えて表示することです。それが目的だったらtoggle使った方がいいんじゃないかと考える方もいるかと思いますが、それでやってみたらスクロール中もスクロール終了後も動画と画像が同時に表示されたりめっちゃチカチカとちらついたりで見られたものじゃなかったわけです。ですので結局個別にトリガーを設定してある箇所をDOM操作することによりYouTubeのトップページっぽい何かを再現することにしました。早速上記のコードやCSSファイルをindex.htmlに書き加えたうえで修正してみましょう。すると、こうなります。
{% extends 'base.html' %}
{% load static %}
{% block title %}YouTubeのフロントサイドを再現します{% endblock %}
<!--CSS追加-->
{% block head %}
<link href="{% static 'mystyle.css' %}" rel="stylesheet">
{% endblock %}
{% block contents %}
<div class="container">
<div class="row">
<div class="mt-2 w-100">
<div class="col-lg-8 col-md-10 mx-auto">
<h1>hogehoge</h1>
{% for items in video_list %}
<div class="post-preview">
<a href="#"> <!--動画詳細移動(省略)-->
<img class="float-right js-trigger" src="{{ items.photo.url }}" width="240" height="130"> <!--クラス追加-->
<video class="float-right is_hide js-trigger" src="{{ items.video.url }}" width="240" height="130" controls muted autoplay playinline loop></video> <!--クラス追加-->
<p class="post-meta text-black">
投稿者:{{ items.user }}
</p>
<h4 class="post-subtitle text-black">
タイトル:{{ items.title }}
</h4>
<h4 class="post-subtitle text-black">
説明:{{ items.content|truncatechars:20 }}
</h4>
</a>
<p class="post-meta mb-1">{{ items.created_at }}</p>
<small class="text-info">
コメント={{items.number_of_comments}}<br>
表示回数={{items.views}}
</small>
</div>
<hr>
{% empty %}
<p>投稿がありません。</p>
{% endfor %}
</div>
</div>
</div>
</div>
<!--JavaScript追加(外部ファイルを作成して読み込ませるのでも可)-->
<script>
let scrollElem = document.querySelectorAll('.js-trigger');
let scrollElem2 = document.querySelectorAll('.js-trigger2');
let scrollAnimation = function() {
for(let i = 0; i < scrollElem.length; i++) {
let trigger = 200;
if (window.innerHeight > scrollElem[i].getBoundingClientRect().top + trigger) {
if (scrollElem[i].getBoundingClientRect().top + trigger + 300 > window.innerHeight) {
scrollElem[i].classList.add('is_hide');
scrollElem2[i].classList.remove('is_hide');
} else {
scrollElem[i].classList.remove('is_hide');
scrollElem2[i].classList.add('is_hide');
}
}
}
}
window.addEventListener('load', scrollAnimation);
window.addEventListener('scroll', scrollAnimation);
</script>
{% endblock %}
トリガーはjs-triggerクラスが設定してあるコンテンツの最上端から200px下にスクロールしたら発生するように設定してありますが、そこについては好みで設定しなおしてもらえればと思います。上のコードでの実行結果ですが、ターゲットとなる動画のアイキャッチ画像までスクロールするとアイキャッチ画像を隠して動画を表示するDOM操作が行われ、ターゲットから300px下にスクロールした時点で再度DOM操作が行われ、動画を隠してアイキャッチ画像を再表示するわけです。YouTubeの場合はスクロールを止めてからおよそ1秒の待ち時間の後で動画が再生される仕様になっているようですのでそこまで再現するにはまだ私の技術が足りないわけですが、とりあえず似通ったものを作ることには成功したかなあと満足しています。
もし参考にしてみたいと思う方がいましたらご自由にコピペしていってください。
以上、ここまでお読みいただきありがとうございました。
###追記:
JavaScript部分にsleep関数を追加することでより再現度を上げることに成功しました。もしよろしければこちらも参考にしていただければと思います。sleep関数についてはこちらのsetIntervalを使ったやり方をコピペさせていただきました。
// setIntervalを使う方法
function sleep(waitSec, callbackFunc) {
// 経過時間(秒)
var spanedSec = 0;
// 1秒間隔で無名関数を実行
var id = setInterval(function () {
spanedSec++;
// 経過時間 >= 待機時間の場合、待機終了。
if (spanedSec >= waitSec) {
// タイマー停止
clearInterval(id);
// 完了時、コールバック関数を実行
if (callbackFunc) callbackFunc();
}
}, 1000);
}
{% extends 'base.html' %}
{% load static %}
{% block title %}YouTubeのフロントサイドを再現します{% endblock %}
<!--CSS追加-->
{% block head %}
<link href="{% static 'mystyle.css' %}" rel="stylesheet">
{% endblock %}
{% block contents %}
<div class="container">
<div class="row">
<div class="mt-2 w-100">
<div class="col-lg-8 col-md-10 mx-auto">
<h1>hogehoge</h1>
{% for items in video_list %}
<div class="post-preview">
<a href="#"> <!--動画詳細移動(省略)-->
<img class="float-right js-trigger" src="{{ items.photo.url }}" width="240" height="130"> <!--クラス追加-->
<video class="float-right is_hide js-trigger" src="{{ items.video.url }}" width="240" height="130" controls muted autoplay playinline loop></video> <!--クラス追加-->
<p class="post-meta text-black">
投稿者:{{ items.user }}
</p>
<h4 class="post-subtitle text-black">
タイトル:{{ items.title }}
</h4>
<h4 class="post-subtitle text-black">
説明:{{ items.content|truncatechars:20 }}
</h4>
</a>
<p class="post-meta mb-1">{{ items.created_at }}</p>
<small class="text-info">
コメント={{items.number_of_comments}}<br>
表示回数={{items.views}}
</small>
</div>
<hr>
{% empty %}
<p>投稿がありません。</p>
{% endfor %}
</div>
</div>
</div>
</div>
<!--JavaScript追加(外部ファイルを作成して読み込ませるのでも可)-->
<script src='sleep.js'></script>
<script>
let scrollElem = document.querySelectorAll('.js-trigger');
let scrollElem2 = document.querySelectorAll('.js-trigger2');
let scrollAnimation = function() {
for(let i = 0; i < scrollElem.length; i++) {
let trigger = 200;
if (window.innerHeight > scrollElem[i].getBoundingClientRect().top + trigger) {
if (scrollElem[i].getBoundingClientRect().top + trigger + 300 > window.innerHeight) {
//処理をsleep関数で挟み込むことによって処理の開始を2秒遅らせる
sleep(2, function() {
scrollElem[i].classList.add('is_hide');
scrollElem2[i].classList.remove('is_hide');
});
} else {
scrollElem[i].classList.remove('is_hide');
scrollElem2[i].classList.add('is_hide');
}
}
}
}
window.addEventListener('load', scrollAnimation);
window.addEventListener('scroll', scrollAnimation);
</script>
{% endblock %}