Correctly implement p2p-media-loader

pull/1625/head
Chocobozzz 2019-01-24 10:16:30 +01:00 committed by Chocobozzz
parent 2adfc7ea9a
commit 3b6f205c34
12 changed files with 241 additions and 61 deletions

View File

@ -87,6 +87,7 @@
"@ngx-translate/i18n-polyfill": "^1.0.0",
"@streamroot/videojs-hlsjs-plugin": "^1.0.7",
"@types/core-js": "^2.5.0",
"@types/hls.js": "^0.12.0",
"@types/jasmine": "^2.8.7",
"@types/jasminewd2": "^2.0.3",
"@types/jest": "^23.3.1",
@ -110,6 +111,7 @@
"extract-text-webpack-plugin": "4.0.0-beta.0",
"file-loader": "^2.0.0",
"focus-visible": "^4.1.5",
"hls.js": "^0.12.2",
"html-loader": "^0.5.5",
"html-webpack-plugin": "^3.2.0",
"https-browserify": "^1.0.0",

View File

@ -1,25 +1,45 @@
// FIXME: something weird with our path definition in tsconfig and typings
// @ts-ignore
import * as videojs from 'video.js'
import { P2PMediaLoaderPluginOptions, VideoJSComponentInterface } from './peertube-videojs-typings'
import { P2PMediaLoaderPluginOptions, PlayerNetworkInfo, VideoJSComponentInterface } from './peertube-videojs-typings'
// videojs-hlsjs-plugin needs videojs in window
window['videojs'] = videojs
import '@streamroot/videojs-hlsjs-plugin'
import { initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
// import { Events } from '../p2p-media-loader/p2p-media-loader-core/lib'
import { Engine, initVideoJsContribHlsJsPlayer } from 'p2p-media-loader-hlsjs'
import * as Hls from 'hls.js'
import { Events } from 'p2p-media-loader-core'
const Plugin: VideoJSComponentInterface = videojs.getPlugin('plugin')
class P2pMediaLoaderPlugin extends Plugin {
private readonly CONSTANTS = {
INFO_SCHEDULER: 1000 // Don't change this
}
private hlsjs: Hls
private p2pEngine: Engine
private statsP2PBytes = {
pendingDownload: [] as number[],
pendingUpload: [] as number[],
numPeers: 0,
totalDownload: 0,
totalUpload: 0
}
private networkInfoInterval: any
constructor (player: videojs.Player, options: P2PMediaLoaderPluginOptions) {
super(player, options)
initVideoJsContribHlsJsPlayer(player)
videojs.Html5Hlsjs.addHook('beforeinitialize', (videojsPlayer: any, hlsjs: Hls) => {
this.hlsjs = hlsjs
console.log(options)
this.initialize()
})
initVideoJsContribHlsJsPlayer(player)
player.src({
type: options.type,
@ -27,6 +47,56 @@ class P2pMediaLoaderPlugin extends Plugin {
})
}
dispose () {
clearInterval(this.networkInfoInterval)
}
private initialize () {
this.p2pEngine = this.player.tech_.options_.hlsjsConfig.loader.getEngine()
this.hlsjs.on(Hls.Events.LEVEL_SWITCHING, (_, data: Hls.levelSwitchingData) => {
this.trigger('resolutionChange', { auto: this.hlsjs.autoLevelEnabled, resolutionId: data.height })
})
this.runStats()
}
private runStats () {
this.p2pEngine.on(Events.PieceBytesDownloaded, (method: string, size: number) => {
if (method === 'p2p') {
this.statsP2PBytes.pendingDownload.push(size)
this.statsP2PBytes.totalDownload += size
}
})
this.p2pEngine.on(Events.PieceBytesUploaded, (method: string, size: number) => {
if (method === 'p2p') {
this.statsP2PBytes.pendingUpload.push(size)
this.statsP2PBytes.totalUpload += size
}
})
this.p2pEngine.on(Events.PeerConnect, () => this.statsP2PBytes.numPeers++)
this.p2pEngine.on(Events.PeerClose, () => this.statsP2PBytes.numPeers--)
this.networkInfoInterval = setInterval(() => {
let downloadSpeed = this.statsP2PBytes.pendingDownload.reduce((a: number, b: number) => a + b, 0)
let uploadSpeed = this.statsP2PBytes.pendingUpload.reduce((a: number, b: number) => a + b, 0)
this.statsP2PBytes.pendingDownload = []
this.statsP2PBytes.pendingUpload = []
return this.player.trigger('p2pInfo', {
p2p: {
downloadSpeed,
uploadSpeed,
numPeers: this.statsP2PBytes.numPeers,
downloaded: this.statsP2PBytes.totalDownload,
uploaded: this.statsP2PBytes.totalUpload
}
} as PlayerNetworkInfo)
}, this.CONSTANTS.INFO_SCHEDULER)
}
}
videojs.registerPlugin('p2pMediaLoader', P2pMediaLoaderPlugin)

View File

@ -24,17 +24,17 @@ videojsUntyped.getComponent('CaptionsButton').prototype.controlText_ = 'Subtitle
// We just want to display 'Off' instead of 'captions off', keep a space so the variable == true (hacky I know)
videojsUntyped.getComponent('CaptionsButton').prototype.label_ = ' '
type PlayerMode = 'webtorrent' | 'p2p-media-loader'
export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
type WebtorrentOptions = {
export type WebtorrentOptions = {
videoFiles: VideoFile[]
}
type P2PMediaLoaderOptions = {
export type P2PMediaLoaderOptions = {
playlistUrl: string
}
type CommonOptions = {
export type CommonOptions = {
playerElement: HTMLVideoElement
autoplay: boolean
@ -137,6 +137,7 @@ export class PeertubePlayerManager {
const commonOptions = options.common
const webtorrentOptions = options.webtorrent
const p2pMediaLoaderOptions = options.p2pMediaLoader
let html5 = {}
const plugins: VideoJSPluginOptions = {
peertube: {
@ -171,6 +172,7 @@ export class PeertubePlayerManager {
}
Object.assign(plugins, { p2pMediaLoader, streamrootHls })
html5 = streamrootHls.html5
}
if (webtorrentOptions) {
@ -184,6 +186,8 @@ export class PeertubePlayerManager {
}
const videojsOptions = {
html5,
// We don't use text track settings for now
textTrackSettings: false,
controls: commonOptions.controls !== undefined ? commonOptions.controls : true,

View File

@ -2,7 +2,14 @@
// @ts-ignore
import * as videojs from 'video.js'
import './videojs-components/settings-menu-button'
import { PeerTubePluginOptions, UserWatching, VideoJSCaption, VideoJSComponentInterface, videojsUntyped } from './peertube-videojs-typings'
import {
PeerTubePluginOptions,
ResolutionUpdateData,
UserWatching,
VideoJSCaption,
VideoJSComponentInterface,
videojsUntyped
} from './peertube-videojs-typings'
import { isMobile, timeToInt } from './utils'
import {
getStoredLastSubtitle,
@ -30,6 +37,7 @@ class PeerTubePlugin extends Plugin {
private videoViewInterval: any
private userWatchingVideoInterval: any
private qualityObservationTimer: any
private lastResolutionChange: ResolutionUpdateData
constructor (player: videojs.Player, options: PeerTubePluginOptions) {
super(player, options)
@ -44,6 +52,22 @@ class PeerTubePlugin extends Plugin {
this.player.ready(() => {
const playerOptions = this.player.options_
if (this.player.webtorrent) {
this.player.webtorrent().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d))
this.player.webtorrent().on('autoResolutionChange', (_: any, d: any) => this.trigger('autoResolutionChange', d))
}
if (this.player.p2pMediaLoader) {
this.player.p2pMediaLoader().on('resolutionChange', (_: any, d: any) => this.handleResolutionChange(d))
}
this.player.tech_.on('loadedqualitydata', () => {
setTimeout(() => {
// Replay a resolution change, now we loaded all quality data
if (this.lastResolutionChange) this.handleResolutionChange(this.lastResolutionChange)
}, 0)
})
const volume = getStoredVolume()
if (volume !== undefined) this.player.volume(volume)
@ -158,6 +182,21 @@ class PeerTubePlugin extends Plugin {
return fetch(url, { method: 'PUT', body, headers })
}
private handleResolutionChange (data: ResolutionUpdateData) {
this.lastResolutionChange = data
const qualityLevels = this.player.qualityLevels()
for (let i = 0; i < qualityLevels.length; i++) {
if (qualityLevels[i].height === data.resolutionId) {
data.id = qualityLevels[i].id
break
}
}
this.trigger('resolutionChange', data)
}
private alterInactivity () {
let saveInactivityTimeout: number

View File

@ -83,13 +83,25 @@ type LoadedQualityData = {
type ResolutionUpdateData = {
auto: boolean,
resolutionId: number
id?: number
}
type AutoResolutionUpdateData = {
possible: boolean
}
type PlayerNetworkInfo = {
p2p: {
downloadSpeed: number
uploadSpeed: number
downloaded: number
uploaded: number
numPeers: number
}
}
export {
PlayerNetworkInfo,
ResolutionUpdateData,
AutoResolutionUpdateData,
VideoJSComponentInterface,

View File

@ -1,4 +1,4 @@
import { VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
import { PlayerNetworkInfo, VideoJSComponentInterface, videojsUntyped } from '../peertube-videojs-typings'
import { bytes } from '../utils'
const Button: VideoJSComponentInterface = videojsUntyped.getComponent('Button')
@ -65,7 +65,7 @@ class P2pInfoButton extends Button {
subDivHttp.appendChild(subDivHttpText)
div.appendChild(subDivHttp)
this.player_.on('p2pInfo', (event: any, data: any) => {
this.player_.on('p2pInfo', (event: any, data: PlayerNetworkInfo) => {
// We are in HTTP fallback
if (!data) {
subDivHttp.className = 'vjs-peertube-displayed'
@ -74,11 +74,13 @@ class P2pInfoButton extends Button {
return
}
const downloadSpeed = bytes(data.downloadSpeed)
const uploadSpeed = bytes(data.uploadSpeed)
const totalDownloaded = bytes(data.downloaded)
const totalUploaded = bytes(data.uploaded)
const numPeers = data.numPeers
const p2pStats = data.p2p
const downloadSpeed = bytes(p2pStats.downloadSpeed)
const uploadSpeed = bytes(p2pStats.uploadSpeed)
const totalDownloaded = bytes(p2pStats.downloaded)
const totalUploaded = bytes(p2pStats.uploaded)
const numPeers = p2pStats.numPeers
subDivWebtorrent.title = this.player_.localize('Total downloaded: ') + totalDownloaded.join(' ') + '\n' +
this.player_.localize('Total uploaded: ' + totalUploaded.join(' '))

View File

@ -14,11 +14,9 @@ class ResolutionMenuButton extends MenuButton {
super(player, options)
this.player = player
player.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data))
player.tech_.on('loadedqualitydata', (e: any, data: any) => this.buildQualities(data))
if (player.webtorrent) {
player.webtorrent().on('videoFileUpdate', () => setTimeout(() => this.trigger('updateLabel'), 0))
}
player.peertube().on('resolutionChange', () => setTimeout(() => this.trigger('updateLabel'), 0))
}
createEl () {
@ -49,11 +47,32 @@ class ResolutionMenuButton extends MenuButton {
return 'vjs-resolution-control ' + super.buildWrapperCSSClass()
}
private addClickListener (component: any) {
component.on('click', () => {
let children = this.menu.children()
for (const child of children) {
if (component !== child) {
child.selected(false)
}
}
})
}
private buildQualities (data: LoadedQualityData) {
// The automatic resolution item will need other labels
const labels: { [ id: number ]: string } = {}
data.qualityData.video.sort((a, b) => {
if (a.id > b.id) return -1
if (a.id === b.id) return 0
return 1
})
for (const d of data.qualityData.video) {
// Skip auto resolution, we'll add it ourselves
if (d.id === -1) continue
this.menu.addChild(new ResolutionMenuItem(
this.player_,
{
@ -77,6 +96,12 @@ class ResolutionMenuButton extends MenuButton {
selected: true // By default, in auto mode
}
))
for (const m of this.menu.children()) {
this.addClickListener(m)
}
this.trigger('menuChanged')
}
}
ResolutionMenuButton.prototype.controlText_ = 'Quality'

View File

@ -28,16 +28,12 @@ class ResolutionMenuItem extends MenuItem {
this.id = options.id
this.callback = options.callback
if (player.webtorrent) {
player.webtorrent().on('videoFileUpdate', (_: any, data: ResolutionUpdateData) => this.updateSelection(data))
player.peertube().on('resolutionChange', (_: any, data: ResolutionUpdateData) => this.updateSelection(data))
// We only want to disable the "Auto" item
if (this.id === -1) {
player.webtorrent().on('autoResolutionUpdate', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data))
}
// We only want to disable the "Auto" item
if (this.id === -1) {
player.peertube().on('autoResolutionChange', (_: any, data: AutoResolutionUpdateData) => this.updateAutoResolution(data))
}
// TODO: update on HLS change
}
handleClick (event: any) {
@ -46,12 +42,12 @@ class ResolutionMenuItem extends MenuItem {
super.handleClick(event)
this.callback(this.id)
this.callback(this.id, 'video')
}
updateSelection (data: ResolutionUpdateData) {
if (this.id === -1) {
this.currentResolutionLabel = this.labels[data.resolutionId]
this.currentResolutionLabel = this.labels[data.id]
}
// Automatic resolution only
@ -60,7 +56,7 @@ class ResolutionMenuItem extends MenuItem {
return
}
this.selected(this.id === data.resolutionId)
this.selected(this.id === data.id)
}
updateAutoResolution (data: AutoResolutionUpdateData) {

View File

@ -223,6 +223,11 @@ class SettingsMenuItem extends MenuItem {
this.subMenu.on('updateLabel', () => {
this.update()
})
this.subMenu.on('menuChanged', () => {
this.bindClickEvents()
this.setSize()
this.update()
})
this.settingsSubMenuTitleEl_.innerHTML = this.player_.localize(this.subMenu.controlText_)
this.settingsSubMenuEl_.appendChild(this.subMenu.menu.el_)
@ -230,7 +235,7 @@ class SettingsMenuItem extends MenuItem {
this.update()
this.createBackButton()
this.getSize()
this.setSize()
this.bindClickEvents()
// prefixed event listeners for CSS TransitionEnd
@ -292,8 +297,9 @@ class SettingsMenuItem extends MenuItem {
// save size of submenus on first init
// if number of submenu items change dynamically more logic will be needed
getSize () {
setSize () {
this.dialog.removeClass('vjs-hidden')
videojsUntyped.dom.removeClass(this.settingsSubMenuEl_, 'vjs-hidden')
this.size = this.settingsButton.getComponentSize(this.settingsSubMenuEl_)
this.setMargin()
this.dialog.addClass('vjs-hidden')

View File

@ -5,7 +5,7 @@ import * as videojs from 'video.js'
import * as WebTorrent from 'webtorrent'
import { VideoFile } from '../../../../shared/models/videos/video.model'
import { renderVideo } from './webtorrent/video-renderer'
import { LoadedQualityData, VideoJSComponentInterface, WebtorrentPluginOptions } from './peertube-videojs-typings'
import { LoadedQualityData, PlayerNetworkInfo, VideoJSComponentInterface, WebtorrentPluginOptions } from './peertube-videojs-typings'
import { videoFileMaxByResolution, videoFileMinByResolution } from './utils'
import { PeertubeChunkStore } from './webtorrent/peertube-chunk-store'
import {
@ -180,7 +180,7 @@ class WebTorrentPlugin extends Plugin {
})
this.changeQuality()
this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id })
this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.currentVideoFile.resolution.id })
}
updateResolution (resolutionId: number, delay = 0) {
@ -216,15 +216,15 @@ class WebTorrentPlugin extends Plugin {
enableAutoResolution () {
this.autoResolution = true
this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
}
disableAutoResolution (forbid = false) {
if (forbid === true) this.autoResolutionPossible = false
this.autoResolution = false
this.trigger('autoResolutionUpdate', { possible: this.autoResolutionPossible })
this.trigger('videoFileUpdate', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
this.trigger('autoResolutionChange', { possible: this.autoResolutionPossible })
this.trigger('resolutionChange', { auto: this.autoResolution, resolutionId: this.getCurrentResolutionId() })
}
getTorrent () {
@ -472,12 +472,14 @@ class WebTorrentPlugin extends Plugin {
if (this.webtorrent.downloadSpeed !== 0) this.downloadSpeeds.push(this.webtorrent.downloadSpeed)
return this.player.trigger('p2pInfo', {
downloadSpeed: this.torrent.downloadSpeed,
numPeers: this.torrent.numPeers,
uploadSpeed: this.torrent.uploadSpeed,
downloaded: this.torrent.downloaded,
uploaded: this.torrent.uploaded
})
p2p: {
downloadSpeed: this.torrent.downloadSpeed,
numPeers: this.torrent.numPeers,
uploadSpeed: this.torrent.uploadSpeed,
downloaded: this.torrent.downloaded,
uploaded: this.torrent.uploaded
}
} as PlayerNetworkInfo)
}, this.CONSTANTS.INFO_SCHEDULER)
}
@ -597,7 +599,7 @@ class WebTorrentPlugin extends Plugin {
video: qualityLevelsPayload
}
}
this.player.trigger('loadedqualitydata', payload)
this.player.tech_.trigger('loadedqualitydata', payload)
}
private buildQualityLabel (file: VideoFile) {

View File

@ -23,7 +23,7 @@ import { peertubeTranslate, ResultList, VideoDetails } from '../../../../shared'
import { PeerTubeResolution } from '../player/definitions'
import { VideoJSCaption } from '../../assets/player/peertube-videojs-typings'
import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model'
import { PeertubePlayerManager, PeertubePlayerManagerOptions } from '../../assets/player/peertube-player-manager'
import { PeertubePlayerManager, PeertubePlayerManagerOptions, PlayerMode } from '../../assets/player/peertube-player-manager'
/**
* Embed API exposes control of the embed player to the outside world via
@ -162,6 +162,7 @@ class PeerTubeEmbed {
subtitle: string
enableApi = false
startTime: number | string = 0
mode: PlayerMode
scope = 'peertube'
static async main () {
@ -255,6 +256,8 @@ class PeerTubeEmbed {
this.scope = this.getParamString(params, 'scope', this.scope)
this.subtitle = this.getParamString(params, 'subtitle')
this.startTime = this.getParamString(params, 'start')
this.mode = this.getParamToggle(params, 'p2p-media-loader') ? 'p2p-media-loader' : 'webtorrent'
} catch (err) {
console.error('Cannot get params from URL.', err)
}
@ -312,20 +315,26 @@ class PeerTubeEmbed {
serverUrl: window.location.origin,
language: navigator.language,
embedUrl: window.location.origin + videoInfo.embedPath
},
webtorrent: {
videoFiles: videoInfo.files
}
// p2pMediaLoader: {
// // playlistUrl: 'https://akamai-axtest.akamaized.net/routes/lapd-v1-acceptance/www_c4/Manifest.m3u8'
// // playlistUrl: 'https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8'
// playlistUrl: 'https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'
// }
}
this.player = await PeertubePlayerManager.initialize('webtorrent', options)
if (this.mode === 'p2p-media-loader') {
Object.assign(options, {
p2pMediaLoader: {
// playlistUrl: 'https://akamai-axtest.akamaized.net/routes/lapd-v1-acceptance/www_c4/Manifest.m3u8'
// playlistUrl: 'https://d2zihajmogu5jn.cloudfront.net/bipbop-advanced/bipbop_16x9_variant.m3u8'
playlistUrl: 'https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8'
}
})
} else {
Object.assign(options, {
webtorrent: {
videoFiles: videoInfo.files
}
})
}
this.player = await PeertubePlayerManager.initialize(this.mode, options)
this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))

View File

@ -411,6 +411,11 @@
resolved "https://registry.yarnpkg.com/@types/core-js/-/core-js-2.5.0.tgz#35cc282488de6f10af1d92902899a3b8ca3fbc47"
integrity sha512-qjkHL3wF0JMHMqgm/kmL8Pf8rIiqvueEiZ0g6NVTcBX1WN46GWDr+V5z+gsHUeL0n8TfAmXnYmF7ajsxmBp4PQ==
"@types/hls.js@^0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@types/hls.js/-/hls.js-0.12.0.tgz#33f73e542201a766fa56792cb81fe9f97d7097ed"
integrity sha512-hJ7eJAQVEazAANK4Ay0YbXlZF36SDy9c8kcHTF7//77ylgV6hV/JrlwhVmobsSacr5aZcbw5MbZ2bSHbS36eOQ==
"@types/jasmine@*":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-3.3.1.tgz#b6c4f356013364e98b583647c7b3b6de6fccd2cc"
@ -3300,7 +3305,7 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
eventemitter3@^3.0.0:
eventemitter3@3.1.0, eventemitter3@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163"
integrity sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA==
@ -4236,6 +4241,14 @@ he@1.2.x:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
hls.js@^0.12.2:
version "0.12.2"
resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-0.12.2.tgz#64a969a78cc25991ed5de19357b1dc3f178ac23b"
integrity sha512-lQBSXggw9OzEuaUllUBoSxPcf7neFgnEiDRfCdCNdIPtUeV7vXZ0OeASx6EWtZTBiqSSPigoOX1Y+AR5dA1Feg==
dependencies:
eventemitter3 "3.1.0"
url-toolkit "^2.1.6"
hmac-drbg@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -9976,7 +9989,7 @@ url-parse@^1.4.3:
querystringify "^2.0.0"
requires-port "^1.0.0"
url-toolkit@^2.1.1, url-toolkit@^2.1.3:
url-toolkit@^2.1.1, url-toolkit@^2.1.3, url-toolkit@^2.1.6:
version "2.1.6"
resolved "https://registry.yarnpkg.com/url-toolkit/-/url-toolkit-2.1.6.tgz#6d03246499e519aad224c44044a4ae20544154f2"
integrity sha512-UaZ2+50am4HwrV2crR/JAf63Q4VvPYphe63WGeoJxeu8gmOm0qxPt+KsukfakPNrX9aymGNEkkaoICwn+OuvBw==