mirror of
https://github.com/pixelfed/pixelfed.git
synced 2024-11-22 14:31:26 +00:00
Merge pull request #4713 from pixelfed/staging
Add WebP2P support for Video
This commit is contained in:
commit
42fb713092
83 changed files with 2784 additions and 817 deletions
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
109
app/Jobs/VideoPipeline/VideoHlsPipeline.php
Normal file
109
app/Jobs/VideoPipeline/VideoHlsPipeline.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
||||
}
|
||||
|
|
27
app/Services/Media/MediaHlsService.php
Normal file
27
app/Services/Media/MediaHlsService.php
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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' => [
|
||||
|
|
|
@ -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
|
||||
]
|
||||
];
|
||||
});
|
||||
|
|
|
@ -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'),
|
||||
];
|
||||
|
|
|
@ -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
2081
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
BIN
public/js/account-import.js
vendored
BIN
public/js/account-import.js
vendored
Binary file not shown.
BIN
public/js/activity.js
vendored
BIN
public/js/activity.js
vendored
Binary file not shown.
BIN
public/js/admin.js
vendored
BIN
public/js/admin.js
vendored
Binary file not shown.
BIN
public/js/admin_invite.js
vendored
BIN
public/js/admin_invite.js
vendored
Binary file not shown.
BIN
public/js/changelog.bundle.742a06ba0a547120.js
vendored
Normal file
BIN
public/js/changelog.bundle.742a06ba0a547120.js
vendored
Normal file
Binary file not shown.
BIN
public/js/changelog.bundle.c4c82057f9628c72.js
vendored
BIN
public/js/changelog.bundle.c4c82057f9628c72.js
vendored
Binary file not shown.
BIN
public/js/collectioncompose.js
vendored
BIN
public/js/collectioncompose.js
vendored
Binary file not shown.
BIN
public/js/collections.js
vendored
BIN
public/js/collections.js
vendored
Binary file not shown.
BIN
public/js/compose-classic.js
vendored
BIN
public/js/compose-classic.js
vendored
Binary file not shown.
BIN
public/js/compose.chunk.6464688bf5b5ef97.js
vendored
BIN
public/js/compose.chunk.6464688bf5b5ef97.js
vendored
Binary file not shown.
BIN
public/js/compose.chunk.965eab35620423e5.js
vendored
Normal file
BIN
public/js/compose.chunk.965eab35620423e5.js
vendored
Normal file
Binary file not shown.
BIN
public/js/compose.js
vendored
BIN
public/js/compose.js
vendored
Binary file not shown.
BIN
public/js/daci.chunk.b17a0b11877389d7.js
vendored
Normal file
BIN
public/js/daci.chunk.b17a0b11877389d7.js
vendored
Normal file
Binary file not shown.
BIN
public/js/daci.chunk.bfa9e4f459fec835.js
vendored
BIN
public/js/daci.chunk.bfa9e4f459fec835.js
vendored
Binary file not shown.
BIN
public/js/developers.js
vendored
BIN
public/js/developers.js
vendored
Binary file not shown.
BIN
public/js/direct.js
vendored
BIN
public/js/direct.js
vendored
Binary file not shown.
BIN
public/js/discover.chunk.56d2d8cfbbecc761.js
vendored
BIN
public/js/discover.chunk.56d2d8cfbbecc761.js
vendored
Binary file not shown.
BIN
public/js/discover.chunk.9606885dad3c8a99.js
vendored
Normal file
BIN
public/js/discover.chunk.9606885dad3c8a99.js
vendored
Normal file
Binary file not shown.
BIN
public/js/discover.js
vendored
BIN
public/js/discover.js
vendored
Binary file not shown.
BIN
public/js/discover~findfriends.chunk.02be60ab26503531.js
vendored
Normal file
BIN
public/js/discover~findfriends.chunk.02be60ab26503531.js
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/js/discover~hashtag.bundle.9cfffc517f35044e.js
vendored
Normal file
BIN
public/js/discover~hashtag.bundle.9cfffc517f35044e.js
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/js/discover~memories.chunk.ce9cc6446020e9b3.js
vendored
Normal file
BIN
public/js/discover~memories.chunk.ce9cc6446020e9b3.js
vendored
Normal file
Binary file not shown.
BIN
public/js/discover~myhashtags.chunk.6eab2414b2b16e19.js
vendored
Normal file
BIN
public/js/discover~myhashtags.chunk.6eab2414b2b16e19.js
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/js/discover~serverfeed.chunk.0f2dcc473fdce17e.js
vendored
Normal file
BIN
public/js/discover~serverfeed.chunk.0f2dcc473fdce17e.js
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/js/discover~settings.chunk.732c1f76a00d9204.js
vendored
Normal file
BIN
public/js/discover~settings.chunk.732c1f76a00d9204.js
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
public/js/dms~message.chunk.15157ff4a6c17cc7.js
vendored
Normal file
BIN
public/js/dms~message.chunk.15157ff4a6c17cc7.js
vendored
Normal file
Binary file not shown.
BIN
public/js/dms~message.chunk.990c68dfc266b0cf.js
vendored
BIN
public/js/dms~message.chunk.990c68dfc266b0cf.js
vendored
Binary file not shown.
BIN
public/js/error404.bundle.182d0aaa2da9ed23.js
vendored
BIN
public/js/error404.bundle.182d0aaa2da9ed23.js
vendored
Binary file not shown.
BIN
public/js/error404.bundle.3bbc118159460db6.js
vendored
Normal file
BIN
public/js/error404.bundle.3bbc118159460db6.js
vendored
Normal file
Binary file not shown.
BIN
public/js/hashtag.js
vendored
BIN
public/js/hashtag.js
vendored
Binary file not shown.
BIN
public/js/home.chunk.351f55e9d09b6482.js
vendored
Normal file
BIN
public/js/home.chunk.351f55e9d09b6482.js
vendored
Normal file
Binary file not shown.
BIN
public/js/home.chunk.bd623a430a5584c2.js
vendored
BIN
public/js/home.chunk.bd623a430a5584c2.js
vendored
Binary file not shown.
BIN
public/js/i18n.bundle.47cbf9f04d955267.js
vendored
Normal file
BIN
public/js/i18n.bundle.47cbf9f04d955267.js
vendored
Normal file
Binary file not shown.
BIN
public/js/i18n.bundle.4a5ff18de549ac4e.js
vendored
BIN
public/js/i18n.bundle.4a5ff18de549ac4e.js
vendored
Binary file not shown.
BIN
public/js/landing.js
vendored
BIN
public/js/landing.js
vendored
Binary file not shown.
BIN
public/js/manifest.js
vendored
BIN
public/js/manifest.js
vendored
Binary file not shown.
Binary file not shown.
BIN
public/js/portfolio.js
vendored
BIN
public/js/portfolio.js
vendored
Binary file not shown.
BIN
public/js/post.chunk.729ca668f46545cb.js
vendored
BIN
public/js/post.chunk.729ca668f46545cb.js
vendored
Binary file not shown.
BIN
public/js/post.chunk.74f8b1d1954f5d01.js
vendored
Normal file
BIN
public/js/post.chunk.74f8b1d1954f5d01.js
vendored
Normal file
Binary file not shown.
BIN
public/js/profile-directory.js
vendored
BIN
public/js/profile-directory.js
vendored
Binary file not shown.
BIN
public/js/profile.chunk.029572d9018fc65f.js
vendored
BIN
public/js/profile.chunk.029572d9018fc65f.js
vendored
Binary file not shown.
BIN
public/js/profile.chunk.0e5bd852054d6355.js
vendored
Normal file
BIN
public/js/profile.chunk.0e5bd852054d6355.js
vendored
Normal file
Binary file not shown.
BIN
public/js/profile.js
vendored
BIN
public/js/profile.js
vendored
Binary file not shown.
BIN
public/js/profile~followers.bundle.731f680cfb96563d.js
vendored
Normal file
BIN
public/js/profile~followers.bundle.731f680cfb96563d.js
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/js/profile~following.bundle.3d95796c9f1678dd.js
vendored
Normal file
BIN
public/js/profile~following.bundle.3d95796c9f1678dd.js
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/js/remote_auth.js
vendored
BIN
public/js/remote_auth.js
vendored
Binary file not shown.
BIN
public/js/search.js
vendored
BIN
public/js/search.js
vendored
Binary file not shown.
BIN
public/js/spa.js
vendored
BIN
public/js/spa.js
vendored
Binary file not shown.
BIN
public/js/status.js
vendored
BIN
public/js/status.js
vendored
Binary file not shown.
BIN
public/js/stories.js
vendored
BIN
public/js/stories.js
vendored
Binary file not shown.
BIN
public/js/story-compose.js
vendored
BIN
public/js/story-compose.js
vendored
Binary file not shown.
BIN
public/js/timeline.js
vendored
BIN
public/js/timeline.js
vendored
Binary file not shown.
BIN
public/js/vendor.js
vendored
BIN
public/js/vendor.js
vendored
Binary file not shown.
|
@ -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.
|
@ -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() {
|
||||
|
|
198
resources/assets/components/presenter/VideoPlayer.vue
Normal file
198
resources/assets/components/presenter/VideoPlayer.vue
Normal 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>
|
|
@ -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">@{{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
11
resources/assets/js/util/debounce.js
vendored
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue