[IMP] website_slides: add Vimeo integration

This commit adds support for the Vimeo provider in the e-learning platform.

This includes managing Vimeo video links and showing the Vimeo embedded player
in the fullscreen and non-fullscreen viewers.

But also managing video events to:
- automatically set the slide as completed when the video reaches the last 30
  seconds
- go to the next slide when the video ends (if there is one)

Task-2510174

Part-of: odoo/odoo#71477
This commit is contained in:
Aurélien Warnon
2021-11-30 15:21:48 +00:00
parent 7b5427f6d6
commit 0ece0b3263
7 changed files with 308 additions and 22 deletions
+4 -1
View File
@@ -1083,7 +1083,7 @@ class WebsiteSlides(WebsiteProfile):
if not slide.video_source_type:
slide.unlink()
return {'error': _('Please enter valid YouTube or Google Drive Link')}
return {'error': _('Please enter valid YouTube, Vimeo or Google Drive Link')}
if slide.video_source_type == 'youtube':
identical_video = existing_videos.filtered(
@@ -1091,6 +1091,9 @@ class WebsiteSlides(WebsiteProfile):
elif slide.video_source_type == 'google_drive':
identical_video = existing_videos.filtered(
lambda existing_video: slide.google_drive_id == existing_video.google_drive_id)
elif slide.video_source_type == 'vimeo':
identical_video = existing_videos.filtered(
lambda existing_video: slide.vimeo_id == existing_video.vimeo_id)
if identical_video:
identical_video_name = identical_video[0].name
return {'error': _('This video already exists in this channel on the following content: %s', identical_video_name)}
+111 -3
View File
@@ -90,6 +90,7 @@ class Slide(models.Model):
YOUTUBE_VIDEO_ID_REGEX = r'^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*'
GOOGLE_DRIVE_DOCUMENT_ID_REGEX = r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)'
VIMEO_VIDEO_ID_REGEX = r'\/\/(player.)?vimeo.com\/(?:[a-z]*\/)*([0-9]{6,11})\/?([0-9a-z]{6,11})?[?]?.*'
# description
name = fields.Char('Title', required=True, translate=True)
@@ -158,7 +159,8 @@ class Slide(models.Model):
('doc', 'Document (Word, Google Doc, ...)'),
('slides', 'Slides (PowerPoint, Google Slides, ...)'),
('youtube_video', 'YouTube Video'),
('google_drive_video', 'Google Drive Video')],
('google_drive_video', 'Google Drive Video'),
('vimeo_video', 'Vimeo Video')],
string="Slide Type", compute='_compute_slide_type', store=True, readonly=False,
help="Subtype of the slide category, allows more precision on the actual file type / source type.")
document_google_url = fields.Char('Document Link', related='url', readonly=False,
@@ -167,12 +169,14 @@ class Slide(models.Model):
help="Used to filter file input to PDF only")
# content - videos
video_url = fields.Char('Video URL', related='url', readonly=False,
help="Link of the video (we support YouTube and Google Drive as sources)")
help="Link of the video (we support YouTube, Google Drive and Vimeo as sources)")
video_source_type = fields.Selection([
('youtube', 'YouTube'),
('google_drive', 'Google Drive')],
('google_drive', 'Google Drive'),
('vimeo', 'Vimeo')],
string='Video Source', compute="_compute_video_source_type")
youtube_id = fields.Char('Video YouTube ID', compute='_compute_youtube_id')
vimeo_id = fields.Char('Video Vimeo ID', compute='_compute_vimeo_id')
# website
website_id = fields.Many2one(related='channel_id.website_id', readonly=True)
date_published = fields.Datetime('Publish Date', readonly=True, tracking=False)
@@ -370,6 +374,8 @@ class Slide(models.Model):
slide.slide_type = 'youtube_video'
elif slide.slide_category == 'video' and slide.video_source_type == 'google_drive':
slide.slide_type = 'google_drive_video'
elif slide.slide_category == 'video' and slide.video_source_type == 'vimeo':
slide.slide_type = 'vimeo_video'
else:
slide.slide_type = False
@@ -404,6 +410,19 @@ class Slide(models.Model):
embed_code = Markup('<iframe src="//www.youtube-nocookie.com/embed/%s?%s" allowFullScreen="true" frameborder="0"></iframe>') % (slide.youtube_id, query_params)
elif slide.video_source_type == 'google_drive':
embed_code = Markup('<iframe src="//drive.google.com/file/d/%s/preview" allowFullScreen="true" frameborder="0"></iframe>') % (slide.google_drive_id)
elif slide.video_source_type == 'vimeo':
if '/' in slide.vimeo_id:
# in case of privacy 'with URL only', vimeo adds a token after the video ID
# the embed url needs to receive that token as a "h" parameter
[vimeo_id, vimeo_token] = slide.vimeo_id.split('/')
embed_code = Markup("""
<iframe src="https://player.vimeo.com/video/%s?h=%s&badge=0&amp;autopause=0&amp;player_id=0"
frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>""") % (
vimeo_id, vimeo_token)
else:
embed_code = Markup("""
<iframe src="https://player.vimeo.com/video/%s?badge=0&amp;autopause=0&amp;player_id=0"
frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>""") % (slide.vimeo_id)
elif slide.slide_category in ['infographic', 'document'] and slide.source_type == 'external' and slide.google_drive_id:
embed_code = Markup('<iframe src="//drive.google.com/file/d/%s/preview" allowFullScreen="true" frameborder="0"></iframe>') % (slide.google_drive_id)
elif slide.slide_category == 'document' and slide.source_type == 'local_file':
@@ -425,6 +444,9 @@ class Slide(models.Model):
video_source_type = 'youtube'
if slide.video_url and not video_source_type and re.match(self.GOOGLE_DRIVE_DOCUMENT_ID_REGEX, slide.video_url):
video_source_type = 'google_drive'
vimeo_match = re.search(self.VIMEO_VIDEO_ID_REGEX, slide.video_url) if slide.video_url else False
if not video_source_type and vimeo_match and len(vimeo_match.groups()) == 3:
video_source_type = 'vimeo'
slide.video_source_type = video_source_type
@@ -440,6 +462,23 @@ class Slide(models.Model):
else:
slide.youtube_id = False
@api.depends('video_url', 'video_source_type')
def _compute_vimeo_id(self):
for slide in self:
if slide.video_url and slide.video_source_type == 'vimeo':
match = re.search(self.VIMEO_VIDEO_ID_REGEX, slide.video_url)
if match and len(match.groups()) == 3:
if match.group(3):
# in case of privacy 'with URL only', vimeo adds a token after the video ID
# the share url is then 'vimeo_id/token'
# the token will be captured in the third group of the regex (if any)
slide.vimeo_id = '%s/%s' % (match.group(2), match.group(3))
else:
# regular video, we just capture the vimeo_id
slide.vimeo_id = match.group(2)
else:
slide.vimeo_id = False
@api.depends('url', 'document_google_url', 'image_google_url', 'video_url')
def _compute_google_drive_id(self):
""" Extracts the Google Drive ID from the url based on the slide category. """
@@ -868,6 +907,8 @@ class Slide(models.Model):
slide_metadata, error = self._fetch_youtube_metadata(image_url_only)
elif self.slide_category == 'video' and self.video_source_type == 'google_drive':
slide_metadata, error = self._fetch_google_drive_metadata(image_url_only)
elif self.slide_category == 'video' and self.video_source_type == 'vimeo':
slide_metadata, error = self._fetch_vimeo_metadata(image_url_only)
elif self.slide_category in ['document', 'infographic'] and self.source_type == 'external':
# external documents & google drive videos share the same method currently
slide_metadata, error = self._fetch_google_drive_metadata(image_url_only)
@@ -1082,6 +1123,73 @@ class Slide(models.Model):
return slide_metadata, None
def _fetch_vimeo_metadata(self, image_url_only=False):
""" Fetches video metadata from the Vimeo API.
See https://developer.vimeo.com/api/oembed/showcases for more information.
Returns a dict containing video metadata with the following keys (matching slide.slide fields):
- 'name' matching the video title
- 'description' matching the video description
- 'image_1920' binary data of the video thumbnail
OR 'image_url' containing an external link to the thumbnail when 'fetch_image' param is False
- 'completion_time' matching the video duration
:param image_url_only: if False, will return 'image_url' instead of binary data
Typically used when displaying a slide preview to the end user.
:return a tuple (values, error) containing the values of the slide and a potential error
(e.g: 'Video could not be found') """
self.ensure_one()
error_message = False
try:
response = requests.get(
'https://vimeo.com/api/oembed.json?%s' % urls.url_encode({'url': self.video_url}),
timeout=3
)
response.raise_for_status()
except requests.exceptions.HTTPError as e:
error_message = e.response.content
if e.response.status_code == 404:
return {}, _('Your video could not be found on Vimeo, please check the link and/or privacy settings')
except requests.exceptions.ConnectionError as e:
error_message = str(e)
if not error_message and 'application/json' in response.headers.get('content-type'):
response = response.json()
if response.get('error'):
error_message = response.get('error', {}).get('errors', [{}])[0].get('reason')
if not response:
error_message = _('Please enter a valid Vimeo video link')
if error_message:
_logger.warning('Could not fetch Vimeo metadata: %s', error_message)
return {}, error_message
vimeo_values = response
slide_metadata = {'slide_type': 'vimeo_video'}
if vimeo_values.get('title'):
slide_metadata['name'] = vimeo_values.get('title')
if vimeo_values.get('description'):
slide_metadata['description'] = vimeo_values.get('description')
if vimeo_values.get('duration'):
# seconds to hours conversion
slide_metadata['completion_time'] = vimeo_values.get('duration') / (60 * 60)
thumbnail_url = vimeo_values.get('thumbnail_url')
if thumbnail_url:
if image_url_only:
slide_metadata['image_url'] = thumbnail_url
else:
slide_metadata['image_1920'] = base64.b64encode(
requests.get(thumbnail_url, timeout=3).content
)
return slide_metadata, None
def _default_website_meta(self):
res = super(Slide, self)._default_website_meta()
res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name
@@ -1,9 +1,10 @@
/** @odoo-module **/
/* global YT */
/* global YT, Vimeo */
import publicWidget from 'web.public.widget';
import { qweb as QWeb, _t } from 'web.core';
import { Markup } from 'web.utils';
import config from 'web.config';
import session from 'web.session';
@@ -30,8 +31,8 @@
* its end, and `slide_completed` when the player is at 30 sec before the
* end of the video (30 sec before is considered as completed).
*/
var VideoPlayer = publicWidget.Widget.extend({
template: 'website.slides.fullscreen.video',
var VideoPlayerYouTube = publicWidget.Widget.extend({
template: 'website.slides.fullscreen.video.youtube',
youtubeUrl: 'https://www.youtube.com/iframe_api',
init: function (parent, slide) {
@@ -129,6 +130,98 @@
},
});
/**
* This widget is responsible of loading the Vimeo video.
*
* Similarly to the YouTube implementation, the widget will trigger an event `change_slide` when
* the video is at its end, and `slide_completed` when the player is at 30 sec before the end of
* the video (30 sec before is considered as completed).
*
* See https://developer.vimeo.com/player/sdk/reference for all the API documentation.
*/
var VideoPlayerVimeo = publicWidget.Widget.extend({
template: 'website.slides.fullscreen.video.vimeo',
vimeoScriptUrl: 'https://player.vimeo.com/api/player.js',
init: function (parent, slide) {
this.slide = slide;
return this._super.apply(this, arguments);
},
/**
* Loads the Vimeo JS API that allows interfacing with the iframe viewer.
* (We only load the API if not already loaded).
*
* @returns {Promise}
*/
willStart: function () {
var self = this;
var vimeoAPIPromise = new Promise(function (resolve, reject) {
if ($(document).find('script[src="' + self.vimeoScriptUrl + '"]').length === 0) {
$.ajax({
url: self.vimeoScriptUrl,
dataType: 'script',
success: function () {resolve();}
});
} else {
resolve();
}
});
return Promise.all([this._super.apply(this, arguments), vimeoAPIPromise]);
},
start: function () {
return this._super.apply(arguments).then(this._setupVideoPlayer.bind(this));
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Instantiate the Vimeo player and register the various events.
*/
_setupVideoPlayer: async function () {
this.player = new Vimeo.Player(this.$('iframe')[0]);
this.videoDuration = await this.player.getDuration();
this.player.on('timeupdate', this._onVideoTimeUpdate.bind(this));
this.player.on('ended', this._onVideoEnded.bind(this));
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* When the player triggers the 'ended' event, we go to the next slide if there is one.
*
* See https://developer.vimeo.com/player/sdk/reference#ended for more information
*/
_onVideoEnded: function () {
if (this.slide.hasNext) {
this.trigger_up('slide_go_next', this.slide);
}
},
/**
* Every time the video changes position, both while viewing and also when seeking manually,
* Vimeo triggers this handy 'timeupdate' event.
* We use it to set the slide as completed as soon as we reach the end (30 last seconds).
*
* See https://developer.vimeo.com/player/sdk/reference#timeupdate for more information
*
* @param {Object} eventData the 'timeupdate' event data
*/
_onVideoTimeUpdate: async function (eventData) {
if (eventData.seconds > (this.videoDuration - 30)) {
if (!this.slide.hasQuestion && !this.slide.completed){
this.trigger_up('slide_to_complete', this.slide);
}
}
}
});
/**
* This widget is responsible of navigation for one slide to another:
@@ -501,7 +594,7 @@
// compute hasNext slide
slideData.hasNext = index < slidesDataList.length-1;
// compute embed url
if (slideData.category === 'video') {
if (slideData.category === 'video' && slideData.videoSourceType !== 'vimeo') {
slideData.embedCode = $(slideData.embedCode).attr('src') || ""; // embedCode contains an iframe tag, where src attribute is the url (youtube or embed document from odoo)
var separator = slideData.embedCode.indexOf("?") !== -1 ? "&" : "?";
var scheme = slideData.embedCode.indexOf('//') === 0 ? 'https:' : '';
@@ -510,6 +603,8 @@
params.autoplay = 1;
}
slideData.embedUrl = slideData.embedCode ? scheme + slideData.embedCode + separator + $.param(params) : "";
} else if (slideData.category === 'video' && slideData.videoSourceType === 'vimeo') {
slideData.embedCode = Markup(slideData.embedCode);
} else if (slideData.category === 'infographic') {
slideData.embedUrl = _.str.sprintf('/web/image/slide.slide/%s/image_1024', slideData.id);
} else if (slideData.category === 'document') {
@@ -573,9 +668,14 @@
// render slide content
if (_.contains(['document', 'infographic'], slide.category)) {
$content.html(QWeb.render('website.slides.fullscreen.content', {widget: this}));
} else if (slide.category === 'video') {
this.videoPlayer = new VideoPlayer(this, slide);
} else if (slide.category === 'video' && slide.videoSourceType === 'youtube') {
this.videoPlayer = new VideoPlayerYouTube(this, slide);
return this.videoPlayer.appendTo($content);
} else if (slide.category === 'video' && slide.videoSourceType === 'vimeo') {
this.videoPlayer = new VideoPlayerVimeo(this, slide);
return this.videoPlayer.appendTo($content);
} else if (slide.category === 'video' && slide.videoSourceType === 'google_drive') {
$content.html(QWeb.render('website.slides.fullscreen.video.google_drive', {widget: this}));
} else if (slide.category === 'webpage'){
var $wpContainer = $('<div>').addClass('o_wslide_fs_webpage_content bg-white block w-100 overflow-auto');
$(slide.htmlContent).appendTo($wpContainer);
@@ -13,10 +13,22 @@
</t>
</t>
<t t-name="website.slides.fullscreen.video">
<t t-name="website.slides.fullscreen.video.google_drive">
<div class="player embed-responsive embed-responsive-16by9 embed-responsive-item h-100">
<iframe t-att-src="widget.get('slide').embedUrl" allowFullScreen="true" frameborder="0" autoplay="1" allow="autoplay"></iframe>
</div>
</t>
<t t-name="website.slides.fullscreen.video.youtube">
<div class="player embed-responsive embed-responsive-16by9 embed-responsive-item h-100">
<iframe t-att-id="'youtube-player' + widget.slide.id" t-att-src="widget.slide.embedUrl" allowFullScreen="true" frameborder="0" enablejsapi="1" autoplay="1" allow="autoplay"></iframe>
</div>
</t>
<t t-name="website.slides.fullscreen.video.vimeo">
<div class="player embed-responsive embed-responsive-16by9 embed-responsive-item h-100">
<t t-out="widget.slide.embedCode"/>
</div>
</t>
</templates>
@@ -239,7 +239,7 @@
<div id="o_wslides_js_slide_upload_left_column" class="col-md-6">
<div class="form-group">
<label for="video_url" class="col-form-label">Video Link</label>
<input id="video_url" name="video_url" class="form-control" placeholder="Youtube or Google Drive Link" required="required"/>
<input id="video_url" name="video_url" class="form-control" placeholder="Youtube, Vimeo or Google Drive Link" required="required"/>
</div>
<canvas id="data_canvas" class="d-none"></canvas>
<t t-call="website.slide.upload.modal.common"/>
@@ -253,6 +253,20 @@
<div>What does <strong>unlisted</strong> means? The YouTube "unlisted" means it is a video which can be viewed only by the users with the link to it. Your video will never come up in the search results nor on your channel.</div>
<div><a href="https://support.google.com/youtube/answer/157177" target="_blank" >Change video privacy settings</a></div>
<br/>
<div class="h6">On Vimeo</div>
<div>
<span>First, upload your videos on Vimeo and mark them as <strong>Private</strong>. This way, they will be secured.</span><br/>
<span>What does <strong>Private</strong> mean? The Vimeo "Private" privacy setting means it is a video which can be viewed only by the users with the link to it.
Your video will never come up in the search results nor on your channel.</span><br/>
<span><a href="https://vimeo.zendesk.com/hc/en-us/articles/224819527-Changing-the-privacy-settings-of-your-videos" target="_blank" >Change video privacy settings</a></span><br/><br/>
<span>The video link to input here can be obtained by using the 'share' button in the Vimeo interface.</span><br/>
<span>It should look similar to</span>
<span class="font-italic">https://vimeo.com/558907333/30da9ff3d8</span>
<span>for 'Private' videos and similar to</span>
<span class="font-italic">https://vimeo.com/558907555</span>
<span>for public ones.</span>
</div>
<br/>
<div class="h6">On Google Drive</div>
<div>
<span>The Google Drive link can be obtained by using the 'share' button in the Google interface.</span><br/>
+58 -10
View File
@@ -168,7 +168,7 @@ class TestSequencing(slides_common.SlidesCase):
class TestFromURL(slides_common.SlidesCase):
def test_video_urls(self):
def test_video_youtube(self):
youtube_urls = {
'W0JQcpGLSFw': [
'https://youtu.be/W0JQcpGLSFw',
@@ -203,12 +203,60 @@ class TestFromURL(slides_common.SlidesCase):
self.assertEqual('youtube', slide.video_source_type)
self.assertEqual(youtube_id, slide.youtube_id)
# test URL from Google Drive when user hits the "share" button (main use case)
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': 'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing',
'slide_category': 'video'
})
self.assertEqual('google_drive', slide.video_source_type)
self.assertEqual('1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR', slide.google_drive_id)
def test_video_google_drive(self):
google_drive_urls = {
'1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR': [
'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR/view?usp=sharing',
'https://drive.google.com/file/d/1qU5nHVNbz_r84P_IS5kDzoCuC1h5ZAZR',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various Google Drive URL formats
for google_drive_id, urls in google_drive_urls.items():
for url in urls:
with self.subTest(url=url, id=google_drive_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('google_drive', slide.video_source_type)
self.assertEqual(google_drive_id, slide.google_drive_id)
def test_video_vimeo(self):
vimeo_urls = {
# regular URL from Vimeo
'545859999': [
'https://vimeo.com/545859999',
'https://vimeo.com/545859999?autoplay=1',
],
# test channel URL from Vimeo
'551979139': [
'https://vimeo.com/channels/staffpicks/551979139',
'https://vimeo.com/channels/staffpicks/551979139?autoplay=1',
],
# test URL from Vimeo with setting 'with URL only'
# we need to store both the ID and the token, see '_compute_embed_code' method for details
'545859999/94dd03ddb0': [
'https://vimeo.com/545859999/94dd03ddb0',
'https://vimeo.com/545859999/94dd03ddb0?autoplay=1',
],
}
Slide = self.env['slide.slide'].with_context(website_slides_skip_fetch_metadata=True)
# test various Vimeo URL formats
for vimeo_id, urls in vimeo_urls.items():
for url in urls:
with self.subTest(url=url, id=vimeo_id):
slide = Slide.create({
'name': 'dummy',
'channel_id': self.channel.id,
'url': url,
'slide_category': 'video'
})
self.assertEqual('vimeo', slide.video_source_type)
self.assertEqual(vimeo_id, slide.vimeo_id)
@@ -812,6 +812,7 @@
<i t-if="slide.slide_type == 'image'" t-att-class="'fa fa-file-picture-o %s' % icon_class"></i>
<i t-if="slide.slide_type == 'youtube_video'" t-att-class="'fa fa-youtube-play %s' % icon_class"></i>
<i t-if="slide.slide_type == 'google_drive_video'" t-att-class="'fa fa-play-circle %s' % icon_class"></i>
<i t-if="slide.slide_type == 'vimeo_video'" t-att-class="'fa fa-vimeo %s' % icon_class"></i>
<i t-if="slide.slide_type == 'webpage'" t-att-class="'fa fa-file-text %s' % icon_class"></i>
<i t-if="slide.slide_type == 'quiz'" t-att-class="'fa fa-question-circle %s' % icon_class"></i>
<i t-if="not slide.slide_type" t-att-class="'fa fa-file-o %s' % icon_class"></i>