Merge pull request #4713 from pixelfed/staging

Add WebP2P support for Video
This commit is contained in:
daniel 2023-10-23 03:38:32 -06:00 committed by GitHub
commit 42fb713092
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 2784 additions and 817 deletions

View file

@ -4,6 +4,7 @@
### Added
- Resilient Media Storage ([#4665](https://github.com/pixelfed/pixelfed/pull/4665)) ([fb1deb6](https://github.com/pixelfed/pixelfed/commit/fb1deb6))
- Video WebP2P ([#4713](https://github.com/pixelfed/pixelfed/pull/4713)) ([0405ef12](https://github.com/pixelfed/pixelfed/commit/0405ef12))
- Added user:2fa command to easily disable 2FA for given account ([c6408fd7](https://github.com/pixelfed/pixelfed/commit/c6408fd7))
- Added `avatar:storage-deep-clean` command to dispatch remote avatar storage cleanup jobs ([c37b7cde](https://github.com/pixelfed/pixelfed/commit/c37b7cde))
@ -34,6 +35,8 @@
- Update CreateAvatar job, add processing constraints and set `is_remote` attribute ([319ced40](https://github.com/pixelfed/pixelfed/commit/319ced40))
- Update RemoteStatusDelete and DecrementPostCount pipelines ([edbcf3ed](https://github.com/pixelfed/pixelfed/commit/edbcf3ed))
- Update lexer regex, fix mention regex and add more tests ([778e83d3](https://github.com/pixelfed/pixelfed/commit/778e83d3))
- Update StatusTransformer, generate autolink on request ([dfe2379b](https://github.com/pixelfed/pixelfed/commit/dfe2379b))
- Update ComposeModal component, fix multi filter bug and allow media re-ordering before upload/posting ([56e315f6](https://github.com/pixelfed/pixelfed/commit/56e315f6))
- ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.9 (2023-08-21)](https://github.com/pixelfed/pixelfed/compare/v0.11.8...v0.11.9)

View file

@ -10,8 +10,11 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
use App\Services\Media\MediaHlsService;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class MediaDeletePipeline implements ShouldQueue
class MediaDeletePipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@ -20,8 +23,34 @@ class MediaDeletePipeline implements ShouldQueue
public $timeout = 300;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
public $deleteWhenMissingModels = true;
/**
* The number of seconds after which the job's unique lock will be released.
*
* @var int
*/
public $uniqueFor = 3600;
/**
* Get the unique ID for the job.
*/
public function uniqueId(): string
{
return 'media:purge-job:id-' . $this->media->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("media:purge-job:id-{$this->media->id}"))->shared()->dontRelease()];
}
public function __construct(Media $media)
{
$this->media = $media;
@ -63,9 +92,17 @@ class MediaDeletePipeline implements ShouldQueue
$disk->delete($thumb);
}
if($media->hls_path != null) {
$files = MediaHlsService::allFiles($media);
if($files && count($files)) {
foreach($files as $file) {
$disk->delete($file);
}
}
}
$media->delete();
return 1;
}
}

View file

@ -0,0 +1,109 @@
<?php
namespace App\Jobs\VideoPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use FFMpeg\Format\Video\X264;
use FFMpeg;
use Cache;
use App\Services\MediaService;
use App\Services\StatusService;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class VideoHlsPipeline implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
public $deleteWhenMissingModels = true;
/**
* The number of seconds after which the job's unique lock will be released.
*
* @var int
*/
public $uniqueFor = 3600;
/**
* Get the unique ID for the job.
*/
public function uniqueId(): string
{
return 'media:video-hls:id-' . $this->media->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("media:video-hls:id-{$this->media->id}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*/
public function __construct($media)
{
$this->media = $media;
}
/**
* Execute the job.
*/
public function handle(): void
{
$depCheck = Cache::rememberForever('video-pipeline:hls:depcheck', function() {
$bin = config('laravel-ffmpeg.ffmpeg.binaries');
$output = shell_exec($bin . ' -version');
if($output && preg_match('/ffmpeg version ([^\s]+)/', $output, $matches)) {
$version = $matches[1];
return (version_compare($version, config('laravel-ffmpeg.min_hls_version')) >= 0) ? 'ok' : false;
} else {
return false;
}
});
if(!$depCheck || $depCheck !== 'ok') {
return;
}
$media = $this->media;
$bitrate = (new X264)->setKiloBitrate(config('media.hls.bitrate') ?? 1000);
$mp4 = $media->media_path;
$man = str_replace('.mp4', '.m3u8', $mp4);
FFMpeg::fromDisk('local')
->open($mp4)
->exportForHLS()
->setSegmentLength(16)
->setKeyFrameInterval(48)
->addFormat($bitrate)
->save($man);
$media->hls_path = $man;
$media->hls_transcoded_at = now();
$media->save();
MediaService::del($media->status_id);
usleep(50000);
StatusService::del($media->status_id);
return;
}
}

View file

@ -16,13 +16,46 @@ use App\Jobs\MediaPipeline\MediaStoragePipeline;
use App\Util\Media\Blurhash;
use App\Services\MediaService;
use App\Services\StatusService;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class VideoThumbnail implements ShouldQueue
class VideoThumbnail implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $media;
public $timeout = 900;
public $tries = 3;
public $maxExceptions = 1;
public $failOnTimeout = true;
public $deleteWhenMissingModels = true;
/**
* The number of seconds after which the job's unique lock will be released.
*
* @var int
*/
public $uniqueFor = 3600;
/**
* Get the unique ID for the job.
*/
public function uniqueId(): string
{
return 'media:video-thumb:id-' . $this->media->id;
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new WithoutOverlapping("media:video-thumb:id-{$this->media->id}"))->shared()->dontRelease()];
}
/**
* Create a new job instance.
*
@ -54,7 +87,7 @@ class VideoThumbnail implements ShouldQueue
$path[$i] = $t;
$save = implode('/', $path);
$video = FFMpeg::open($base)
->getFrameFromSeconds(0)
->getFrameFromSeconds(1)
->export()
->toDisk('local')
->save($save);
@ -68,6 +101,9 @@ class VideoThumbnail implements ShouldQueue
$media->save();
}
if(config('media.hls.enabled')) {
VideoHlsPipeline::dispatch($media)->onQueue('mmo');
}
} catch (Exception $e) {
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Services\Media;
use Storage;
class MediaHlsService
{
public static function allFiles($media)
{
$path = $media->media_path;
if(!$path) { return; }
$parts = explode('/', $path);
$filename = array_pop($parts);
$dir = implode('/', $parts);
[$name, $ext] = explode('.', $filename);
$files = Storage::files($dir);
return collect($files)
->filter(function($p) use($dir, $name) {
return str_starts_with($p, $dir . '/' . $name);
})
->values()
->toArray();
}
}

View file

@ -18,7 +18,7 @@ class MediaService
public static function get($statusId)
{
return Cache::remember(self::CACHE_KEY.$statusId, 86400, function() use($statusId) {
return Cache::remember(self::CACHE_KEY.$statusId, 21600, function() use($statusId) {
$media = Media::whereStatusId($statusId)->orderBy('order')->get();
if(!$media) {
return [];
@ -46,7 +46,8 @@ class MediaService
$media['orientation'],
$media['filter_name'],
$media['filter_class'],
$media['mime']
$media['mime'],
$media['hls_manifest']
);
$media['type'] = $mime ? strtolower($mime[0]) : 'unknown';

View file

@ -4,6 +4,7 @@ namespace App\Transformer\Api;
use App\Media;
use League\Fractal;
use Storage;
class MediaTransformer extends Fractal\TransformerAbstract
{
@ -28,6 +29,10 @@ class MediaTransformer extends Fractal\TransformerAbstract
'blurhash' => $media->blurhash ?? 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay'
];
if(config('media.hls.enabled') && $media->hls_transcoded_at != null && $media->hls_path) {
$res['hls_manifest'] = url(Storage::url($media->hls_path));
}
if($media->width && $media->height) {
$res['meta'] = [
'focus' => [

View file

@ -11,6 +11,19 @@ class Config {
public static function get() {
return Cache::remember(self::CACHE_KEY, 900, function() {
$hls = [
'enabled' => config('media.hls.enabled'),
];
if(config('media.hls.enabled')) {
$hls = [
'enabled' => true,
'debug' => (bool) config('media.hls.debug'),
'p2p' => (bool) config('media.hls.p2p'),
'p2p_debug' => (bool) config('media.hls.p2p_debug'),
'tracker' => config('media.hls.tracker'),
'ice' => config('media.hls.ice')
];
}
return [
'version' => config('pixelfed.version'),
'open_registration' => (bool) config_cache('pixelfed.open_registration'),
@ -80,7 +93,8 @@ class Config {
'org' => config('instance.label.covid.org'),
'url' => config('instance.label.covid.url'),
]
]
],
'hls' => $hls
]
];
});

View file

@ -3,8 +3,7 @@
return [
'ffmpeg' => [
'binaries' => env('FFMPEG_BINARIES', 'ffmpeg'),
'threads' => 12, // set to false to disable the default 'threads' filter
'threads' => env('FFMPEG_THREADS', false),
],
'ffprobe' => [
@ -18,4 +17,6 @@ return [
'temporary_files_root' => env('FFMPEG_TEMPORARY_FILES_ROOT', sys_get_temp_dir()),
'temporary_files_encrypted_hls' => env('FFMPEG_TEMPORARY_ENCRYPTED_HLS', env('FFMPEG_TEMPORARY_FILES_ROOT', sys_get_temp_dir())),
'min_hls_version' => env('FFMPEG_MIN_HLS_VERSION', '4.3.0'),
];

View file

@ -22,5 +22,39 @@ return [
'resilient_mode' => env('ALT_PRI_ENABLED', false) || env('ALT_SEC_ENABLED', false),
],
],
'hls' => [
/*
|--------------------------------------------------------------------------
| Enable HLS
|--------------------------------------------------------------------------
|
| Enable optional HLS support, required for video p2p support. Requires FFMPEG
| Disabled by default.
|
*/
'enabled' => env('MEDIA_HLS_ENABLED', false),
'debug' => env('MEDIA_HLS_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Enable Video P2P support
|--------------------------------------------------------------------------
|
| Enable optional video p2p support. Requires FFMPEG + HLS
| Disabled by default.
|
*/
'p2p' => env('MEDIA_HLS_P2P', false),
'p2p_debug' => env('MEDIA_HLS_P2P_DEBUG', false),
'bitrate' => env('MEDIA_HLS_BITRATE', 1000),
'tracker' => env('MEDIA_HLS_P2P_TRACKER', 'wss://tracker.webtorrent.dev'),
'ice' => env('MEDIA_HLS_P2P_ICE_SERVER', 'stun:stun.l.google.com:19302'),
]
];

2081
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -34,9 +34,12 @@
},
"dependencies": {
"@fancyapps/fancybox": "^3.5.7",
"@hcaptcha/vue-hcaptcha": "^1.3.0",
"@peertube/p2p-media-loader-core": "^1.0.14",
"@peertube/p2p-media-loader-hlsjs": "^1.0.14",
"@trevoreyre/autocomplete-vue": "^2.2.0",
"@web3-storage/parse-link-header": "^3.1.0",
"@zip.js/zip.js": "^2.7.14",
"@zip.js/zip.js": "^2.7.24",
"animate.css": "^4.1.0",
"bigpicture": "^2.6.2",
"blurhash": "^1.1.3",

Binary file not shown.

BIN
public/js/activity.js vendored

Binary file not shown.

BIN
public/js/admin.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/compose.js vendored

Binary file not shown.

BIN
public/js/daci.chunk.b17a0b11877389d7.js vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/direct.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/discover.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/hashtag.js vendored

Binary file not shown.

BIN
public/js/home.chunk.351f55e9d09b6482.js vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/landing.js vendored

Binary file not shown.

BIN
public/js/manifest.js vendored

Binary file not shown.

BIN
public/js/portfolio.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/js/post.chunk.74f8b1d1954f5d01.js vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/profile.js vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/search.js vendored

Binary file not shown.

BIN
public/js/spa.js vendored

Binary file not shown.

BIN
public/js/status.js vendored

Binary file not shown.

BIN
public/js/stories.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/js/timeline.js vendored

Binary file not shown.

BIN
public/js/vendor.js vendored

Binary file not shown.

View file

@ -31,13 +31,13 @@
*/
/*!
* Cropper.js v1.5.13
* Cropper.js v1.6.1
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2022-11-20T05:30:46.114Z
* Date: 2023-09-17T03:44:19.860Z
*/
/*!
@ -56,6 +56,20 @@
* Licensed under GPL 3.
*/
/*!
* The buffer module from node.js, for the browser.
*
* @author Feross Aboukhadijeh <http://feross.org>
* @license MIT
*/
/*!
* The buffer module from node.js, for the browser.
*
* @author Feross Aboukhadijeh <https://feross.org>
* @license MIT
*/
/*!
* Vue.js v2.7.14
* (c) 2014-2022 Evan You
@ -63,14 +77,14 @@
*/
/*!
* jQuery JavaScript Library v3.7.0
* jQuery JavaScript Library v3.7.1
* https://jquery.com/
*
* Copyright OpenJS Foundation and other contributors
* Released under the MIT license
* https://jquery.org/license
*
* Date: 2023-05-11T18:29Z
* Date: 2023-08-28T13:37Z
*/
/*!
@ -143,6 +157,18 @@ and limitations under the License.
/*! https://mths.be/punycode v1.4.1 by @mathias */
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! queue-microtask. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! run-parallel. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! simple-peer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! simple-websocket. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
/**
* vue-class-component v7.2.3
* (c) 2015-present Evan You
@ -158,6 +184,23 @@ and limitations under the License.
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/
/**
* @license Apache-2.0
* Copyright 2018 Novage LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* filesize
*

Binary file not shown.

View file

@ -12,7 +12,7 @@
</div>
<div v-else-if="status.pf_type === 'video'" class="w-100">
<video-presenter :status="status" v-on:togglecw="status.sensitive = false"></video-presenter>
<video-player :status="status" :fixedHeight="fixedHeight" v-on:togglecw="status.sensitive = false" />
</div>
<div v-else-if="status.pf_type === 'photo:album'" class="w-100">
@ -108,27 +108,11 @@
</div>
</div>
<template v-else-if="status.pf_type === 'video'">
<div v-if="status.sensitive == true" class="content-label-wrapper">
<div class="text-light content-label">
<p class="text-center">
<i class="far fa-eye-slash fa-2x"></i>
</p>
<p class="h4 font-weight-bold text-center">
Sensitive Content
</p>
<p class="text-center py-2 content-label-text">
{{ status.spoiler_text ? status.spoiler_text : 'This post may contain sensitive content.'}}
</p>
<p class="mb-0">
<button @click="status.sensitive = false" class="btn btn-outline-light btn-block btn-sm font-weight-bold">See Post</button>
</p>
</div>
</div>
<video v-else class="card-img-top shadow" :class="{ fixedHeight: fixedHeight }" style="border-radius:15px;object-fit: contain;background-color: #000;" controls :poster="getPoster(status)">
<source :src="status.media_attachments[0].url" :type="status.media_attachments[0].mime">
</video>
</template>
<video-player
v-else-if="status.pf_type === 'video'"
:status="status"
:fixedHeight="fixedHeight"
/>
<div v-else-if="status.pf_type === 'photo:album'" class="card-img-top shadow" style="border-radius: 15px;">
<photo-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="toggleContentWarning()" style="border-radius:15px !important;object-fit: contain;background-color: #000;overflow: hidden;" :class="{ fixedHeight: fixedHeight }"/>
@ -185,12 +169,14 @@
<script type="text/javascript">
import BigPicture from 'bigpicture';
import ReadMore from './ReadMore.vue';
import VideoPlayer from './../../presenter/VideoPlayer.vue';
export default {
props: ['status'],
components: {
"read-more": ReadMore,
"video-player": VideoPlayer
},
data() {

View file

@ -0,0 +1,198 @@
<template>
<div>
<div v-if="status.sensitive == true" class="content-label-wrapper">
<div class="text-light content-label">
<p class="text-center">
<i class="far fa-eye-slash fa-2x"></i>
</p>
<p class="h4 font-weight-bold text-center">
Sensitive Content
</p>
<p class="text-center py-2 content-label-text">
{{ status.spoiler_text ? status.spoiler_text : 'This post may contain sensitive content.'}}
</p>
<p class="mb-0">
<button @click="status.sensitive = false" class="btn btn-outline-light btn-block btn-sm font-weight-bold">See Post</button>
</p>
</div>
</div>
<template v-else>
<div v-if="!shouldPlay" class="content-label-wrapper" :style="{ background: `linear-gradient(rgba(0, 0, 0, 0.2),rgba(0, 0, 0, 0.8)),url(${getPoster(status)})`, backgroundSize: 'cover'}">
<div class="text-light content-label">
<p class="mb-0">
<button @click.prevent="handleShouldPlay" class="btn btn-link btn-block btn-sm font-weight-bold">
<i class="fas fa-play fa-5x text-white"></i>
</button>
</p>
</div>
</div>
<template v-else>
<video v-if="hasHls" ref="video" :class="{ fixedHeight: fixedHeight }" style="margin:0" playsinline controls autoplay="false" :poster="getPoster(status)">
</video>
<video v-else class="card-img-top shadow" :class="{ fixedHeight: fixedHeight }" style="border-radius:15px;object-fit: contain;background-color: #000;" autoplay="false" controls :poster="getPoster(status)">
<source :src="status.media_attachments[0].url" :type="status.media_attachments[0].mime">
</video>
</template>
</template>
</div>
</template>
<script type="text/javascript">
import Hls from 'hls.js';
import "plyr/dist/plyr.css";
import Plyr from 'plyr';
import { p2pml } from '@peertube/p2p-media-loader-core'
import { Engine, initHlsJsPlayer } from '@peertube/p2p-media-loader-hlsjs'
export default {
props: ['status', 'fixedHeight'],
data() {
return {
shouldPlay: false,
hasHls: undefined,
hlsConfig: window.App.config.features.hls,
liveSyncDurationCount: 7,
isHlsSupported: false,
isP2PSupported: false,
engine: undefined,
}
},
mounted() {
this.$nextTick(() => {
this.init();
})
},
methods: {
handleShouldPlay(){
this.shouldPlay = true;
this.isHlsSupported = this.hlsConfig.enabled && Hls.isSupported();
this.isP2PSupported = this.hlsConfig.enabled && this.hlsConfig.p2p && Engine.isSupported();
this.$nextTick(() => {
this.init();
})
},
init() {
if(!this.status.sensitive && this.status.media_attachments[0]?.hls_manifest && this.isHlsSupported) {
this.hasHls = true;
this.$nextTick(() => {
this.initHls();
})
} else {
this.hasHls = false;
}
},
initHls() {
let loader;
if(this.isP2PSupported) {
const config = {
loader: {
trackerAnnounce: [this.hlsConfig.tracker],
rtcConfig: {
iceServers: [
{
urls: [this.hlsConfig.ice]
}
],
}
}
};
var engine = new Engine(config);
if(this.hlsConfig.p2p_debug) {
engine.on("peer_connect", peer => console.log("peer_connect", peer.id, peer.remoteAddress));
engine.on("peer_close", peerId => console.log("peer_close", peerId));
engine.on("segment_loaded", (segment, peerId) => console.log("segment_loaded from", peerId ? `peer ${peerId}` : "HTTP", segment.url));
}
loader = engine.createLoaderClass();
} else {
loader = Hls.DefaultConfig.loader;
}
const video = this.$refs.video;
const source = this.status.media_attachments[0].hls_manifest;
const player = new Plyr(video, {
captions: {
active: true,
update: true,
},
});
const hls = new Hls({
liveSyncDurationCount: this.liveSyncDurationCount,
loader: loader,
});
let self = this;
initHlsJsPlayer(hls);
hls.loadSource(source);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function(event, data) {
if(this.hlsConfig.debug) {
console.log(event);
console.log(data);
}
const defaultOptions = {};
const availableQualities = hls.levels.map((l) => l.height)
if(this.hlsConfig.debug) {
console.log(availableQualities);
}
availableQualities.unshift(0);
defaultOptions.quality = {
default: 0,
options: availableQualities,
forced: true,
onChange: (e) => self.updateQuality(e),
}
defaultOptions.i18n = {
qualityLabel: {
0: 'Auto',
},
}
hls.on(Hls.Events.LEVEL_SWITCHED, function(event, data) {
var span = document.querySelector(".plyr__menu__container [data-plyr='quality'][value='0'] span")
if (hls.autoLevelEnabled) {
span.innerHTML = `Auto (${hls.levels[data.level].height}p)`
} else {
span.innerHTML = `Auto`
}
})
var player = new Plyr(video, defaultOptions);
});
},
updateQuality(newQuality) {
if (newQuality === 0) {
window.hls.currentLevel = -1;
} else {
window.hls.levels.forEach((level, levelIndex) => {
if (level.height === newQuality) {
if(this.hlsConfig.debug) {
console.log("Found quality match with " + newQuality);
}
window.hls.currentLevel = levelIndex;
}
});
}
},
getPoster(status) {
let url = status.media_attachments[0].preview_url;
if(url.endsWith('no-preview.jpg') || url.endsWith('no-preview.png')) {
return;
}
return url;
}
}
}
</script>

View file

@ -0,0 +1,800 @@
<template>
<div class="card-body">
<p class="lead text-center font-weight-bold">Sign-in with Mastodon</p>
<hr>
<template v-if="step === 1">
<div class="wrapper-mh">
<div class="flex-grow-1">
<p class="text-dark">Hello {{ initialData['_webfinger'] }},</p>
<p class="lead font-weight-bold">Welcome to Pixelfed!</p>
<p>You are moments away from joining our vibrant photo and video focused community with members from around the world.</p>
</div>
<p class="text-xs text-lighter">Your Mastodon account <strong>avatar</strong>, <strong>bio</strong>, <strong>display name</strong>, <strong>followed accounts</strong> and <strong>username</strong> will be imported to speed up the sign-up process. We will never post on your behalf, we only access your public profile data (avatar, bio, display name, followed accounts and username).</p>
</div>
</template>
<template v-else-if="step === 2">
<div class="wrapper-mh">
<div class="pt-3">
<div class="form-group has-float-label">
<input class="form-control form-control-lg" id="f_username" aria-describedby="f_username_help" v-model="username" autofocus/>
<label for="f_username">Username</label>
<p v-if="validUsername && !usernameError" id="f_username_help" class="text-xs text-success font-weight-bold mt-1 mb-0">Available</p>
<p v-else-if="!validUsername && !usernameError" id="f_username_help" class="text-xs text-danger font-weight-bold mt-1 mb-0">Username taken</p>
<p v-else-if="usernameError" id="f_username_help" class="text-xs text-danger font-weight-bold mt-1 mb-0">{{ usernameError }}</p>
</div>
</div>
<div class="pt-3">
<p class="text-sm font-weight-bold mb-1">Avatar</p>
<div class="border rounded-lg p-3 d-flex align-items-center justify-content-between gap-1">
<img v-if="form.importAvatar" :src="initialData.avatar" width="40" height="40" class="rounded-circle" />
<img v-else src="/storage/avatars/default.jpg" width="40" height="40" class="rounded-circle" />
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="customCheck1" v-model="form.importAvatar">
<label class="custom-control-label text-xs font-weight-bold" style="line-height: 24px;" for="customCheck1">Import my Mastodon avatar</label>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="step === 3">
<div class="wrapper-mh">
<div class="pt-3">
<div class="form-group has-float-label">
<input class="form-control form-control-lg" id="f_name" aria-describedby="f_name_help" v-model="form.display_name" />
<label for="f_name">Display Name</label>
<div id="f_name_help" class="text-xs text-muted mt-1">Your display name, shown on your profile. You can change this later.</div>
</div>
</div>
<div class="pt-3">
<div class="form-group has-float-label">
<textarea class="form-control" id="f_bio" aria-describedby="f_bio_help" rows="5" v-model="form.bio"></textarea>
<label for="f_bio">Bio</label>
<div id="f_bio_help" class="text-xs text-muted mt-1 d-flex justify-content-between align-items-center">
<div>Describe yourself, you can change this later.</div>
<div>{{ form.bio ? form.bio.length : 0 }}/500</div>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="step === 4">
<div class="wrapper-mh">
<div class="pt-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<p class="font-weight-bold mb-0">Import accounts you follow</p>
<p class="text-muted text-xs mb-0">You can skip this step and follow accounts later</p>
</div>
<div style="min-width: 100px;text-align:right;">
<p v-if="following && selectedFollowing && following.length == selectedFollowing.length" class="mb-0"><a class="font-weight-bold text-xs text-danger" href="#" @click.prevent="handleFollowerUnselectAll()">Unselect All</a></p>
<p v-else class="mb-0"><a class="font-weight-bold text-xs" href="#" @click.prevent="handleFollowerSelectAll()">Select All</a></p>
</div>
</div>
<div v-if="!followingFetched" class="d-flex align-items-center justify-content-center limit-h">
<div class="w-100">
<instagram-loader></instagram-loader>
</div>
</div>
<div v-else class="list-group limit-h">
<div v-for="(account, idx) in following" class="list-group-item">
<div class="d-flex align-items-center" style="gap:8px;">
<div class="d-flex align-items-center" style="gap:5px;">
<div class="custom-control custom-checkbox">
<input
type="checkbox"
class="custom-control-input"
:value="account.url"
:id="'fac' + idx"
v-model="selectedFollowing"
@change="handleFollower($event, account)">
<label class="custom-control-label" :for="'fac' + idx"></label>
</div>
<img v-if="account.avatar" :src="account.avatar" width="34" height="34" class="rounded-circle" />
<img v-else src="/storage/avatars/default.jpg" width="34" height="34" class="rounded-circle" />
</div>
<div style="max-width: 70%">
<p class="font-weight-bold mb-0 text-truncate">&commat;{{account.username}}</p>
<p class="text-xs text-lighter mb-0 text-truncate">{{account.url.replace('https://', '')}}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="step === 5">
<div class="wrapper-mh">
<div class="pt-3">
<div class="pb-3">
<p class="font-weight-bold mb-0">We need a bit more info</p>
<p class="text-xs text-muted">Enter your email so you recover access to your account in the future</p>
</div>
<div class="form-group has-float-label">
<input class="form-control" id="f_email" aria-describedby="f_email_help" v-model="email" autofocus="autofocus" />
<label for="f_email">Email address</label>
<p v-if="email && validEmail && !emailError" id="f_email_help" class="text-xs text-success font-weight-bold mt-1 mb-0">Available</p>
<p v-else-if="email && !validEmail && !emailError" id="f_email_help" class="text-xs text-danger font-weight-bold mt-1 mb-0">Email already in use</p>
<p v-else-if="email && emailError" id="f_email_help" class="text-xs text-danger font-weight-bold mt-1 mb-0">{{ emailError }}</p>
<p v-else id="f_email_help" class="text-xs text-muted mt-1 mb-0">We'll never share your email with anyone else.</p>
</div>
</div>
<div v-if="email && email.length && validEmail" class="pt-3">
<div class="form-group has-float-label">
<input type="password" class="form-control" id="f_password" aria-describedby="f_password_help" autocomplete="new-password" v-model="password" autofocus="autofocus" />
<label for="f_password">Password</label>
<div id="f_password_help" class="text-xs text-muted">Use a memorable password that you don't use on other sites.</div>
</div>
</div>
<div v-if="password && password.length >= 8" class="pt-3">
<div class="form-group has-float-label">
<input type="password" class="form-control" id="f_passwordConfirm" aria-describedby="f_passwordConfirm_help" autocomplete="new-password" v-model="passwordConfirm" autofocus="autofocus" />
<label for="f_passwordConfirm">Confirm Password</label>
<div id="f_passwordConfirm_help" class="text-xs text-muted">Re-enter your password.</div>
</div>
</div>
</div>
</template>
<template v-else-if="step === 6">
<div class="wrapper-mh">
<div class="my-5">
<p class="lead text-center font-weight-bold mb-0">You're almost ready!</p>
<p class="text-center text-lighter text-xs">Confirm your email and other info</p>
</div>
<div class="card shadow-none border" style="border-radius: 1rem;">
<div class="card-body">
<div class="d-flex gap-1">
<img :src="initialData.avatar" width="90" height="90" class="rounded-circle">
<div>
<p class="lead font-weight-bold mb-n1">@{{username}}</p>
<p class="small font-weight-light text-muted mb-1">{{username}}@pixelfed.test</p>
<p class="text-xs mb-0 text-lighter">{{ form.bio.slice(0, 80) + '...' }}</p>
</div>
</div>
</div>
</div>
<div class="list-group mt-3" style="border-radius: 1rem;">
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-xs">Email</div>
<div class="font-weight-bold">{{ email }}</div>
</div>
<div class="list-group-item d-flex align-items-center justify-content-between">
<div class="text-xs">Following Imports</div>
<div class="font-weight-bold">{{ selectedFollowing ? selectedFollowing.length : 0 }}</div>
</div>
</div>
</div>
</template>
<template v-else-if="step === 7">
<div class="wrapper-mh">
<div class="w-100 d-flex flex-column gap-1">
<b-progress :value="submitProgress" :max="100" height="1rem" animated></b-progress>
<p class="text-center text-xs text-lighter">{{ submitMessage }}</p>
</div>
</div>
</template>
<hr>
<template v-if="step === 7">
<div class="d-flex align-items-center justify-content-center gap-1 mb-2">
<button class="btn btn-outline-primary font-weight-bold btn-block my-0" @click="handleBack()">Back</button>
<button class="btn btn-primary font-weight-bold btn-block my-0" @click="handleJoin()" disabled>Continue</button>
</div>
</template>
<template v-else>
<div class="d-flex align-items-center justify-content-center gap-1 mb-2">
<button v-if="step > 1" class="btn btn-outline-primary font-weight-bold btn-block my-0" :disabled="isSubmitting" @click="handleBack()">Back</button>
<button
v-if="step === 6"
class="btn btn-primary font-weight-bold btn-block my-0"
:disabled="isSubmitting"
@click="handleJoin()">
<b-spinner v-if="isSubmitting" small />
<span v-else>Continue</span>
</button>
<button v-else class="btn btn-primary font-weight-bold btn-block my-0" :disabled="canProceed()" @click="handleProceed()">Next</button>
</div>
<template v-if="isSubmitting ? false : step <= 6">
<hr>
<p class="text-center mb-0">
<a class="font-weight-bold" href="/login">Go back to login</a>
</p>
</template>
</template>
</div>
</template>
<script type="text/javascript">
import {debounce} from './../../../js/util/debounce.js';
import { InstagramLoader } from 'vue-content-loader';
export default {
props: {
initialData: {
type: Object
}
},
components: {
InstagramLoader,
},
data() {
return {
step: 1,
validUsername: false,
usernameError: undefined,
username: this.initialData.username,
email: undefined,
emailError: undefined,
validEmail: false,
password: undefined,
passwordConfirm: undefined,
passwordValid: false,
following: [],
followingFetched: false,
selectedFollowing: [],
form: {
importAvatar: true,
bio: this.stripTagsPreserveNewlines(this.initialData.note),
display_name: this.initialData.display_name,
},
isSubmitting: false,
submitProgress: 0,
submitMessage: 'Please wait...',
isImportingFollowing: false,
accountToId: [],
followingIds: [],
accessToken: undefined,
}
},
mounted() {
this.checkUsernameAvailability();
},
watch: {
username: debounce(function(username) {
this.checkUsernameAvailability();
}, 500),
email: debounce(function(email) {
this.checkEmailAvailability();
}, 500),
passwordConfirm: function(confirm) {
this.checkPasswordConfirm(confirm);
},
selectedFollowing: function(account) {
this.lookupSelected(account);
}
},
methods: {
checkPasswordConfirm(password) {
if(!this.password || !password) {
return;
}
this.passwordValid = password.trim() === this.password.trim();
},
handleBack() {
event.currentTarget.blur();
this.step--;
},
handleProceed() {
event.currentTarget.blur();
this.step++;
if(!this.followingFetched) {
this.fetchFollowing();
}
},
checkUsernameAvailability() {
axios.post('/auth/raw/mastodon/s/username-check', {
username: this.username
})
.then(res => {
if(res.data && res.data.hasOwnProperty('exists')) {
this.usernameError = undefined;
this.validUsername = res.data.exists == false;
}
})
.catch(err => {
this.usernameError = err.response.data.message;
})
},
checkEmailAvailability() {
axios.post('/auth/raw/mastodon/s/email-check', {
email: this.email
})
.then(res => {
if(!res.data) {
this.emailError = undefined;
this.validEmail = false;
return;
}
if(res.data && res.data.hasOwnProperty('banned') && res.data.banned) {
this.emailError = 'This email provider is not supported, please use a different email address.';
this.validEmail = false;
return;
}
if(res.data && res.data.hasOwnProperty('exists')) {
this.emailError = undefined;
this.validEmail = res.data.exists == false;
}
})
.catch(err => {
this.emailError = err.response.data.message;
})
},
canProceed() {
switch(this.step) {
case 1:
return false;
break;
case 2:
return (this.usernameError || !this.validUsername);
break;
case 3:
return false;
break;
case 4:
return false;
break;
case 5:
return (
!this.email ||
!this.validEmail ||
!this.password ||
!this.password.length ||
this.password.length < 8 ||
!this.passwordConfirm ||
!this.passwordConfirm.length ||
this.passwordConfirm.length < 8 ||
!this.passwordValid
);
break;
case 6:
break;
}
},
handleFollower(event, account) {
let state = event.target.checked;
if(state) {
if(this.selectedFollowing.indexOf(account.url) == -1) {
this.selectedFollowing.push(account.url)
}
} else {
this.selectedFollowing = this.selectedFollowing.filter(s => s !== account.url);
}
},
handleFollowerSelectAll() {
this.selectedFollowing = this.following.map(f => f.url);
},
handleFollowerUnselectAll() {
this.selectedFollowing = [];
},
lookupSelected(accounts) {
if(!accounts || !accounts.length) {
return;
}
for (var i = accounts.length - 1; i >= 0; i--) {
let acct = accounts[i];
if(!this.accountToId.map(a => a.url).includes(acct)) {
axios.post('/auth/raw/mastodon/s/account-to-id', {
account: acct
})
.then(res => {
this.accountToId.push({
id: res.data.id,
url: acct
})
})
}
}
},
fetchFollowing() {
axios.post('/auth/raw/mastodon/s/following')
.then(res => {
this.following = res.data.following;
this.followingFetched = true;
})
.finally(() => {
setTimeout(() => {
this.followingFetched = true;
}, 1000)
})
},
stripTagsPreserveNewlines(htmlString) {
const parser = new DOMParser();
const document = parser.parseFromString(htmlString, 'text/html');
const body = document.body;
let strippedString = '';
function traverse(element) {
const nodeName = element.nodeName.toLowerCase();
if (nodeName === 'p') {
strippedString += '\n';
} else if (nodeName === '#text') {
strippedString += element.textContent;
}
const childNodes = element.childNodes;
for (let i = 0; i < childNodes.length; i++) {
traverse(childNodes[i]);
}
}
traverse(body);
strippedString = strippedString.trim();
return strippedString;
},
handleJoin() {
this.isSubmitting = true;
this.step = 7;
this.submitProgress = 10;
axios.post('/auth/raw/mastodon/s/submit', {
email: this.email,
name: this.form.display_name,
password: this.password,
password_confirmation: this.passwordConfirm,
username: this.username,
})
.then(res => {
if(res.data.hasOwnProperty('token') && res.data.token) {
this.accessToken = res.data.token;
setTimeout(() => {
this.submitProgress = 20;
this.submitMessage = 'Claiming your username...';
this.storeBio();
}, 2000);
} else {
swal('Something went wrong', 'An unexpected error occured, please try again later');
}
})
},
storeBio() {
axios.post('/auth/raw/mastodon/s/store-bio', {
bio: this.form.bio
})
.then(res => {
this.submitProgress = 30;
this.submitMessage = 'Importing your bio...';
})
.finally(() => {
this.storeFollowing();
})
},
storeFollowing() {
this.submitProgress = 40;
this.submitMessage = 'Importing following accounts...';
if(!this.selectedFollowing || !this.selectedFollowing.length) {
setTimeout(() => {
this.iterateFollowing();
}, 500);
return;
}
let ids = this.selectedFollowing
.map(id => {
return this.accountToId.filter(ai => ai.url == id).map(ai => ai.id);
})
.flat()
.filter(r => r && r.length && typeof r === 'string')
this.followingIds = ids;
setTimeout(() => {
this.iterateFollowing();
}, 500);
// axios.post('/auth/raw/mastodon/s/store-following', {
// accounts: this.selectedFollowing
// })
// .then(res => {
// this.followingIds = res.data;
// this.submitProgress = 40;
// this.submitMessage = 'Importing following accounts...';
// setTimeout(() => {
// this.iterateFollowing();
// }, 1000);
// })
},
iterateFollowing() {
if(!this.followingIds || !this.followingIds.length) {
this.storeAvatar();
return;
}
let id = this.followingIds.pop();
return this.handleFollow(id);
},
handleFollow(id) {
const config = {
headers: { Authorization: `Bearer ${this.accessToken}` }
};
axios.post(`/api/v1/accounts/${id}/follow`, {}, config)
.then(res => {
})
.finally(() => {
this.iterateFollowing();
})
},
storeAvatar() {
this.submitProgress = 70;
this.submitMessage = 'Importing your avatar...';
if(this.form.importAvatar == false) {
this.submitProgress = 90;
this.submitMessage = 'Preparing your account...';
this.finishUp();
return;
}
axios.post('/auth/raw/mastodon/s/store-avatar', {
avatar_url: this.initialData.avatar
})
.then(res => {
this.submitProgress = 90;
this.submitMessage = 'Preparing your account...';
this.finishUp();
})
},
finishUp() {
this.submitProgress = 92;
this.submitMessage = 'Finishing up...';
axios.post('/auth/raw/mastodon/s/finish-up')
.then(() => {
this.$emit('setCanReload');
this.submitProgress = 95;
this.submitMessage = 'Logging you in...';
setTimeout(() => {
this.submitProgress = 100;
window.location.reload();
}, 5000)
})
}
}
}
</script>
<style lang="scss">
.wrapper-mh {
min-height: 429px;
display: flex;
justify-content: center;
flex-direction: column;
}
.limit-h {
height: 300px;
overflow-x: hidden;
overflow-y: auto;
}
.has-float-label {
display: block;
position: relative;
}
.has-float-label label, .has-float-label > span {
position: absolute;
left: 0;
top: 0;
cursor: text;
font-size: 75%;
font-weight: bold;
opacity: 1;
-webkit-transition: all .2s;
transition: all .2s;
top: -.5em;
left: 0.75rem;
z-index: 3;
line-height: 1;
padding: 0 4px;
background: #fff;
}
.has-float-label label::after, .has-float-label > span::after {
content: " ";
display: block;
position: absolute;
background: #fff;
height: 2px;
top: 50%;
left: -.2em;
right: -.2em;
z-index: -1;
}
.has-float-label .form-control::-webkit-input-placeholder {
opacity: 1;
-webkit-transition: all .2s;
transition: all .2s;
}
.has-float-label .form-control::-moz-placeholder {
opacity: 1;
transition: all .2s;
}
.has-float-label .form-control:-ms-input-placeholder {
opacity: 1;
transition: all .2s;
}
.has-float-label .form-control::placeholder {
opacity: 1;
-webkit-transition: all .2s;
transition: all .2s;
}
.has-float-label .form-control:placeholder-shown:not(:focus)::-webkit-input-placeholder {
opacity: 0;
}
.has-float-label .form-control:placeholder-shown:not(:focus)::-moz-placeholder {
opacity: 0;
}
.has-float-label .form-control:placeholder-shown:not(:focus):-ms-input-placeholder {
opacity: 0;
}
.has-float-label .form-control:placeholder-shown:not(:focus)::placeholder {
opacity: 0;
}
.has-float-label .form-control:placeholder-shown:not(:focus) + * {
font-size: 150%;
opacity: .5;
top: .3em;
}
.input-group .has-float-label {
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
-ms-flex-positive: 1;
flex-grow: 1;
margin-bottom: 0;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
.input-group .has-float-label .form-control {
width: 100%;
border-radius: 0.25rem;
}
.input-group .has-float-label:not(:last-child), .input-group .has-float-label:not(:last-child) .form-control {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
border-right: 0;
}
.input-group .has-float-label:not(:first-child), .input-group .has-float-label:not(:first-child) .form-control {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
.opacity-0 {
opacity: 0;
transition: opacity 0.5s;
}
.sl {
.progress {
background-color: #fff;
}
#tick {
stroke: #63bc01;
stroke-width: 6;
transition: all 1s;
}
#circle {
stroke: #63bc01;
stroke-width: 6;
transform-origin: 50px 50px 0;
transition: all 1s;
}
.progress #tick {
opacity: 0;
}
.ready #tick {
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
animation: draw 8s ease-out forwards;
}
.progress #circle {
stroke: #4c4c4c;
stroke-dasharray: 314;
stroke-dashoffset: 1000;
animation: spin 3s linear infinite;
}
.ready #circle {
stroke-dashoffset: 66;
stroke: #63bc01;
}
#circle {
stroke-dasharray: 500;
}
@keyframes spin {
0% {
transform: rotate(0deg);
stroke-dashoffset: 66;
}
50% {
transform: rotate(540deg);
stroke-dashoffset: 314;
}
100% {
transform: rotate(1080deg);
stroke-dashoffset: 66;
}
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
#scheck {
width: 300px;
height: 300px;
}
}
</style>

11
resources/assets/js/util/debounce.js vendored Normal file
View file

@ -0,0 +1,11 @@
export function debounce (fn, delay) {
var timeoutID = null
return function () {
clearTimeout(timeoutID)
var args = arguments
var that = this
timeoutID = setTimeout(function () {
fn.apply(that, args)
}, delay)
}
}