Working livestream module

This commit is contained in:
Fritz Ramirez
2024-04-25 16:48:43 +08:00
parent e65189f50c
commit 37cbc072e9
75 changed files with 58854 additions and 1 deletions

1
Modules/Livestream/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/node_modules

View File

View File

@@ -0,0 +1,5 @@
<?php
return [
'name' => 'Livestream'
];

View File

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateRestreamTokensTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('restream_tokens', function (Blueprint $table) {
$table->id();
$table->string('client_id');
$table->string('access_token');
$table->string('refresh_token');
$table->json('scope');
$table->string('type');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('restream_tokens');
}
}

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateRestreamSettingsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('restream_settings', function (Blueprint $table) {
$table->id();
$table->string('client_id', 255);
$table->string('client_secret', 255);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('restream_settings');
}
}

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class CreateLivestreamMenuAndPermission extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$sql = [
['id' => 240425100, 'module_id' => 18, 'parent_id' => 329, 'name' => 'Livestream Settings', 'route' => 'livestream.index', 'type' => 2 ],
];
DB::table('permissions')->insert($sql);
$sql = [
['id' => 240425100, 'parent_id' => 133, 'is_admin' => 1,'is_seller' => 0, 'icon' =>null, 'name' => 'Livestream Settings', 'route' => 'livestream.index', 'position' => 1],//Submenu
];
DB::table('backendmenus')->insert($sql);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Modules\Livestream\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
class LivestreamDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
// $this->call("OthersTableSeeder");
}
}

View File

View File

@@ -0,0 +1,16 @@
<?php
namespace Modules\Livestream\Entities;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class RestreamSetting extends Model
{
use HasFactory;
protected $fillable = [
'client_id',
'client_secret',
];
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Modules\Livestream\Entities;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class RestreamToken extends Model
{
use HasFactory;
protected $fillable = [
'client_id',
'access_token',
'refresh_token',
'scope',
'type',
];
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Modules\Livestream\Facades;
use Illuminate\Support\Facades\Facade;
use Modules\Livestream\Services\Restream as RestreamService;
class Restream extends Facade
{
protected static function getFacadeAccessor()
{
return RestreamService::class;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Modules\Livestream\Http\Controllers;
use GuzzleHttp\Client;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Modules\Livestream\Entities\RestreamSetting;
use Modules\Livestream\Entities\RestreamToken;
use Modules\Livestream\Facades\Restream;
class LivestreamController extends Controller
{
public function index()
{
$isConnected = Restream::isConnected();
$settings = RestreamSetting::first();
$connection = session('restream-connect-result');
if (!empty($connection)) {
$message = session('restream-connect-message');
Session::forget(['restream-connect-result', 'restream-connect-message']);
Session::save();
return view('livestream::index', compact('connection', 'message', 'settings', 'isConnected'));
}
return view('livestream::index', compact('settings', 'isConnected'));
}
public function platforms(Request $request)
{
$client = new Client();
$response = $client->get('https://api.restream.io/v2/platform/all');
$data = json_decode($response->getBody());
Log::info($data);
return redirect()->route('livestream.index');
}
public function servers(Request $request)
{
$client = new Client();
$response = $client->get('https://api.restream.io/v2/server/all');
$data = json_decode($response->getBody());
Log::info($data);
return redirect()->route('livestream.index');
}
public function channels(Request $request)
{
$token = RestreamToken::first();
if ($token) {
$client = new Client();
$response = $client->get('https://api.restream.io/v2/user/channel/all', [
'headers' => [
'Authorization' => "{$token->type} {$token->access_token}",
],
]);
$data = json_decode($response->getBody());
Log::info($data);
}
return redirect()->route('livestream.index');
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Modules\Livestream\Http\Controllers;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Modules\Livestream\Facades\Restream;
use Modules\Livestream\Http\Requests\UpdateRestreamSettingsRequest;
use Modules\Livestream\Traits\ApiResponse;
class RestreamController extends Controller
{
use ApiResponse;
public function connect()
{
return Restream::connect();
}
public function callback(Request $request)
{
Log::info($request);
$message = 'Failed to connect Restream.';
$stateToken = session('state-token');
if ($request->state === $stateToken) {
$result = Restream::exchange($request->code);
Log::info('Restream exchange result:');
Log::info($result);
if ($result) {
$message = 'Restream has been successfully connected.';
}
} else {
$message = 'Mismatch state token.';
Log::info($message);
$result = false;
}
session([
'restream-connect-result' => $result ? 'success' : 'failed',
'restream-connect-message' => $message,
]);
return redirect()->route('livestream.index');
}
public function updateSettings(UpdateRestreamSettingsRequest $request)
{
Restream::updateSettings($request->safe()->client_id, $request->safe()->client_secret);
return $this->success('Restream settings has been updated.');
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Modules\Livestream\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateRestreamSettingsRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'client_id' => ['required', 'string', 'max:255'],
'client_secret' => ['required', 'string', 'max:255'],
];
}
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
}

View File

View File

@@ -0,0 +1,112 @@
<?php
namespace Modules\Livestream\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Database\Eloquent\Factory;
class LivestreamServiceProvider extends ServiceProvider
{
/**
* @var string $moduleName
*/
protected $moduleName = 'Livestream';
/**
* @var string $moduleNameLower
*/
protected $moduleNameLower = 'livestream';
/**
* Boot the application events.
*
* @return void
*/
public function boot()
{
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->loadMigrationsFrom(module_path($this->moduleName, 'Database/Migrations'));
}
/**
* Register the service provider.
*
* @return void
*/
public function register()
{
$this->app->register(RouteServiceProvider::class);
}
/**
* Register config.
*
* @return void
*/
protected function registerConfig()
{
$this->publishes([
module_path($this->moduleName, 'Config/config.php') => config_path($this->moduleNameLower . '.php'),
], 'config');
$this->mergeConfigFrom(
module_path($this->moduleName, 'Config/config.php'), $this->moduleNameLower
);
}
/**
* Register views.
*
* @return void
*/
public function registerViews()
{
$viewPath = resource_path('views/modules/' . $this->moduleNameLower);
$sourcePath = module_path($this->moduleName, 'Resources/views');
$this->publishes([
$sourcePath => $viewPath
], ['views', $this->moduleNameLower . '-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->moduleNameLower);
}
/**
* Register translations.
*
* @return void
*/
public function registerTranslations()
{
$langPath = resource_path('lang/modules/' . $this->moduleNameLower);
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->moduleNameLower);
} else {
$this->loadTranslationsFrom(module_path($this->moduleName, 'Resources/lang'), $this->moduleNameLower);
}
}
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{
return [];
}
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (\Config::get('view.paths') as $path) {
if (is_dir($path . '/modules/' . $this->moduleNameLower)) {
$paths[] = $path . '/modules/' . $this->moduleNameLower;
}
}
return $paths;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Modules\Livestream\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
/**
* The module namespace to assume when generating URLs to actions.
*
* @var string
*/
protected $moduleNamespace = 'Modules\Livestream\Http\Controllers';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*
* @return void
*/
public function boot()
{
parent::boot();
}
/**
* Define the routes for the application.
*
* @return void
*/
public function map()
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
{
Route::middleware('web')
->namespace($this->moduleNamespace)
->group(module_path('Livestream', '/Routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
->namespace($this->moduleNamespace)
->group(module_path('Livestream', '/Routes/api.php'));
}
}

View File

@@ -0,0 +1,191 @@
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style:
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style:
}
.m-0 {
margin: 0px
}
.my-3 {
margin-top: 0.75rem;
margin-bottom: 0.75rem
}
.flex {
display: flex
}
.w-1\/3 {
width: 33.333333%
}
.w-1\/4 {
width: 25%
}
.w-full {
width: 100%
}
.flex-col {
flex-direction: column
}
.items-center {
align-items: center
}
.gap-1 {
gap: 0.25rem
}
.gap-4 {
gap: 1rem
}
.gap-8 {
gap: 2rem
}
.gap-2 {
gap: 0.5rem
}
.rounded {
border-radius: 0.25rem
}
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity))
}
.\!py-\[0\.5rem\] {
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem
}
.font-bold {
font-weight: 700
}
.leading-none {
line-height: 1
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity))
}
.text-red-500 {
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity))
}
.text-green-500 {
--tw-text-opacity: 1;
color: rgb(34 197 94 / var(--tw-text-opacity))
}
.opacity-75 {
opacity: 0.75
}

View File

@@ -0,0 +1 @@

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
{
"/js/livestream.js": "/js/livestream.js",
"/css/base.css": "/css/base.css",
"/css/livestream.css": "/css/livestream.css",
"/css/primevue.css": "/css/primevue.css"
}

View File

@@ -0,0 +1,5 @@
@use "sass:meta";
.frontend {
@include meta.load-css('theme.css');
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,139 @@
<template>
<div>
<h2 class="text-3xl">Livestream</h2>
<Group
title="Restream"
description="Manage Restream connection here."
>
<div class="flex gap-8 py-2">
<div class="w-1/3">
<div class="flex flex-col gap-4">
<InputField label="Client Id">
<InputText
v-model="clientId"
class="w-full"
:pt="{
root: 'py-2',
}"
/>
</InputField>
<InputField label="Client Secret">
<InputText
v-model="clientSecret"
class="w-full"
:pt="{
root: 'py-2',
}"
/>
</InputField>
<div>
<Button
label="Save"
size="small"
:loading="processing"
:pt="{
root: '!py-[0.5rem]',
}"
@click="onSave"
>
</Button>
</div>
</div>
</div>
<div class="w-1/4">
<div class="flex flex-col gap-4">
<InputField
label="Status"
>
<div class="flex">
<div v-if="isConnected" class="flex items-center gap-2 rounded font-bold text-green-500">
<i class="pi pi-check-circle"></i>
<span>Connected</span>
</div>
<div v-else class="flex items-center gap-1 rounded font-bold text-red-500">
<span>Not connected</span>
</div>
</div>
</InputField>
<div>
<Button
label="Connect"
size="small"
:pt="{
root: '!py-[0.5rem]',
}"
@click="onConnect"
>
</Button>
</div>
</div>
</div>
</div>
</Group>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { toast } from "vue3-toastify";
import { useRestream } from "@/api/useRestream";
import Group from "@/components/Group";
import InputField from "@/components/InputField";
const props = defineProps({
clientId: {
type: String,
default: null,
},
clientSecret: {
type: String,
default: null,
},
isConnected: {
type: Boolean,
default: false,
},
connection: {
type: String,
default: null,
},
message: {
type: String,
default: null,
},
});
const { updateRestreamSettings } = useRestream();
const clientId = ref();
const clientSecret = ref();
const processing = ref(false);
onMounted(() => {
if (!!props.connection) {
if (props.connection === "success") {
toast(props.message, {
type: toast.TYPE.SUCCESS,
position: toast.POSITION.TOP_RIGHT,
});
} else {
toast(props.message, {
type: toast.TYPE.ERROR,
position: toast.POSITION.TOP_RIGHT,
});
}
}
clientId.value = props.clientId;
clientSecret.value = props.clientSecret;
});
function onSave() {
processing.value = true;
updateRestreamSettings(clientId, clientSecret)
.finally(() => processing.value = false);
}
function onConnect() {
window.location.href = route("restream.connect");
}
</script>

View File

@@ -0,0 +1,17 @@
export function useData() {
function getData(name, key) {
let obj = window[name];
return key.split(".").reduce((o, k) => {
if (o && typeof o === 'object' && k in o) {
return o[k];
}
return undefined;
}, obj);
}
return {
getData,
};
}

View File

@@ -0,0 +1,42 @@
import { toValue } from "vue";
import { toast } from "vue3-toastify";
import { request } from "@/helpers/axios";
export function useRestream() {
function updateRestreamSettings(clientId, clientSecret) {
return new Promise(async (resolve, reject) => {
try {
const response = await request().put(route("restream.update-settings"), {
client_id: toValue(clientId),
client_secret: toValue(clientSecret),
});
const { data } = response;
const { message, item } = data;
toast(message, {
type: toast.TYPE.SUCCESS,
position: toast.POSITION.TOP_RIGHT,
});
resolve(item);
} catch (error) {
handleError(error);
reject(error);
}
});
}
function handleError(error) {
const { response } = error;
const { data } = response;
const { message } = data;
toast(message, {
type: toast.TYPE.ERROR,
position: toast.POSITION.TOP_RIGHT,
});
}
return {
updateRestreamSettings,
};
}

View File

@@ -0,0 +1,24 @@
<template>
<div class="my-3">
<GroupHeader
:title="title"
:description="description"
/>
<slot></slot>
</div>
</template>
<script setup>
import GroupHeader from "@/components/GroupHeader";
const props = defineProps({
title: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
});
</script>

View File

@@ -0,0 +1,20 @@
<template>
<div>
<h3 class="m-0 leading-none">{{ title }}</h3>
<small class="opacity-75">{{ description }}</small>
</div>
<hr class="m-0">
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
});
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex flex-col gap-1">
<div>{{ label }}</div>
<div>
<slot></slot>
</div>
</div>
</template>
<script setup>
const props = defineProps({
label: {
type: String,
default: null,
},
});
</script>

View File

@@ -0,0 +1,5 @@
import axios from "axios";
export function request() {
return axios.create();
}

View File

@@ -0,0 +1,37 @@
import { nextTick } from "vue";
import { createI18n } from "vue-i18n";
import { request } from "@/helpers/axios";
let instance = null;
export function setupI18n() {
const i18n = createI18n({
legacy: false,
locale: 'en',
});
instance = i18n;
loadTranslations();
return i18n;
}
export function loadTranslations(i18n = null) {
if (window.i18n) {
const { locale, messages } = window.i18n;
loadMessages(instance ?? i18n, locale, messages);
}
}
function loadMessages(i18n, locale, messages) {
if (i18n) {
if (i18n.mode == 'legacy') {
i18n.global.locale = locale;
} else {
i18n.global.locale.value = locale;
}
i18n.global.setLocaleMessage(locale, messages);
}
}

View File

@@ -0,0 +1,25 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import PrimeVue from "primevue/config";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Vue3Toastify from "vue3-toastify";
import App from "@/App";
import "primevue/resources/primevue.min.css";
import "vue3-toastify/dist/index.css";
const app = createApp({
components: {
"app": App,
},
});
app.use(createPinia());
app.use(PrimeVue, { ripple: true });
app.use(Vue3Toastify, { autoClose: 3000 });
app.component("Button", Button);
app.component("InputText", InputText);
app.mount("#livestream");

View File

@@ -0,0 +1,34 @@
@extends('backEnd.master')
@section('styles')
<link rel="stylesheet" href="{{ module_asset_url('Livestream', 'Public/css/base.css') }}">
<link rel="stylesheet" href="{{ module_asset_url('Livestream', 'Public/css/primeicons.css') }}">
<link rel="stylesheet" href="{{ module_asset_url('Livestream', 'Public/css/livestream.css') }}">
@endsection
@section('mainContent')
<link rel="stylesheet" href="{{ module_asset_url('Livestream', 'Public/css/primevue.css') }}">
<div id="livestream" class="frontend">
<app
client-id="{{ $settings->client_id }}"
client-secret="{{ $settings->client_secret }}"
:is-connected="{{ $isConnected ? 'true' : 'false' }}"
connection="{{ isset($connection) ? $connection : null }}"
message="{{ isset($message) ? $message : null }}"
>
</app>
</div>
@if (false)
<h2>Livestream Module</h2>
<a href="{{ route('livestream.restream.connect') }}">Connect</a>
<a href="{{ route('livestream.platforms') }}">List Platforms</a>
<a href="{{ route('livestream.servers') }}">Servers</a>
<a href="{{ route('livestream.channels') }}">Channels</a>
@endif
@endsection
@push('scripts')
@routes
<script src="{{ module_asset_url('Livestream', 'Public/js/livestream.js') }}"></script>
@endpush

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Module Livestream</title>
{{-- Laravel Mix - CSS File --}}
{{-- <link rel="stylesheet" href="{{ mix('css/livestream.css') }}"> --}}
</head>
<body>
@yield('content')
{{-- Laravel Mix - JS File --}}
{{-- <script src="{{ mix('js/livestream.js') }}"></script> --}}
</body>
</html>

View File

View File

@@ -0,0 +1,14 @@
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/

View File

@@ -0,0 +1,32 @@
<?php
/*
*--------------------------------------------------------------------------
* Web Routes
*--------------------------------------------------------------------------
*
* Here is where you can register web routes for your application. These
* routes are loaded by the RouteServiceProvider within a group which
* contains the "web" middleware group. Now create something great!
*
*/
use Illuminate\Support\Facades\Route;
use Modules\Livestream\Http\Controllers\LivestreamController;
use Modules\Livestream\Http\Controllers\RestreamController;
Route::middleware('auth')->group(function () {
Route::prefix('livestream')->group(function() {
Route::get('/', [LivestreamController::class, 'index'])->name('livestream.index');
Route::get('auth/callback', [LivestreamController::class, 'authCallback'])->name('livestream.auth.callback');
Route::get('platforms', [LivestreamController::class, 'platforms'])->name('livestream.platforms');
Route::get('servers', [LivestreamController::class, 'servers'])->name('livestream.servers');
Route::get('channels', [LivestreamController::class, 'channels'])->name('livestream.channels');
Route::prefix('restream')->name('restream.')->group(function () {
Route::get('connect', [RestreamController::class, 'connect'])->name('connect');
Route::get('callback', [RestreamController::class, 'callback'])->name('callback');
Route::put('update-settings', [RestreamController::class, 'updateSettings'])->name('update-settings');
});
});
});

View File

@@ -0,0 +1,92 @@
<?php
namespace Modules\Livestream\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Str;
use Modules\Livestream\Entities\RestreamSetting;
use Modules\Livestream\Entities\RestreamToken;
class Restream
{
protected $base_url = 'https://api.restream.io';
public $client_id;
public $client_secret;
public function __construct()
{
$settings = RestreamSetting::first();
if ($settings) {
$this->client_id = $settings->client_id;
$this->client_secret = $settings->client_secret;
}
}
public function connect()
{
$redirectUri = route('restream.callback');
$stateToken = Str::random();
session(['state-token' => $stateToken]);
$url = "{$this->base_url}/login?response_type=code&client_id={$this->client_id}&redirect_uri={$redirectUri}&state={$stateToken}";
return Redirect::away($url);
}
public function exchange($code)
{
try {
$client = new Client();
$response = $client->post("{$this->base_url}/oauth/token", [
'form_params' => [
'grant_type' => 'authorization_code',
'redirect_uri' => route('restream.callback'),
'code' => $code,
'client_id' => $this->client_id,
'client_secret' => $this->client_secret,
],
]);
$status = $response->getStatusCode();
$data = json_decode($response->getBody());
if ($status == 200) {
$item = RestreamToken::updateOrCreate([ 'client_id' => $this->client_id ], [
'access_token' => $data->access_token,
'refresh_token' => $data->refresh_token,
'scope' => json_encode($data->scope),
'type' => $data->token_type,
]);
Log::info('Restream Connected');
return true;
} else {
Log::info("Status code: {$status}");
Log::info("Message: {$data->message}");
}
} catch (ClientException $exception) {
}
return false;
}
public function isConnected()
{
$token = RestreamToken::first();
if ($token) {
return true;
}
return false;
}
public function updateSettings($clientId, $clientSecret)
{
RestreamSetting::truncate();
RestreamSetting::updateOrCreate([ 'client_id' => $clientId ], [ 'client_secret' => $clientSecret ]);
}
}

View File

View File

@@ -0,0 +1,30 @@
<?php
namespace Modules\Livestream\Traits;
use Illuminate\Http\JsonResponse;
trait ApiResponse
{
public function success($message, $item = null): JsonResponse
{
$response = [
'message' => $message,
];
if (is_array($item)) {
$response = array_merge($response, $item);
} else {
$response['item'] = $item;
}
return response()->json($response);
}
public function failed($message, $status = 400): JsonResponse
{
return response()->json([
'message' => $message,
], $status);
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "nwidart/livestream",
"description": "",
"authors": [
{
"name": "Nicolas Widart",
"email": "n.widart@gmail.com"
}
],
"extra": {
"laravel": {
"providers": [],
"aliases": {
}
}
},
"autoload": {
"psr-4": {
"Modules\\Livestream\\": ""
}
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "Livestream",
"alias": "livestream",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Livestream\\Providers\\LivestreamServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}

10766
Modules/Livestream/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "mix",
"watch": "mix watch",
"watch-poll": "mix watch -- --watch-options-poll=1000",
"hot": "mix watch --hot",
"prod": "npm run production",
"production": "mix --production"
},
"devDependencies": {
"autoprefixer": "^10.4.16",
"axios": "^1.6.1",
"cross-env": "^7.0",
"dotenv": "^10.0.0",
"dotenv-expand": "^5.1.0",
"laravel-mix": "^6.0.31",
"laravel-mix-merge-manifest": "^2.1.0",
"laravel-vite-plugin": "^0.8.0",
"lodash": "^4.17.21",
"postcss": "^8.4.32",
"resolve-url-loader": "^5.0.0",
"sass": "^1.69.5",
"sass-loader": "^12.1.0",
"tailwindcss": "^3.3.5",
"vue-loader": "^17.0.1"
},
"dependencies": {
"pinia": "^2.1.7",
"primeicons": "^6.0.1",
"primevue": "^3.42.0",
"vue": "^3.3.9",
"vue3-toastify": "^0.2.1"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./Resources/**/*.blade.php",
"./Resources/**/*.js",
"./Resources/**/*.vue",
],
theme: {
extend: {},
},
plugins: [],
corePlugins: {
preflight: false,
},
}

View File

@@ -0,0 +1,27 @@
const dotenvExpand = require('dotenv-expand');
dotenvExpand(require('dotenv').config({ path: '../../.env'/*, debug: true*/}));
const mix = require('laravel-mix');
require('laravel-mix-merge-manifest');
mix.setPublicPath('./Public').mergeManifest();
mix.webpackConfig({
resolve: {
extensions: ['*', '.js', '.vue', '.json'],
}
});
mix.alias({
'@': __dirname + '/Resources/client',
'~': __dirname,
})
mix.js(__dirname + '/Resources/client/main.js', 'js/livestream.js')
.vue()
.sass(__dirname + '/Resources/assets/sass/primevue.scss', 'css/primevue.css')
.sass(__dirname + '/Resources/assets/sass/app.scss', 'css/livestream.css')
.postCss(__dirname + '/Resources/assets/sass/tailwind.css', 'css/base.css', [require('tailwindcss')]);
if (mix.inProduction()) {
mix.version();
}

View File

@@ -75,5 +75,6 @@
"Theme": true,
"Frontend": true,
"Staff": true,
"LaraBuilder": true
"LaraBuilder": true,
"Livestream": true
}