├── assets ├── .eslintignore ├── .prettierrc.json ├── js │ ├── types │ │ ├── emoji-mart │ │ │ └── index.d.ts │ │ ├── react-swm-icon-pack │ │ │ └── index.d.ts │ │ └── types.ts │ ├── components │ │ ├── event │ │ │ ├── MobileLeftSidebar.tsx │ │ │ ├── animations │ │ │ │ └── ConfettiAnimation.tsx │ │ │ ├── ShareButton.tsx │ │ │ ├── MobileBottomPanel.tsx │ │ │ ├── MobileRightSidebar.tsx │ │ │ ├── ModePanel.tsx │ │ │ ├── RtcPlayer.tsx │ │ │ ├── MobileHlsBar.tsx │ │ │ ├── HlsControlBar.tsx │ │ │ ├── NamePopup.tsx │ │ │ └── ShareList.tsx │ │ ├── helpers │ │ │ ├── svg │ │ │ │ ├── FacebookIcon.tsx │ │ │ │ ├── MessengerIcon.tsx │ │ │ │ └── TwitterIcon.tsx │ │ │ ├── GenericButton.tsx │ │ │ ├── MenuPopover.tsx │ │ │ └── GoogleButton.tsx │ │ ├── dashboard │ │ │ ├── UserField.tsx │ │ │ ├── SearchAndCreatePanel.tsx │ │ │ ├── MobileHeader.tsx │ │ │ ├── SideDashboardPanel.tsx │ │ │ ├── WelcomePanel.tsx │ │ │ ├── EventField.tsx │ │ │ ├── MembraneLogo.tsx │ │ │ └── EventsArea.tsx │ │ └── Router.tsx │ ├── utils │ │ ├── collectionUtils.ts │ │ ├── useToggle.tsx │ │ ├── useAutoHideMobileBottomBar.tsx │ │ ├── ScreenTypeContext.ts │ │ ├── StreamStartContext.tsx │ │ ├── reactUtils.ts │ │ ├── hlsLatancy.ts │ │ ├── modePanelUtils.ts │ │ ├── const.ts │ │ ├── googleAuthUtils.ts │ │ ├── chatUtils.ts │ │ ├── useRecordingChatMessages.ts │ │ ├── useHls.ts │ │ ├── dashboardUtils.ts │ │ └── headerUtils.ts │ ├── app.tsx │ ├── services │ │ ├── jwtApi.ts │ │ └── index.tsx │ └── pages │ │ └── Dashboard.tsx ├── css │ ├── recording │ │ └── recording.css │ ├── dashboard │ │ ├── dashboard.css │ │ ├── userfield.css │ │ ├── searchandcreatepanel.css │ │ ├── welcomepanel.css │ │ ├── modalform.css │ │ ├── sidedashboardpanel.css │ │ └── eventform.css │ ├── event │ │ ├── event.css │ │ ├── namepopup.css │ │ ├── streamarea.css │ │ ├── mobilebottompanel.css │ │ ├── modepanel.css │ │ ├── presenterarea.css │ │ ├── animation.css │ │ ├── mobilesidebars.css │ │ ├── hlscontrolbar.css │ │ ├── shareButton.css │ │ ├── hlsplayer.css │ │ ├── rtcplayer.css │ │ ├── participants.css │ │ ├── controlpanel.css │ │ └── header.css │ ├── toast.css │ └── main.css ├── tsconfig.json ├── helpers │ └── timer.html ├── .eslintrc.json └── package.json ├── lib ├── membrane_live_web │ ├── templates │ │ ├── page │ │ │ └── index.html.heex │ │ └── layout │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ └── root.html.heex │ ├── views │ │ ├── page_view.ex │ │ ├── layout_view.ex │ │ ├── login_view.ex │ │ ├── error_view.ex │ │ ├── chat_view.ex │ │ ├── user_view.ex │ │ ├── changeset_view.ex │ │ ├── error_helpers.ex │ │ ├── recordings_view.ex │ │ └── webinar_view.ex │ ├── controllers │ │ ├── page_controller.ex │ │ ├── user_info_controller.ex │ │ ├── chat_controller.ex │ │ ├── fallback_controller.ex │ │ ├── login_controller.ex │ │ ├── recordings_controller.ex │ │ ├── user_controller.ex │ │ └── webinar_controller.ex │ ├── channels │ │ ├── event_presence.ex │ │ └── event_socket.ex │ ├── gettext.ex │ ├── endpoint.ex │ ├── helpers │ │ ├── token_error_info.ex │ │ ├── controller_callback_helper.ex │ │ └── ets_helper.ex │ ├── plugs │ │ └── auth.ex │ └── router.ex ├── membrane_live │ ├── mailer.ex │ ├── repo.ex │ ├── helpers.ex │ ├── tokens │ │ ├── google_token.ex │ │ └── custom_tokens.ex │ ├── accounts │ │ └── user.ex │ ├── application.ex │ ├── webinars │ │ └── webinar.ex │ ├── chats │ │ └── chat.ex │ ├── accounts.ex │ └── tokens.ex ├── membrane_live.ex └── membrane_live_web.ex ├── priv ├── repo │ └── migrations │ │ ├── .formatter.exs │ │ ├── 20220824133830_add_constraints.exs │ │ ├── 20230323112706_add_is_private_field_to_webinars.exs │ │ ├── 20221013194113_add_is_finished_field_to_webinars.exs │ │ ├── 20220822133355_add_moderator_to_webinar.exs │ │ ├── 20220804123329_create_users.exs │ │ ├── 20220718102030_create_webinars.exs │ │ └── 20221115140746_add_chats_table.exs ├── static │ ├── favicon.ico │ ├── robots.txt │ └── icons │ │ ├── heart-regular.svg │ │ ├── share-nodes-regular.svg │ │ ├── comments-regular.svg │ │ └── gifts-regular.svg └── gettext │ └── errors.pot ├── .formatter.exs ├── config ├── prod.exs ├── dev.exs ├── test.exs ├── config.exs └── runtime.exs ├── test ├── membrane_live_web │ └── views │ │ ├── error_view_test.exs │ │ ├── layout_view_test.exs │ │ └── page_view_test.exs ├── test_helper.exs ├── support │ ├── fixtures │ │ ├── webinars_fixtures.ex │ │ └── accounts_fixtures.ex │ ├── channel_case.ex │ ├── conn_case.ex │ ├── token │ │ └── google_token_mock.ex │ └── data_case.ex └── membrane_live │ ├── accounts_test.exs │ └── event_service_test.exs ├── docker-entrypoint.sh ├── .editorconfig ├── .env.sample ├── make_release.sh ├── scripts └── key-gen.sh ├── .circleci └── config.yml ├── docker-compose.yml ├── README.md ├── .github └── workflows │ ├── staging_build_and_deploy.yml │ ├── sandbox_build_and_deploy.template │ └── tag_build_and_deploy.yml ├── Dockerfile └── docker-compose-test.yml /assets/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /assets/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /lib/membrane_live_web/templates/page/index.html.heex: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /assets/js/types/emoji-mart/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@emoji-mart/react"; 2 | -------------------------------------------------------------------------------- /lib/membrane_live_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 | <%= @inner_content %> 2 | -------------------------------------------------------------------------------- /lib/membrane_live_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 | <%= @inner_content %> -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework-labs/membrane_live/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /lib/membrane_live_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.PageView do 2 | use MembraneLiveWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "{lib,test,config}/**/*.{ex,exs}", 4 | ".formatter.exs", 5 | "*.exs" 6 | ] 7 | ] 8 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, level: :info 4 | 5 | config :membrane_live, MembraneLiveWeb.Endpoint, server: true 6 | -------------------------------------------------------------------------------- /lib/membrane_live/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Mailer do 2 | @moduledoc false 3 | 4 | use Swoosh.Mailer, otp_app: :membrane_live 5 | end 6 | -------------------------------------------------------------------------------- /test/membrane_live_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.ErrorViewTest do 2 | use MembraneLiveWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/membrane_live_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.LayoutViewTest do 2 | use MembraneLiveWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/membrane_live_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.PageViewTest do 2 | use MembraneLiveWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Docker entrypoint script. 3 | 4 | bin/membrane_live eval "MembraneLive.Release.create_and_migrate" 5 | 6 | exec "$@" 7 | -------------------------------------------------------------------------------- /lib/membrane_live/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Repo do 2 | use Ecto.Repo, 3 | otp_app: :membrane_live, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(capture_log: true) 2 | Ecto.Adapters.SQL.Sandbox.mode(MembraneLive.Repo, :manual) 3 | Application.ensure_all_started(:bypass) 4 | -------------------------------------------------------------------------------- /lib/membrane_live_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.LayoutView do 2 | use MembraneLiveWeb, :view 3 | 4 | @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} 5 | end 6 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220824133830_add_constraints.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Repo.Migrations.AddConstraints do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index(:users, :email) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 100 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /lib/membrane_live_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.PageController do 2 | use MembraneLiveWeb, :controller 3 | 4 | @spec index(Plug.Conn.t(), any) :: Plug.Conn.t() 5 | def index(conn, _params) do 6 | render(conn, "index.html") 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/membrane_live_web/views/login_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.LoginView do 2 | use MembraneLiveWeb, :view 3 | 4 | def render("token.json", %{auth_token: auth_token, refresh_token: ref_token}) do 5 | %{authToken: auth_token, refreshToken: ref_token} 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230323112706_add_is_private_field_to_webinars.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Repo.Migrations.AddIsPrivateFieldToWebinars do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:webinars) do 6 | add(:is_private, :boolean, default: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221013194113_add_is_finished_field_to_webinars.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Repo.Migrations.AddIsFinishedFieldToWebinars do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:webinars) do 6 | add(:is_finished, :boolean, default: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /assets/js/components/event/MobileLeftSidebar.tsx: -------------------------------------------------------------------------------- 1 | export const MobileLeftSidebar = () => { 2 | return ( 3 |
4 |
5 | {" 6 |
7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /assets/js/utils/collectionUtils.ts: -------------------------------------------------------------------------------- 1 | export const groupBy = (arr: T[], fn: (item: T) => string): Record => 2 | arr.reduce>((prev, curr) => { 3 | const groupKey = fn(curr); 4 | const group = prev[groupKey] || []; 5 | group.push(curr); 6 | return { ...prev, [groupKey]: group }; 7 | }, {}); 8 | -------------------------------------------------------------------------------- /assets/js/utils/useToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | export const useToggle = (initialState = false): [boolean, () => void] => { 4 | const [state, setState] = useState(initialState); 5 | 6 | const toggle = useCallback(() => setState((state) => !state), []); 7 | 8 | return [state, toggle]; 9 | }; 10 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=membrane 2 | POSTGRES_PASSWORD=use-some-random-words-maybe 3 | POSTGRES_DB=live_db 4 | POSTGRES_HOST=localhost 5 | POSTGRES_PORT=5432 6 | VIRTUAL_HOST=localhost 7 | GOOGLE_CLIENT_ID=1273239583725-yourgoogleclientid1234.apps.googleusercontent.com 8 | JELLYFISH_ADDRESS=localhost:5002 9 | JELLYFISH_TOKEN=development 10 | JELLYFISH_SECURE=false -------------------------------------------------------------------------------- /priv/repo/migrations/20220822133355_add_moderator_to_webinar.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Repo.Migrations.AddModeratorToWebinar do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:webinars) do 6 | add(:moderator_id, references("users", column: :uuid, type: :binary_id, on_delete: :delete_all), null: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /assets/css/recording/recording.css: -------------------------------------------------------------------------------- 1 | .RecordingHlsDiv { 2 | flex: 1; 3 | min-height: 0; 4 | width: 100%; 5 | display: flex; 6 | justify-content: center; 7 | } 8 | 9 | @media screen and (max-height: 500px) { 10 | } 11 | 12 | .RecordingHlsDiv { 13 | min-height: 0; 14 | display: flex; 15 | flex-direction: column; 16 | height: -webkit-fill-available; 17 | } 18 | -------------------------------------------------------------------------------- /assets/js/utils/useAutoHideMobileBottomBar.tsx: -------------------------------------------------------------------------------- 1 | import { useOnScreenTypeChange } from "./ScreenTypeContext"; 2 | import type { CardStatus } from "../types/types"; 3 | 4 | export const useAutoHideMobileBottomBar = (setCard: (value: CardStatus) => void) => { 5 | useOnScreenTypeChange(({ orientation }) => { 6 | if (orientation === "landscape") setCard("hidden"); 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /lib/membrane_live_web/channels/event_presence.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.Presence do 2 | @moduledoc """ 3 | Stores and propagates data about viewers activity. 4 | """ 5 | use Phoenix.Presence, 6 | otp_app: :membrane_live, 7 | pubsub_server: MembraneLive.PubSub 8 | 9 | def absent?(topic, key) do 10 | get_by_key(topic, key) |> Enum.empty?() 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/membrane_live_web/channels/event_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.EventSocket do 2 | use Phoenix.Socket 3 | 4 | channel("event:*", MembraneLiveWeb.EventChannel) 5 | channel("private:*", MembraneLiveWeb.EventChannel) 6 | 7 | @impl true 8 | def connect(_params, socket, _connect_info) do 9 | {:ok, socket} 10 | end 11 | 12 | @impl true 13 | def id(_socket), do: nil 14 | end 15 | -------------------------------------------------------------------------------- /assets/js/components/helpers/svg/FacebookIcon.tsx: -------------------------------------------------------------------------------- 1 | const FacebookIcon = (props: React.SVGProps) => ( 2 | 3 | 7 | 8 | ); 9 | 10 | export default FacebookIcon; 11 | -------------------------------------------------------------------------------- /lib/membrane_live.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive do 2 | @moduledoc """ 3 | MembraneLive keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | 10 | @spec get_env!(atom) :: any 11 | def get_env!(key), do: Application.fetch_env!(:membrane_live, key) 12 | end 13 | -------------------------------------------------------------------------------- /assets/css/dashboard/dashboard.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .Dashboard { 4 | background: var(--bg-light-color-1); 5 | width: 100vw; 6 | height: calc(var(--window-inner-height) - 1px); 7 | overflow: hidden; 8 | padding: 2.5rem; 9 | display: flex; 10 | gap: 2.5rem; 11 | box-sizing: border-box; 12 | } 13 | 14 | .MainDashboardArea { 15 | flex: 1; 16 | display: flex; 17 | flex-direction: column; 18 | } 19 | -------------------------------------------------------------------------------- /assets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "jsx": "react-jsx", 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "outDir": "../priv/static", 12 | "strict": true, 13 | "target": "es2020" 14 | }, 15 | "include": ["js"] 16 | } 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220804123329_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users, primary_key: false) do 6 | add(:uuid, :binary_id, primary_key: true) 7 | add(:name, :string, null: false) 8 | add(:email, :string, null: false) 9 | add(:picture, :string, null: false) 10 | 11 | timestamps() 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /make_release.sh: -------------------------------------------------------------------------------- 1 | mix deps.get --only prod 2 | MIX_ENV=prod mix compile 3 | 4 | npm install --prefix assets 5 | MIX_ENV=prod mix assets.deploy 6 | 7 | yes | mix phx.gen.release 8 | 9 | yes | MIX_ENV=prod mix release 10 | 11 | echo """ 12 | //////////////////////////////////////////////////// 13 | 14 | Release ready. On server type: 15 | 16 | $> tmux 17 | $> _build/prod/rel/membrane_live/bin/membrane_live start 18 | 19 | crtl + b then d - to go out of tmux 20 | """ 21 | -------------------------------------------------------------------------------- /lib/membrane_live_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.ErrorView do 2 | # credo:disable-for-this-file 3 | use MembraneLiveWeb, :view 4 | 5 | alias Plug.Conn.Status 6 | 7 | def render("error.json", %{error: error_atom, message: message}) do 8 | %{status: Status.code(error_atom), message: message} 9 | end 10 | 11 | def render(_any_error, %{error: error_atom, message: message}) do 12 | %{status: Status.code(error_atom), message: message} 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/membrane_live_web/controllers/user_info_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.UserInfoController do 2 | use MembraneLiveWeb, :controller 3 | 4 | alias MembraneLive.Accounts 5 | 6 | def index(conn, _params) do 7 | {:ok, user_info} = Accounts.get_user(conn.assigns.user_id) 8 | 9 | json( 10 | conn, 11 | %{ 12 | name: user_info.name, 13 | email: user_info.email, 14 | picture: user_info.picture 15 | } 16 | ) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220718102030_create_webinars.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Repo.Migrations.CreateWebinars do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:webinars, primary_key: false) do 6 | add(:uuid, :binary_id, primary_key: true) 7 | add(:title, :string, null: false) 8 | add(:start_date, :naive_datetime, null: false) 9 | add(:description, :string) 10 | add(:presenters, {:array, :string}) 11 | 12 | timestamps() 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/membrane_live_web/views/chat_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.ChatView do 2 | use MembraneLiveWeb, :view 3 | 4 | alias MembraneLiveWeb.ChatView 5 | 6 | def render("index.json", %{chat_messages: chat_messages}) do 7 | %{chats: render_many(chat_messages, ChatView, "chat.json")} 8 | end 9 | 10 | def render("chat.json", %{chat: chat}) do 11 | %{ 12 | content: chat.content, 13 | email: chat.email, 14 | name: chat.name, 15 | offset: chat.offset 16 | } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /assets/js/utils/ScreenTypeContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect } from "react"; 2 | import { ScreenType } from "../types/types"; 3 | 4 | export const ScreenTypeContext = createContext({ device: "desktop", orientation: "landscape" }); 5 | 6 | export const useOnScreenTypeChange = (callback: (screenType: ScreenType) => void) => { 7 | const screenType: ScreenType = useContext(ScreenTypeContext); 8 | 9 | useEffect(() => { 10 | callback(screenType); 11 | }, [callback, screenType]); 12 | }; 13 | -------------------------------------------------------------------------------- /assets/js/app.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom/client"; 2 | import { ChakraProvider } from "@chakra-ui/react"; 3 | import Router from "./components/Router"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | 6 | const queryClient = new QueryClient(); 7 | 8 | const main = document.getElementById("main"); 9 | if (main != null) 10 | ReactDOM.createRoot(main).render( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /assets/js/components/helpers/svg/MessengerIcon.tsx: -------------------------------------------------------------------------------- 1 | const MessengerIcon = (props: React.SVGProps) => ( 2 | 3 | 7 | 8 | ); 9 | 10 | export default MessengerIcon; 11 | -------------------------------------------------------------------------------- /lib/membrane_live_web/views/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.UserView do 2 | use MembraneLiveWeb, :view 3 | alias MembraneLiveWeb.UserView 4 | 5 | def render("index.json", %{users: users}) do 6 | %{data: render_many(users, UserView, "user.json")} 7 | end 8 | 9 | def render("show.json", %{user: user}) do 10 | %{data: render_one(user, UserView, "user.json")} 11 | end 12 | 13 | def render("user.json", %{user: user}) do 14 | %{ 15 | uuid: user.uuid, 16 | name: user.name, 17 | email: user.email, 18 | picture: user.picture 19 | } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /assets/css/event/event.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .EventPage { 4 | background: var(--bg-light-color-1); 5 | width: 100vw; 6 | height: calc(var(--window-inner-height) - 1px); 7 | overflow: hidden; 8 | padding: 2.5rem; 9 | display: flex; 10 | flex-direction: column; 11 | gap: 2.5rem; 12 | box-sizing: border-box; 13 | } 14 | 15 | .MainGrid { 16 | flex: 1; 17 | min-height: 0; 18 | display: flex; 19 | gap: 2.5rem; 20 | position: relative; 21 | } 22 | 23 | @media screen and (max-width: 500px), screen and (max-height: 500px) { 24 | .EventPage { 25 | overflow: scroll; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /assets/js/components/helpers/GenericButton.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | 3 | type GenericButtonProps = { 4 | icon: JSX.Element; 5 | onClick?: () => void; 6 | className?: string; 7 | disabled?: boolean; 8 | }; 9 | 10 | const GenericButton = forwardRef( 11 | ({ icon, onClick, className, disabled }, ref) => { 12 | return ( 13 | 16 | ); 17 | } 18 | ); 19 | GenericButton.displayName = "GenericButton"; 20 | 21 | export default GenericButton; 22 | -------------------------------------------------------------------------------- /lib/membrane_live/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Helpers do 2 | @moduledoc false 3 | 4 | @spec pid_hash(pid()) :: String.t() 5 | def pid_hash(pid) do 6 | pid |> pid_to_hash() 7 | end 8 | 9 | defp pid_to_hash(pid) do 10 | :crypto.hash(:md5, :erlang.pid_to_list(pid)) |> Base.encode16(case: :lower) 11 | end 12 | 13 | def valid_uuid?(uuid) do 14 | String.match?(uuid, ~r/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) 15 | end 16 | 17 | def underscore_keys(attrs) do 18 | attrs 19 | |> Enum.map(fn {k, v} -> {Inflex.underscore(k), v} end) 20 | |> Enum.into(%{}) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /assets/css/dashboard/userfield.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .UserField { 4 | display: flex; 5 | align-items: center; 6 | gap: 1.5rem; 7 | } 8 | 9 | .UserIcon { 10 | border-radius: 50%; 11 | height: 4.5rem; 12 | width: auto; 13 | } 14 | 15 | .UserIcon path { 16 | stroke: var(--font-dark-color); 17 | } 18 | 19 | .UserName { 20 | color: var(--font-dark-color); 21 | font-size: 2rem; 22 | } 23 | 24 | .LogInButton { 25 | color: var(--bg-light-color-1); 26 | background: var(--button-red); 27 | font-size: 1.6rem; 28 | font-weight: 600; 29 | border-radius: 40px; 30 | padding: 1.3rem 3rem; 31 | margin-left: auto; 32 | } 33 | -------------------------------------------------------------------------------- /assets/css/event/namepopup.css: -------------------------------------------------------------------------------- 1 | .SaveButton { 2 | background-color: var(--font-dark-color); 3 | color: var(--bg-light-color-1); 4 | font-size: 1.6rem; 5 | font-weight: 600; 6 | border-radius: 20px; 7 | padding: 1.3rem 3rem; 8 | margin-left: auto; 9 | } 10 | 11 | .UsernameInput { 12 | border: 2px solid var(--font-dark-color); 13 | border-radius: 20px; 14 | padding: 1.6rem 1.4rem; 15 | outline: none; 16 | font-weight: 400; 17 | } 18 | 19 | .PopupHeader { 20 | padding: 1.6rem 1.4rem; 21 | color: var(--font-dark-color); 22 | font-size: 1.6rem; 23 | font-weight: 600; 24 | } 25 | 26 | .ModalForm { 27 | width: 90%; 28 | max-width: 40rem; 29 | } 30 | -------------------------------------------------------------------------------- /lib/membrane_live/tokens/google_token.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Tokens.GoogleToken do 2 | @moduledoc """ 3 | Module for verifying and validating google tokens 4 | """ 5 | 6 | alias Joken.Config 7 | 8 | @possible_issuers ["accounts.google.com", "https://accounts.google.com"] 9 | 10 | def verify_and_validate(jwt, signer) do 11 | Joken.verify_and_validate(get_claims(), jwt, signer) 12 | end 13 | 14 | defp get_claims() do 15 | %{} 16 | |> Config.add_claim("aud", nil, &(&1 == MembraneLive.get_env!(:client_id))) 17 | |> Config.add_claim("exp", nil, &(Joken.current_time() <= &1)) 18 | |> Config.add_claim("iss", nil, &Enum.member?(@possible_issuers, &1)) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/membrane_live/accounts/user.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Accounts.User do 2 | @moduledoc """ 3 | Data about users persisted into the database 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | 8 | @derive {Phoenix.Param, key: :uuid} 9 | @primary_key {:uuid, :binary_id, autogenerate: true} 10 | 11 | schema "users" do 12 | field(:email, EctoFields.Email) 13 | field(:name, :string) 14 | field(:picture, :string) 15 | 16 | timestamps() 17 | end 18 | 19 | @doc false 20 | def changeset(user, attrs) do 21 | user 22 | |> cast(attrs, [:name, :email, :picture]) 23 | |> validate_required([:name, :email, :picture]) 24 | |> unique_constraint(:email) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /scripts/key-gen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | DIR="./test/files/keys" 3 | ORGIN_DIR=`pwd` 4 | 5 | mkdir -p $DIR 6 | cd $DIR 7 | 8 | FILENAME="jwtRS256" 9 | 10 | if ! [ -f "${FILENAME}.key" ] && \ 11 | ssh-keygen -t rsa -P "" -b 4096 -m PEM -f "${FILENAME}.key" 1> /dev/null && \ 12 | ssh-keygen -e -m PEM -f "${FILENAME}.key" > "${FILENAME}.key.pub"; then 13 | 14 | echo "JWT for tests was generated successfully" 15 | fi 16 | 17 | if ! [ -f "${FILENAME}-invalid.key" ] && \ 18 | ssh-keygen -t rsa -P "" -b 4096 -m PEM -f "${FILENAME}-invalid.key" 1> /dev/null && \ 19 | rm "${FILENAME}-invalid.key.pub" ; then 20 | 21 | echo "Invalid JWT for tests was generated successfully" 22 | fi 23 | 24 | cd $ORGIN_DIR -------------------------------------------------------------------------------- /assets/css/event/streamarea.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .StreamArea { 4 | flex: 1; 5 | min-width: 0; 6 | display: flex; 7 | flex-direction: column; 8 | gap: 2.5rem; 9 | } 10 | 11 | .Stream { 12 | flex: 1; 13 | min-height: 0; 14 | width: 100%; 15 | display: flex; 16 | justify-content: center; 17 | position: relative; 18 | } 19 | 20 | .Hidden { 21 | position: absolute; 22 | z-index: -1; 23 | top: 0; 24 | } 25 | 26 | @media screen and (max-width: 500px), screen and (max-height: 500px) { 27 | .MobileHlsBar { 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | padding: 1rem; 32 | gap: 2rem; 33 | } 34 | } 35 | 36 | .disable-safaris-zoom { 37 | font-size: 16px; 38 | } 39 | -------------------------------------------------------------------------------- /assets/js/components/helpers/svg/TwitterIcon.tsx: -------------------------------------------------------------------------------- 1 | const TwitterIcon = (props: React.SVGProps) => ( 2 | 3 | 7 | 8 | ); 9 | 10 | export default TwitterIcon; 11 | -------------------------------------------------------------------------------- /lib/membrane_live_web/controllers/chat_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.ChatController do 2 | use MembraneLiveWeb, :controller 3 | 4 | alias MembraneLive.Chats 5 | 6 | action_fallback(MembraneLiveWeb.FallbackController) 7 | 8 | def index(conn, %{"uuid" => id}) do 9 | chat_messages = 10 | id 11 | |> Chats.get_event_chat_messages() 12 | |> Enum.map( 13 | &%{ 14 | content: &1.content, 15 | email: if(is_nil(&1.anon_id), do: &1.auth_user_email, else: &1.anon_id), 16 | name: if(is_nil(&1.user_name), do: &1.auth_user_name, else: &1.user_name), 17 | offset: &1.offset 18 | } 19 | ) 20 | 21 | render(conn, "index.json", chat_messages: chat_messages) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/membrane_live_web/views/changeset_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.ChangesetView do 2 | use MembraneLiveWeb, :view 3 | # credo:disable-for-this-file 4 | 5 | @spec translate_errors(Ecto.Changeset.t()) :: %{optional(atom) => list} 6 | @doc """ 7 | Traverses and translates changeset errors. 8 | 9 | See `Ecto.Changeset.traverse_errors/2` and 10 | `MembraneLiveWeb.ErrorHelpers.translate_error/1` for more details. 11 | """ 12 | def translate_errors(changeset) do 13 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1) 14 | end 15 | 16 | def render("error.json", %{changeset: changeset}) do 17 | # When encoded, the changeset returns its errors 18 | # as a JSON object. So we just pass it forward. 19 | %{errors: translate_errors(changeset)} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :membrane_live, MembraneLiveWeb.Endpoint, 4 | http: [ip: {0, 0, 0, 0}, port: 4000], 5 | check_origin: false, 6 | code_reloader: true, 7 | debug_errors: true, 8 | secret_key_base: "QjAIZb1uCiEFSjAFQB/iEMBnvlNTOSC6afbYaWNSzpOfKFLq3vHhdO+L4QXlZHqC", 9 | watchers: [ 10 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} 11 | ] 12 | 13 | config :membrane_live, MembraneLiveWeb.Endpoint, 14 | live_reload: [ 15 | patterns: [ 16 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 17 | ~r"priv/gettext/.*(po)$", 18 | ~r"lib/membrane_live_web/(live|views)/.*(ex)$", 19 | ~r"lib/membrane_live_web/templates/.*(eex)$" 20 | ] 21 | ] 22 | 23 | config :phoenix, :stacktrace_depth, 20 24 | 25 | config :phoenix, :plug_init_mode, :runtime 26 | -------------------------------------------------------------------------------- /assets/css/dashboard/searchandcreatepanel.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .SearchInput { 4 | font-size: 1.6rem; 5 | border: 0; 6 | outline: 0; 7 | width: 100%; 8 | } 9 | 10 | .SearchInput:focus { 11 | outline: none !important; 12 | } 13 | 14 | .SearchAndCreatePanel { 15 | display: flex; 16 | padding: 1.6rem 0; 17 | justify-content: space-between; 18 | } 19 | 20 | .SearchBar { 21 | color: var(--font-dark-color); 22 | width: 30rem; 23 | border: 1px solid var(--bg-light-color-4); 24 | border-radius: 40px; 25 | padding: 1.2rem; 26 | display: flex; 27 | align-items: center; 28 | gap: 0.75rem; 29 | } 30 | 31 | .SearchIcon path { 32 | stroke: var(--font-dark-color); 33 | } 34 | 35 | @media screen and (max-width: 500px) { 36 | .SearchAndCreatePanel { 37 | flex-direction: column; 38 | gap: 1.6rem; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/membrane_live_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import MembraneLiveWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :membrane_live 24 | end 25 | -------------------------------------------------------------------------------- /assets/js/components/helpers/MenuPopover.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Popover, PopoverArrow, PopoverBody, PopoverContent, PopoverTrigger } from "@chakra-ui/react"; 3 | import { MenuHorizontal } from "react-swm-icon-pack"; 4 | import GenericButton from "./GenericButton"; 5 | 6 | type MenuPopoverProps = { 7 | className?: string; 8 | children?: React.ReactNode; 9 | }; 10 | 11 | const MenuPopover = ({ className, children }: MenuPopoverProps) => { 12 | return ( 13 | 14 | 15 | } className={className} /> 16 | 17 | 18 | 19 | {children} 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default MenuPopover; 26 | -------------------------------------------------------------------------------- /assets/helpers/timer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | A simple clock 7 | 8 | 9 | 10 |
22 | 23 | 24 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /priv/static/icons/heart-regular.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/membrane_live_web/controllers/fallback_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.FallbackController do 2 | @moduledoc """ 3 | Translates controller action results into valid `Plug.Conn` responses. 4 | 5 | See `Phoenix.Controller.action_fallback/1` for more details. 6 | """ 7 | # credo:disable-for-this-file 8 | use MembraneLiveWeb, :controller 9 | 10 | # This clause handles errors returned by Ecto's insert/update/delete. 11 | def call(conn, {:error, %Ecto.Changeset{} = changeset}) do 12 | conn 13 | |> put_status(:unprocessable_entity) 14 | |> put_view(MembraneLiveWeb.ChangesetView) 15 | |> render("error.json", changeset: changeset) 16 | end 17 | 18 | def call(conn, %{error: error_atom} = params) do 19 | conn 20 | |> put_status(error_atom) 21 | |> put_view(MembraneLiveWeb.ErrorView) 22 | |> render("error.json", params) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /assets/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": "latest", 6 | "sourceType": "module" 7 | }, 8 | "env": { 9 | "browser": true, 10 | "es2021": true 11 | }, 12 | "plugins": ["react", "react-hooks", "@typescript-eslint"], 13 | "extends": [ 14 | "eslint:recommended", 15 | "plugin:@typescript-eslint/recommended", 16 | "plugin:@typescript-eslint/eslint-recommended", 17 | "plugin:react/recommended", 18 | "plugin:react/jsx-runtime", 19 | "plugin:react-hooks/recommended", 20 | "prettier" 21 | ], 22 | "rules": { 23 | "no-warning-comments": "warn", 24 | "react-hooks/rules-of-hooks": "error", 25 | "react-hooks/exhaustive-deps": "error", 26 | "@typescript-eslint/ban-ts-comment": "off" 27 | }, 28 | "settings": { 29 | "react": { 30 | "version": "detect" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /assets/css/event/mobilebottompanel.css: -------------------------------------------------------------------------------- 1 | .MobileBottomPanel { 2 | z-index: 100; 3 | width: 100%; 4 | background-color: white; 5 | border-radius: 16px 16px 0 0; 6 | overscroll-behavior-y: contain; 7 | } 8 | 9 | .MobileBottomPanelShadow { 10 | -webkit-box-shadow: 0px -4px 25px 0px rgba(66, 68, 90, 0.45); 11 | -moz-box-shadow: 0px -4px 25px 0px rgba(66, 68, 90, 0.45); 12 | box-shadow: 0px -4px 25px 0px rgba(66, 68, 90, 0.45); 13 | } 14 | 15 | .MobileBottomPanelTopBar { 16 | display: flex; 17 | flex-direction: row; 18 | align-items: center; 19 | justify-content: center; 20 | height: 16px; 21 | width: 100%; 22 | } 23 | 24 | .MobileBottomPanelTopBarBar { 25 | background-color: #8b8b8b; 26 | height: 4px; 27 | width: 100px; 28 | border-radius: 4px; 29 | } 30 | 31 | .MobileBottomPanelContent { 32 | display: flex; 33 | flex-direction: column; 34 | align-items: center; 35 | height: 50vh; 36 | width: 100%; 37 | } 38 | -------------------------------------------------------------------------------- /assets/js/components/dashboard/UserField.tsx: -------------------------------------------------------------------------------- 1 | import { User1 } from "react-swm-icon-pack"; 2 | import GoogleButton from "../helpers/GoogleButton"; 3 | import { rectangleGoogleButton } from "../../utils/const"; 4 | import { Channel } from "phoenix"; 5 | import "../../../css/dashboard/userfield.css"; 6 | 7 | type UserFieldProps = { 8 | picture: string; 9 | name: string; 10 | isAuthenticated: boolean; 11 | eventChannel?: Channel; 12 | }; 13 | 14 | const UserField = ({ isAuthenticated, picture, name, eventChannel }: UserFieldProps) => { 15 | return isAuthenticated ? ( 16 |
17 | {picture ? : } 18 |
{name}
19 |
20 | ) : ( 21 | 22 | ); 23 | }; 24 | 25 | export default UserField; 26 | -------------------------------------------------------------------------------- /assets/js/utils/StreamStartContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react"; 2 | import { createContext } from "react"; 3 | 4 | export type StartStreamContextType = { 5 | streamStart: Date | null; 6 | setStreamStart: React.Dispatch>; 7 | }; 8 | 9 | const StreamStartContext = createContext(undefined); 10 | 11 | type Props = { 12 | children: React.ReactNode; 13 | }; 14 | 15 | export const StreamStartProvider = ({ children }: Props) => { 16 | const [streamStart, setStreamStart] = useState(null); 17 | 18 | return {children}; 19 | }; 20 | 21 | export const useStartStream = (): StartStreamContextType => { 22 | const context = useContext(StreamStartContext); 23 | if (!context) throw new Error("useStartStream must be used within a UserProvider"); 24 | return context; 25 | }; 26 | -------------------------------------------------------------------------------- /assets/css/event/modepanel.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .ModePanel { 4 | background-color: var(--bg-light-color-2); 5 | color: var(--font-dark-color); 6 | font-size: 1.6rem; 7 | border: 1px solid var(--bg-light-color-3); 8 | border-radius: 24px; 9 | padding: 1.3rem 1.5rem; 10 | display: flex; 11 | align-items: center; 12 | gap: 0.7em; 13 | white-space: nowrap; 14 | } 15 | 16 | .PresentingNow { 17 | line-height: 4.5rem; 18 | } 19 | 20 | .ScreenIcon { 21 | height: 3rem; 22 | width: auto; 23 | } 24 | 25 | .ScreenIcon path { 26 | stroke: var(--font-dark-color); 27 | } 28 | 29 | .ModeButtons { 30 | margin-left: auto; 31 | display: flex; 32 | gap: 1em; 33 | } 34 | 35 | .ModeButton { 36 | color: var(--font-dark-color); 37 | border-radius: 16px; 38 | font-weight: 600; 39 | font-size: 1.6rem; 40 | padding: 1rem 1.5rem; 41 | } 42 | 43 | .ModeButton.Clicked { 44 | background: var(--font-dark-color); 45 | color: var(--bg-light-color-1); 46 | } 47 | -------------------------------------------------------------------------------- /lib/membrane_live_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= live_title_tag assigns[:page_title] || "Membrane Live" %> 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | <%= @inner_content %> 19 | 20 | 21 | -------------------------------------------------------------------------------- /assets/js/services/jwtApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from "axios"; 2 | import { authTokenKey, refreshTokenKey } from "../utils/storageUtils"; 3 | import { storageGetRefreshToken, storageGetAuthToken } from "../utils/storageUtils"; 4 | 5 | export const addJwtToHeader = (config: AxiosRequestConfig) => { 6 | if (config.headers) { 7 | config.headers.Authorization = getAuthBearer(); 8 | const refreshToken = storageGetRefreshToken(); 9 | if (refreshToken) config.headers.RefreshToken = refreshToken; 10 | else console.error("Jwt refresh token is not defined"); 11 | } 12 | return config; 13 | }; 14 | 15 | export const isUserAuthenticated = (): boolean => { 16 | return localStorage.getItem(authTokenKey) != null; 17 | }; 18 | 19 | export const destroyTokens = (): void => { 20 | localStorage.removeItem(authTokenKey); 21 | localStorage.removeItem(refreshTokenKey); 22 | }; 23 | 24 | export const getAuthBearer = (): string => { 25 | return `Bearer ${storageGetAuthToken()}`; 26 | }; 27 | -------------------------------------------------------------------------------- /lib/membrane_live/application.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | @impl true 6 | def start(_type, _args) do 7 | children = [ 8 | MembraneLive.Repo, 9 | {Phoenix.PubSub, name: MembraneLive.PubSub}, 10 | MembraneLiveWeb.Presence, 11 | MembraneLiveWeb.Endpoint, 12 | {MembraneLive.EventService, name: EventService} 13 | ] 14 | 15 | [ 16 | :presenters, 17 | :presenting_requests, 18 | :banned_from_chat, 19 | :start_timestamps, 20 | :client_start_timestamps, 21 | :main_presenters, 22 | :partial_segments 23 | ] 24 | |> Enum.each(&:ets.new(&1, [:public, :set, :named_table])) 25 | 26 | opts = [strategy: :one_for_one, name: MembraneLive.Supervisor] 27 | Supervisor.start_link(children, opts) 28 | end 29 | 30 | @impl true 31 | def config_change(changed, _new, removed) do 32 | MembraneLiveWeb.Endpoint.config_change(changed, removed) 33 | :ok 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/fixtures/webinars_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.WebinarsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `MembraneLive.Webinars` context. 5 | """ 6 | alias MembraneLive.Accounts.User 7 | 8 | @default_webinar_attrs %{ 9 | "description" => "some description", 10 | "presenters" => [], 11 | "start_date" => ~N[2022-07-17 10:20:00], 12 | "title" => "some title", 13 | "is_private" => false 14 | } 15 | 16 | @spec webinar_fixture(any, User.t()) :: Webinar.t() 17 | @doc """ 18 | Generate a webinar. 19 | """ 20 | def webinar_fixture(attrs \\ %{}, %User{} = user) do 21 | {:ok, webinar} = 22 | attrs 23 | |> Enum.into(@default_webinar_attrs) 24 | |> MembraneLive.Webinars.create_webinar(user.uuid) 25 | 26 | webinar 27 | end 28 | 29 | def webinar_attrs(), do: @default_webinar_attrs 30 | 31 | def webinar_attrs(moderator_uuid), 32 | do: Enum.into(@default_webinar_attrs, %{"moderator" => moderator_uuid}) 33 | end 34 | -------------------------------------------------------------------------------- /assets/css/event/presenterarea.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .PresenterArea { 4 | height: 100%; 5 | width: 100%; 6 | flex: 1; 7 | min-height: 0; 8 | display: flex; 9 | flex-direction: column; 10 | justify-content: flex-end; 11 | align-items: center; 12 | gap: 2.5rem; 13 | } 14 | 15 | .StreamsGrid { 16 | position: relative; 17 | flex: 1; 18 | min-height: 0; 19 | width: 100%; 20 | display: grid; 21 | gap: 1rem; 22 | justify-content: center; 23 | justify-items: center; 24 | } 25 | 26 | .Grid2 { 27 | grid-template-columns: 1fr 1fr; 28 | } 29 | 30 | .Grid3, 31 | .Grid4 { 32 | grid-template-columns: 1fr 1fr; 33 | grid-template-rows: 1fr 1fr; 34 | } 35 | 36 | .Grid5, 37 | .Grid6 { 38 | display: grid; 39 | grid-template-columns: 1fr 1fr 1fr; 40 | grid-template-rows: 1fr 1fr; 41 | } 42 | 43 | .StartPresentingButton { 44 | color: var(--bg-light-color-1); 45 | background: var(--font-dark-color); 46 | font-size: 1.6rem; 47 | font-weight: 600; 48 | border-radius: 40px; 49 | padding: 1.3rem 3rem; 50 | } 51 | -------------------------------------------------------------------------------- /assets/js/utils/reactUtils.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useDynamicResizing = () => { 4 | useEffect(() => { 5 | const changeCssHeightVariable = () => { 6 | const fiftyMiliseconds = 50; 7 | setTimeout( 8 | () => window.document.documentElement.style.setProperty("--window-inner-height", `${window.innerHeight}px`), 9 | fiftyMiliseconds 10 | ); 11 | }; 12 | 13 | changeCssHeightVariable(); 14 | window.addEventListener("resize", changeCssHeightVariable); 15 | 16 | return () => { 17 | window.onresize = null; 18 | }; 19 | }, []); 20 | }; 21 | 22 | export const useStateTimeout = ( 23 | callback: () => void, 24 | defaultValue = false, 25 | deactivationTime = 5_000 26 | ): [boolean, () => void] => { 27 | const [status, setStatus] = useState(defaultValue); 28 | 29 | const toggle = () => { 30 | setStatus(true); 31 | callback(); 32 | setTimeout(() => setStatus((prevStatus) => !prevStatus), deactivationTime); 33 | }; 34 | return [status, toggle]; 35 | }; 36 | -------------------------------------------------------------------------------- /lib/membrane_live_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @spec error_tag(atom | %{:errors => [{any, any}], optional(any) => any}, atom) :: list 9 | @doc """ 10 | Generates tag for inlined form input errors. 11 | """ 12 | def error_tag(form, field) do 13 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 14 | content_tag(:span, translate_error(error), 15 | class: "invalid-feedback", 16 | phx_feedback_for: input_name(form, field) 17 | ) 18 | end) 19 | end 20 | 21 | @spec translate_error({binary, keyword | map}) :: binary 22 | @doc """ 23 | Translates an error message using gettext. 24 | """ 25 | def translate_error({msg, opts}) do 26 | if count = opts[:count] do 27 | Gettext.dngettext(MembraneLiveWeb.Gettext, "errors", msg, msg, count, opts) 28 | else 29 | Gettext.dgettext(MembraneLiveWeb.Gettext, "errors", msg, opts) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /assets/css/event/animation.css: -------------------------------------------------------------------------------- 1 | @keyframes heartfade { 2 | 0% { 3 | opacity: 1; 4 | } 5 | 6 | 50% { 7 | opacity: 0; 8 | } 9 | } 10 | 11 | .heart { 12 | z-index: 999; 13 | animation: heartfade 6s linear; 14 | position: absolute; 15 | } 16 | 17 | .heart:before { 18 | content: ""; 19 | background-color: #fc2a62; 20 | position: absolute; 21 | height: 30px; 22 | width: 45px; 23 | border-radius: 15px 0px 0px 15px; 24 | transform: rotate(45deg); 25 | } 26 | 27 | .heart:after { 28 | content: ""; 29 | background-color: #fc2a62; 30 | position: absolute; 31 | height: 30px; 32 | width: 45px; 33 | border-radius: 15px 0px 0px 15px; 34 | left: 10.5px; 35 | transform: rotate(135deg); 36 | } 37 | 38 | .heartButton, 39 | .confettiButton { 40 | padding: 0 1.5rem; 41 | font-size: 24px; 42 | } 43 | 44 | .heartContainer, 45 | .confettiContainer { 46 | position: absolute; 47 | width: 0; 48 | height: 0; 49 | left: 50%; 50 | z-index: 1; 51 | } 52 | 53 | .heartContainer { 54 | bottom: 3rem; 55 | } 56 | 57 | .confettiContainer { 58 | top: 8rem; 59 | } 60 | -------------------------------------------------------------------------------- /assets/js/components/event/animations/ConfettiAnimation.tsx: -------------------------------------------------------------------------------- 1 | import { Channel } from "phoenix"; 2 | import "../../../../css/event/animation.css"; 3 | import { useEffect, useState } from "react"; 4 | import ConfettiExplosion from "react-confetti-explosion"; 5 | 6 | type ConfettiAnimationProps = { 7 | eventChannel: Channel | undefined; 8 | }; 9 | 10 | const ConfettiAnimation = ({ eventChannel }: ConfettiAnimationProps) => { 11 | const [isRun, setIsRun] = useState(false); 12 | const confettiAnimationMessage = "animation_confetti"; 13 | 14 | useEffect(() => { 15 | let timeoutRef: NodeJS.Timeout | null = null; 16 | const ref = eventChannel?.on(confettiAnimationMessage, () => { 17 | setIsRun(true); 18 | timeoutRef = setTimeout(() => setIsRun(false), 2_500); 19 | }); 20 | 21 | return () => { 22 | timeoutRef && clearTimeout(timeoutRef); 23 | eventChannel?.off(confettiAnimationMessage, ref); 24 | }; 25 | }, [eventChannel]); 26 | 27 | return
{isRun ? : <>}
; 28 | }; 29 | 30 | export default ConfettiAnimation; 31 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | bypass_port = 2137 4 | 5 | config :membrane_live, MembraneLive.Repo, 6 | username: "swm", 7 | password: "swm123", 8 | hostname: "localhost", 9 | database: "membrane_live_test", 10 | pool: Ecto.Adapters.SQL.Sandbox 11 | 12 | config :membrane_live, MembraneLiveWeb.Endpoint, 13 | http: [ip: {127, 0, 0, 1}, port: 4002], 14 | secret_key_base: "/KNhS9gaf+T6d+c+o1fFbocEd5sfyHFSGW5kd4VOQb7+7TAta0C44JJrTI9YEWxT", 15 | server: false 16 | 17 | config :membrane_live, MembraneLive.Mailer, adapter: Swoosh.Adapters.Test 18 | 19 | config :membrane_live, 20 | bypass_port: bypass_port, 21 | google_private_key_path: Path.expand("./test/files/keys/jwtRS256.key"), 22 | google_invalid_priv_key_path: Path.expand("./test/files/keys/jwtRS256-invalid.key"), 23 | google_public_key_path: Path.expand("./test/files/keys/jwtRS256.key.pub"), 24 | google_pems_url: "http://localhost:#{bypass_port}", 25 | empty_event_timeout_ms: 100, 26 | last_peer_timeout_ms: 100, 27 | response_timeout_ms: 100 28 | 29 | config :logger, level: :warning 30 | 31 | config :phoenix, :plug_init_mode, :runtime 32 | -------------------------------------------------------------------------------- /priv/static/icons/share-nodes-regular.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/js/services/index.tsx: -------------------------------------------------------------------------------- 1 | import * as JwtApi from "./jwtApi"; 2 | import { logOut, storageGetRefreshToken, storageSetJwt } from "../utils/storageUtils"; 3 | import axios from "axios"; 4 | 5 | export const axiosWithInterceptor = axios.create(); 6 | export const axiosWithoutInterceptor = axios.create(); 7 | 8 | axiosWithInterceptor.interceptors.request.use( 9 | (config) => { 10 | return JwtApi.addJwtToHeader(config); 11 | }, 12 | (error) => { 13 | Promise.reject(error); 14 | } 15 | ); 16 | 17 | axiosWithInterceptor.interceptors.response.use( 18 | (response) => response, 19 | async (error) => { 20 | const status = error.response ? error.response.status : null; 21 | if (status != 401) { 22 | return Promise.reject(error); 23 | } 24 | 25 | const refreshToken = storageGetRefreshToken(); 26 | try { 27 | const response = await axios.post("/auth/refresh", { refreshToken }); 28 | storageSetJwt(response.data); 29 | const updatedConfig = JwtApi.addJwtToHeader(error.response.config); 30 | return axiosWithInterceptor(updatedConfig); 31 | } catch (err) { 32 | logOut(); 33 | } 34 | } 35 | ); 36 | -------------------------------------------------------------------------------- /lib/membrane_live/tokens/custom_tokens.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Tokens.AuthToken do 2 | @moduledoc """ 3 | Module with auth token with custom configuration 4 | """ 5 | 6 | use Joken.Config 7 | 8 | import MembraneLive.Helpers 9 | 10 | @one_day 24 * 60 * 60 11 | 12 | def token_config do 13 | :token_issuer 14 | |> MembraneLive.get_env!() 15 | |> then(&default_claims(default_exp: @one_day, iss: &1, aud: &1)) 16 | |> add_claim("user_id", nil, &valid_uuid?/1) 17 | end 18 | end 19 | 20 | defmodule MembraneLive.Tokens.RefreshToken do 21 | @moduledoc """ 22 | Module with refresh token 23 | """ 24 | 25 | use Joken.Config 26 | 27 | import MembraneLive.Helpers 28 | 29 | @one_week 7 * 24 * 60 * 60 30 | 31 | @impl true 32 | def token_config do 33 | :token_issuer 34 | |> MembraneLive.get_env!() 35 | |> then(&default_claims(default_exp: @one_week, iss: &1, aud: &1)) 36 | |> add_claim("user_id", nil, &valid_uuid?/1) 37 | end 38 | 39 | def has_uuid?(jwt) do 40 | case Joken.peek_claims(jwt) do 41 | {:ok, %{"user_id" => _user_id}} -> true 42 | _error -> false 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use MembraneLiveWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import MembraneLiveWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint MembraneLiveWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | MembraneLive.DataCase.setup_sandbox(tags) 33 | :ok 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/fixtures/accounts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.AccountsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `MembraneLive.Accounts` context. 5 | """ 6 | alias MembraneLive.Accounts 7 | alias MembraneLive.Tokens 8 | 9 | @default_user_attrs %{ 10 | email: "john@gmail.com", 11 | name: "John Kowalski", 12 | picture: "kowalski.img" 13 | } 14 | 15 | @fake_user_attrs %{ 16 | email: "fake@gmail.com", 17 | name: "Fake Fakeston", 18 | picture: "fakeimage.img" 19 | } 20 | 21 | @doc """ 22 | Generate a user. 23 | """ 24 | def user_fixture(attrs \\ %{}) do 25 | {:ok, user} = 26 | attrs 27 | |> Enum.into(@default_user_attrs) 28 | |> MembraneLive.Accounts.create_user() 29 | 30 | user 31 | end 32 | 33 | def fake_user_fixture(), do: user_fixture(@fake_user_attrs) 34 | 35 | def user_attrs(), do: @default_user_attrs 36 | 37 | def create_user_with_token(google_claims) do 38 | {:ok, user} = Accounts.create_user_if_not_exists(google_claims) 39 | {:ok, token, _claims} = Tokens.auth_encode(user.uuid) 40 | {:ok, user, token} 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /assets/css/event/mobilesidebars.css: -------------------------------------------------------------------------------- 1 | .MobileRightSidebar { 2 | position: absolute; 3 | bottom: 50%; 4 | right: 0; 5 | transform: translate(0, 50%); 6 | 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | justify-content: center; 11 | gap: 1rem; 12 | 13 | color: white; 14 | } 15 | 16 | .MobileRightSidebarButton { 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | gap: 8px; 21 | width: fit-content; 22 | padding: 0 1rem; 23 | } 24 | 25 | .MobileRightSidebarButton > img { 26 | width: 3em; 27 | height: 3em; 28 | 29 | /* Hack for changing svg color 30 | * source: https://stackoverflow.com/a/68589357 31 | * 32 | * White color 33 | */ 34 | filter: invert(92%) sepia(100%) saturate(2%) hue-rotate(241deg) brightness(102%) contrast(101%) 35 | drop-shadow(0px 0px 4px rgb(0 0 0 / 0.4)); 36 | } 37 | 38 | @media screen and (max-height: 500px) { 39 | .MobileRightSidebar { 40 | bottom: 16rem; 41 | gap: 0.5rem; 42 | } 43 | 44 | .MobileRightSidebarButton { 45 | font-size: 1rem; 46 | } 47 | 48 | .MobileRightSidebarButton > img { 49 | width: 2em; 50 | height: 2em; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /assets/css/toast.css: -------------------------------------------------------------------------------- 1 | .Popup { 2 | background: var(--font-dark-color); 3 | border-radius: 16px; 4 | padding: 1.3rem; 5 | position: relative; 6 | display: flex; 7 | flex-direction: row; 8 | align-items: center; 9 | gap: 1.5rem; 10 | } 11 | 12 | .NormalPopup { 13 | padding-right: 4rem; 14 | } 15 | 16 | .PresenterPopupButton { 17 | color: var(--font-dark-color); 18 | background: var(--bg-light-color-2); 19 | font-family: var(--font-main); 20 | font-weight: 600; 21 | font-size: 1.6rem; 22 | padding: 1rem 1.5rem; 23 | border-radius: 16px; 24 | transition: color 0.5s, background 0.5s; 25 | } 26 | 27 | .PresenterPopupButton:hover { 28 | background: var(--bg-light-color-3); 29 | color: var(--font-dark-color); 30 | transition: color 0.5s, background 0.5s; 31 | } 32 | 33 | .PopupText { 34 | color: var(--bg-light-color-1); 35 | font-family: var(--font-main); 36 | font-weight: 600; 37 | font-size: 1.6rem; 38 | } 39 | 40 | .PopupIcon path { 41 | stroke: var(--bg-light-color-1); 42 | } 43 | 44 | .CrossBox { 45 | height: 2.5rem; 46 | position: absolute; 47 | right: 0.3rem; 48 | top: 0.3rem; 49 | } 50 | 51 | .CrossIcon path { 52 | stroke: var(--bg-light-color-1); 53 | } 54 | -------------------------------------------------------------------------------- /lib/membrane_live_web/views/recordings_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.RecordingsView do 2 | use MembraneLiveWeb, :view 3 | alias MembraneLiveWeb.WebinarView 4 | 5 | # credo:disable-for-this-file 6 | 7 | def render("link.json", %{link: link}) do 8 | %{link: link} 9 | end 10 | 11 | def render("index.json", %{webinars: webinars}) do 12 | %{webinars: render_many(webinars, WebinarView, "webinar.json")} 13 | end 14 | 15 | def render("show.json", %{webinar: webinar}) do 16 | %{webinar: render_one(webinar, WebinarView, "webinar.json")} 17 | end 18 | 19 | def render("webinar.json", %{webinar: %{moderator_email: email} = webinar}) do 20 | %{ 21 | uuid: webinar.uuid, 22 | title: webinar.title, 23 | start_date: webinar.start_date, 24 | description: webinar.description, 25 | presenters: webinar.presenters, 26 | moderator_email: email 27 | } 28 | end 29 | 30 | def render("webinar.json", %{webinar: webinar}) do 31 | %{ 32 | uuid: webinar.uuid, 33 | title: webinar.title, 34 | start_date: webinar.start_date, 35 | description: webinar.description, 36 | presenters: webinar.presenters 37 | } 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /assets/js/components/Router.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 2 | import Dashboard from "../pages/Dashboard"; 3 | import Event from "../pages/Event"; 4 | import Recording from "../pages/Recording"; 5 | import { useDynamicResizing } from "../utils/reactUtils"; 6 | import { ScreenTypeContext } from "../utils/ScreenTypeContext"; 7 | import useCheckScreenType from "../utils/useCheckScreenType"; 8 | import { StreamStartProvider } from "../utils/StreamStartContext"; 9 | 10 | const Router = () => { 11 | useDynamicResizing(); 12 | const screenType = useCheckScreenType(); 13 | 14 | return ( 15 | 16 | 17 | 18 | } /> 19 | 23 | 24 | 25 | } 26 | /> 27 | } /> 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default Router; 35 | -------------------------------------------------------------------------------- /assets/css/event/hlscontrolbar.css: -------------------------------------------------------------------------------- 1 | .MediaControlBar { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | padding: 0 1rem 1rem 1rem; 6 | min-height: auto; 7 | } 8 | 9 | .BottomBar { 10 | display: flex; 11 | flex-direction: row; 12 | } 13 | 14 | .BottomBarLeft { 15 | width: 30%; 16 | padding-left: 1rem; 17 | } 18 | 19 | .BottomBarRight { 20 | width: 30%; 21 | display: flex; 22 | justify-content: flex-end; 23 | padding-right: 1rem; 24 | } 25 | 26 | .BottomBarCenter { 27 | width: 40%; 28 | display: flex; 29 | justify-content: center; 30 | } 31 | 32 | .TopBar { 33 | display: flex; 34 | } 35 | 36 | .MediaTimeRange { 37 | flex-grow: 1; 38 | --media-range-track-height: 0.6rem; 39 | --media-range-thumb-height: 1.2rem; 40 | --media-range-thumb-width: 1.2rem; 41 | --media-range-track-border-radius: 24px; 42 | --media-range-bar-color: var(--button-red); 43 | --media-range-thumb-background: var(--button-red); 44 | } 45 | 46 | .MediaTimeDisplay { 47 | } 48 | 49 | .MediaVolumeRange { 50 | width: 20%; 51 | } 52 | 53 | .MediaBackground { 54 | background-color: transparent; 55 | } 56 | 57 | .ControlButtons path { 58 | stroke: white; 59 | fill: transparent; 60 | } 61 | -------------------------------------------------------------------------------- /assets/js/utils/hlsLatancy.ts: -------------------------------------------------------------------------------- 1 | import { createWorker, createScheduler } from "tesseract.js"; 2 | import moment from "moment"; 3 | 4 | const scheduler = createScheduler(); 5 | 6 | export const doOCR = async (playerRef: React.RefObject) => { 7 | if (scheduler.getNumWorkers() == 0) await initializeTeseract(); 8 | 9 | const canvas = document.createElement("canvas"); 10 | canvas.width = 400; 11 | canvas.height = 100; 12 | 13 | const ctx = canvas.getContext("2d"); 14 | const video = playerRef.current; 15 | 16 | ctx && video && ctx.drawImage(video, 0, 0, canvas.width, canvas.height, 0, 0, canvas.width, canvas.height); 17 | 18 | const { 19 | data: { text }, 20 | } = await scheduler.addJob("recognize", canvas); 21 | 22 | const currentTime = new Date(); 23 | const formatString = "DD/MM/YYYY HH:mm:ss:SS"; 24 | const detectedDate = moment(text, formatString).toDate(); 25 | 26 | console.log((currentTime.getTime() - detectedDate.getTime()) / 1000); 27 | }; 28 | 29 | const initializeTeseract = async () => { 30 | const worker = await createWorker(); 31 | await worker.load(); 32 | await worker.loadLanguage("eng"); 33 | await worker.initialize("eng"); 34 | scheduler.addWorker(worker); 35 | }; 36 | -------------------------------------------------------------------------------- /priv/repo/migrations/20221115140746_add_chats_table.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Repo.Migrations.AddChatsTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:chats, primary_key: false) do 6 | add(:uuid, :binary_id, primary_key: true) 7 | add(:event_id, references("webinars", column: :uuid, type: :binary_id, on_delete: :delete_all), null: false) 8 | add(:user_id, references("users", column: :uuid, type: :binary_id, on_delete: :delete_all), default: nil) 9 | add(:anon_id, :text, default: nil) 10 | add(:user_name, :text, default: nil) 11 | add(:content, :text, null: false) 12 | add(:time_offset, :integer, null: false) 13 | 14 | timestamps() 15 | end 16 | 17 | create constraint("chats", :only_one_of_user_columns, check: 18 | "(user_id IS NULL) <> (user_name IS NULL)" 19 | ) 20 | create constraint("chats", :anon_user_must_have_id_and_name, check: 21 | "(user_name IS NULL AND anon_id IS NULL) OR (user_name IS NOT NULL AND anon_id IS NOT NULL)" 22 | ) 23 | create constraint("chats", :offset_is_valid, check: "time_offset >= 0") 24 | create constraint("chats", :content_non_empty, check: "(content <> '') IS TRUE") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/membrane_live_web/views/webinar_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.WebinarView do 2 | use MembraneLiveWeb, :view 3 | alias MembraneLiveWeb.WebinarView 4 | 5 | # credo:disable-for-this-file 6 | 7 | def render("index.json", %{webinars: webinars}) do 8 | %{webinars: render_many(webinars, WebinarView, "webinar.json")} 9 | end 10 | 11 | def render("show.json", %{webinar: webinar}) do 12 | %{webinar: render_one(webinar, WebinarView, "webinar.json")} 13 | end 14 | 15 | def render("show_link.json", %{link: link}) do 16 | %{link: link} 17 | end 18 | 19 | def render("webinar.json", %{webinar: %{moderator_email: email} = webinar}) do 20 | %{ 21 | uuid: webinar.uuid, 22 | title: webinar.title, 23 | start_date: webinar.start_date, 24 | description: webinar.description, 25 | presenters: webinar.presenters, 26 | moderator_email: email, 27 | is_private: webinar.is_private 28 | } 29 | end 30 | 31 | def render("webinar.json", %{webinar: webinar}) do 32 | %{ 33 | uuid: webinar.uuid, 34 | title: webinar.title, 35 | start_date: webinar.start_date, 36 | description: webinar.description, 37 | presenters: webinar.presenters, 38 | is_private: webinar.is_private 39 | } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use MembraneLiveWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import MembraneLiveWeb.ConnCase 26 | 27 | alias MembraneLiveWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint MembraneLiveWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | MembraneLive.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/membrane_live_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :membrane_live 3 | 4 | socket("/socket", MembraneLiveWeb.EventSocket, 5 | websocket: [timeout: 10_000], 6 | longpoll: false 7 | ) 8 | 9 | @session_options [ 10 | store: :cookie, 11 | key: "_membrane_live_key", 12 | signing_salt: "eeeRs4HV" 13 | ] 14 | 15 | socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]) 16 | 17 | plug(Plug.Static, 18 | at: "/", 19 | from: :membrane_live, 20 | gzip: false, 21 | only: ~w(assets fonts images icons favicon.ico robots.txt) 22 | ) 23 | 24 | if code_reloading? do 25 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) 26 | plug(Phoenix.LiveReloader) 27 | plug(Phoenix.CodeReloader) 28 | plug(Phoenix.Ecto.CheckRepoStatus, otp_app: :membrane_live) 29 | end 30 | 31 | plug(Plug.RequestId) 32 | plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) 33 | 34 | plug(Plug.Parsers, 35 | parsers: [:urlencoded, :multipart, :json], 36 | pass: ["*/*"], 37 | json_decoder: Phoenix.json_library() 38 | ) 39 | 40 | plug(Plug.MethodOverride) 41 | plug(Plug.Head) 42 | plug(Plug.Session, @session_options) 43 | plug(MembraneLiveWeb.Router) 44 | end 45 | -------------------------------------------------------------------------------- /lib/membrane_live_web/controllers/login_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.LoginController do 2 | @moduledoc """ 3 | Controller for login purposes 4 | """ 5 | 6 | use MembraneLiveWeb, :controller 7 | 8 | alias MembraneLive.{Accounts, Tokens} 9 | alias MembraneLiveWeb.Helpers.TokenErrorInfo 10 | 11 | action_fallback(MembraneLiveWeb.FallbackController) 12 | 13 | def create(conn, %{"credential" => google_jwt}) do 14 | with {:ok, google_claims} <- Tokens.google_decode(google_jwt), 15 | {:ok, user} <- Accounts.create_user_if_not_exists(google_claims) do 16 | return_tokens(conn, user.uuid) 17 | else 18 | err -> TokenErrorInfo.get_error_info(err) 19 | end 20 | end 21 | 22 | def refresh(conn, %{"refreshToken" => old_refresh_token}) do 23 | with {:ok, %{"user_id" => user_id}} <- Tokens.refresh_decode(old_refresh_token) do 24 | return_tokens(conn, user_id) 25 | else 26 | err -> TokenErrorInfo.get_error_info(err) 27 | end 28 | end 29 | 30 | defp return_tokens(conn, user_id) do 31 | {:ok, auth_token, _claims} = Tokens.auth_encode(user_id) 32 | {:ok, refresh_token, _claims} = Tokens.refresh_encode(user_id) 33 | 34 | conn 35 | |> put_status(200) 36 | |> render("token.json", %{auth_token: auth_token, refresh_token: refresh_token}) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /assets/js/components/event/ShareButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalBody, 4 | ModalContent, 5 | ModalFooter, 6 | ModalHeader, 7 | ModalOverlay, 8 | useDisclosure, 9 | } from "@chakra-ui/react"; 10 | import GenericButton from "../helpers/GenericButton"; 11 | import { Share1 } from "react-swm-icon-pack"; 12 | 13 | import "../../../css/event/shareButton.css"; 14 | import { ShareListElements, ShareTitle } from "./ShareList"; 15 | 16 | type ShareButtonProps = { 17 | eventTitle: string; 18 | }; 19 | 20 | const ShareButton = ({ eventTitle }: ShareButtonProps) => { 21 | const { isOpen, onOpen, onClose } = useDisclosure(); 22 | 23 | return ( 24 | <> 25 | } onClick={onOpen} /> 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | export default ShareButton; 48 | -------------------------------------------------------------------------------- /assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Roboto); 2 | 3 | :root { 4 | --font-main: aktiv-grotesk, Roboto; 5 | --font-title: roc-grotesk, Roboto; 6 | 7 | --name-background-color: rgba(21, 26, 40, 0.5); 8 | --font-light-color: #dbe0f0; 9 | --font-dark-color: #001a72; 10 | --font-medium-color: #3f57a6; 11 | --font-medium-color-2: #2d4186; 12 | --font-medium-color-3: #506195; 13 | --bg-green: #339988; 14 | --bg-light-color-1: #ffffff; 15 | --bg-light-color-2: #f5f7fe; 16 | --bg-light-color-3: #bfccf8; 17 | --bg-light-color-4: #7089db; 18 | --button-red: #c32222; 19 | --button-red-hover: #e46767; 20 | } 21 | 22 | html { 23 | font-size: 10px; 24 | width: 100vw; 25 | height: 100vh; 26 | overflow: hidden; 27 | } 28 | 29 | @media screen and (max-width: 500px), screen and (max-height: 500px) { 30 | html { 31 | height: calc(var(--window-inner-height) - 1px); 32 | } 33 | } 34 | 35 | .FlexContainer { 36 | display: flex; 37 | justify-content: space-between; 38 | /* todo check it*/ 39 | flex-direction: column; 40 | } 41 | 42 | * { 43 | font-family: var(--font-main); 44 | } 45 | 46 | .ReactModal__Overlay { 47 | opacity: 0; 48 | transition: opacity 400ms ease-in-out; 49 | } 50 | 51 | .ReactModal__Overlay--after-open { 52 | opacity: 1; 53 | } 54 | 55 | .ReactModal__Overlay--before-open { 56 | opacity: 0; 57 | } 58 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | elixir: membraneframework/elixir@1 4 | 5 | executors: 6 | machine_executor_amd64: 7 | machine: 8 | image: ubuntu-2204:2022.04.2 9 | environment: 10 | architecture: "amd64" 11 | platform: "linux/amd64" 12 | 13 | jobs: 14 | test: 15 | executor: machine_executor_amd64 16 | steps: 17 | - checkout 18 | - run: docker compose -f docker-compose-test.yml up test --exit-code-from test 19 | 20 | # Install dependencies and run linter on front-end, possibly run tests in the future 21 | lint_frontend: 22 | working_directory: ~/membrane_live/assets 23 | docker: 24 | - image: cimg/node:18.4.0 25 | 26 | steps: 27 | - checkout: 28 | path: ~/membrane_live 29 | - run: 30 | command: npm install --legacy-peer-deps 31 | name: Install front-end dependencies 32 | - run: 33 | command: npm run format:check 34 | name: Run prettier on front-end 35 | - run: 36 | command: npm run typing:check 37 | name: Run typescript typechecking on front-end 38 | - run: 39 | command: npm run lint:check 40 | name: Run linter on front-end 41 | 42 | workflows: 43 | version: 2 44 | build: 45 | jobs: 46 | - test 47 | - elixir/build_test 48 | - elixir/lint 49 | - lint_frontend 50 | -------------------------------------------------------------------------------- /priv/static/icons/comments-regular.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/css/event/shareButton.css: -------------------------------------------------------------------------------- 1 | .CloseShareModal { 2 | color: var(--bg-light-color-1); 3 | background-color: var(--font-dark-color); 4 | border-radius: 16px; 5 | font-weight: 600; 6 | font-size: 1.4rem; 7 | padding: 1rem 1.5rem; 8 | min-width: 6rem; 9 | } 10 | 11 | .ListShareElement { 12 | display: flex; 13 | flex-direction: row; 14 | gap: 3rem; 15 | margin: 1rem 0; 16 | width: 100%; 17 | } 18 | 19 | .ShareElementLogo, 20 | .ShareElementText { 21 | display: flex; 22 | flex-direction: column; 23 | justify-content: center; 24 | align-items: center; 25 | } 26 | 27 | .ShareElementLogo { 28 | box-sizing: content-box; 29 | background: var(--bg-light-color-3); 30 | width: 4rem; 31 | height: 4rem; 32 | border-radius: 50%; 33 | text-align: center; 34 | 35 | box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px; 36 | } 37 | 38 | .ShareElementText { 39 | font-size: 1.6rem; 40 | color: var(--font-dark-color); 41 | } 42 | 43 | .ShareListElementsWrapper { 44 | padding: 0 1.5rem; 45 | } 46 | 47 | .ShareTitle { 48 | font-size: 2rem; 49 | color: var(--font-dark-color); 50 | font-weight: 600; 51 | } 52 | 53 | .ShareTitleWrapper { 54 | padding: 0.5rem 1.5rem 0 1.5rem; 55 | } 56 | 57 | /* Logos */ 58 | .FacebookLogo { 59 | background: #4267b2; 60 | } 61 | 62 | .TwitterLogo { 63 | background: #00acee; 64 | } 65 | 66 | .MessengerLogo { 67 | background: #006aff; 68 | } 69 | -------------------------------------------------------------------------------- /lib/membrane_live/webinars/webinar.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Webinars.Webinar do 2 | @moduledoc """ 3 | Struct for webinar 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | 8 | alias MembraneLive.Webinars.Webinar 9 | 10 | @derive {Phoenix.Param, key: :uuid} 11 | @primary_key {:uuid, :binary_id, autogenerate: true} 12 | 13 | @type t :: %__MODULE__{ 14 | description: String.t(), 15 | presenters: list, 16 | start_date: NaiveDateTime.t(), 17 | title: String.t(), 18 | is_finished: boolean(), 19 | is_private: boolean() 20 | } 21 | 22 | @desc_limit 255 23 | 24 | schema "webinars" do 25 | field(:description, :string) 26 | field(:presenters, {:array, :string}) 27 | field(:start_date, :naive_datetime) 28 | field(:title, :string) 29 | field(:is_finished, :boolean) 30 | field(:is_private, :boolean) 31 | belongs_to(:moderator, Webinar, references: :uuid, type: :binary_id) 32 | 33 | timestamps() 34 | end 35 | 36 | @doc false 37 | def changeset(webinar, attrs) do 38 | webinar 39 | |> cast(attrs, [:title, :start_date, :description, :presenters, :moderator_id, :is_private]) 40 | |> Ecto.Changeset.put_change(:is_finished, false) 41 | |> validate_required([:title, :start_date, :moderator_id, :is_private]) 42 | |> validate_length(:description, max: @desc_limit) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /assets/js/components/dashboard/SearchAndCreatePanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { Search } from "react-swm-icon-pack"; 3 | import ModalForm from "./ModalForm"; 4 | import { getIsAuthenticated } from "../../utils/storageUtils"; 5 | 6 | import "../../../css/dashboard/searchandcreatepanel.css"; 7 | import { ScreenTypeContext } from "../../utils/ScreenTypeContext"; 8 | 9 | type SearchAndCreatePanelProps = { 10 | currentEvents: string; 11 | searchText: string; 12 | setSearchText: React.Dispatch>; 13 | }; 14 | 15 | const SearchAndCreatePanel = ({ currentEvents, searchText, setSearchText }: SearchAndCreatePanelProps) => { 16 | const screenType = useContext(ScreenTypeContext); 17 | const isAuthenticated = getIsAuthenticated(); 18 | 19 | return ( 20 |
21 |
22 | 23 | setSearchText((e.target as HTMLTextAreaElement).value)} 26 | placeholder="Search events" 27 | className="SearchInput" 28 | /> 29 |
30 | {screenType.device == "desktop" && currentEvents == "All events" && isAuthenticated && ( 31 | 32 | )} 33 |
34 | ); 35 | }; 36 | 37 | export default SearchAndCreatePanel; 38 | -------------------------------------------------------------------------------- /lib/membrane_live_web/helpers/token_error_info.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.Helpers.TokenErrorInfo do 2 | @moduledoc """ 3 | module for error infos considering tokens (mostly their validation) 4 | """ 5 | 6 | def get_error_info({:error, :invalid_jwt_header}), 7 | do: %{error: :bad_request, message: "Invalid jwt header"} 8 | 9 | def get_error_info({:error, :no_jwt_in_header}), 10 | do: %{error: :bad_request, message: "Lack of authentication data"} 11 | 12 | def get_error_info({:error, :signature_error}), 13 | do: %{error: :unauthorized, message: "Token has an invalid signature"} 14 | 15 | def get_error_info({:error, :no_uuid_in_header}), 16 | do: %{error: :bad_request, message: "User id was not provided in the jwt"} 17 | 18 | def get_error_info({:error, [{:message, "Invalid token"} | [{:claim, "exp"} | _tail]]}), 19 | do: %{error: :unauthorized, message: "Auth token expiration time exceeded"} 20 | 21 | def get_error_info({:error, [{:message, "Invalid token"} | [{:claim, "aud"} | _tail]]}), 22 | do: %{ 23 | error: :unauthorized, 24 | message: "Audience (aud) claim is invalid. Did you set correct GOOGLE_CLIENT_ID env?" 25 | } 26 | 27 | def get_error_info({:error, %HTTPoison.Error{} = error}), 28 | do: %{error: :service_unavailable, message: HTTPoison.Error.message(error)} 29 | 30 | def get_error_info({:error, _error_reason}), 31 | do: %{error: :unauthorized, message: "Unknown token validation error"} 32 | end 33 | -------------------------------------------------------------------------------- /priv/static/icons/gifts-regular.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/membrane_live/chats/chat.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Chats.Chat do 2 | @moduledoc """ 3 | Database schema for the chat table 4 | """ 5 | use Ecto.Schema 6 | import Ecto.Changeset 7 | 8 | @derive {Phoenix.Param, key: :uuid} 9 | @primary_key {:uuid, :binary_id, autogenerate: true} 10 | 11 | @type t :: %__MODULE__{ 12 | event_id: Ecto.UUID.t(), 13 | user_id: Ecto.UUID.t() | nil, 14 | user_name: String.t() | nil, 15 | anon_id: String.t() | nil, 16 | content: String.t(), 17 | time_offset: integer() 18 | } 19 | 20 | schema "chats" do 21 | belongs_to(:event, MembraneLive.Webinars.Webinar, references: :uuid, type: :binary_id) 22 | belongs_to(:user, MembraneLive.Accounts.User, references: :uuid, type: :binary_id) 23 | field(:user_name, :string) 24 | field(:anon_id, :string) 25 | field(:content, :string) 26 | field(:time_offset, :integer) 27 | 28 | timestamps() 29 | end 30 | 31 | @doc false 32 | def changeset(message, attrs) do 33 | key = if Map.has_key?(attrs, :user_id), do: :user_name, else: :user_id 34 | 35 | message 36 | |> cast(attrs, [:event_id, :user_id, :user_name, :anon_id, :content, :time_offset]) 37 | |> put_change(key, nil) 38 | |> validate_required([:event_id, :content, :time_offset]) 39 | |> check_constraint(:user_id, name: :only_one_of_user_columns) 40 | |> check_constraint(:user_id, name: :anon_user_must_have_id_and_name) 41 | |> validate_number(:time_offset, greater_than_or_equal_to: 0) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /assets/js/pages/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import WelcomePanel from "../components/dashboard/WelcomePanel"; 3 | import SearchAndCreatePanel from "../components/dashboard/SearchAndCreatePanel"; 4 | import SideDashboardPanel from "../components/dashboard/SideDashboardPanel"; 5 | import EventsArea from "../components/dashboard/EventsArea"; 6 | import { pageTitlePrefix } from "../utils/const"; 7 | import { ScreenTypeContext } from "../utils/ScreenTypeContext"; 8 | import type { CurrentEvents } from "../types/types"; 9 | import "../../css/dashboard/dashboard.css"; 10 | 11 | const Dashboard = () => { 12 | const [searchText, setSearchText] = useState(""); 13 | const [currentEvents, setCurrentEvents] = useState("All events"); 14 | const screenType = useContext(ScreenTypeContext); 15 | 16 | useEffect(() => { 17 | document.title = pageTitlePrefix; 18 | }, []); 19 | 20 | return ( 21 |
22 | {screenType.device == "desktop" && ( 23 | 24 | )} 25 |
26 | 27 | 28 | 29 |
30 |
31 | ); 32 | }; 33 | 34 | export default Dashboard; 35 | -------------------------------------------------------------------------------- /assets/css/event/hlsplayer.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .HlsDiv { 4 | display: flex; 5 | flex-direction: column; 6 | position: relative; 7 | } 8 | 9 | .HlsStream { 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | height: 100%; 14 | width: 100%; 15 | margin: 0; 16 | } 17 | 18 | .HlsPlayerWrapper { 19 | background-color: black; 20 | height: 100%; 21 | width: 100%; 22 | border-radius: 24px; 23 | overflow: hidden; 24 | z-index: 0; 25 | position: relative; 26 | } 27 | 28 | .HlsPlayer { 29 | height: 100%; 30 | width: 100%; 31 | } 32 | 33 | .WaitText { 34 | color: var(--font-dark-color); 35 | font-size: 1.6rem; 36 | display: flex; 37 | align-items: center; 38 | gap: 0.5rem; 39 | } 40 | 41 | .HlsPresenterName { 42 | background: var(--bg-green); 43 | color: var(--bg-light-color-1); 44 | position: absolute; 45 | margin: 2rem; 46 | border-radius: 16px; 47 | font-size: 1.6rem; 48 | padding: 0.4rem 1rem; 49 | } 50 | 51 | .RotateIcon path { 52 | stroke: var(--font-dark-color); 53 | } 54 | 55 | .HlsTopBar { 56 | position: absolute; 57 | width: 100%; 58 | top: 3rem; 59 | z-index: 1; 60 | display: flex; 61 | flex-direction: row; 62 | align-items: center; 63 | } 64 | 65 | @media screen and (max-width: 500px) { 66 | .HlsDiv { 67 | width: 100%; 68 | } 69 | 70 | .HlsStream { 71 | height: 90%; 72 | width: auto; 73 | } 74 | } 75 | 76 | @media screen and (max-height: 500px) { 77 | .HlsStream { 78 | height: 80%; 79 | aspect-ratio: 16 / 9; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/membrane_live_web/plugs/auth.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.Plugs.Auth do 2 | @moduledoc """ 3 | Plug responsible for fetching jwt token from HTTP request, 4 | decoding it and assigning decoded user_id to conn object 5 | """ 6 | import Plug.Conn 7 | alias MembraneLive.Tokens 8 | alias MembraneLiveWeb.FallbackController 9 | alias MembraneLiveWeb.Helpers.TokenErrorInfo 10 | 11 | @auth_header_key "authorization" 12 | 13 | @doc """ 14 | restricted mode won't let through requests that don't have token in a header. 15 | """ 16 | @type mode :: :restricted | :unrestricted 17 | 18 | @spec init(mode()) :: mode() 19 | def init(mode) do 20 | mode 21 | end 22 | 23 | def call(conn, mode) do 24 | with {:ok, token} <- find_token(conn.req_headers, mode), 25 | {:ok, %{"user_id" => user_id}} <- Tokens.auth_decode(token) do 26 | assign(conn, :user_id, user_id) 27 | else 28 | :unauthorized -> 29 | assign(conn, :user_id, :unauthorized) 30 | 31 | error -> 32 | conn 33 | |> FallbackController.call(TokenErrorInfo.get_error_info(error)) 34 | |> halt() 35 | end 36 | end 37 | 38 | defp find_token(headers, mode) do 39 | headers 40 | |> Enum.find(fn 41 | {@auth_header_key, _value} -> true 42 | _other -> false 43 | end) 44 | |> get_value(mode) 45 | end 46 | 47 | defp get_value(nil, :restricted), do: {:error, :no_jwt_in_header} 48 | defp get_value(nil, :unrestricted), do: :unauthorized 49 | defp get_value({_key, "Bearer " <> token}, _mode), do: {:ok, token} 50 | end 51 | -------------------------------------------------------------------------------- /assets/css/event/rtcplayer.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .RtcPlayer { 4 | position: relative; 5 | border-radius: 24px; 6 | overflow: hidden; 7 | display: flex; 8 | justify-content: center; 9 | width: 100%; 10 | } 11 | 12 | .UpperBarPresenter { 13 | position: absolute; 14 | width: 100%; 15 | top: 0; 16 | left: 0; 17 | 18 | z-index: 1; 19 | display: flex; 20 | flex-direction: row; 21 | justify-content: flex-start; 22 | gap: 0.8rem; 23 | padding: 2.4rem 2.4rem; 24 | } 25 | 26 | .IconDisabled { 27 | background: var(--name-background-color); 28 | padding: 1.2rem; 29 | border-radius: 1.6rem; 30 | } 31 | 32 | .PresenterDisabledSource path { 33 | stroke: var(--font-light-color); 34 | } 35 | 36 | .PresenterVideo { 37 | object-fit: cover; 38 | min-height: 100%; 39 | min-width: 100%; 40 | z-index: 0; 41 | border-radius: 24px; 42 | } 43 | 44 | .BottomBarPresenter { 45 | position: absolute; 46 | width: 100%; 47 | left: 0; 48 | bottom: 3rem; 49 | z-index: 1; 50 | display: flex; 51 | flex-direction: row; 52 | align-items: center; 53 | justify-content: flex-end; 54 | } 55 | 56 | .PresenterName { 57 | background: var(--name-background-color); 58 | color: var(--font-light-color); 59 | font-size: 1.6rem; 60 | position: absolute; 61 | margin: 2rem; 62 | border-radius: 16px; 63 | padding: 0.4rem 1rem; 64 | background-blend-mode: multiply; 65 | backdrop-filter: blur(12px); 66 | display: flex; 67 | align-items: center; 68 | gap: 0.5rem; 69 | } 70 | 71 | .YouIcon path { 72 | stroke: var(--font-light-color); 73 | } 74 | -------------------------------------------------------------------------------- /assets/js/utils/modePanelUtils.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Channel, Presence } from "phoenix"; 3 | import type { Client, Metas } from "../types/types"; 4 | 5 | export const syncPresentersNumber = ( 6 | eventChannel: Channel | undefined, 7 | setPresentersNumber: React.Dispatch> 8 | ): void => { 9 | if (eventChannel) { 10 | const presence = new Presence(eventChannel); 11 | 12 | const updateParticipantsNumber = () => { 13 | setPresentersNumber(presence.list().filter((elem) => elem.metas[0].is_presenter).length); 14 | }; 15 | 16 | presence.onSync(() => { 17 | updateParticipantsNumber(); 18 | }); 19 | 20 | eventChannel.push("sync_presence", {}); 21 | } 22 | }; 23 | 24 | export const syncAmIPresenter = ( 25 | eventChannel: Channel | undefined, 26 | setAmIPresenter: React.Dispatch>, 27 | client: Client 28 | ) => { 29 | if (eventChannel) { 30 | const presence = new Presence(eventChannel); 31 | 32 | presence.onSync(() => { 33 | updateAmIPresenter(presence, setAmIPresenter, client); 34 | }); 35 | 36 | eventChannel.push("sync_presence", {}); 37 | } 38 | }; 39 | 40 | const updateAmIPresenter = ( 41 | presence: Presence, 42 | setIsPresenter: React.Dispatch>, 43 | client: Client 44 | ) => { 45 | const newAmIPresenter = presence 46 | .list((email: string, metas: Metas) => ({ email, metas })) 47 | .some(({ email, metas }) => metas.metas[0].is_presenter && email === client.email); 48 | 49 | setIsPresenter(newAmIPresenter); 50 | }; 51 | -------------------------------------------------------------------------------- /assets/js/components/helpers/GoogleButton.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useToast } from "@chakra-ui/react"; 3 | import { pageTitlePrefix } from "../../utils/const"; 4 | import { fetchTokenAndRedirect } from "../../utils/googleAuthUtils"; 5 | import { CredentialResponse, GsiButtonConfiguration } from "google-one-tap"; 6 | import { googleClientId } from "../../utils/const"; 7 | import { Channel } from "phoenix"; 8 | 9 | // currently GSI library is available only via script 10 | // this is a hacky way to get rid of the error 11 | // see membrane_live/lib/membrane_live_web/templates/layout/root.html.heex 12 | declare global { 13 | const google: typeof import("google-one-tap"); 14 | } 15 | 16 | type GoogleButtonProps = { 17 | eventChannel?: Channel; 18 | options: GsiButtonConfiguration; 19 | buttonId: string; 20 | className?: string; 21 | }; 22 | 23 | const GoogleButton = ({ eventChannel, buttonId, options, className }: GoogleButtonProps) => { 24 | const toast = useToast(); 25 | 26 | useEffect(() => { 27 | document.title = `${pageTitlePrefix} | Login`; 28 | 29 | google.accounts.id.initialize({ 30 | client_id: googleClientId, 31 | ux_mode: "popup", 32 | callback: (response: CredentialResponse) => fetchTokenAndRedirect(response, eventChannel, toast), 33 | }); 34 | 35 | const buttonElement = document.getElementById(buttonId); 36 | if (buttonElement) google.accounts.id.renderButton(buttonElement, options); 37 | }, [buttonId, eventChannel, options, toast]); 38 | 39 | return
; 40 | }; 41 | 42 | export default GoogleButton; 43 | -------------------------------------------------------------------------------- /assets/css/dashboard/welcomepanel.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .WelcomePanel { 4 | display: flex; 5 | align-items: flex-start; 6 | } 7 | 8 | .NamePanel { 9 | padding: 2rem 0; 10 | display: flex; 11 | flex-direction: column; 12 | gap: 1rem; 13 | } 14 | 15 | .HiText { 16 | color: var(--font-dark-color); 17 | font-family: var(--font-title); 18 | letter-spacing: 0.05rem; 19 | font-size: 3.6rem; 20 | font-weight: 500; 21 | } 22 | 23 | .EncouragingText { 24 | color: var(--font-medium-color-2); 25 | font-family: var(--font-title); 26 | font-size: 2.2rem; 27 | font-weight: 500; 28 | } 29 | 30 | .UserContainer { 31 | padding-top: 2rem; 32 | margin-left: auto; 33 | } 34 | 35 | @media screen and (max-width: 500px), screen and (max-height: 500px) { 36 | .WelcomePanel { 37 | display: flex; 38 | flex-direction: column; 39 | gap: 1rem; 40 | } 41 | 42 | .ModeButtonsMobile { 43 | width: 100%; 44 | display: flex; 45 | align-items: left; 46 | justify-content: left; 47 | gap: 1em; 48 | } 49 | 50 | .ModeButtonMobile { 51 | min-width: 13rem; 52 | color: var(--font-dark-color); 53 | border-radius: 16px; 54 | font-weight: 600; 55 | font-size: 1.6rem; 56 | padding: 1rem 1.5rem; 57 | border: 1px solid var(--font-dark-color); 58 | } 59 | 60 | .MobileLoggingButton { 61 | margin-left: auto; 62 | } 63 | 64 | .Clicked { 65 | background-color: var(--font-dark-color); 66 | color: var(--bg-light-color-1); 67 | } 68 | 69 | .HiText { 70 | font-size: 2.8rem; 71 | } 72 | 73 | .EncouragingText { 74 | font-size: 1.8rem; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | membrane-live-db: 5 | container_name: membrane_live_db 6 | image: postgres:14.5-alpine 7 | environment: 8 | POSTGRES_USER: "${POSTGRES_USER:-membrane}" 9 | POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" 10 | POSTGRES_DB: "${POSTGRES_DB:-live_db}" 11 | PGDATA: /var/lib/postgresql/data/pgdata 12 | healthcheck: 13 | test: 14 | [ 15 | "CMD", 16 | "pg_isready", 17 | "-q", 18 | "-d", 19 | "${POSTGRES_DB:-live_db}", 20 | "-U", 21 | "${POSTGRES_USER:-membrane}" 22 | ] 23 | timeout: 45s 24 | interval: 10s 25 | retries: 10 26 | restart: always 27 | ports: 28 | - "5432:5432" 29 | volumes: 30 | - pgdata:/var/lib/postgresql/data 31 | 32 | membrane-live: 33 | image: membraneframeworklabs/membrane_live:${TAG:-latest} 34 | container_name: membrane_live 35 | restart: on-failure 36 | network_mode: "host" 37 | # build: . 38 | depends_on: 39 | - membrane-live-db 40 | environment: 41 | POSTGRES_USER: "${POSTGRES_USER:-membrane}" 42 | POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" 43 | POSTGRES_DB: "${POSTGRES_DB:-live_db}" 44 | POSTGRES_HOST: localhost 45 | POSTGRES_PORT: 5432 46 | VIRTUAL_HOST: "${VIRTUAL_HOST:-localhost}" 47 | USE_INTEGRATED_TURN: "true" 48 | INTEGRATED_TURN_IP: "${INTEGRATED_TURN_IP:-127.0.0.1}" 49 | INTEGRATED_TURN_PORT_RANGE: "50000-65355" 50 | INTEGRATED_TCP_TURN_PORT: "49999" 51 | GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}" 52 | 53 | volumes: 54 | pgdata: 55 | -------------------------------------------------------------------------------- /lib/membrane_live_web/controllers/recordings_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.RecordingsController do 2 | use MembraneLiveWeb, :controller 3 | 4 | alias MembraneLive.Webinars 5 | alias MembraneLive.Webinars.Webinar 6 | alias MembraneLiveWeb.Helpers.ControllerCallbackHelper 7 | 8 | action_fallback(MembraneLiveWeb.FallbackController) 9 | 10 | @spec index(Plug.Conn.t(), any) :: Plug.Conn.t() 11 | def index(conn, %{"recording_id" => id}) do 12 | jellyfish_address = Application.fetch_env!(:jellyfish_server_sdk, :server_address) 13 | link = "http://#{jellyfish_address}/recording/#{id}/index.m3u8" 14 | render(conn, "link.json", link: link) 15 | end 16 | 17 | @spec index(Plug.Conn.t(), any) :: Plug.Conn.t() 18 | def index(conn, _params) do 19 | webinars = Webinars.list_recordings(conn.assigns.user_id) 20 | render(conn, "index.json", webinars: webinars) 21 | end 22 | 23 | @spec show(Plug.Conn.t(), map) :: Plug.Conn.t() 24 | def show(conn, params) do 25 | ControllerCallbackHelper.get_webinar_and_fire_callback(conn, params, &show_callback/2, true) 26 | end 27 | 28 | @spec delete(any, map) :: any 29 | def delete(conn, params) do 30 | ControllerCallbackHelper.get_webinar_and_fire_callback( 31 | conn, 32 | params, 33 | &delete_callback/2, 34 | false 35 | ) 36 | end 37 | 38 | defp show_callback(conn, %{"webinar_db" => webinar}) do 39 | render(conn, "show.json", webinar: webinar) 40 | end 41 | 42 | defp delete_callback(conn, %{"webinar_db" => webinar}) do 43 | with {:ok, %Webinar{}} <- Webinars.delete_webinar(webinar) do 44 | send_resp(conn, :no_content, "") 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "format:write": "prettier --write .", 4 | "format:check": "prettier --check .", 5 | "typing:check": "tsc --noEmit --skipLibCheck", 6 | "lint:check": "eslint . --ext .ts,.tsx", 7 | "lint:fix": "eslint . --fix --ext .ts,.tsx" 8 | }, 9 | "dependencies": { 10 | "@chakra-ui/react": "^2.2.4", 11 | "@emoji-mart/react": "^1.0.1", 12 | "@emotion/react": "^11.9.3", 13 | "@emotion/styled": "^11.9.3", 14 | "@jellyfish-dev/react-client-sdk": "^0.2.0", 15 | "@tanstack/react-query": "^4.20.4", 16 | "@tanstack/react-query-devtools": "^4.20.4", 17 | "axios": "^0.27.2", 18 | "ci": "^2.2.0", 19 | "emoji-mart": "^5.2.2", 20 | "framer-motion": "^6.5.1", 21 | "hls.js": "^1.2.8", 22 | "media-chrome": "^0.14.1", 23 | "moment": "^2.29.4", 24 | "phoenix": "^1.6.11", 25 | "react": "^18.2.0", 26 | "react-confetti-explosion": "^2.1.2", 27 | "react-dom": "^18.2.0", 28 | "react-modal": "^3.15.1", 29 | "react-router-dom": "^6.3.0", 30 | "react-swm-icon-pack": "^1.0.14", 31 | "tesseract.js": "^4.0.2", 32 | "zod": "^3.22.4" 33 | }, 34 | "devDependencies": { 35 | "@types/google-one-tap": "^1.2.2", 36 | "@types/phoenix": "^1.5.4", 37 | "@types/react": "^18.0.15", 38 | "@types/react-dom": "^18.0.6", 39 | "@types/react-modal": "^3.13.1", 40 | "@typescript-eslint/eslint-plugin": "^5.39.0", 41 | "@typescript-eslint/parser": "^5.39.0", 42 | "eslint": "^8.24.0", 43 | "eslint-config-prettier": "^8.5.0", 44 | "eslint-plugin-react": "^7.31.8", 45 | "eslint-plugin-react-hooks": "^4.6.0", 46 | "prettier": "^2.7.1", 47 | "typescript": "^4.8.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :membrane_live, 4 | ecto_repos: [MembraneLive.Repo], 5 | migration_primary_key: [name: :uuid, type: :binary_id] 6 | 7 | config :membrane_live, MembraneLiveWeb.Endpoint, 8 | url: [host: "localhost"], 9 | render_errors: [ 10 | view: MembraneLiveWeb.ErrorView, 11 | format: "json", 12 | accepts: ~w(html json), 13 | layout: false 14 | ], 15 | pubsub_server: MembraneLive.PubSub, 16 | live_view: [signing_salt: "s4HsMuAM"] 17 | 18 | config :membrane_live, MembraneLive.Mailer, adapter: Swoosh.Adapters.Local 19 | 20 | config :membrane_live, 21 | hls_output_mount_path: "output", 22 | custom_secret: "secret", 23 | google_pems_url: "https://www.googleapis.com/oauth2/v1/certs" 24 | 25 | config :swoosh, :api_client, false 26 | 27 | config :phoenix, :json_library, Jason 28 | 29 | config :esbuild, 30 | version: "0.16.5", 31 | default: [ 32 | args: 33 | ~w(js/app.tsx --bundle --loader:.svg=dataurl --target=es2020 --jsx=automatic --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 34 | cd: Path.expand("../assets", __DIR__), 35 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 36 | ] 37 | 38 | config :membrane_live, MembraneLiveWeb.Endpoint, pubsub_server: MembraneLive.PubSub 39 | 40 | config :logger, :console, 41 | format: "$time $metadata[$level] $message\n", 42 | metadata: [:request_id, :event_id] 43 | 44 | config :membrane_live, 45 | empty_event_timeout_ms: 15 * 60 * 1000, 46 | last_peer_timeout_ms: 2 * 60 * 1000, 47 | response_timeout_ms: 2 * 60 * 1000 48 | 49 | config :membrane_live, HLS.PubSub, pubsub: [name: HLS.PubSub, adapter: Phoenix.PubSub.PG2] 50 | 51 | import_config "#{config_env()}.exs" 52 | -------------------------------------------------------------------------------- /assets/css/event/participants.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .Participants { 4 | background: var(--bg-light-color-2); 5 | width: 38rem; 6 | border-radius: 24px; 7 | border: 1px solid var(--bg-light-color-3); 8 | display: flex; 9 | flex-direction: column; 10 | overflow: hidden; 11 | align-items: center; 12 | } 13 | 14 | .ParticipantsButtons { 15 | padding: 1.3rem 1.5rem; 16 | width: 100%; 17 | display: grid; 18 | grid-template-columns: 50% 50%; 19 | } 20 | 21 | .ParticipantsButton { 22 | color: var(--font-dark-color); 23 | width: 84%; 24 | border-radius: 16px; 25 | font-weight: 600; 26 | font-size: 1.6rem; 27 | justify-self: center; 28 | padding: 1rem 1.5rem; 29 | white-space: nowrap; 30 | } 31 | 32 | .ParticipantIcon path { 33 | stroke: var(--font-dark-color); 34 | } 35 | 36 | .ParticipantsButton.Clicked { 37 | background: var(--font-dark-color); 38 | color: var(--bg-light-color-1); 39 | } 40 | 41 | .ParticipantsList { 42 | overflow-y: auto; 43 | width: 100%; 44 | } 45 | 46 | .Participant { 47 | display: flex; 48 | align-items: center; 49 | padding: 1rem 2rem; 50 | gap: 1.5rem; 51 | width: 100%; 52 | } 53 | 54 | .Participant * { 55 | color: var(--font-dark-color); 56 | } 57 | 58 | .ParticipantText { 59 | color: var(--font-dark-color); 60 | font-size: 1.8rem; 61 | white-space: nowrap; 62 | } 63 | 64 | .OptionButton { 65 | height: 2rem; 66 | width: auto; 67 | margin-left: auto; 68 | } 69 | 70 | .OptionButton path { 71 | stroke: var(--font-dark-color); 72 | } 73 | 74 | .MenuOptionText { 75 | font-size: 1.6rem; 76 | } 77 | 78 | .InfoTooltip { 79 | background: var(--font-dark-color) !important; 80 | border-radius: 16px !important; 81 | font-size: 1.3rem !important; 82 | } 83 | -------------------------------------------------------------------------------- /assets/js/components/event/MobileBottomPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Channel } from "phoenix"; 2 | import ChatBox from "./ChatBox"; 3 | import type { CardStatus, ChatMessage, Client } from "../../types/types"; 4 | import { Slide } from "@chakra-ui/react"; 5 | import ShareList from "./ShareList"; 6 | 7 | import "../../../css/event/mobilebottompanel.css"; 8 | 9 | type Props = { 10 | onBarClick: () => void; 11 | card: CardStatus; 12 | client: Client; 13 | eventChannel: Channel | undefined; 14 | chatMessages: ChatMessage[]; 15 | isChatLoaded: boolean; 16 | isBannedFromChat: boolean; 17 | eventTitle: string; 18 | }; 19 | 20 | export const MobileBottomPanel = ({ 21 | onBarClick, 22 | card, 23 | client, 24 | chatMessages, 25 | eventChannel, 26 | isChatLoaded, 27 | isBannedFromChat, 28 | eventTitle, 29 | }: Props) => { 30 | const isOpen = card !== "hidden"; 31 | 32 | return ( 33 | 34 |
35 |
36 |
37 |
38 | 39 |
40 |
41 | {card === "share" && } 42 | {card === "chat" && ( 43 | 51 | )} 52 |
53 |
54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /assets/js/components/event/MobileRightSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { ScreenTypeContext } from "../../utils/ScreenTypeContext"; 2 | import { useCallback, useContext } from "react"; 3 | import { useToast } from "@chakra-ui/react"; 4 | import { getInfoToast } from "../../utils/toastUtils"; 5 | import type { CardStatus } from "../../types/types"; 6 | import "../../../css/event/mobilesidebars.css"; 7 | 8 | type Button = { id: string; icon: string; text: string; onClick?: () => void }; 9 | 10 | type Props = { 11 | setCard: (name: CardStatus) => void; 12 | }; 13 | 14 | export const MobileRightSidebar = ({ setCard }: Props) => { 15 | const { orientation } = useContext(ScreenTypeContext); 16 | const toast = useToast(); 17 | 18 | const onButtonClick = useCallback( 19 | (card: CardStatus) => { 20 | if (orientation === "portrait") { 21 | setCard(card); 22 | } else { 23 | getInfoToast(toast, `Change your screen orientation to see ${card} panel`); 24 | } 25 | }, 26 | [orientation, setCard, toast] 27 | ); 28 | 29 | const buttons: Button[] = [ 30 | { 31 | id: "share", 32 | icon: "/icons/share-nodes-regular.svg", 33 | text: "SHARE", 34 | onClick: () => onButtonClick("share"), 35 | }, 36 | { 37 | id: "chat", 38 | icon: "/icons/comments-regular.svg", 39 | text: "CHAT", 40 | onClick: () => onButtonClick("chat"), 41 | }, 42 | ]; 43 | 44 | return ( 45 |
46 | {buttons.map(({ id, icon, text, onClick }) => ( 47 |
48 | {" 49 | {text} 50 |
51 | ))} 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /assets/js/utils/const.ts: -------------------------------------------------------------------------------- 1 | import { GsiButtonConfiguration } from "google-one-tap"; 2 | import { HlsConfig } from "hls.js"; 3 | 4 | // @ts-ignore 5 | export const googleClientId: string = GOOGLE_CLIENT_ID; 6 | 7 | export const FRAME_RATE = 24; 8 | export const CANVAS_WIDTH = 1920; 9 | export const CANVAS_HEIGHT = 1080; 10 | 11 | export const MILLISECONDS_IN_MINUTE = 60_000; 12 | export const DESCRIPTION_CHAR_LIMIT = 255; 13 | 14 | export const AUDIO_CONSTRAINTS: MediaTrackConstraints | boolean = true; 15 | 16 | export const VIDEO_CONSTRAINTS: MediaTrackConstraints = { 17 | width: 1920, 18 | height: 1080, 19 | frameRate: FRAME_RATE, 20 | }; 21 | 22 | export const SCREEN_CONSTRAINTS: MediaTrackConstraints = { 23 | width: CANVAS_WIDTH, 24 | height: CANVAS_HEIGHT, 25 | frameRate: FRAME_RATE, 26 | }; 27 | 28 | export const shortMonthNames = ["Jan", "Feb", "Mar", "Apr", "May", "June", "July", "Aug", "Sep", "Oct", "Nov", "Dec"]; 29 | 30 | export const monthNames = [ 31 | "January", 32 | "February", 33 | "March", 34 | "April", 35 | "May", 36 | "June", 37 | "July", 38 | "August", 39 | "September", 40 | "October", 41 | "November", 42 | "December", 43 | ]; 44 | 45 | export const pageTitlePrefix = "Membrane Live"; 46 | 47 | export const roundedGoogleButton: GsiButtonConfiguration = { 48 | theme: "outline", 49 | type: "icon", 50 | size: "large", 51 | logo_alignment: "left", 52 | shape: "circle", 53 | }; 54 | 55 | export const rectangleGoogleButton: GsiButtonConfiguration = { 56 | theme: "outline", 57 | size: "large", 58 | logo_alignment: "left", 59 | shape: "pill", 60 | text: "signin_with", 61 | }; 62 | 63 | export const liveConfig: Partial = { 64 | lowLatencyMode: true, 65 | }; 66 | 67 | export const recordingConfig: Partial = { 68 | lowLatencyMode: false, 69 | }; 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Membrane Live 2 | 3 | [![API Docs](https://img.shields.io/badge/api-docs-yellow.svg?style=flat)](https://hexdocs.pm/membrane_core) 4 | [![CircleCI](https://circleci.com/gh/membraneframework-labs/membrane_live.svg?style=svg)](https://circleci.com/gh/membraneframework-labs/membrane_live) 5 | 6 | This repository contains a project created during the Summer Internship of 2022 in Software Mansion. 7 | 8 | ## Running app locally 9 | 10 | To run the app locally it to have certain tools installed that is: 11 | 12 | - Elixir 13 | - Docker 14 | - Rust 15 | 16 | Also you need to set environment variables. File `.env.sample` contains all required environment variables with example values. Remember: having a valid google client id is also required. 17 | 18 | For valid WebRTC connection follow instructions given at [Membrane Videoroom GitHub repository](https://github.com/membraneframework/membrane_videoroom). 19 | 20 | After setting all needed environment variables, you have to start a database. To do this you can use this command: 21 | 22 | ```sh 23 | docker-compose up membrane-live-db 24 | ``` 25 | 26 | Next you have to initialize database with commands: 27 | 28 | ```sh 29 | mix ecto.create 30 | mix ecto.migrate 31 | ``` 32 | 33 | or simply with 34 | 35 | ```sh 36 | mix ecto.setup 37 | ``` 38 | 39 | On the end you can start phoenix app: 40 | 41 | ```sh 42 | mix phx.server 43 | ``` 44 | 45 | ## Copyright and License 46 | 47 | Copyright 2022, [Software Mansion](https://swmansion.com/?utm_source=git&utm_medium=readme&utm_campaign=membrane_template_plugin) 48 | 49 | [![Software Mansion](https://logo.swmansion.com/logo?color=white&variant=desktop&width=200&tag=membrane-github)](https://swmansion.com/?utm_source=git&utm_medium=readme&utm_campaign=membrane_template_plugin) 50 | 51 | Licensed under the [Apache License, Version 2.0](LICENSE) 52 | -------------------------------------------------------------------------------- /test/support/token/google_token_mock.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Support.GoogleTokenMock do 2 | @moduledoc """ 3 | Module imitating the google auth jwt creation process 4 | """ 5 | 6 | use Joken.Config 7 | 8 | @kid "1727b6b49402b9cf95be4e8fd38aa7e7c11644b1" 9 | 10 | @impl true 11 | def token_config, 12 | do: 13 | default_claims( 14 | iss: "accounts.google.com", 15 | aud: MembraneLive.get_env!(:client_id) 16 | ) 17 | 18 | @doc """ 19 | Mocks the response from the www.googleapis.com/oauth2/v1/certs 20 | """ 21 | def get_google_public_key() do 22 | :google_public_key_path 23 | |> MembraneLive.get_env!() 24 | |> File.read!() 25 | |> then(fn pem -> %{@kid => pem} end) 26 | |> Jason.encode!() 27 | end 28 | 29 | def get_mock_jwt(user) do 30 | user 31 | |> claims_from_user() 32 | |> generate_and_sign(get_default_signer()) 33 | end 34 | 35 | defp get_default_signer() do 36 | private_key = 37 | MembraneLive.get_env!(:google_private_key_path) 38 | |> File.read!() 39 | 40 | Joken.Signer.create("RS256", %{"pem" => private_key}, %{"kid" => @kid}) 41 | end 42 | 43 | defp claims_from_user(user) do 44 | %{ 45 | "name" => user.name, 46 | "email" => user.email, 47 | "picture" => user.picture 48 | } 49 | end 50 | 51 | def wrongly_signed_jwt() do 52 | signer = get_invalid_signer() 53 | generate_and_sign(%{}, signer) 54 | end 55 | 56 | defp get_invalid_signer() do 57 | get_signer(:google_invalid_priv_key_path) 58 | end 59 | 60 | defp get_signer(env_key) do 61 | Joken.Signer.create("RS256", %{"pem" => get_private_key(env_key)}, %{"kid" => @kid}) 62 | end 63 | 64 | defp get_private_key(env_key) do 65 | env_key 66 | |> MembraneLive.get_env!() 67 | |> File.read!() 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /assets/js/components/dashboard/MobileHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MembraneLogo from "./MembraneLogo"; 3 | import type { CurrentEvents } from "../../types/types"; 4 | import GoogleButton from "../helpers/GoogleButton"; 5 | import { roundedGoogleButton } from "../../utils/const"; 6 | import { Logout } from "react-swm-icon-pack"; 7 | import { logOut } from "../../utils/storageUtils"; 8 | import "../../../css/dashboard/welcomepanel.css"; 9 | 10 | type MobileHeaderProps = { 11 | isAuthenticated: boolean; 12 | currentEvents: CurrentEvents; 13 | setCurrentEvents: React.Dispatch>; 14 | }; 15 | 16 | const MobileHeader = ({ isAuthenticated, currentEvents, setCurrentEvents }: MobileHeaderProps) => { 17 | return ( 18 | <> 19 |
20 | 21 |

Membrane

22 | {isAuthenticated ? ( 23 | 26 | ) : ( 27 | 28 | )} 29 |
30 |
31 | 37 | 43 |
44 | 45 | ); 46 | }; 47 | 48 | export default MobileHeader; 49 | -------------------------------------------------------------------------------- /assets/css/dashboard/modalform.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .ModalFormCreateButton { 4 | color: var(--bg-light-color-1); 5 | background: var(--font-dark-color); 6 | font-size: 1.6rem; 7 | font-weight: 600; 8 | border-radius: 40px; 9 | padding: 1.3rem 3rem; 10 | margin-left: auto; 11 | } 12 | 13 | .ModalForm { 14 | display: flex; 15 | flex-direction: column; 16 | padding: 2.4rem; 17 | padding-right: 0.4rem; 18 | gap: 1rem; 19 | 20 | position: absolute; 21 | left: 50%; 22 | top: 50%; 23 | transform: translate(-50%, -50%); 24 | 25 | max-height: 75rem; 26 | width: 44rem; 27 | 28 | box-shadow: 0.5rem 0.5rem 0.5rem var(--name-background-color); 29 | 30 | border: 2px solid var(--font-dark-color); 31 | background-color: var(--bg-light-color-1); 32 | border-radius: 3rem; 33 | overflow-y: auto; 34 | } 35 | 36 | .ModalTitle { 37 | color: var(--font-dark-color); 38 | font-family: var(--font-title); 39 | font-weight: 500; 40 | font-size: 2.5rem; 41 | } 42 | 43 | .ModalFormHeader { 44 | display: flex; 45 | } 46 | 47 | .ModalFormHeader > button { 48 | margin-left: auto; 49 | } 50 | 51 | .ModalFormFooter { 52 | margin-top: auto; 53 | display: flex; 54 | justify-content: space-between; 55 | } 56 | 57 | .ModalFormSubmitButton { 58 | width: 24rem; 59 | padding: 0rem 3.2rem; 60 | min-height: 4.5rem; 61 | line-height: 2.4rem; 62 | 63 | border-radius: 100px; 64 | 65 | color: white; 66 | background-color: #001a72; 67 | justify-content: center; 68 | font-size: 1.8rem; 69 | font-weight: 600; 70 | } 71 | 72 | .ModalFormCancelButton { 73 | min-height: 5rem; 74 | padding: 0rem 3.2rem; 75 | line-height: 2.4rem; 76 | 77 | color: #981b1b; 78 | font-weight: 600; 79 | font-size: 1.8rem; 80 | } 81 | 82 | .ModalWrapper { 83 | overflow: auto; 84 | 85 | scrollbar-gutter: stable; 86 | padding-right: 1.1rem; 87 | } 88 | -------------------------------------------------------------------------------- /assets/js/types/react-swm-icon-pack/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "react-swm-icon-pack" { 2 | import { FC } from "react"; 3 | 4 | type IconProps = { 5 | color: string; 6 | strokeWidth: string | number; 7 | size: string | number; 8 | set: "broken" | "curved" | "duotone" | "outline"; 9 | }; 10 | 11 | export type Icon = FC & React.HTMLAttributes>; 12 | 13 | export const ArrowLeft: Icon; 14 | export const Calendar: Icon; 15 | export const CalendarClock: Icon; 16 | export const Cam: Icon; 17 | export const CamDisabled: Icon; 18 | export const Copy: Icon; 19 | export const Cross: Icon; 20 | export const CrossCircle: Icon; 21 | export const CrossSmall: Icon; 22 | export const Crown1: Icon; 23 | export const EmoteSmile: Icon; 24 | export const Fullscreen: Icon; 25 | export const InfoCircle: Icon; 26 | export const Logout: Icon; 27 | export const MenuHorizontal: Icon; 28 | export const MenuVertical: Icon; 29 | export const Microphone: Icon; 30 | export const MicrophoneDisabled: Icon; 31 | export const Minus: Icon; 32 | export const Package: Icon; 33 | export const Pause: Icon; 34 | export const PhoneDown: Icon; 35 | export const Play: Icon; 36 | export const Plus: Icon; 37 | export const QuestionCircle: Icon; 38 | export const Redo: Icon; 39 | export const RotateLeft: Icon; 40 | export const RotateRight: Icon; 41 | export const Screen: Icon; 42 | export const ScreenDisabled: Icon; 43 | export const ScreenShare: Icon; 44 | export const Search: Icon; 45 | export const Settings: Icon; 46 | export const Share1: Icon; 47 | export const Speaker0: Icon; 48 | export const Speaker1: Icon; 49 | export const Speaker2: Icon; 50 | export const SpeakerCross: Icon; 51 | export const Star1: Icon; 52 | export const User1: Icon; 53 | export const UserPlus: Icon; 54 | export const Users: Icon; 55 | export const WarningCircle: Icon; 56 | } 57 | -------------------------------------------------------------------------------- /lib/membrane_live/accounts.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Accounts do 2 | @moduledoc """ 3 | The Accounts context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias MembraneLive.Repo 8 | 9 | alias MembraneLive.Accounts.User 10 | 11 | def list_users do 12 | Repo.all(User) 13 | end 14 | 15 | def get_user(uuid) do 16 | case Repo.get(User, uuid) do 17 | nil -> {:error, :no_user} 18 | user -> {:ok, user} 19 | end 20 | end 21 | 22 | def get_user!(uuid), do: Repo.get!(User, uuid) 23 | 24 | def get_user_by_email(email), do: Repo.get_by(User, email: email) 25 | 26 | def create_user(attrs \\ %{}) do 27 | %User{} 28 | |> User.changeset(attrs) 29 | |> Repo.insert() 30 | end 31 | 32 | def update_user(%User{} = user, attrs) do 33 | user 34 | |> User.changeset(attrs) 35 | |> Repo.update() 36 | end 37 | 38 | def delete_user(%User{} = user) do 39 | Repo.delete(user) 40 | end 41 | 42 | def change_user(%User{} = user, attrs \\ %{}) do 43 | User.changeset(user, attrs) 44 | end 45 | 46 | def create_user_if_not_exists(%{"email" => email} = attrs) do 47 | case get_user_by_email(email) do 48 | nil -> create_user(attrs) 49 | user -> {:ok, user} 50 | end 51 | end 52 | 53 | def create_user_id_not_exists(_attrs), do: {:error, :email_not_provided} 54 | 55 | @spec get_username(String.t()) :: {:ok, String.t()} | {:error, String.t()} 56 | def get_username(uuid) do 57 | with {:ok, %{name: name}} <- get_user(uuid) do 58 | {:ok, name} 59 | else 60 | {:error, :no_user} -> {:error, "User with this id #{uuid} doesn't exist."} 61 | end 62 | end 63 | 64 | @spec get_email(String.t()) :: {:ok, String.t()} | {:error, String.t()} 65 | def get_email(uuid) do 66 | with {:ok, %{email: email}} <- get_user(uuid) do 67 | {:ok, email} 68 | else 69 | {:error, :no_user} -> {:error, "User with this id #{uuid} doesn't exist."} 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /assets/js/components/dashboard/SideDashboardPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MembraneLogo from "./MembraneLogo"; 3 | import { Calendar, Package, QuestionCircle, Logout, Icon } from "react-swm-icon-pack"; 4 | import { logOut } from "../../utils/storageUtils"; 5 | import type { CurrentEvents } from "../../types/types"; 6 | import "../../../css/dashboard/sidedashboardpanel.css"; 7 | 8 | type SideDashboardPanelProps = { 9 | currentEvents: CurrentEvents; 10 | setCurrentEvents: React.Dispatch>; 11 | }; 12 | 13 | const SideDashboardPanel = ({ currentEvents, setCurrentEvents }: SideDashboardPanelProps) => { 14 | const getButton = (Icon: Icon, text: string, onClick: () => void) => { 15 | const isActive = currentEvents == text; 16 | 17 | return ( 18 | 23 | ); 24 | }; 25 | 26 | return ( 27 |
28 |
29 | 30 |

Membrane

31 |
32 |
33 | {getButton(Calendar, "All events", () => { 34 | setCurrentEvents("All events"); 35 | })} 36 | {getButton(Package, "Recorded events", () => { 37 | setCurrentEvents("Recorded events"); 38 | })} 39 |
40 |
41 | {getButton(QuestionCircle, "Help", () => { 42 | // TODO 43 | })} 44 | {getButton(Logout, "Logout", () => logOut())} 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default SideDashboardPanel; 51 | -------------------------------------------------------------------------------- /assets/js/utils/googleAuthUtils.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Channel } from "phoenix"; 3 | import { axiosWithInterceptor } from "../services"; 4 | import { isUserAuthenticated } from "../services/jwtApi"; 5 | import { storageSetJwt, storageSetName, storageSetEmail, sessionStorageSetPicture } from "./storageUtils"; 6 | import { CredentialResponse } from "google-one-tap"; 7 | import { getErrorToast } from "./toastUtils"; 8 | import type { Toast } from "../types/types"; 9 | 10 | const fetchToken = async (googleResponse: CredentialResponse, toast: Toast) => { 11 | try { 12 | const response = await axios.post(window.location.origin + "/auth", googleResponse); 13 | if (!response.data.authToken || !response.data.refreshToken) throw "Token is empty"; 14 | storageSetJwt(response.data); 15 | } catch (error) { 16 | console.log(error); 17 | getErrorToast(toast, "Couldn't get the token. Please try again in a moment."); 18 | } 19 | }; 20 | 21 | export const fetchTokenAndRedirect = async ( 22 | googleResponse: CredentialResponse, 23 | eventChannel: Channel | undefined, 24 | toast: Toast 25 | ) => { 26 | await fetchToken(googleResponse, toast); 27 | if (isUserAuthenticated()) { 28 | axiosWithInterceptor 29 | .get("/me") 30 | .then((response) => { 31 | if (!response.data.name || !response.data.email) throw "User information aren't correct"; 32 | storageSetName(response.data.name); 33 | storageSetEmail(response.data.email); 34 | sessionStorageSetPicture(response.data.picture); 35 | if (eventChannel) eventChannel.leave(); 36 | window.location.reload(); 37 | }) 38 | .catch((error) => { 39 | console.error(error); 40 | if (error.response.status === 403) { 41 | getErrorToast(toast, "Invalid access token. Please log in again."); 42 | } else { 43 | getErrorToast(toast, "Couldn't get the user information. Please try again in a moment."); 44 | } 45 | }); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /assets/css/event/controlpanel.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .ControlPanel { 4 | width: 100%; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | font-size: 1.6rem; 9 | } 10 | 11 | .PanelButton { 12 | box-sizing: content-box; 13 | height: 2.5rem; 14 | width: auto; 15 | background-color: var(--bg-light-color-1); 16 | border: 1px solid var(--bg-light-color-3); 17 | border-radius: 50%; 18 | padding: 1rem; 19 | transition: background 0.25s; 20 | } 21 | 22 | .PanelButton:hover { 23 | background: var(--bg-light-color-2); 24 | transition: background 0.25s; 25 | } 26 | 27 | .PanelButton path { 28 | stroke: var(--font-dark-color); 29 | } 30 | 31 | .DisconnectButton { 32 | box-sizing: content-box; 33 | height: 2.7rem; 34 | width: auto; 35 | background-color: var(--button-red); 36 | border-radius: 50%; 37 | padding: 1.2rem; 38 | } 39 | 40 | .DisconnectButton path { 41 | stroke: var(--bg-light-color-1); 42 | } 43 | 44 | .CenterIcons { 45 | display: flex; 46 | align-items: center; 47 | gap: 2rem; 48 | } 49 | 50 | .SettingsModalBody { 51 | color: var(--font-dark-color); 52 | gap: 1rem; 53 | display: flex; 54 | justify-content: center; 55 | align-items: center; 56 | } 57 | 58 | .SettingsModalHeader, 59 | .SettingsModal, 60 | .SettingsModalClose { 61 | color: var(--font-dark-color); 62 | font-size: 2.4rem; 63 | } 64 | 65 | .SettingsModal { 66 | position: absolute; 67 | } 68 | 69 | .MenuButtonText { 70 | font-size: 1.6rem; 71 | } 72 | 73 | .SettingsMenuItem { 74 | font-size: 1.6rem; 75 | } 76 | 77 | .OptionsPopoverHeader { 78 | font-size: 2rem; 79 | color: var(--font-dark-color); 80 | } 81 | 82 | .OptionsPopoverBody { 83 | color: var(--font-dark-color); 84 | display: flex; 85 | align-items: center; 86 | gap: 2rem; 87 | } 88 | 89 | .OptionsPopoverButton { 90 | color: var(--bg-light-color-1); 91 | background-color: var(--font-dark-color); 92 | border-radius: 16px; 93 | font-weight: 600; 94 | font-size: 1.6rem; 95 | padding: 1rem 1.5rem; 96 | } 97 | -------------------------------------------------------------------------------- /assets/css/dashboard/sidedashboardpanel.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .SideDashboardPanel { 4 | background: var(--font-dark-color); 5 | overflow: hidden; 6 | width: 27rem; 7 | border-radius: 24px; 8 | padding: 2.5rem 0; 9 | padding-right: 0; 10 | display: flex; 11 | flex-direction: column; 12 | gap: 3rem; 13 | } 14 | 15 | .LogoContainer { 16 | color: var(--bg-light-color-1); 17 | height: 5rem; 18 | padding-left: 3.2rem; 19 | display: flex; 20 | align-items: center; 21 | gap: 1.2rem; 22 | } 23 | 24 | .LogoText { 25 | font-family: var(--font-title); 26 | font-size: 2.4rem; 27 | font-weight: 500; 28 | letter-spacing: 0.2rem; 29 | } 30 | 31 | .DashboardPanelItems { 32 | display: flex; 33 | flex-direction: column; 34 | } 35 | 36 | .DashboardPanelButton { 37 | letter-spacing: 0.05rem; 38 | height: 6rem; 39 | padding-left: 3.2rem; 40 | display: flex; 41 | align-items: center; 42 | gap: 1rem; 43 | } 44 | 45 | .SideDashboardPanelButton path { 46 | stroke: var(--bg-light-color-4); 47 | } 48 | 49 | .DashboardPanelButton:hover { 50 | box-shadow: inset 0 0 100px 100px rgba(255, 255, 255, 0.1); 51 | } 52 | 53 | .DashboardPanelButton:hover .PanelText { 54 | color: var(--bg-light-color-1); 55 | } 56 | 57 | .PanelFooter { 58 | margin-top: auto; 59 | } 60 | 61 | .PanelText { 62 | color: var(--bg-light-color-3); 63 | font-size: 1.7rem; 64 | } 65 | 66 | .ActiveButton { 67 | color: var(--bg-light-color-1); 68 | } 69 | 70 | .ActiveButton path { 71 | stroke: var(--bg-light-color-3); 72 | } 73 | 74 | .Accent { 75 | margin-left: auto; 76 | width: 0.4rem; 77 | height: 90%; 78 | border-radius: 24px 0 0 24px; 79 | transition: background 0.5s; 80 | } 81 | 82 | .ActiveAccent { 83 | background: var(--bg-light-color-1); 84 | transition: background 0.5s; 85 | } 86 | 87 | @media screen and (max-width: 500px), screen and (max-height: 500px) { 88 | .LogoContainer { 89 | width: 100%; 90 | color: var(--font-dark-color); 91 | padding-left: 0; 92 | padding-bottom: 1rem; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use MembraneLive.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | alias MembraneLive.Repo 21 | 22 | import Ecto 23 | import Ecto.Changeset 24 | import Ecto.Query 25 | import MembraneLive.DataCase 26 | end 27 | end 28 | 29 | setup tags do 30 | MembraneLive.DataCase.setup_sandbox(tags) 31 | :ok 32 | end 33 | 34 | @doc """ 35 | Sets up the sandbox based on the test tags. 36 | """ 37 | @spec setup_sandbox(any()) :: any() 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MembraneLive.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @spec errors_on(Ecto.Changeset.t()) :: %{optional(atom) => list} 44 | @doc """ 45 | A helper that transforms changeset errors into a map of messages. 46 | 47 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 48 | assert "password is too short" in errors_on(changeset).password 49 | assert %{password: ["password is too short"]} = errors_on(changeset) 50 | 51 | """ 52 | def errors_on(changeset) do 53 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 54 | Regex.replace(~r"%{(\w+)}", message, fn _sth, key -> 55 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 56 | end) 57 | end) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /assets/css/dashboard/eventform.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .EventFormFieldDiv, 4 | .EventFormDiv { 5 | margin-bottom: 1.5rem; 6 | display: flex; 7 | flex-direction: column; 8 | 9 | font-size: 1.5rem; 10 | color: var(--font-dark-color); 11 | } 12 | 13 | .EventFormFieldLabel { 14 | font-weight: 400; 15 | margin-bottom: 0.5rem; 16 | } 17 | 18 | .EventFormFieldInput { 19 | border: 2px solid var(--font-dark-color); 20 | border-radius: 25px; 21 | padding: 1.6rem 1.4rem; 22 | outline: none; 23 | font-size: 16px; 24 | } 25 | 26 | .EventFormFieldInput:hover { 27 | border-color: var(--bg-light-color-4); 28 | } 29 | 30 | .EventFormFieldWithButtonDiv { 31 | display: flex; 32 | justify-content: space-between; 33 | } 34 | 35 | .EventFormFieldWithButtonDiv > input { 36 | flex-grow: 2; 37 | border: none; 38 | outline: none; 39 | } 40 | 41 | .EventFormFieldWithButtonDiv > input[type="datetime-local"]::-webkit-calendar-picker-indicator { 42 | display: none; 43 | -webkit-appearance: none; 44 | } 45 | 46 | .EventFormCounter { 47 | font-size: 1.2rem; 48 | line-height: 1.6rem; 49 | 50 | display: flex; 51 | align-items: center; 52 | } 53 | 54 | .EventFormDescriptionWrapper { 55 | height: 100%; 56 | } 57 | 58 | .EventFormDescriptionWrapper:hover .EventFormScrollAdjuster, 59 | .EventFormDescriptionWrapper:hover .EventFormFieldInput { 60 | border-color: var(--bg-light-color-4); 61 | } 62 | 63 | .EventFormScrollAdjuster { 64 | height: 1.6rem; 65 | border-radius: 1.6rem; 66 | border: 2px solid var(--font-dark-color); 67 | 68 | border-bottom: 0; 69 | border-bottom-left-radius: 0; 70 | border-bottom-right-radius: 0; 71 | } 72 | 73 | #EventFormDescriptionField { 74 | height: 10rem; 75 | width: 100%; 76 | 77 | border-top: 0; 78 | border-radius: 1.6rem; 79 | border-top-left-radius: 0; 80 | border-top-right-radius: 0; 81 | 82 | padding-top: 0; 83 | 84 | outline: none; 85 | } 86 | 87 | .EventFormUnorderedList { 88 | padding: 0rem 1.1rem 0rem 2rem; 89 | margin-top: 0.5rem; 90 | margin-left: 2rem; 91 | } 92 | -------------------------------------------------------------------------------- /assets/js/utils/chatUtils.ts: -------------------------------------------------------------------------------- 1 | import { Presence } from "phoenix"; 2 | import { getByKey } from "./channelUtils"; 3 | import type { AwaitingMessage, ChatMessage, MetasUser } from "../types/types"; 4 | 5 | export const pushToShownMessages = ( 6 | shownMessages: ChatMessage[], 7 | messagesToAdd: AwaitingMessage[], 8 | presence: Presence | undefined 9 | ) => { 10 | messagesToAdd.forEach(({ name, email, content, offset }: AwaitingMessage) => { 11 | const last = shownMessages[shownMessages.length - 1]; 12 | if (last && last.email == email) last.contents.push({ content, offset }); 13 | else { 14 | const data = getByKey(presence, email); 15 | const newChatMessage: ChatMessage = { 16 | id: last ? last.id + 1 : 0, 17 | email: email, 18 | name: name, 19 | title: getTitle(data), 20 | moderatedNo: 0, // number of hidden messages counting from the start 21 | contents: [{ content, offset }], 22 | }; 23 | shownMessages.push(newChatMessage); 24 | } 25 | }); 26 | 27 | return [...shownMessages]; 28 | }; 29 | 30 | export const popFromShownMessages = ( 31 | shownMessages: ChatMessage[], 32 | chatMessagesStack: AwaitingMessage[], 33 | offset: number 34 | ) => { 35 | const messagesToRemove: AwaitingMessage[] = []; 36 | 37 | let message = shownMessages.pop(); 38 | let content = message ? message.contents.pop() : undefined; 39 | while (message && content && content.offset > offset) { 40 | messagesToRemove.push({ ...content, name: message.name, email: message.email }); 41 | if (message.contents.length === 0) message = shownMessages.pop(); 42 | content = message ? message.contents.pop() : undefined; 43 | } 44 | if (content && message) message.contents.push(content); 45 | if (message) shownMessages.push(message); 46 | 47 | chatMessagesStack.push(...messagesToRemove); 48 | return [...shownMessages]; 49 | }; 50 | 51 | export const getTitle = (data: MetasUser | undefined) => 52 | data ? (data.is_moderator ? "(moderator)" : data.is_presenter ? "(presenter)" : "") : ""; 53 | -------------------------------------------------------------------------------- /assets/js/utils/useRecordingChatMessages.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | import axios from "axios"; 3 | import { pushToShownMessages, popFromShownMessages } from "./chatUtils"; 4 | import type { AwaitingMessage, ChatMessage } from "../types/types"; 5 | 6 | export const useRecordingChatMessages = (): { 7 | chatMessages: ChatMessage[]; 8 | isChatLoaded: boolean; 9 | addMessage: (offset: number) => void; 10 | } => { 11 | const [chatMessages, setChatMessages] = useState([]); 12 | const [isChatLoaded, setIsChatLoaded] = useState(false); 13 | const chatMessagesStack = useRef([]); 14 | const requestedPreviousMessages = useRef(false); 15 | 16 | const addMessage = useCallback((offset: number) => { 17 | const messagesToAdd: AwaitingMessage[] = []; 18 | let lastOnStack = chatMessagesStack.current.pop(); 19 | while (lastOnStack && offset >= lastOnStack.offset) { 20 | messagesToAdd.push(lastOnStack); 21 | lastOnStack = chatMessagesStack.current.pop(); 22 | } 23 | if (lastOnStack) chatMessagesStack.current.push(lastOnStack); 24 | setChatMessages((prev) => pushToShownMessages(prev, messagesToAdd, undefined)); 25 | 26 | if (messagesToAdd.length === 0) { 27 | setChatMessages((prev) => popFromShownMessages(prev, chatMessagesStack.current, offset)); 28 | } 29 | }, []); 30 | 31 | useEffect(() => { 32 | if (!requestedPreviousMessages.current) { 33 | requestedPreviousMessages.current = true; 34 | const id = window.location.href.split("/").slice(-1)[0]; 35 | axios 36 | .get(`${window.location.origin}/resources/chat/${id}`) 37 | .then(({ data: { chats: prevChatMessages } }: { data: { chats: AwaitingMessage[] } }) => { 38 | chatMessagesStack.current = prevChatMessages.reverse(); 39 | setIsChatLoaded(true); 40 | }) 41 | .catch((error) => console.error("Fetching previous chat messages failed: ", error)); 42 | } 43 | }, []); 44 | 45 | return { chatMessages, isChatLoaded, addMessage }; 46 | }; 47 | -------------------------------------------------------------------------------- /assets/js/components/event/ModePanel.tsx: -------------------------------------------------------------------------------- 1 | import { Screen } from "react-swm-icon-pack"; 2 | import { Channel } from "phoenix"; 3 | import { useStateTimeout } from "../../utils/reactUtils"; 4 | 5 | import "../../../css/event/modepanel.css"; 6 | import "../../../css/event/animation.css"; 7 | 8 | export type ModeButtonProps = { 9 | onClick: () => void; 10 | name: string; 11 | active?: boolean; 12 | }; 13 | 14 | export const ModeButton = ({ onClick, name, active = true }: ModeButtonProps) => { 15 | return ( 16 | 19 | ); 20 | }; 21 | 22 | type ModePanelProps = { 23 | presenterName: string; 24 | eventChannel: Channel | undefined; 25 | amIPresenter: boolean; 26 | }; 27 | 28 | const ModePanel = ({ presenterName, eventChannel, amIPresenter }: ModePanelProps) => { 29 | const heartReactionMessage = "reaction_heart"; 30 | const confettiReactionMessage = "reaction_confetti"; 31 | 32 | const [heart, toggleHeart] = useStateTimeout( 33 | () => { 34 | eventChannel?.push(heartReactionMessage, {}); 35 | }, 36 | false, 37 | 5_000 38 | ); 39 | 40 | const [confetti, toggleConfetti] = useStateTimeout( 41 | () => { 42 | eventChannel?.push(confettiReactionMessage, {}); 43 | }, 44 | false, 45 | 2_500 46 | ); 47 | 48 | return ( 49 |
50 | 51 |
52 | {presenterName ? `Presenting now...` : "Waiting for the presenter to be chosen..."} 53 |
54 |
55 | {presenterName && !amIPresenter && ( 56 |
57 | 60 | 63 |
64 | )} 65 |
66 |
67 | ); 68 | }; 69 | 70 | export default ModePanel; 71 | -------------------------------------------------------------------------------- /assets/js/components/event/RtcPlayer.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { User1, CamDisabled, MicrophoneDisabled } from "react-swm-icon-pack"; 3 | import type { User } from "../../types/types"; 4 | import "../../../css/event/rtcplayer.css"; 5 | 6 | type RtcPlayerProps = { 7 | metadata: User | undefined; 8 | audioStream: MediaStream | null; 9 | videoStream: MediaStream | null; 10 | isMyself: boolean; 11 | isMuted: boolean; 12 | isCamDisabled: boolean; 13 | }; 14 | 15 | const RtcPlayer = ({ isMyself, audioStream, videoStream, metadata, isMuted, isCamDisabled }: RtcPlayerProps) => { 16 | const loadVideo = useCallback( 17 | (media: HTMLAudioElement | null) => { 18 | if (!media) return; 19 | media.srcObject = videoStream || null; 20 | }, 21 | [videoStream] 22 | ); 23 | 24 | const loadAudio = useCallback( 25 | (media: HTMLAudioElement | null) => { 26 | if (!media) return; 27 | media.srcObject = audioStream || null; 28 | }, 29 | [audioStream] 30 | ); 31 | 32 | return ( 33 |
34 |
35 | {isMuted && ( 36 |
37 | 38 |
39 | )} 40 | {isCamDisabled && ( 41 |
42 | 43 |
44 | )} 45 |
46 |
64 | ); 65 | }; 66 | 67 | export default RtcPlayer; 68 | -------------------------------------------------------------------------------- /lib/membrane_live_web/helpers/controller_callback_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.Helpers.ControllerCallbackHelper do 2 | @moduledoc false 3 | # Helper functions for WebinarController and RecordingsController 4 | 5 | alias MembraneLive.Webinars 6 | 7 | def get_webinar_and_fire_callback( 8 | conn, 9 | params, 10 | callback, 11 | show_callback? \\ false 12 | ) 13 | 14 | def get_webinar_and_fire_callback( 15 | %{assigns: %{user_id: user_id}} = conn, 16 | %{"uuid" => uuid} = params, 17 | callback, 18 | show_callback? 19 | ) do 20 | with {:ok, webinar} <- Webinars.get_webinar(uuid), 21 | {:ok, webinar_db} <- user_authorized?(webinar, user_id, show_callback?) do 22 | callback.(conn, Map.put(params, "webinar_db", webinar_db)) 23 | else 24 | {:error, :no_webinar} -> 25 | %{error: :not_found, message: "Webinar with uuid #{uuid} could not be found"} 26 | 27 | {:error, :forbidden} -> 28 | %{ 29 | error: :forbidden, 30 | message: "User with uuid #{user_id} does not have access to webinar with uuid #{uuid}" 31 | } 32 | end 33 | end 34 | 35 | def get_webinar_and_fire_callback( 36 | conn, 37 | %{"uuid" => uuid} = params, 38 | callback, 39 | show_callback? 40 | ) do 41 | with {:ok, webinar} <- Webinars.get_webinar(uuid), 42 | true <- show_callback? do 43 | callback.(conn, Map.put(params, "webinar_db", webinar)) 44 | else 45 | {:error, :no_webinar} -> 46 | %{error: :not_found, message: "Webinar with uuid #{uuid} could not be found"} 47 | 48 | false -> 49 | %{error: :forbidden, message: "Unauthenticated user does not have access to this action."} 50 | end 51 | end 52 | 53 | defp user_authorized?(webinar, _jwt_user_uuid, true), do: {:ok, webinar} 54 | 55 | defp user_authorized?(webinar, jwt_user_uuid, _is_show_callback?) 56 | when jwt_user_uuid == webinar.moderator_id, 57 | do: {:ok, webinar} 58 | 59 | defp user_authorized?(_webinar, _jwt_user_uuid, _is_show_callback?), do: {:error, :forbidden} 60 | end 61 | -------------------------------------------------------------------------------- /assets/js/components/dashboard/WelcomePanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { getIsAuthenticated, storageGetName, storageGetPicture } from "../../utils/storageUtils"; 3 | import UserField from "./UserField"; 4 | import MobileHeader from "./MobileHeader"; 5 | import { ScreenTypeContext } from "../../utils/ScreenTypeContext"; 6 | import type { CurrentEvents } from "../../types/types"; 7 | import "../../../css/dashboard/welcomepanel.css"; 8 | 9 | type WelcomePanelProps = { 10 | currentEvents: CurrentEvents; 11 | setCurrentEvents: React.Dispatch>; 12 | }; 13 | 14 | const WelcomePanel = ({ currentEvents, setCurrentEvents }: WelcomePanelProps) => { 15 | const name = storageGetName(); 16 | const picture = storageGetPicture(); 17 | const isAuthenticated = getIsAuthenticated(); 18 | const screenType = useContext(ScreenTypeContext); 19 | 20 | return ( 21 |
22 | {screenType.device == "mobile" && ( 23 | 28 | )} 29 |
30 | {currentEvents == "All events" && ( 31 | <> 32 |

Hi{name ? ` ${name.split(" ")[0]}` : ""}!

33 |

What event would you like to join today?

34 | 35 | )} 36 | {currentEvents == "Recorded events" && ( 37 | <> 38 |

Recorded events

39 |

40 | Here you will find past events that have been recorded 41 |
42 | Enjoy your watch! 43 |

44 | 45 | )} 46 |
47 | {screenType.device == "desktop" && ( 48 |
49 | 50 |
51 | )} 52 |
53 | ); 54 | }; 55 | 56 | export default WelcomePanel; 57 | -------------------------------------------------------------------------------- /assets/js/components/event/MobileHlsBar.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from "@chakra-ui/react"; 2 | import { useContext, useState } from "react"; 3 | import type { Client } from "../../types/types"; 4 | import { UserPlus } from "react-swm-icon-pack"; 5 | import { getInfoToast } from "../../utils/toastUtils"; 6 | import { ScreenTypeContext } from "../../utils/ScreenTypeContext"; 7 | import GenericButton from "../helpers/GenericButton"; 8 | import MenuPopover from "../helpers/MenuPopover"; 9 | import { storageGetPresentingRequest, sessionStorageSetPresentingRequest } from "../../utils/storageUtils"; 10 | 11 | type MobileHlsBarProps = { 12 | client: Client; 13 | eventTitle: string; 14 | amIPresenter: boolean; 15 | switchAsking: (isAsking: boolean) => void; 16 | }; 17 | 18 | const MobileHlsBar = ({ client, eventTitle, amIPresenter, switchAsking }: MobileHlsBarProps) => { 19 | const toast = useToast(); 20 | const screenType = useContext(ScreenTypeContext); 21 | 22 | const [isAskingForPresenter, setIsAskingForPresenter] = useState(storageGetPresentingRequest()); 23 | 24 | const MobilePresenterButton = () => { 25 | const className = "MobileControlButton"; 26 | const two_seconds = 2_000; 27 | const toastText = (isAsking: boolean) => { 28 | return isAsking ? "Asking for presenter..." : "Stopped asking."; 29 | }; 30 | 31 | return amIPresenter ? ( 32 | 33 | ) : ( 34 | } 36 | onClick={() => { 37 | switchAsking(isAskingForPresenter); 38 | 39 | const newAskingState = !isAskingForPresenter; 40 | setIsAskingForPresenter(newAskingState); 41 | sessionStorageSetPresentingRequest(newAskingState); 42 | getInfoToast(toast, toastText(newAskingState), two_seconds); 43 | }} 44 | className={className} 45 | /> 46 | ); 47 | }; 48 | 49 | return ( 50 |
51 | {screenType.orientation === "landscape" &&
{eventTitle}
} 52 | {client.isAuthenticated && } 53 |
54 | ); 55 | }; 56 | 57 | export default MobileHlsBar; 58 | -------------------------------------------------------------------------------- /lib/membrane_live_web/controllers/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.UserController do 2 | use MembraneLiveWeb, :controller 3 | 4 | alias MembraneLive.Accounts 5 | alias MembraneLive.Accounts.User 6 | 7 | action_fallback(MembraneLiveWeb.FallbackController) 8 | 9 | def index(conn, _params) do 10 | users = Accounts.list_users() 11 | render(conn, "index.json", users: users) 12 | end 13 | 14 | def show(conn, params) do 15 | get_user_and_fire_callback(conn, params, &show_callback/2) 16 | end 17 | 18 | def update(conn, params) do 19 | get_user_and_fire_callback(conn, params, &update_callback/2) 20 | end 21 | 22 | def delete(conn, params) do 23 | get_user_and_fire_callback(conn, params, &delete_callback/2) 24 | end 25 | 26 | defp get_user_and_fire_callback( 27 | %{assigns: %{user_id: jwt_user_id}} = conn, 28 | %{"uuid" => uuid} = params, 29 | callback 30 | ) do 31 | with {:ok, user} <- Accounts.get_user(uuid), 32 | {:ok, user} <- return_user_if_is_authorized(user, jwt_user_id) do 33 | callback.(conn, Map.put(params, "user_db", user)) 34 | else 35 | {:error, :no_user} -> 36 | %{error: :not_found, message: "Account with uuid #{uuid} could not be found"} 37 | 38 | {:error, :forbidden} -> 39 | %{ 40 | error: :forbidden, 41 | message: "User with uuid #{jwt_user_id} does not have access to user with uuid #{uuid}" 42 | } 43 | end 44 | end 45 | 46 | def show_callback(conn, %{"user_db" => user}) do 47 | render(conn, "show.json", user: user) 48 | end 49 | 50 | def update_callback(conn, %{"user_db" => user, "user" => user_params}) do 51 | with {:ok, %User{} = user} <- Accounts.update_user(user, user_params) do 52 | render(conn, "show.json", user: user) 53 | end 54 | end 55 | 56 | def delete_callback(conn, %{"user_db" => user}) do 57 | with {:ok, %User{}} <- Accounts.delete_user(user) do 58 | send_resp(conn, :no_content, "") 59 | end 60 | end 61 | 62 | defp return_user_if_is_authorized(user, jwt_uuid) when user.uuid == jwt_uuid, do: {:ok, user} 63 | defp return_user_if_is_authorized(_user, _jwt_uuid), do: {:error, :forbidden} 64 | end 65 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :membrane_live, 4 | integrated_turn_domain: System.get_env("VIRTUAL_HOST"), 5 | token_auth_secret: "auth_secret", 6 | token_refresh_secret: "refresh_secret", 7 | token_issuer: System.get_env("TOKEN_ISSUER", "swmansion.com"), 8 | client_id: System.fetch_env!("GOOGLE_CLIENT_ID") 9 | 10 | # if System.get_env("PHX_SERVER") do 11 | # config :membrane_live, MembraneLiveWeb.Endpoint, server: true 12 | # end 13 | 14 | protocol = if System.get_env("USE_TLS") == "true", do: :https, else: :http 15 | 16 | get_env = fn env, default -> 17 | if config_env() == :prod do 18 | System.fetch_env!(env) 19 | else 20 | System.get_env(env, default) 21 | end 22 | end 23 | 24 | host = System.get_env("VIRTUAL_HOST", "localhost") 25 | port = System.get_env("PHOENIX_PORT", "4000") 26 | 27 | config :membrane_live, MembraneLive.Repo, 28 | username: System.get_env("POSTGRES_USER", "swm"), 29 | password: System.get_env("POSTGRES_PASSWORD", "swm123"), 30 | hostname: System.get_env("POSTGRES_HOST", "localhost"), 31 | database: System.get_env("POSTGRES_DB", "membrane_live_db"), 32 | port: System.get_env("POSTGRES_PORT", "5432"), 33 | stacktrace: true, 34 | show_sensitive_data_on_connection_error: true, 35 | pool_size: 10 36 | 37 | args = 38 | if protocol == :https do 39 | [ 40 | keyfile: get_env.("KEY_FILE_PATH", "priv/certs/key.pem"), 41 | certfile: get_env.("CERT_FILE_PATH", "priv/certs/certificate.pem"), 42 | cipher_suite: :strong 43 | ] 44 | else 45 | [] 46 | end 47 | |> Keyword.merge(otp_app: :membrane_live, port: port) 48 | 49 | endpoint_config = [ 50 | {:url, [host: host]}, 51 | {protocol, args} 52 | ] 53 | 54 | config :membrane_live, MembraneLiveWeb.Endpoint, [ 55 | {:url, [host: host]}, 56 | {protocol, args} 57 | ] 58 | 59 | secure? = 60 | case System.get_env("JELLYFISH_SECURE", "false") do 61 | "true" -> true 62 | "false" -> false 63 | _else -> raise "`JELLYFISH_SECURE` must be set to either `true` or `false`." 64 | end 65 | 66 | config :jellyfish_server_sdk, 67 | server_address: System.get_env("JELLYFISH_ADDRESS", "localhost:5002"), 68 | server_api_token: System.get_env("JELLYFISH_TOKEN", "development"), 69 | secure?: secure? 70 | -------------------------------------------------------------------------------- /lib/membrane_live_web/helpers/ets_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.Helpers.EtsHelper do 2 | @moduledoc false 3 | # Helper functions for EventChannel 4 | 5 | def check_if_banned_from_chat(email, id), 6 | do: check_if_exist_in_ets(:banned_from_chat, email, true, id) 7 | 8 | def check_if_presenter(email, should_be_presenter, id), 9 | do: check_if_exist_in_ets(:presenters, email, should_be_presenter, id) 10 | 11 | def check_if_request_presenting(email, requests_presenting, id), 12 | do: check_if_exist_in_ets(:presenting_requests, email, requests_presenting, id) 13 | 14 | def remove_from_banned_from_chat(email, id), 15 | do: remove_from_list_in_ets(:banned_from_chat, email, id) 16 | 17 | def add_to_banned_from_chat(email, id), do: add_to_list_in_ets(:banned_from_chat, email, id) 18 | def remove_from_presenters(email, id), do: remove_from_list_in_ets(:presenters, email, id) 19 | 20 | def add_to_presenters(email, id), do: add_to_list_in_ets(:presenters, email, id) 21 | 22 | def remove_from_presenting_requests(email, id), 23 | do: remove_from_list_in_ets(:presenting_requests, email, id) 24 | 25 | def add_to_presenting_request(email, id), 26 | do: add_to_list_in_ets(:presenting_requests, email, id) 27 | 28 | def ets_empty?(ets_key, id) do 29 | [{_key, presenters}] = :ets.lookup(ets_key, id) 30 | if presenters == MapSet.new([]), do: true, else: false 31 | end 32 | 33 | defp check_if_exist_in_ets(ets_key, email, client_bool, id) do 34 | [{_key, presenters}] = :ets.lookup(ets_key, id) 35 | in_ets = MapSet.member?(presenters, email) 36 | 37 | case {client_bool, in_ets} do 38 | {true, _in_ets} -> 39 | {:ok, in_ets} 40 | 41 | {false, true} -> 42 | remove_from_presenters(email, id) 43 | {:ok, false} 44 | 45 | {false, false} -> 46 | {:ok, false} 47 | end 48 | end 49 | 50 | defp remove_from_list_in_ets(ets_key, email, id) do 51 | [{_key, presenters}] = :ets.lookup(ets_key, id) 52 | :ets.insert(ets_key, {id, MapSet.delete(presenters, email)}) 53 | end 54 | 55 | defp add_to_list_in_ets(ets_key, email, id) do 56 | [{_key, presenters}] = :ets.lookup(ets_key, id) 57 | :ets.insert(ets_key, {id, MapSet.put(presenters, email)}) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /.github/workflows/staging_build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Staging Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - "staging" 6 | 7 | env: 8 | VIRTUAL_HOST: live.membrane.work 9 | INTEGRATED_TURN_IP: 65.108.158.247 10 | POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} 11 | GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Cache Docker layers 20 | uses: actions/cache@v2 21 | with: 22 | path: /tmp/.buildx-cache 23 | key: ${{ runner.os }}-buildx-${{ github.sha }} 24 | restore-keys: | 25 | ${{ runner.os }}-buildx 26 | 27 | - name: Login to Docker Hub 28 | uses: docker/login-action@v1 29 | with: 30 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 31 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 32 | 33 | - name: Set up Docker Buildx 34 | id: buildx 35 | uses: docker/setup-buildx-action@v1 36 | 37 | - name: Build and push latest version 38 | id: docker_build_latest 39 | uses: docker/build-push-action@v2 40 | with: 41 | context: . 42 | file: Dockerfile 43 | push: true 44 | build-args: VERSION= ${{ github.ref_name }} 45 | tags: membraneframeworklabs/membrane_live:latest 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | needs: build 50 | steps: 51 | - uses: actions/checkout@v3 52 | 53 | - name: Prepare .env file for the deployment 54 | id: variables_population 55 | run: echo "VIRTUAL_HOST=$VIRTUAL_HOST \nINTEGRATED_TURN_IP=$INTEGRATED_TURN_IP \nPOSTGRES_PASSWORD=$POSTGRES_PASSWORD \nGOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID" > .env 56 | 57 | - name: Deploy docker compose to a pre-configured server 58 | id: deploy 59 | uses: TapTap21/docker-remote-deployment-action@v1.1 60 | with: 61 | remote_docker_host: ${{ secrets.STAGING_HOST }} 62 | ssh_private_key: ${{ secrets.SSH_PRIV_KEY }} 63 | ssh_public_key: ${{ secrets.SSH_KNOWN_HOSTS}} 64 | stack_file_name: docker-compose.yml 65 | args: -p staging --env-file .env up -d --remove-orphans 66 | -------------------------------------------------------------------------------- /.github/workflows/sandbox_build_and_deploy.template: -------------------------------------------------------------------------------- 1 | name: Sandbox Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - "sandbox" 6 | 7 | env: 8 | VIRTUAL_HOST: live.membrane.ovh 9 | INTEGRATED_TURN_IP: 95.217.215.156 10 | POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} 11 | TAG: sandbox 12 | GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Cache Docker layers 21 | uses: actions/cache@v2 22 | with: 23 | path: /tmp/.buildx-cache 24 | key: ${{ runner.os }}-buildx-${{ github.sha }} 25 | restore-keys: | 26 | ${{ runner.os }}-buildx 27 | 28 | - name: Login to Docker Hub 29 | uses: docker/login-action@v1 30 | with: 31 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 32 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 33 | 34 | - name: Set up Docker Buildx 35 | id: buildx 36 | uses: docker/setup-buildx-action@v1 37 | 38 | - name: Build and push latest version 39 | id: docker_build_latest 40 | uses: docker/build-push-action@v2 41 | with: 42 | context: . 43 | file: Dockerfile 44 | push: true 45 | build-args: VERSION= ${{ github.ref_name }} 46 | tags: membraneframeworklabs/membrane_live:${{ env.TAG }} 47 | 48 | deploy: 49 | runs-on: ubuntu-latest 50 | needs: build 51 | steps: 52 | - uses: actions/checkout@v3 53 | 54 | - name: Prepare .env file for the deployment 55 | id: variables_population 56 | run: echo "VIRTUAL_HOST=$VIRTUAL_HOST \nINTEGRATED_TURN_IP=$INTEGRATED_TURN_IP \nPOSTGRES_PASSWORD=$POSTGRES_PASSWORD \nTAG=$TAG \nGOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID" > .env 57 | 58 | - name: Deploy docker compose to a pre-configured server 59 | id: deploy 60 | uses: TapTap21/docker-remote-deployment-action@v1.1 61 | with: 62 | remote_docker_host: ${{ secrets.SANDBOX_HOST }} 63 | ssh_private_key: ${{ secrets.SSH_PRIV_KEY }} 64 | ssh_public_key: ${{ secrets.SSH_KNOWN_HOSTS}} 65 | stack_file_name: docker-compose.yml 66 | args: -p sandbox --env-file .env up -d --remove-orphans 67 | -------------------------------------------------------------------------------- /lib/membrane_live_web/controllers/webinar_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.WebinarController do 2 | use MembraneLiveWeb, :controller 3 | 4 | alias MembraneLive.Webinars 5 | alias MembraneLive.Webinars.Webinar 6 | alias MembraneLiveWeb.Helpers.ControllerCallbackHelper 7 | 8 | action_fallback(MembraneLiveWeb.FallbackController) 9 | 10 | @spec index(Plug.Conn.t(), any) :: Plug.Conn.t() 11 | def index(conn, _params) do 12 | webinars = Webinars.list_webinars(conn.assigns.user_id) 13 | render(conn, "index.json", webinars: webinars) 14 | end 15 | 16 | @spec create(any, map) :: any 17 | def create(conn, %{"webinar" => webinar_params}) do 18 | with {:ok, %Webinar{} = webinar} <- 19 | Webinars.create_webinar(webinar_params, conn.assigns.user_id) do 20 | conn 21 | |> put_status(:created) 22 | |> put_resp_header("location", Routes.webinar_path(conn, :show, webinar)) 23 | |> render("show_link.json", link: Webinars.get_link(webinar)) 24 | end 25 | end 26 | 27 | @spec show(Plug.Conn.t(), map) :: Plug.Conn.t() 28 | def show(conn, params) do 29 | ControllerCallbackHelper.get_webinar_and_fire_callback( 30 | conn, 31 | params, 32 | &show_callback/2, 33 | true 34 | ) 35 | end 36 | 37 | @spec update(any, map) :: any 38 | def update(conn, params) do 39 | ControllerCallbackHelper.get_webinar_and_fire_callback( 40 | conn, 41 | params, 42 | &update_callback/2, 43 | false 44 | ) 45 | end 46 | 47 | @spec delete(any, map) :: any 48 | def delete(conn, params) do 49 | ControllerCallbackHelper.get_webinar_and_fire_callback( 50 | conn, 51 | params, 52 | &delete_callback/2, 53 | false 54 | ) 55 | end 56 | 57 | defp show_callback(conn, %{"webinar_db" => webinar}) do 58 | render(conn, "show.json", webinar: webinar) 59 | end 60 | 61 | defp update_callback(conn, %{"webinar_db" => webinar, "webinar" => webinar_params}) do 62 | with {:ok, %Webinar{} = webinar} <- Webinars.update_webinar(webinar, webinar_params) do 63 | render(conn, "show.json", webinar: webinar) 64 | end 65 | end 66 | 67 | defp delete_callback(conn, %{"webinar_db" => webinar}) do 68 | with {:ok, %Webinar{}} <- Webinars.delete_webinar(webinar) do 69 | send_resp(conn, :no_content, "") 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /assets/js/components/dashboard/EventField.tsx: -------------------------------------------------------------------------------- 1 | import { CalendarClock } from "react-swm-icon-pack"; 2 | import { shortMonthNames } from "../../utils/const"; 3 | import ModalForm from "./ModalForm"; 4 | import { useToast } from "@chakra-ui/react"; 5 | import { getEventType } from "../../utils/dashboardUtils"; 6 | import { deleteEventPopup } from "../../utils/toastUtils"; 7 | import { getIsAuthenticated, storageGetEmail, clearSessionStorageName } from "../../utils/storageUtils"; 8 | import type { EventInfo } from "../../types/types"; 9 | import "../../../css/dashboard/eventsarea.css"; 10 | 11 | type EventFieldProps = { 12 | isRecording: boolean; 13 | webinarInfo: EventInfo; 14 | }; 15 | 16 | const EventField = ({ isRecording, webinarInfo }: EventFieldProps) => { 17 | const toast = useToast(); 18 | const isAuthenticated = getIsAuthenticated(); 19 | const eventType = getEventType(isRecording); 20 | const userEmail = storageGetEmail(); 21 | const formatDate = (date: Date) => { 22 | const time = date.toLocaleTimeString().replace(/^(\d+:\d\d)(:\d\d)(.*$)/, "$1$3"); 23 | 24 | return `${shortMonthNames[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()} ${time}`; 25 | }; 26 | 27 | const isUserEventModerator = userEmail == webinarInfo.moderatorEmail; 28 | 29 | return ( 30 |
31 |
32 |

{webinarInfo.presenters.join(", ")}

33 | clearSessionStorageName()}> 34 | {webinarInfo.title} 35 | 36 |

{webinarInfo.description}

37 |
38 |
39 | 40 | {formatDate(webinarInfo.startDate)} 41 |
42 | {isAuthenticated && isUserEventModerator && ( 43 |
44 | {!isRecording && ( 45 | 46 | )} 47 | 50 |
51 | )} 52 |
53 |
54 |
55 | ); 56 | }; 57 | 58 | export default EventField; 59 | -------------------------------------------------------------------------------- /test/membrane_live/accounts_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.AccountsTest do 2 | use MembraneLive.DataCase 3 | 4 | alias MembraneLive.Accounts 5 | 6 | describe "users" do 7 | alias MembraneLive.Accounts.User 8 | 9 | import MembraneLive.AccountsFixtures 10 | 11 | @invalid_attrs %{email: nil, name: nil, picture: nil} 12 | 13 | test "list_users/0 returns all users" do 14 | user = user_fixture() 15 | assert Accounts.list_users() == [user] 16 | end 17 | 18 | test "get_user!/1 returns the user with given uuid" do 19 | user = user_fixture() 20 | assert Accounts.get_user!(user.uuid) == user 21 | end 22 | 23 | test "create_user/1 with valid data creates a user" do 24 | valid_attrs = %{ 25 | email: "john@gmail.com", 26 | name: "John Kowalski", 27 | picture: "kowalski.img" 28 | } 29 | 30 | assert {:ok, %User{} = user} = Accounts.create_user(valid_attrs) 31 | assert user.email == "john@gmail.com" 32 | assert user.name == "John Kowalski" 33 | assert user.picture == "kowalski.img" 34 | end 35 | 36 | test "create_user/1 with invalid data returns error changeset" do 37 | assert {:error, %Ecto.Changeset{}} = Accounts.create_user(@invalid_attrs) 38 | end 39 | 40 | test "update_user/2 with valid data updates the user" do 41 | user = user_fixture() 42 | 43 | update_attrs = %{ 44 | email: "john-update@gmail.com", 45 | name: "John Update Kowalski", 46 | picture: "kowalski_update.img" 47 | } 48 | 49 | assert {:ok, %User{} = user} = Accounts.update_user(user, update_attrs) 50 | assert user.email == "john-update@gmail.com" 51 | assert user.name == "John Update Kowalski" 52 | assert user.picture == "kowalski_update.img" 53 | end 54 | 55 | test "update_user/2 with invalid data returns error changeset" do 56 | user = user_fixture() 57 | assert {:error, %Ecto.Changeset{}} = Accounts.update_user(user, @invalid_attrs) 58 | assert user == Accounts.get_user!(user.uuid) 59 | end 60 | 61 | test "delete_user/1 deletes the user" do 62 | user = user_fixture() 63 | assert {:ok, %User{}} = Accounts.delete_user(user) 64 | assert_raise Ecto.NoResultsError, fn -> Accounts.get_user!(user.uuid) end 65 | end 66 | 67 | test "change_user/1 returns a user changeset" do 68 | user = user_fixture() 69 | assert %Ecto.Changeset{} = Accounts.change_user(user) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /assets/js/utils/useHls.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from "react"; 2 | import Hls, { HlsConfig } from "hls.js"; 3 | 4 | export const useHls = ( 5 | autoPlay: boolean, 6 | hlsConfig?: Partial 7 | ): { 8 | attachVideo: (videoElem: HTMLVideoElement | null) => void; 9 | setSrc: React.Dispatch>; 10 | } => { 11 | const [src, setSrc] = useState(""); 12 | const hls = useRef(new Hls({ enableWorker: false, ...hlsConfig })); 13 | const playerRef = useRef(); 14 | 15 | const attachVideo = useCallback( 16 | (video_ref: HTMLVideoElement | null) => { 17 | if (hls && video_ref) { 18 | playerRef.current = video_ref; 19 | hls.current.attachMedia(video_ref); 20 | } 21 | }, 22 | [hls, playerRef] 23 | ); 24 | 25 | useEffect(() => { 26 | const initHls = () => { 27 | if (hls) { 28 | hls.current.destroy(); 29 | } 30 | 31 | hls.current = new Hls({ enableWorker: false, ...hlsConfig }); 32 | 33 | hls.current.on(Hls.Events.MEDIA_ATTACHED, () => { 34 | hls.current.loadSource(src); 35 | }); 36 | 37 | hls.current.on(Hls.Events.MANIFEST_PARSED, () => { 38 | if (autoPlay) { 39 | if (playerRef.current) playerRef.current.muted = true; 40 | playerRef?.current?.play().catch((error) => console.error(error)); 41 | } 42 | }); 43 | 44 | if (playerRef.current) attachVideo(playerRef.current); 45 | 46 | hls.current.on(Hls.Events.ERROR, function (event, data) { 47 | if (data.fatal) { 48 | switch (data.type) { 49 | case Hls.ErrorTypes.NETWORK_ERROR: 50 | hls.current.startLoad(); 51 | break; 52 | case Hls.ErrorTypes.MEDIA_ERROR: 53 | hls.current.recoverMediaError(); 54 | break; 55 | default: 56 | initHls(); 57 | break; 58 | } 59 | } 60 | }); 61 | }; 62 | 63 | if (Hls.isSupported()) initHls(); 64 | 65 | return () => { 66 | if (hls) hls.current.destroy(); 67 | }; 68 | }, [attachVideo, autoPlay, hlsConfig, src]); 69 | 70 | useEffect(() => { 71 | const video: HTMLVideoElement | null = document.getElementById("hlsVideo") as HTMLVideoElement; 72 | if (!Hls.isSupported() && video && video.canPlayType("application/vnd.apple.mpegurl")) video.src = src; 73 | }), 74 | [src]; 75 | 76 | return { attachVideo, setSrc }; 77 | }; 78 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM membraneframeworklabs/docker_membrane AS build 2 | 3 | 4 | # install build dependencies 5 | RUN apt-get update \ 6 | && DEBIAN_FRONTEND=noninteractive apt-get install -y \ 7 | npm \ 8 | git \ 9 | python3 \ 10 | make \ 11 | cmake \ 12 | libssl-dev \ 13 | libsrtp2-dev \ 14 | ffmpeg \ 15 | clang-format \ 16 | libopus-dev \ 17 | pkgconf 18 | 19 | 20 | ARG VERSION 21 | ENV VERSION=${VERSION} 22 | 23 | # Create build workdir 24 | WORKDIR /app 25 | 26 | # install hex + rebar 27 | RUN mix local.hex --force && \ 28 | mix local.rebar --force 29 | 30 | # set build ENV 31 | ENV MIX_ENV=prod 32 | 33 | # install mix dependencies 34 | COPY mix.exs mix.lock ./ 35 | COPY config config 36 | COPY assets assets 37 | COPY priv priv 38 | # the lib code must be there first so the tailwindcss can properly inspect the code 39 | # to gather necessary classes to generate 40 | COPY lib lib 41 | 42 | RUN mix deps.get 43 | RUN mix setup 44 | RUN mix deps.compile 45 | 46 | RUN mix assets.deploy 47 | 48 | # compile and build release 49 | RUN mix do compile, release 50 | 51 | # prepare release image 52 | FROM ubuntu:20.04 AS app 53 | 54 | # install runtime dependencies 55 | RUN apt-get update \ 56 | && DEBIAN_FRONTEND=noninteractive apt-get install -y \ 57 | openssl \ 58 | libncurses5-dev \ 59 | libncursesw5-dev \ 60 | libsrtp2-dev \ 61 | ffmpeg \ 62 | clang-format \ 63 | curl \ 64 | wget \ 65 | build-essential 66 | 67 | RUN cd /tmp/ \ 68 | && wget https://downloads.sourceforge.net/opencore-amr/fdk-aac-2.0.0.tar.gz \ 69 | && tar -xf fdk-aac-2.0.0.tar.gz && cd fdk-aac-2.0.0 \ 70 | && ./configure --prefix=/usr --disable-static \ 71 | && make && make install \ 72 | && cd / \ 73 | && rm -rf /tmp/* 74 | 75 | RUN apt remove build-essential -y \ 76 | wget \ 77 | && apt autoremove -y 78 | 79 | WORKDIR /app 80 | 81 | COPY docker-entrypoint.sh ./docker-entrypoint.sh 82 | 83 | RUN chmod +x docker-entrypoint.sh 84 | 85 | ENTRYPOINT [ "./docker-entrypoint.sh" ] 86 | 87 | RUN groupadd -r membrane && useradd --no-log-init -r -g membrane membrane 88 | 89 | RUN chown membrane:membrane /app 90 | 91 | USER membrane:membrane 92 | 93 | COPY --from=build /app/_build/prod/rel/membrane_live ./ 94 | 95 | ENV HOME=/app 96 | 97 | EXPOSE 4000 98 | 99 | HEALTHCHECK CMD curl --fail http://localhost:4000 || exit 1 100 | 101 | CMD ["bin/membrane_live", "start"] -------------------------------------------------------------------------------- /lib/membrane_live_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb.Router do 2 | use MembraneLiveWeb, :router 3 | 4 | pipeline :browser do 5 | plug(:accepts, ["json"]) 6 | plug(:put_root_layout, {MembraneLiveWeb.LayoutView, :root}) 7 | end 8 | 9 | pipeline :api do 10 | plug(:accepts, ["json"]) 11 | end 12 | 13 | pipeline :auth_unrestricted do 14 | plug(MembraneLiveWeb.Plugs.Auth, :unrestricted) 15 | end 16 | 17 | pipeline :auth_restricted do 18 | plug(MembraneLiveWeb.Plugs.Auth, :restricted) 19 | end 20 | 21 | scope "/", MembraneLiveWeb do 22 | pipe_through(:browser) 23 | 24 | get("/", PageController, :index) 25 | get("/event/*page", PageController, :index) 26 | get("/recordings/*page", PageController, :index) 27 | end 28 | 29 | scope "/", MembraneLiveWeb do 30 | pipe_through(:browser) 31 | pipe_through(:auth_restricted) 32 | get("/me", UserInfoController, :index) 33 | end 34 | 35 | scope "/resources/webinars", MembraneLiveWeb do 36 | pipe_through(:browser) 37 | 38 | get("/:uuid", WebinarController, :show) 39 | end 40 | 41 | scope "/resources/webinars", MembraneLiveWeb do 42 | pipe_through(:browser) 43 | pipe_through(:auth_unrestricted) 44 | 45 | get("/", WebinarController, :index) 46 | end 47 | 48 | scope "/resources/recordings", MembraneLiveWeb do 49 | pipe_through(:browser) 50 | 51 | get("/:uuid", RecordingsController, :show) 52 | end 53 | 54 | scope "/resources/recordings", MembraneLiveWeb do 55 | pipe_through(:browser) 56 | pipe_through(:auth_unrestricted) 57 | 58 | get("/link/:recording_id", RecordingsController, :index) 59 | get("/", RecordingsController, :index) 60 | end 61 | 62 | scope "/resources/chat", MembraneLiveWeb do 63 | pipe_through(:browser) 64 | 65 | get("/:uuid", ChatController, :index) 66 | end 67 | 68 | scope "/resources", MembraneLiveWeb do 69 | pipe_through(:browser) 70 | pipe_through(:auth_restricted) 71 | 72 | resources("/recordings", RecordingsController, 73 | except: [:edit, :new, :index, :create, :update, :show], 74 | param: "uuid" 75 | ) 76 | 77 | resources("/users", UserController, except: [:edit, :new, :create], param: "uuid") 78 | resources("/webinars", WebinarController, except: [:edit, :new, :index, :show], param: "uuid") 79 | end 80 | 81 | scope "/auth", MembraneLiveWeb do 82 | pipe_through(:browser) 83 | 84 | post("/", LoginController, :create) 85 | post("/refresh", LoginController, :refresh) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /assets/js/components/event/HlsControlBar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MediaControlBar, 3 | MediaTimeRange, 4 | MediaTimeDisplay, 5 | MediaVolumeRange, 6 | MediaPlayButton, 7 | MediaSeekBackwardButton, 8 | MediaSeekForwardButton, 9 | MediaMuteButton, 10 | MediaFullscreenButton, 11 | MediaPipButton, 12 | } from "media-chrome/dist/react"; 13 | import { 14 | Speaker2, 15 | Speaker1, 16 | Speaker0, 17 | SpeakerCross, 18 | Play, 19 | Pause, 20 | RotateLeft, 21 | RotateRight, 22 | Fullscreen, 23 | } from "react-swm-icon-pack"; 24 | import "../../../css/event/hlscontrolbar.css"; 25 | 26 | const HlsControlBar = () => { 27 | return ( 28 | 29 |
30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
43 | 44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
56 |
57 | 58 | 59 | 60 | 61 | 62 |
63 |
64 |
65 | ); 66 | }; 67 | 68 | export default HlsControlBar; 69 | -------------------------------------------------------------------------------- /assets/js/components/event/NamePopup.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "react-modal"; 2 | import React, { useState } from "react"; 3 | import GoogleButton from "../helpers/GoogleButton"; 4 | import { roundedGoogleButton } from "../../utils/const"; 5 | import { sessionStorageSetName } from "../../utils/storageUtils"; 6 | import { useNavigate } from "react-router-dom"; 7 | import { getErrorToast } from "../../utils/toastUtils"; 8 | import { useToast } from "@chakra-ui/react"; 9 | import type { Client } from "../../types/types"; 10 | import "../../../css/event/namepopup.css"; 11 | import "../../../css/dashboard/modalform.css"; 12 | 13 | type NamePopupProps = { 14 | client: Client; 15 | setClient: React.Dispatch>; 16 | }; 17 | 18 | const NamePopup = ({ client, setClient }: NamePopupProps) => { 19 | const [name, setName] = useState(""); 20 | const [isOpen, setIsOpen] = useState(true); 21 | const toast = useToast(); 22 | const navigate = useNavigate(); 23 | 24 | const saveNameAndClosePopup = () => { 25 | if (name.trim() === "") { 26 | getErrorToast(toast, "Username cannot be empty or contain only whitespaces"); 27 | return; 28 | } 29 | 30 | sessionStorageSetName(name); 31 | setClient({ ...client, name: name }); 32 | setIsOpen(false); 33 | }; 34 | 35 | const goBackToDashboard = () => { 36 | setIsOpen(false); 37 | navigate("/"); 38 | }; 39 | 40 | return ( 41 | 49 |
50 |
51 |
Pass your name
52 |
53 |
54 |
55 | setName((e.target as HTMLTextAreaElement).value)} 61 | required 62 | /> 63 |
64 |
65 |
66 | 67 | 70 |
71 |
72 |
73 | ); 74 | }; 75 | 76 | export default NamePopup; 77 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /assets/js/utils/dashboardUtils.ts: -------------------------------------------------------------------------------- 1 | import { getErrorToast, getInfoToast } from "./toastUtils"; 2 | import { mapToEventInfo } from "./headerUtils"; 3 | import { axiosWithInterceptor } from "../services"; 4 | import type { EventFormInput, EventInfo, ModalForm, OriginalEventInfo, Toast } from "../types/types"; 5 | import { getIsAuthenticated } from "./storageUtils"; 6 | import axios from "axios"; 7 | 8 | export const checkEventForm = (eventForm: EventFormInput): boolean => { 9 | return eventForm.start_date != "" && eventForm.title != ""; 10 | }; 11 | 12 | const methodMap = { 13 | create: axiosWithInterceptor.post, 14 | update: axiosWithInterceptor.put, 15 | }; 16 | 17 | export const sendEventForm = async (modalType: ModalForm, eventForm: EventFormInput, uuid = ""): Promise => { 18 | const endpoint = "resources/webinars/" + uuid; 19 | const method = methodMap[modalType]; 20 | 21 | return method(endpoint, { webinar: eventForm }); 22 | }; 23 | 24 | export const deleteEvent = (uuid: string, toast: Toast, isRecording: boolean): void => { 25 | const eventResourcesType = getEventResourcesType(isRecording); 26 | 27 | axiosWithInterceptor 28 | .delete(`resources/${eventResourcesType}/` + uuid) 29 | .then(() => { 30 | window.location.reload(); 31 | getInfoToast(toast, "The webinar has been deleted."); 32 | }) 33 | .catch((error) => { 34 | console.log(error); 35 | if (error.response.status === 401) { 36 | getErrorToast(toast, "Problem with access token. Please log in again."); 37 | } 38 | if (error.response.status === 403) { 39 | getErrorToast(toast, "You are not permitted to delete this webinar"); 40 | } else { 41 | getErrorToast(toast, "The webinar could not be deleted."); 42 | } 43 | }); 44 | }; 45 | 46 | export const getWebinarsInfo = async ( 47 | toast: Toast, 48 | setWebinars: React.Dispatch>, 49 | isRecording: boolean 50 | ) => { 51 | const eventResourcesType = getEventResourcesType(isRecording); 52 | 53 | (getIsAuthenticated() ? axiosWithInterceptor : axios) 54 | .get(`resources/${eventResourcesType}/`) 55 | .then((response: { data: { webinars: OriginalEventInfo[] } }) => { 56 | setWebinars(response.data.webinars.map((elem) => mapToEventInfo(elem))); 57 | }) 58 | .catch((error) => { 59 | console.log(error); 60 | getErrorToast(toast, "The webinar information could not be obtained."); 61 | }); 62 | }; 63 | 64 | export const getEventType = (isRecording: boolean) => (isRecording ? "recordings" : "event"); 65 | export const getEventResourcesType = (isRecording: boolean) => (isRecording ? "recordings" : "webinars"); 66 | -------------------------------------------------------------------------------- /docker-compose-test.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | test: 5 | image: hexpm/elixir:1.14.4-erlang-25.3.2-alpine-3.16.5 6 | command: > 7 | sh -c "cd app/ && 8 | mkdir -p priv && 9 | chmod +x ./scripts/key-gen.sh && 10 | apk add openssh && 11 | apk add git && 12 | mix local.hex --force && 13 | mix local.rebar --force && 14 | mix deps.get && 15 | mix deps.compile && 16 | mix ecto.create && 17 | mix ecto.migrate && 18 | ./scripts/key-gen.sh && 19 | mix test --warnings-as-errors" 20 | environment: 21 | POSTGRES_USER: swm 22 | POSTGRES_PASSWORD: swm123 23 | POSTGRES_HOST: membrane-live-db 24 | JELLYFISH_ADDRESS: jellyfish:5002 25 | GOOGLE_CLIENT_ID: m0cKiNgGo0g7eId2137-13371234567890.apps.google.id 26 | volumes: 27 | - .:/app 28 | ports: 29 | - "4000:4000" 30 | depends_on: 31 | membrane-live-db: 32 | condition: service_started 33 | jellyfish: 34 | condition: service_healthy 35 | 36 | membrane-live-db: 37 | image: postgres:14.5-alpine 38 | environment: 39 | POSTGRES_USER: swm 40 | POSTGRES_PASSWORD: swm123 41 | POSTGRES_DB: live_db 42 | PGDATA: /var/lib/postgresql/data/pgdata 43 | healthcheck: 44 | test: 45 | [ 46 | "CMD", 47 | "pg_isready", 48 | "-q", 49 | "-d", 50 | "${POSTGRES_DB:-membrane_live_db}", 51 | "-U", 52 | "${POSTGRES_USER:-membrane}" 53 | ] 54 | timeout: 45s 55 | interval: 10s 56 | retries: 10 57 | restart: always 58 | ports: 59 | - "5432:5432" 60 | volumes: 61 | - pgdata:/var/lib/postgresql/data 62 | 63 | 64 | jellyfish: 65 | image: "ghcr.io/jellyfish-dev/jellyfish:${TAG:-edge}" 66 | container_name: jellyfish 67 | restart: on-failure 68 | healthcheck: 69 | test: > 70 | curl --fail -H "authorization: Bearer development" http://localhost:5002/room || exit 1 71 | interval: 3s 72 | retries: 2 73 | timeout: 2s 74 | start_period: 30s 75 | environment: 76 | JF_HOST: "jellyfish:5002" 77 | JF_INTEGRATED_TURN_IP: "${INTEGRATED_TURN_IP:-127.0.0.1}" 78 | JF_INTEGRATED_TURN_LISTEN_IP: "0.0.0.0" 79 | JF_INTEGRATED_TURN_PORT_RANGE: "50000-50050" 80 | JF_INTEGRATED_TCP_TURN_PORT: "49999" 81 | JF_SERVER_API_TOKEN: "development" 82 | JF_PORT: 5002 83 | ports: 84 | - "5002:5002" 85 | - "49999:49999" 86 | - "50000-50050:50000-50050/udp" 87 | 88 | volumes: 89 | pgdata: -------------------------------------------------------------------------------- /test/membrane_live/event_service_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.EventServiceTest do 2 | @moduledoc false 3 | 4 | use MembraneLiveWeb.ChannelCase, async: false 5 | 6 | alias MembraneLive.EventService 7 | 8 | setup do 9 | event_id = Ecto.UUID.generate() 10 | @endpoint.subscribe("event:#{event_id}") 11 | 12 | :ok = EventService.start_room(event_id) 13 | room_pid = :global.whereis_name(event_id) 14 | Process.monitor(room_pid) 15 | 16 | %{room_pid: room_pid, event_id: event_id} 17 | end 18 | 19 | test "sends notify when there is one user", %{room_pid: room_pid, event_id: event_id} do 20 | EventService.user_joined(event_id) 21 | 22 | assert_last_viewer_active() 23 | assert_finish_event() 24 | assert_down(room_pid) 25 | end 26 | 27 | test "sends only finish event when event is empty", %{room_pid: room_pid, event_id: event_id} do 28 | EventService.user_joined(event_id) 29 | EventService.user_left(event_id) 30 | 31 | refute_last_viewer_active() 32 | assert_finish_event() 33 | assert_down(room_pid) 34 | end 35 | 36 | test "sends nothing when there are more then one user", %{ 37 | room_pid: room_pid, 38 | event_id: event_id 39 | } do 40 | EventService.user_joined(event_id) 41 | EventService.user_joined(event_id) 42 | 43 | refute_last_viewer_active() 44 | refute_finish_event() 45 | refute_down(room_pid) 46 | end 47 | 48 | test "handles client responses correctly", %{room_pid: room_pid, event_id: event_id} do 49 | EventService.user_joined(event_id) 50 | 51 | assert_last_viewer_active() 52 | 53 | EventService.stay_response(event_id) 54 | 55 | refute_finish_event(100) 56 | assert_last_viewer_active() 57 | 58 | EventService.leave_response(event_id) 59 | 60 | assert_finish_event() 61 | assert_down(room_pid) 62 | end 63 | 64 | test "returns `:already_started` when room already exists", %{event_id: event_id} do 65 | response = EventService.start_room(event_id) 66 | assert :already_started = response 67 | end 68 | 69 | defp assert_finish_event(timeout \\ 1_000), do: assert_broadcast("finish_event", %{}, timeout) 70 | defp refute_finish_event(timeout \\ 1_000), do: refute_broadcast("finish_event", %{}, timeout) 71 | 72 | defp assert_last_viewer_active(), do: assert_broadcast("last_viewer_active", %{}, 1_000) 73 | defp refute_last_viewer_active(), do: refute_broadcast("last_viewer_active", %{}, 1_000) 74 | 75 | def assert_down(room_pid), 76 | do: assert_receive({:DOWN, _ref, :process, ^room_pid, _reason}, 11_000) 77 | 78 | def refute_down(room_pid), 79 | do: refute_receive({:DOWN, _ref, :process, ^room_pid, _reason}, 11_000) 80 | end 81 | -------------------------------------------------------------------------------- /.github/workflows/tag_build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Tag Build and Deploy 2 | on: 3 | push: 4 | tags: 5 | - "*.*.*" 6 | 7 | env: 8 | VIRTUAL_HOST: live.membrane.stream 9 | INTEGRATED_TURN_IP: 95.217.177.119 10 | TAG: ${{ github.ref_name }} 11 | POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} 12 | GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Cache Docker layers 21 | uses: actions/cache@v2 22 | with: 23 | path: /tmp/.buildx-cache 24 | key: ${{ runner.os }}-buildx-${{ github.sha }} 25 | restore-keys: | 26 | ${{ runner.os }}-buildx 27 | 28 | - name: Login to Docker Hub 29 | uses: docker/login-action@v1 30 | with: 31 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 32 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 33 | 34 | - name: Set up Docker Buildx 35 | id: buildx 36 | uses: docker/setup-buildx-action@v1 37 | 38 | - name: Build and push tagged version 39 | id: docker_build_tag 40 | uses: docker/build-push-action@v2 41 | with: 42 | context: . 43 | file: Dockerfile 44 | push: true 45 | build-args: VERSION= ${{ github.ref_name }} 46 | tags: membraneframeworklabs/membrane_live:${{ github.ref_name }} 47 | 48 | - name: Build and push latest version 49 | id: docker_build_latest 50 | uses: docker/build-push-action@v2 51 | with: 52 | context: . 53 | file: Dockerfile 54 | push: true 55 | build-args: VERSION= ${{ github.ref_name }} 56 | tags: membraneframeworklabs/membrane_live:latest 57 | 58 | deploy: 59 | runs-on: ubuntu-latest 60 | needs: build 61 | steps: 62 | - uses: actions/checkout@v3 63 | 64 | - name: Prepare .env file for the deployment 65 | id: variables_population 66 | run: echo "VIRTUAL_HOST=$VIRTUAL_HOST \nINTEGRATED_TURN_IP=$INTEGRATED_TURN_IP \nPOSTGRES_PASSWORD=$POSTGRES_PASSWORD \nTAG=$TAG \nGOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID" > .env 67 | 68 | - name: Deploy docker compose to a pre-configured server 69 | id: deploy 70 | uses: TapTap21/docker-remote-deployment-action@v1.1 71 | with: 72 | remote_docker_host: ${{ secrets.PRODUCTION_HOST }} 73 | ssh_private_key: ${{ secrets.SSH_PRIV_KEY }} 74 | ssh_port: ${{ secrets.SSH_PROD_PORT }} 75 | ssh_public_key: ${{ secrets.SSH_KNOWN_HOSTS}} 76 | stack_file_name: docker-compose.yml 77 | args: -p production --env-file .env up -d --remove-orphans 78 | -------------------------------------------------------------------------------- /assets/js/components/dashboard/MembraneLogo.tsx: -------------------------------------------------------------------------------- 1 | const MembraneLogo = () => ( 2 | 12 | 46 | 47 | ); 48 | 49 | export default MembraneLogo; 50 | -------------------------------------------------------------------------------- /assets/js/components/dashboard/EventsArea.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import EventField from "./EventField"; 3 | import { getWebinarsInfo } from "../../utils/dashboardUtils"; 4 | import { useToast } from "@chakra-ui/react"; 5 | import type { EventInfo } from "../../types/types"; 6 | import "../../../css/dashboard/eventsarea.css"; 7 | 8 | type EventsAreaProps = { 9 | searchText: string; 10 | currentEvents: string; 11 | }; 12 | 13 | const EventsArea = ({ searchText, currentEvents }: EventsAreaProps) => { 14 | const [webinars, setWebinars] = useState([]); 15 | const [recordings, setRecordings] = useState([]); 16 | const toast = useToast(); 17 | 18 | const listEvents = (isRecording: boolean, events: EventInfo[], upcoming: boolean) => { 19 | const curDate = new Date(); 20 | 21 | const filtered_events = events 22 | .filter((elem) => { 23 | const upcomingEventCondition = upcoming ? elem.startDate >= curDate : elem.startDate < curDate; 24 | return isRecording || upcomingEventCondition; 25 | }) 26 | .filter((elem) => elem.title.toLowerCase().includes(searchText.toLowerCase())) 27 | .sort((a, b) => 28 | upcoming ? a.startDate.getTime() - b.startDate.getTime() : b.startDate.getTime() - a.startDate.getTime() 29 | ) 30 | .map((elem) => ); 31 | 32 | return filtered_events.length ? filtered_events : null; 33 | }; 34 | 35 | const listRecordings = (upcoming: boolean) => listEvents(true, recordings, upcoming); 36 | const listWebinars = (upcoming: boolean) => listEvents(false, webinars, upcoming); 37 | 38 | useEffect(() => { 39 | getWebinarsInfo(toast, setWebinars, false); 40 | getWebinarsInfo(toast, setRecordings, true); 41 | }, [toast]); 42 | 43 | return ( 44 |
45 |
46 | {currentEvents == "All events" && ( 47 | <> 48 |

Upcoming events

49 |
50 | {listWebinars(true) ||

No upcoming events! Create one with the button above!

} 51 |
52 |

Past events

53 |
{listWebinars(false) ||

No past events!

}
54 | 55 | )} 56 | {currentEvents == "Recorded events" && ( 57 | <> 58 |

Recorded events

59 |
60 | {listRecordings(false) ||

No available recorded events

} 61 |
62 | 63 | )} 64 |
65 |
66 | ); 67 | }; 68 | 69 | export default EventsArea; 70 | -------------------------------------------------------------------------------- /assets/js/types/types.ts: -------------------------------------------------------------------------------- 1 | import { useToast } from "@chakra-ui/react"; 2 | import { z } from "zod"; 3 | 4 | export type ClientStatus = "idle" | "connected" | "disconnected"; 5 | 6 | export type CardStatus = "hidden" | "share" | "chat"; 7 | 8 | export const userSchema = z.object({ 9 | name: z.string(), 10 | email: z.string(), 11 | }); 12 | 13 | export type User = z.infer; 14 | 15 | export interface Participant extends User { 16 | isPresenter: boolean; 17 | isModerator: boolean; 18 | isAuth: boolean; 19 | isRequestPresenting: boolean; 20 | isBannedFromChat: boolean; 21 | } 22 | 23 | export interface Client extends User { 24 | isModerator: boolean; 25 | isAuthenticated: boolean; 26 | } 27 | 28 | export type EventFormInput = { 29 | title: string; 30 | description: string; 31 | start_date: string; 32 | presenters: string[]; 33 | is_private: boolean; 34 | }; 35 | 36 | export type OriginalEventInfo = { 37 | title: string; 38 | description: string; 39 | presenters: string[]; 40 | start_date?: string; 41 | is_private: boolean; 42 | uuid: string; 43 | moderator_email?: string; 44 | }; 45 | 46 | export type EventInfo = { 47 | title: string; 48 | description: string; 49 | presenters: string[]; 50 | startDate: Date; 51 | uuid: string; 52 | moderatorEmail: string; 53 | isPrivate: boolean; 54 | }; 55 | 56 | export type ModalForm = "create" | "update"; 57 | 58 | export type AuthTokenKey = "authJwt"; 59 | 60 | export type RefreshTokenKey = "refreshJwt"; 61 | 62 | export type AuthResponseData = { 63 | authToken: AuthTokenKey; 64 | refreshToken: RefreshTokenKey; 65 | }; 66 | 67 | export type Toast = ReturnType; 68 | 69 | export type MetasUser = { 70 | is_moderator: boolean; 71 | is_presenter: boolean; 72 | name: string; 73 | phx_ref: string; 74 | is_auth: boolean; 75 | is_request_presenting: boolean; 76 | is_banned_from_chat: boolean; 77 | }; 78 | 79 | export type Metas = { 80 | metas: [MetasUser]; 81 | }; 82 | 83 | export type ScreenType = { 84 | orientation: "landscape" | "portrait"; 85 | device: "desktop" | "mobile"; 86 | }; 87 | 88 | export type CurrentEvents = "All events" | "Recorded events"; 89 | 90 | export type ChatMessage = { 91 | id: number; 92 | email: string; 93 | name: string; 94 | title: string; 95 | moderatedNo: number; 96 | contents: { content: string; offset: number }[]; 97 | }; 98 | 99 | export type AwaitingMessage = { 100 | email: string; 101 | name: string; 102 | content: string; 103 | offset: number; 104 | }; 105 | 106 | export type PlaylistPlayableMessage = { 107 | name: string; 108 | playlist_ready: string; 109 | start_time: string; 110 | link: string; 111 | }; 112 | 113 | export type PresenterProposition = { 114 | moderatorTopic: string; 115 | }; 116 | -------------------------------------------------------------------------------- /assets/js/components/event/ShareList.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from "@chakra-ui/react"; 2 | import { Copy } from "react-swm-icon-pack"; 3 | import { getInfoToast } from "../../utils/toastUtils"; 4 | import GenericButton from "../helpers/GenericButton"; 5 | import FacebookIcon from "../helpers/svg/FacebookIcon"; 6 | import MessengerIcon from "../helpers/svg/MessengerIcon"; 7 | import TwitterIcon from "../helpers/svg/TwitterIcon"; 8 | 9 | import "../../../css/event/shareButton.css"; 10 | 11 | type ListElementType = { 12 | name: string; 13 | icon: JSX.Element; 14 | logoClass?: string; 15 | buttonHandler?: () => void; 16 | }; 17 | 18 | const ListElement = ({ name, icon, logoClass, buttonHandler }: ListElementType) => ( 19 |
20 | 21 | 24 |
25 | ); 26 | 27 | const CopyLinkListElement = () => { 28 | const toast = useToast(); 29 | 30 | const handleCopy = () => { 31 | navigator.clipboard.writeText(window.location.href); 32 | getInfoToast(toast, "Link copied!"); 33 | }; 34 | 35 | return } buttonHandler={handleCopy} />; 36 | }; 37 | 38 | type LinkElementType = { 39 | url: string; 40 | name: string; 41 | icon: JSX.Element; 42 | logoClass?: string; 43 | }; 44 | 45 | const LinkElement = ({ url, name, icon, logoClass }: LinkElementType) => ( 46 | 47 | 48 | 49 | ); 50 | 51 | const FacebookListElement = () => ( 52 | } url="https://www.facebook.com" logoClass="FacebookLogo" /> 53 | ); 54 | 55 | const TwitterListElement = () => ( 56 | } url="https://www.twitter.com" logoClass="TwitterLogo" /> 57 | ); 58 | 59 | const MessengerListElement = () => ( 60 | } url="https://www.messenger.com" logoClass="MessengerLogo" /> 61 | ); 62 | 63 | type ShareListProps = { 64 | eventTitle: string; 65 | }; 66 | 67 | export const ShareTitle = ({ eventTitle }: ShareListProps) => ( 68 |
69 |

Share ({eventTitle})

70 |
71 | ); 72 | 73 | export const ShareListElements = () => ( 74 |
75 | 76 | 77 | 78 | 79 |
80 | ); 81 | 82 | const ShareList = ({ eventTitle }: ShareListProps) => { 83 | return ( 84 |
85 | 86 | 87 |
88 | ); 89 | }; 90 | 91 | export default ShareList; 92 | -------------------------------------------------------------------------------- /assets/js/utils/headerUtils.ts: -------------------------------------------------------------------------------- 1 | import { Channel, Presence } from "phoenix"; 2 | import { axiosWithoutInterceptor } from "../services"; 3 | import { getChannelId } from "./channelUtils"; 4 | import { getErrorToast } from "./toastUtils"; 5 | import { getEventResourcesType } from "./dashboardUtils"; 6 | import { NavigateFunction } from "react-router-dom"; 7 | import type { EventInfo, OriginalEventInfo, Toast } from "../types/types"; 8 | import { AxiosResponse } from "axios"; 9 | 10 | export const initEventInfo = (): EventInfo => { 11 | return { 12 | uuid: "", 13 | title: "", 14 | description: "", 15 | startDate: new Date(), 16 | presenters: [], 17 | moderatorEmail: "", 18 | isPrivate: true, 19 | }; 20 | }; 21 | 22 | export const mapToEventInfo = (originalEventInfo: OriginalEventInfo) => { 23 | const newDate = new Date(); 24 | if (originalEventInfo.start_date) newDate.setTime(Date.parse(originalEventInfo.start_date)); 25 | const email = originalEventInfo.moderator_email; 26 | const newEventInfo: EventInfo = { 27 | title: originalEventInfo.title, 28 | description: originalEventInfo.description, 29 | presenters: originalEventInfo.presenters, 30 | uuid: originalEventInfo.uuid, 31 | startDate: newDate, 32 | moderatorEmail: email || "", 33 | isPrivate: originalEventInfo.is_private, 34 | }; 35 | return newEventInfo; 36 | }; 37 | 38 | export const getEventInfo = ( 39 | toast: Toast, 40 | setEventInfo: React.Dispatch>, 41 | isRecording: boolean 42 | ) => { 43 | const eventResourcesType = getEventResourcesType(isRecording); 44 | 45 | axiosWithoutInterceptor 46 | .get(`/resources/${eventResourcesType}/` + getChannelId()) 47 | .then((response: { data: { webinar: OriginalEventInfo } }) => { 48 | setEventInfo(mapToEventInfo(response.data.webinar)); 49 | }) 50 | .catch((error) => { 51 | console.log(error); 52 | getErrorToast(toast, "Event information could not be obtained..."); 53 | }); 54 | }; 55 | 56 | export const syncParticipantsNumber = ( 57 | eventChannel: Channel | undefined, 58 | setParticipantsNumber: React.Dispatch> 59 | ) => { 60 | if (eventChannel) { 61 | const presence = new Presence(eventChannel); 62 | 63 | const updateParticipantsNumber = () => { 64 | setParticipantsNumber(presence.list().length); 65 | }; 66 | 67 | presence.onSync(() => { 68 | updateParticipantsNumber(); 69 | }); 70 | 71 | eventChannel.push("sync_presence", {}); 72 | } 73 | }; 74 | 75 | export const redirectToHomePage = (navigate: NavigateFunction) => { 76 | navigate("/"); 77 | // the line above does not break the socket connection 78 | // which is desired in this case, so the page is reloaded manually 79 | window.location.reload(); 80 | }; 81 | 82 | export const getRecordingLink = (): Promise> => 83 | axiosWithoutInterceptor.get(`/resources/recordings/link/` + getChannelId()); 84 | -------------------------------------------------------------------------------- /assets/css/event/header.css: -------------------------------------------------------------------------------- 1 | @import "../main.css"; 2 | 3 | .Header { 4 | white-space: nowrap; 5 | display: flex; 6 | align-items: center; 7 | gap: 3rem; 8 | } 9 | 10 | .Arrow { 11 | box-sizing: content-box; 12 | background: var(--bg-light-color-2); 13 | height: 3.2rem; 14 | width: auto; 15 | padding: 1rem; 16 | border-radius: 50%; 17 | } 18 | 19 | .Arrow path { 20 | stroke: var(--font-dark-color); 21 | } 22 | 23 | .Title { 24 | color: var(--font-dark-color); 25 | font-family: var(--font-title); 26 | font-size: 2.4rem; 27 | font-weight: 500; 28 | } 29 | 30 | .InfoWrapper { 31 | margin-right: auto; 32 | display: flex; 33 | flex-direction: column; 34 | } 35 | 36 | .BackArrowInfoTooltip { 37 | background: var(--font-dark-color) !important; 38 | border-radius: 16px !important; 39 | font-size: 1.3rem !important; 40 | } 41 | 42 | .WebinarInfo { 43 | color: var(--font-dark-color); 44 | font-size: 1.6rem; 45 | display: flex; 46 | align-items: center; 47 | gap: 2rem; 48 | } 49 | 50 | .CopyLink { 51 | color: var(--font-dark-color); 52 | background: var(--bg-light-color-2); 53 | border: 1px solid var(--bg-light-color-3); 54 | border-radius: 24px; 55 | padding: 0.5rem 1.7rem; 56 | display: flex; 57 | align-items: center; 58 | gap: 1rem; 59 | } 60 | 61 | .CopyIcon path { 62 | stroke: var(--font-dark-color); 63 | } 64 | 65 | .UsersIcon { 66 | height: 2rem; 67 | width: auto; 68 | } 69 | 70 | .UsersIcon path { 71 | stroke: var(--font-dark-color); 72 | } 73 | 74 | .CopyButton { 75 | margin-right: 0; 76 | margin-left: auto; 77 | } 78 | 79 | .Link { 80 | max-width: 18ch; 81 | font-size: 1.6rem; 82 | overflow: hidden; 83 | text-overflow: ellipsis; 84 | white-space: nowrap; 85 | } 86 | 87 | .ParticipantsNumber { 88 | display: flex; 89 | align-items: center; 90 | gap: 0.7rem; 91 | } 92 | 93 | .ArrowLeftPopoverHeader { 94 | font-size: 2rem; 95 | color: var(--font-dark-color); 96 | padding: 0.5rem 0rem; 97 | } 98 | 99 | .ArrowLeftPopoverDiv { 100 | color: var(--font-dark-color); 101 | display: flex; 102 | justify-content: space-between; 103 | align-items: center; 104 | gap: 2rem; 105 | padding: 0.5rem 0rem; 106 | } 107 | 108 | .ArrowLeftPopoverButton { 109 | color: var(--bg-light-color-1); 110 | background-color: var(--font-dark-color); 111 | border-radius: 16px; 112 | font-weight: 600; 113 | font-size: 1.6rem; 114 | padding: 1rem 1.5rem; 115 | min-width: 7.6rem; 116 | } 117 | 118 | @media screen and (max-width: 500px), screen and (max-height: 500px) { 119 | .Header { 120 | flex-direction: column; 121 | justify-content: space-around; 122 | } 123 | 124 | .InfoWrapper { 125 | width: 100%; 126 | align-items: baseline; 127 | } 128 | 129 | .ArrowButton { 130 | margin-right: auto; 131 | } 132 | 133 | .Title { 134 | text-align: center; 135 | white-space: pre-line; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /lib/membrane_live_web.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLiveWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use MembraneLiveWeb, :controller 9 | use MembraneLiveWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: MembraneLiveWeb 23 | 24 | import Plug.Conn 25 | import MembraneLiveWeb.Gettext 26 | alias MembraneLiveWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/membrane_live_web/templates", 34 | namespace: MembraneLiveWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def live_view do 46 | quote do 47 | use Phoenix.LiveView, 48 | layout: {MembraneLiveWeb.LayoutView, "live.html"} 49 | 50 | unquote(view_helpers()) 51 | end 52 | end 53 | 54 | def live_component do 55 | quote do 56 | use Phoenix.LiveComponent 57 | 58 | unquote(view_helpers()) 59 | end 60 | end 61 | 62 | def component do 63 | quote do 64 | use Phoenix.Component 65 | 66 | unquote(view_helpers()) 67 | end 68 | end 69 | 70 | def router do 71 | quote do 72 | use Phoenix.Router 73 | 74 | import Plug.Conn 75 | import Phoenix.Controller 76 | import Phoenix.LiveView.Router 77 | end 78 | end 79 | 80 | def channel do 81 | quote do 82 | use Phoenix.Channel 83 | import MembraneLiveWeb.Gettext 84 | end 85 | end 86 | 87 | defp view_helpers do 88 | quote do 89 | # Use all HTML functionality (forms, tags, etc) 90 | use Phoenix.HTML 91 | 92 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) 93 | import Phoenix.LiveView.Helpers 94 | 95 | # Import basic rendering functionality (render, render_layout, etc) 96 | import Phoenix.View 97 | 98 | import MembraneLiveWeb.ErrorHelpers 99 | import MembraneLiveWeb.Gettext 100 | alias MembraneLiveWeb.Router.Helpers, as: Routes 101 | end 102 | end 103 | 104 | @doc """ 105 | When used, dispatch to the appropriate controller/view/etc. 106 | """ 107 | defmacro __using__(which) when is_atom(which) do 108 | apply(__MODULE__, which, []) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/membrane_live/tokens.ex: -------------------------------------------------------------------------------- 1 | defmodule MembraneLive.Tokens do 2 | @moduledoc """ 3 | Module for generating, signing, verifying and validating the tokens used in the app. 4 | 5 | Google Token: - decode: verification and validation 6 | Auth Token: 7 | - encode: generation and signing 8 | - decode: verification and validation 9 | Refresh Token: 10 | - encode: generation and signing 11 | - decode: verification and validation 12 | 13 | Google public keys are stored in pem format. 14 | """ 15 | alias MembraneLive.Tokens.{AuthToken, GoogleToken, RefreshToken} 16 | 17 | def google_decode(jwt) do 18 | with {:ok, signer} <- get_signer(jwt) do 19 | GoogleToken.verify_and_validate(jwt, signer) 20 | end 21 | end 22 | 23 | defp get_signer(jwt) do 24 | with {:ok, public_keys} <- get_public_keys(jwt) do 25 | {:ok, Joken.Signer.create("RS256", public_keys)} 26 | end 27 | end 28 | 29 | defp get_public_keys(jwt) do 30 | with {:ok, %{"kid" => key_id}} <- Joken.peek_header(jwt), 31 | {:ok, pem_response} <- fetch_google_public_pems() do 32 | pem_response 33 | |> Map.get(:body) 34 | |> Jason.decode!() 35 | |> Map.get(key_id) 36 | |> then(&{:ok, %{"pem" => &1}}) 37 | else 38 | {:error, :token_malformed} -> {:error, :invalid_jwt_header} 39 | err -> err 40 | end 41 | end 42 | 43 | defp fetch_google_public_pems() do 44 | :google_pems_url 45 | |> MembraneLive.get_env!() 46 | |> HTTPoison.get() 47 | end 48 | 49 | @spec auth_encode(any) :: {:error, atom | keyword} | {:ok, binary, %{optional(binary) => any}} 50 | def auth_encode(user_id) do 51 | signer = create_auth_signer() 52 | AuthToken.generate_and_sign(%{"user_id" => user_id}, signer) 53 | end 54 | 55 | @spec auth_decode(binary) :: {:error, atom | keyword} | {:ok, %{optional(binary) => any}} 56 | def auth_decode(jwt) do 57 | signer = create_auth_signer() 58 | AuthToken.verify_and_validate(jwt, signer) 59 | end 60 | 61 | @spec refresh_encode(any) :: 62 | {:error, atom | keyword} | {:ok, binary, %{optional(binary) => any}} 63 | def refresh_encode(user_id) do 64 | signer = create_refresh_signer() 65 | RefreshToken.generate_and_sign(%{"user_id" => user_id}, signer) 66 | end 67 | 68 | @spec refresh_decode(binary) :: {:error, atom | keyword} | {:ok, %{optional(binary) => any}} 69 | def refresh_decode(jwt) do 70 | if RefreshToken.has_uuid?(jwt) do 71 | signer = create_refresh_signer() 72 | RefreshToken.verify_and_validate(jwt, signer) 73 | else 74 | {:error, :no_uuid_in_header} 75 | end 76 | end 77 | 78 | defp create_auth_signer(), 79 | do: create_other_signer(:token_auth_secret) 80 | 81 | defp create_refresh_signer(), 82 | do: create_other_signer(:token_refresh_secret) 83 | 84 | defp create_other_signer(env_variable_atom), 85 | do: 86 | :membrane_live 87 | |> Application.fetch_env!(env_variable_atom) 88 | |> create_signer() 89 | 90 | defp create_signer(secret), 91 | do: Joken.Signer.create("HS256", secret) 92 | end 93 | --------------------------------------------------------------------------------