├── rel ├── overlays │ └── bin │ │ ├── server.bat │ │ ├── migrate.bat │ │ ├── server │ │ └── migrate └── env.sh.eex ├── test ├── test_helper.exs ├── glitchtv_web │ ├── controllers │ │ ├── page_controller_test.exs │ │ ├── error_json_test.exs │ │ └── error_html_test.exs │ └── live │ │ └── recording_live_test.exs ├── support │ ├── fixtures │ │ └── recordings_fixtures.ex │ ├── conn_case.ex │ └── data_case.ex └── glitchtv │ └── recordings_test.exs ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ └── 20250212122810_create_recordings.exs │ └── seeds.exs └── static │ ├── favicon.ico │ ├── images │ ├── swm-logo.png │ ├── github-logo.svg │ ├── github-dark-mode-logo.svg │ ├── swm-white-logo.svg │ ├── elixir-webrtc-logo.svg │ ├── elixir-webrtc-dark-mode-logo.svg │ ├── swm-dark-mode-logo.svg │ └── swm-logo.svg │ └── robots.txt ├── lib ├── glitchtv │ ├── repo.ex │ ├── release.ex │ ├── recordings │ │ └── recording.ex │ ├── application.ex │ ├── stream_service.ex │ ├── recordings_service.ex │ └── recordings.ex ├── glitchtv.ex ├── glitchtv_web │ ├── controllers │ │ ├── page_controller.ex │ │ ├── page_html.ex │ │ ├── error_json.ex │ │ ├── error_html.ex │ │ └── page_html │ │ │ └── home.html.heex │ ├── channels │ │ └── presence.ex │ ├── components │ │ ├── layouts.ex │ │ ├── layouts │ │ │ ├── root.html.heex │ │ │ └── app.html.heex │ │ └── core_components.ex │ ├── live │ │ ├── recording_live │ │ │ ├── show.ex │ │ │ ├── index.ex │ │ │ ├── index.html.heex │ │ │ └── show.html.heex │ │ ├── stream_viewer_live.ex │ │ ├── chat_live.ex │ │ └── streamer_live.ex │ ├── router.ex │ ├── endpoint.ex │ └── telemetry.ex └── glitchtv_web.ex ├── assets ├── js │ ├── ScrollDownHook.js │ ├── ShareButtonHook.js │ ├── DarkModeToggleHook.js │ └── app.js ├── css │ └── app.css ├── tailwind.config.js └── vendor │ └── topbar.js ├── .formatter.exs ├── litestream.sh ├── config ├── litestream.yml ├── prod.exs ├── test.exs ├── config.exs ├── dev.exs └── runtime.exs ├── .github └── workflows │ └── fly-deploy.yml ├── README.md ├── fly.toml ├── .gitignore ├── .dockerignore ├── mix.exs ├── Dockerfile └── mix.lock /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\glitchtv" start 3 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate.bat: -------------------------------------------------------------------------------- 1 | call "%~dp0\glitchtv" eval Glitchtv.Release.migrate 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Glitchtv.Repo, :manual) 3 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-webrtc/glitchtv-demo/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/static/images/swm-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elixir-webrtc/glitchtv-demo/HEAD/priv/static/images/swm-logo.png -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | PHX_SERVER=true exec ./glitchtv start 6 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eu 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | exec ./glitchtv eval Glitchtv.Release.migrate 6 | -------------------------------------------------------------------------------- /lib/glitchtv/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.Repo do 2 | use Ecto.Repo, 3 | otp_app: :glitchtv, 4 | adapter: Ecto.Adapters.SQLite3 5 | end 6 | -------------------------------------------------------------------------------- /assets/js/ScrollDownHook.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("phoenix_live_view").ViewHookInterface} 3 | */ 4 | export default { 5 | mounted() { 6 | this.el.scrollTo(0, this.el.scrollHeight); 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 6 | ] 7 | -------------------------------------------------------------------------------- /test/glitchtv_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.PageControllerTest do 2 | use GlitchtvWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, ~p"/") 6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/glitchtv.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv do 2 | @moduledoc """ 3 | Glitchtv 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 | end 10 | -------------------------------------------------------------------------------- /lib/glitchtv_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.PageController do 2 | use GlitchtvWeb, :controller 3 | 4 | def home(conn, _params) do 5 | # The home page is often custom made, 6 | # so skip the default app layout. 7 | render(conn, :home, layout: false) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/glitchtv_web/controllers/page_html.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.PageHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use GlitchtvWeb, :html 8 | 9 | embed_templates "page_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /lib/glitchtv_web/channels/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.Presence do 2 | @moduledoc """ 3 | Provides presence tracking to channels and processes. 4 | 5 | See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html) 6 | docs for more details. 7 | """ 8 | use Phoenix.Presence, 9 | otp_app: :glitchtv, 10 | pubsub_server: Glitchtv.PubSub 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Glitchtv.Repo.insert!(%Glitchtv.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /litestream.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # If db doesn't exist, try restoring from object storage 5 | if [ ! -f "$DATABASE_PATH" ] && [ -n "$BUCKET_NAME" ]; then 6 | litestream restore -if-replica-exists "$DATABASE_PATH" 7 | fi 8 | 9 | # Migrate database 10 | /app/bin/migrate 11 | 12 | # Launch application 13 | if [ -n "$BUCKET_NAME" ]; then 14 | litestream replicate -exec "${*}" 15 | else 16 | exec "${@}" 17 | fi -------------------------------------------------------------------------------- /config/litestream.yml: -------------------------------------------------------------------------------- 1 | # This is the configuration file for litestream. 2 | # 3 | # For more details, see: https://litestream.io/reference/config/ 4 | # 5 | dbs: 6 | - path: $DATABASE_PATH 7 | replicas: 8 | - type: s3 9 | endpoint: $AWS_ENDPOINT_URL_S3 10 | bucket: $BUCKET_NAME 11 | path: litestream${DATABASE_PATH} 12 | access-key-id: $AWS_ACCESS_KEY_ID 13 | secret-access-key: $AWS_SECRET_ACCESS_KEY 14 | region: $AWS_REGION -------------------------------------------------------------------------------- /test/glitchtv_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.ErrorJSONTest do 2 | use GlitchtvWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert GlitchtvWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert GlitchtvWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /assets/js/ShareButtonHook.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("phoenix_live_view").ViewHookInterface} 3 | */ 4 | export default { 5 | mounted() { 6 | this.el.addEventListener("click", async () => { 7 | await navigator.clipboard.writeText(window.location.href); 8 | 9 | const previous = this.el.innerHTML; 10 | this.el.innerHTML = "Copied!"; 11 | 12 | setTimeout(() => (this.el.innerHTML = previous), 2000); 13 | }); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /test/glitchtv_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.ErrorHTMLTest do 2 | use GlitchtvWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(GlitchtvWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(GlitchtvWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20250212122810_create_recordings.exs: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.Repo.Migrations.CreateRecordings do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:recordings) do 6 | add :title, :string 7 | add :description, :string 8 | add :link, :string 9 | add :thumbnail_link, :string 10 | add :length_seconds, :integer 11 | add :date, :utc_datetime 12 | add :views_count, :integer 13 | 14 | timestamps(type: :utc_datetime) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/glitchtv_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use GlitchtvWeb, :controller` and 9 | `use GlitchtvWeb, :live_view`. 10 | """ 11 | use GlitchtvWeb, :html 12 | 13 | embed_templates "layouts/*" 14 | end 15 | -------------------------------------------------------------------------------- /rel/env.sh.eex: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # configure node for distributed erlang with IPV6 support 4 | export ERL_AFLAGS="-proto_dist inet6_tcp" 5 | export ECTO_IPV6="true" 6 | export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal" 7 | export RELEASE_DISTRIBUTION="name" 8 | export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}" 9 | 10 | # Uncomment to send crash dumps to stderr 11 | # This can be useful for debugging, but may log sensitive information 12 | # export ERL_CRASH_DUMP=/dev/stderr 13 | # export ERL_CRASH_DUMP_BYTES=4096 14 | -------------------------------------------------------------------------------- /.github/workflows/fly-deploy.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly Deploy 4 | on: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | concurrency: deploy-group # optional: ensure only one action runs at a time 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :glitchtv, GlitchtvWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 9 | 10 | # Do not print debug messages in production 11 | config :logger, level: :info 12 | 13 | # Runtime production configuration, including reading 14 | # of environment variables, is done on config/runtime.exs. 15 | -------------------------------------------------------------------------------- /lib/glitchtv_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Glitch.tv"> 8 | {assigns[:page_title]} 9 | 10 | 11 | 13 | 14 | 15 | {@inner_content} 16 | 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glitch.tv - demo 2 | 3 | To start your Phoenix server: 4 | 5 | * Run `mix setup` to install and setup dependencies 6 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 7 | 8 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 9 | 10 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 11 | 12 | ## Learn more 13 | 14 | * Official website: https://www.phoenixframework.org/ 15 | * Guides: https://hexdocs.pm/phoenix/overview.html 16 | * Docs: https://hexdocs.pm/phoenix 17 | * Forum: https://elixirforum.com/c/phoenix-forum 18 | * Source: https://github.com/phoenixframework/phoenix 19 | -------------------------------------------------------------------------------- /lib/glitchtv/release.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :glitchtv 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/glitchtv_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/support/fixtures/recordings_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.RecordingsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Glitchtv.Recordings` context. 5 | """ 6 | 7 | @doc """ 8 | Generate a recording. 9 | """ 10 | def recording_fixture(attrs \\ %{}) do 11 | {:ok, recording} = 12 | attrs 13 | |> Enum.into(%{ 14 | date: ~U[2025-02-11 12:28:00Z], 15 | description: "some description", 16 | length_seconds: 42, 17 | link: "some link", 18 | thumbnail_link: "some thumbnail_link", 19 | title: "some title", 20 | views_count: 42 21 | }) 22 | |> Glitchtv.Recordings.create_recording() 23 | 24 | recording 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/glitchtv_web/live/recording_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.RecordingLive.Show do 2 | use GlitchtvWeb, :live_view 3 | 4 | alias Glitchtv.Recordings 5 | 6 | @impl true 7 | def mount(_params, _session, socket) do 8 | socket = assign(socket, :recordings, Recordings.list_five_recordings()) 9 | {:ok, socket} 10 | end 11 | 12 | @impl true 13 | def handle_params(%{"id" => id}, _, socket) do 14 | recording = 15 | if connected?(socket) do 16 | Recordings.get_and_increment_views!(id) 17 | else 18 | Recordings.get_recording!(id) 19 | end 20 | 21 | socket = 22 | socket 23 | |> assign(:page_title, recording.title) 24 | |> assign(:recording, recording) 25 | 26 | {:noreply, socket} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/glitchtv_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use GlitchtvWeb, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/glitchtv_web/controllers/error_html/404.html.heex 14 | # * lib/glitchtv_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/glitchtv/recordings/recording.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.Recordings.Recording do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "recordings" do 6 | field :date, :utc_datetime 7 | field :link, :string 8 | field :description, :string 9 | field :title, :string 10 | field :thumbnail_link, :string 11 | field :length_seconds, :integer 12 | field :views_count, :integer 13 | 14 | timestamps(type: :utc_datetime) 15 | end 16 | 17 | @doc false 18 | def changeset(recording, attrs) do 19 | recording 20 | |> cast(attrs, [ 21 | :title, 22 | :description, 23 | :link, 24 | :thumbnail_link, 25 | :length_seconds, 26 | :date, 27 | :views_count 28 | ]) 29 | |> validate_required([ 30 | :title, 31 | :description, 32 | :link, 33 | :thumbnail_link, 34 | :length_seconds, 35 | :date, 36 | :views_count 37 | ]) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :glitchtv, Glitchtv.Repo, 9 | database: Path.expand("../glitchtv_test.db", __DIR__), 10 | pool_size: 5, 11 | pool: Ecto.Adapters.SQL.Sandbox 12 | 13 | # We don't run a server during test. If one is required, 14 | # you can enable the server option below. 15 | config :glitchtv, GlitchtvWeb.Endpoint, 16 | http: [ip: {127, 0, 0, 1}, port: 4002], 17 | secret_key_base: "V/9BCo3NCJgEC60+VGQj6sI75LX76c0Oz06VByxZp7O1USMvaDgcuo3HS8mk69Sy", 18 | server: false 19 | 20 | # Print only warnings and errors during test 21 | config :logger, level: :warning 22 | 23 | # Initialize plugs at runtime for faster test compilation 24 | config :phoenix, :plug_init_mode, :runtime 25 | 26 | # Enable helpful, but potentially expensive runtime checks 27 | config :phoenix_live_view, 28 | enable_expensive_runtime_checks: true 29 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for twitch-clone-demo-purple-pond-2473 on 2025-04-14T23:39:40+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'twitch-clone-demo-purple-pond-2473' 7 | primary_region = 'fra' 8 | kill_signal = 'SIGTERM' 9 | 10 | [build] 11 | 12 | [env] 13 | DATABASE_PATH = '/mnt/name/name.db' 14 | PHX_HOST = 'twitch-clone-demo-purple-pond-2473.fly.dev' 15 | PORT = '8080' 16 | 17 | [[mounts]] 18 | source = 'name' 19 | destination = '/mnt/name' 20 | auto_extend_size_threshold = 80 21 | auto_extend_size_increment = '1GB' 22 | auto_extend_size_limit = '10GB' 23 | 24 | [http_service] 25 | internal_port = 8080 26 | force_https = true 27 | auto_stop_machines = 'stop' 28 | auto_start_machines = true 29 | min_machines_running = 0 30 | processes = ['app'] 31 | 32 | [http_service.concurrency] 33 | type = 'connections' 34 | hard_limit = 1000 35 | soft_limit = 1000 36 | 37 | [[vm]] 38 | memory = '1gb' 39 | cpu_kind = 'shared' 40 | cpus = 1 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | glitchtv-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | 31 | # Ignore digested assets cache. 32 | /priv/static/cache_manifest.json 33 | 34 | # In case you use Node.js/npm, you want to ignore these. 35 | npm-debug.log 36 | /assets/node_modules/ 37 | 38 | # Database files 39 | *.db 40 | *.db-* 41 | 42 | priv/static/content/ 43 | priv/static/fonts 44 | /recordings/ 45 | /converter/ -------------------------------------------------------------------------------- /priv/static/images/github-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /priv/static/images/github-dark-mode-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/js/DarkModeToggleHook.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("phoenix_live_view").ViewHookInterface} 3 | */ 4 | export default { 5 | mounted() { 6 | const darkModePreference = window.matchMedia( 7 | "(prefers-color-scheme: dark)" 8 | ); 9 | 10 | if ( 11 | localStorage.getItem("color-theme") === "dark" || 12 | (!("color-theme" in localStorage) && darkModePreference.matches) 13 | ) { 14 | document.documentElement.classList.add("dark"); 15 | this.el.checked = true; 16 | } else { 17 | document.documentElement.classList.remove("dark"); 18 | this.el.checked = false; 19 | } 20 | 21 | darkModePreference.addEventListener("change", (e) => { 22 | if (e.matches) { 23 | document.documentElement.classList.add("dark"); 24 | } else { 25 | document.documentElement.classList.remove("dark"); 26 | } 27 | }); 28 | 29 | this.el.addEventListener("change", (e) => { 30 | if (e.target.checked) { 31 | document.documentElement.classList.add("dark"); 32 | localStorage.setItem("color-theme", "dark"); 33 | } else { 34 | document.documentElement.classList.remove("dark"); 35 | localStorage.setItem("color-theme", "light"); 36 | } 37 | }); 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.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 GlitchtvWeb.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 | # The default endpoint for testing 23 | @endpoint GlitchtvWeb.Endpoint 24 | 25 | use GlitchtvWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import GlitchtvWeb.ConnCase 31 | end 32 | end 33 | 34 | setup tags do 35 | Glitchtv.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/glitchtv_web/live/recording_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.RecordingLive.Index do 2 | use GlitchtvWeb, :live_view 3 | 4 | alias Glitchtv.Recordings 5 | 6 | @impl true 7 | def mount(_params, _session, socket) do 8 | socket = 9 | socket 10 | |> assign(:page_title, "Recordings") 11 | |> assign(:recording, nil) 12 | |> stream(:recordings, Recordings.list_recordings()) 13 | 14 | {:ok, socket} 15 | end 16 | 17 | @impl true 18 | def handle_info({GlitchtvWeb.RecordingLive.FormComponent, {:saved, recording}}, socket) do 19 | {:noreply, stream_insert(socket, :recordings, recording)} 20 | end 21 | 22 | @impl true 23 | def handle_event("delete", %{"id" => id}, socket) do 24 | recording = Recordings.get_recording!(id) 25 | {:ok, _} = Recordings.delete_recording(recording) 26 | 27 | {:noreply, stream_delete(socket, :recordings, recording)} 28 | end 29 | 30 | defp seconds_to_duration_string(seconds) do 31 | hours = div(seconds, 3600) 32 | minutes = div(seconds - hours * 3600, 60) 33 | seconds = rem(seconds, 60) 34 | 35 | "#{pad_number(hours)}:#{pad_number(minutes)}:#{pad_number(seconds)}" 36 | end 37 | 38 | defp pad_number(number) when number < 10 do 39 | "0#{number}" 40 | end 41 | 42 | defp pad_number(number) do 43 | "#{number}" 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/glitchtv_web/live/recording_live/index.html.heex: -------------------------------------------------------------------------------- 1 | <%= if Enum.count(@streams.recordings) == 0 do %> 2 |
3 | There aren't any recordings yet! 4 |
5 | <% else %> 6 |
7 | <.link 8 | :for={{_, recording} <- @streams.recordings} 9 | href={"/recordings/#{recording.id}"} 10 | class="flex flex-col gap-2" 11 | > 12 |
13 | 14 |

15 | {seconds_to_duration_string(recording.length_seconds)} 16 |

17 |
18 |

19 | {recording.title} 20 |

21 |
22 | 23 | <.icon name="hero-eye" class="w-3 h-3" /> 24 | {recording.views_count} 25 | 26 | 27 | 28 | {Calendar.strftime(recording.date, "%d %b %Y")} 29 | 30 |
31 | 32 |
33 | <% end %> 34 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /lib/glitchtv_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.Router do 2 | use GlitchtvWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, html: {GlitchtvWeb.Layouts, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", GlitchtvWeb do 18 | pipe_through :browser 19 | 20 | live "/", StreamViewerLive, :show 21 | live "/streamer", StreamerLive, :show 22 | 23 | live "/recordings", RecordingLive.Index, :index 24 | live "/recordings/:id", RecordingLive.Show, :show 25 | end 26 | 27 | # Other scopes may use custom stacks. 28 | # scope "/api", GlitchtvWeb do 29 | # pipe_through :api 30 | # end 31 | 32 | # Enable LiveDashboard in development 33 | if Application.compile_env(:glitchtv, :dev_routes) do 34 | # If you want to use the LiveDashboard in production, you should put 35 | # it behind authentication and allow only admins to access it. 36 | # If your application does not have an admins-only section yet, 37 | # you can use Plug.BasicAuth to set up some basic authentication 38 | # as long as you are also using SSL (which you should anyway). 39 | import Phoenix.LiveDashboard.Router 40 | 41 | scope "/dev" do 42 | pipe_through :browser 43 | 44 | live_dashboard "/dashboard", metrics: GlitchtvWeb.Telemetry 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/glitchtv/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | GlitchtvWeb.Telemetry, 12 | Glitchtv.Repo, 13 | {Ecto.Migrator, 14 | repos: Application.fetch_env!(:glitchtv, :ecto_repos), skip: skip_migrations?()}, 15 | {DNSCluster, query: Application.get_env(:glitchtv, :dns_cluster_query) || :ignore}, 16 | {Phoenix.PubSub, name: Glitchtv.PubSub}, 17 | Glitchtv.RecordingsService, 18 | Glitchtv.StreamService, 19 | # Start a worker by calling: Glitchtv.Worker.start_link(arg) 20 | # {Glitchtv.Worker, arg}, 21 | # Start to serve requests, typically the last entry 22 | GlitchtvWeb.Presence, 23 | GlitchtvWeb.Endpoint 24 | ] 25 | 26 | # See https://hexdocs.pm/elixir/Supervisor.html 27 | # for other strategies and supported options 28 | opts = [strategy: :one_for_one, name: Glitchtv.Supervisor] 29 | Supervisor.start_link(children, opts) 30 | end 31 | 32 | # Tell Phoenix to update the endpoint configuration 33 | # whenever the application is updated. 34 | @impl true 35 | def config_change(changed, _new, removed) do 36 | GlitchtvWeb.Endpoint.config_change(changed, removed) 37 | :ok 38 | end 39 | 40 | defp skip_migrations?() do 41 | # By default, sqlite migrations are run when using a release 42 | System.get_env("RELEASE_NAME") != nil 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/glitchtv_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :glitchtv 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_glitchtv_key", 10 | signing_salt: "2P9zfXHY", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [session: @session_options]], 16 | longpoll: [connect_info: [session: @session_options]] 17 | 18 | # Serve at "/" the static files from "priv/static" directory. 19 | # 20 | # You should set gzip to true if you are running phx.digest 21 | # when deploying your static files in production. 22 | plug Plug.Static, 23 | at: "/", 24 | from: :glitchtv, 25 | gzip: false, 26 | only: GlitchtvWeb.static_paths() 27 | 28 | # Code reloading can be explicitly enabled under the 29 | # :code_reloader configuration of your endpoint. 30 | if code_reloading? do 31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 32 | plug Phoenix.LiveReloader 33 | plug Phoenix.CodeReloader 34 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :glitchtv 35 | end 36 | 37 | plug Phoenix.LiveDashboard.RequestLogger, 38 | param_key: "request_logger", 39 | cookie_key: "request_logger" 40 | 41 | plug Plug.RequestId 42 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 43 | 44 | plug Plug.Parsers, 45 | parsers: [:urlencoded, :multipart, :json], 46 | pass: ["*/*"], 47 | json_decoder: Phoenix.json_library() 48 | 49 | plug Plug.MethodOverride 50 | plug Plug.Head 51 | plug Plug.Session, @session_options 52 | plug GlitchtvWeb.Router 53 | end 54 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.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 Glitchtv.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Glitchtv.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Glitchtv.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | Glitchtv.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Glitchtv.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /priv/static/images/swm-white-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :glitchtv, 11 | ecto_repos: [Glitchtv.Repo], 12 | generators: [timestamp_type: :utc_datetime] 13 | 14 | # Configures the endpoint 15 | config :glitchtv, GlitchtvWeb.Endpoint, 16 | url: [host: "localhost"], 17 | adapter: Bandit.PhoenixAdapter, 18 | render_errors: [ 19 | formats: [html: GlitchtvWeb.ErrorHTML, json: GlitchtvWeb.ErrorJSON], 20 | layout: false 21 | ], 22 | pubsub_server: Glitchtv.PubSub, 23 | live_view: [signing_salt: "9Ks23IJs"] 24 | 25 | # Configure esbuild (the version is required) 26 | config :esbuild, 27 | version: "0.17.11", 28 | glitchtv: [ 29 | args: 30 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 31 | cd: Path.expand("../assets", __DIR__), 32 | env: %{ 33 | "NODE_PATH" => "#{Path.expand("../deps", __DIR__)}:/Users/kuba/git/w/lw" 34 | } 35 | ] 36 | 37 | # Configure tailwind (the version is required) 38 | config :tailwind, 39 | version: "3.4.3", 40 | glitchtv: [ 41 | args: ~w( 42 | --config=tailwind.config.js 43 | --input=css/app.css 44 | --output=../priv/static/assets/app.css 45 | ), 46 | cd: Path.expand("../assets", __DIR__) 47 | ] 48 | 49 | # Configures Elixir's Logger 50 | config :logger, :console, 51 | format: "$time $metadata[$level] $message\n", 52 | metadata: [:request_id] 53 | 54 | # Use Jason for JSON parsing in Phoenix 55 | config :phoenix, :json_library, Jason 56 | 57 | config :ex_aws, 58 | access_key_id: {:system, "AWS_ACCESS_KEY_ID"}, 59 | secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"} 60 | 61 | config :ex_aws, :s3, 62 | scheme: "https://", 63 | host: "fly.storage.tigris.dev", 64 | region: "auto" 65 | 66 | # Import environment specific config. This must remain at the bottom 67 | # of this file so it overrides the configuration defined above. 68 | import_config "#{config_env()}.exs" 69 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap'); 6 | 7 | /* This file is for your main application CSS */ 8 | 9 | /* https://stackoverflow.com/questions/77936984/scrollbar-with-white-background */ 10 | 11 | *::-webkit-scrollbar { 12 | width: 4px; 13 | } 14 | 15 | *::-webkit-scrollbar-thumb { 16 | background-color: rgba(0, 0, 0, 0.5); 17 | border-radius: 4px; 18 | } 19 | 20 | *::-webkit-scrollbar-track { 21 | background-color: auto; 22 | } 23 | 24 | .dark *::-webkit-scrollbar-thumb { 25 | background-color: rgb(255, 255, 255, 0.5); 26 | border-radius: 4px; 27 | } 28 | 29 | /* */ 30 | 31 | .glitchtv-container-primary { 32 | @apply flex flex-col border border-indigo-200 rounded-lg dark:border-zinc-800; 33 | } 34 | 35 | .glitchtv-recordings-container { 36 | @apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-rows-fr justify-items-center gap-4; 37 | } 38 | 39 | .glitchtv-live-dropping-container { 40 | @apply uppercase inline text-sm bg-red-600 p-1 px-2 text-xs text-white rounded-md font-medium tracking-[8%]; 41 | } 42 | 43 | .glitchtv-dropping-featured-text { 44 | @apply text-indigo-800 font-medium dark:text-neutral-200; 45 | } 46 | 47 | .glitchtv-input-primary { 48 | @apply rounded-lg border border-indigo-200 disabled:bg-neutral-100 disabled:placeholder-neutral-300 text-sm flex-1 dark:bg-zinc-800 dark:disabled:bg-zinc-800 dark:disabled:placeholder-zinc-700 dark:border-none dark:text-red-500; 49 | } 50 | 51 | .glitchtv-button-primary { 52 | @apply bg-indigo-800 disabled:bg-indigo-500 disabled:text-indigo-300 text-white px-12 py-2 rounded-lg text-sm font-medium; 53 | } 54 | 55 | .glitchtv-button-browser-all { 56 | @apply flex items-center justify-center gap-2 border border-indigo-200 rounded-lg w-full h-[44px] text-indigo-800 text-sm font-medium hover:bg-indigo-50 dark:border-indigo-400 dark:text-indigo-400 dark:hover:bg-stone-800; 57 | } 58 | 59 | .glitchtv-swm-video-watermark { 60 | @apply absolute top-6 right-6 pointer-events-none; 61 | } 62 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import {Socket} from "phoenix" 22 | import {LiveSocket} from "phoenix_live_view" 23 | import topbar from "../vendor/topbar" 24 | import { createPublisherHook, createPlayerHook } from "live_ex_webrtc"; 25 | import ScrollDownHook from "./ScrollDownHook"; 26 | import ShareButtonHook from "./ShareButtonHook"; 27 | import DarkModeToggleHook from "./DarkModeToggleHook"; 28 | 29 | let Hooks = {}; 30 | 31 | Hooks.ScrollDownHook = ScrollDownHook; 32 | Hooks.ShareButton = ShareButtonHook; 33 | Hooks.DarkModeToggleHook = DarkModeToggleHook; 34 | 35 | const iceServers = [{ urls: "stun:stun.l.google.com:19302" }]; 36 | Hooks.Publisher = createPublisherHook(iceServers); 37 | Hooks.Player = createPlayerHook(iceServers); 38 | 39 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 40 | let liveSocket = new LiveSocket("/live", Socket, { 41 | longPollFallbackMs: 2500, 42 | params: {_csrf_token: csrfToken}, 43 | hooks: Hooks 44 | }) 45 | 46 | // Show progress bar on live navigation and form submits 47 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 48 | window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) 49 | window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) 50 | 51 | // connect if there are any LiveViews on the page 52 | liveSocket.connect() 53 | 54 | // expose liveSocket on window for web console debug logs and latency simulation: 55 | // >> liveSocket.enableDebug() 56 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 57 | // >> liveSocket.disableLatencySim() 58 | window.liveSocket = liveSocket 59 | 60 | -------------------------------------------------------------------------------- /lib/glitchtv/stream_service.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.StreamService do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | def start_link(args) do 7 | GenServer.start_link(__MODULE__, args, name: __MODULE__) 8 | end 9 | 10 | def get_stream_metadata do 11 | GenServer.call(__MODULE__, :get_stream_metadata) 12 | end 13 | 14 | def stream_started do 15 | GenServer.call(__MODULE__, :stream_started) 16 | end 17 | 18 | def stream_ended do 19 | GenServer.call(__MODULE__, :stream_ended) 20 | end 21 | 22 | def put_stream_metadata(metadata) do 23 | GenServer.call(__MODULE__, {:put_stream_metadata, metadata}) 24 | end 25 | 26 | @impl true 27 | def init(_arg) do 28 | state = %{ 29 | streaming?: false, 30 | title: nil, 31 | description: nil, 32 | started: nil, 33 | timer_ref: nil 34 | } 35 | 36 | {:ok, state} 37 | end 38 | 39 | @impl true 40 | def handle_call(:get_stream_metadata, _from, state) do 41 | metadata = %{ 42 | title: state.title, 43 | description: state.description, 44 | started: state.started, 45 | streaming?: state.streaming? 46 | } 47 | 48 | {:reply, metadata, state} 49 | end 50 | 51 | @impl true 52 | def handle_call({:put_stream_metadata, metadata}, _from, state) do 53 | state = %{state | title: metadata.title || "", description: metadata.description || ""} 54 | 55 | Phoenix.PubSub.broadcast( 56 | Glitchtv.PubSub, 57 | "stream_info:status", 58 | {:changed, {state.title, state.description}} 59 | ) 60 | 61 | {:reply, :ok, state} 62 | end 63 | 64 | @impl true 65 | def handle_call(:stream_started, _from, state) do 66 | started = DateTime.utc_now() 67 | Phoenix.PubSub.broadcast(Glitchtv.PubSub, "stream_info:status", {:started, started}) 68 | {:ok, timer_ref} = :timer.send_interval(60_000, self(), :tick) 69 | 70 | state = %{state | streaming?: true, started: started, timer_ref: timer_ref} 71 | {:reply, :ok, state} 72 | end 73 | 74 | @impl true 75 | def handle_call(:stream_ended, _from, state) do 76 | state = %{state | streaming?: false, started: nil} 77 | Phoenix.PubSub.broadcast(Glitchtv.PubSub, "stream_info:status", :finished) 78 | :timer.cancel(state.timer_ref) 79 | {:reply, :ok, state} 80 | end 81 | 82 | @impl true 83 | def handle_info(:tick, state) do 84 | Phoenix.PubSub.broadcast(Glitchtv.PubSub, "stream_info:status", :tick) 85 | {:noreply, state} 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/glitchtv/recordings_service.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.RecordingsService do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | def start_link(args) do 7 | GenServer.start_link(__MODULE__, args, name: __MODULE__) 8 | end 9 | 10 | def upload_started(ref, metadata) do 11 | GenServer.cast(__MODULE__, {:upload_started, ref, metadata}) 12 | end 13 | 14 | def upload_complete(ref, manifest) do 15 | GenServer.cast(__MODULE__, {:upload_complete, ref, manifest}) 16 | end 17 | 18 | @impl true 19 | def init(_arg) do 20 | s3_config = Application.fetch_env!(:ex_aws, :s3) 21 | 22 | state = %{ 23 | upload_tasks: %{}, 24 | s3_config: s3_config 25 | } 26 | 27 | {:ok, state} 28 | end 29 | 30 | @impl true 31 | def handle_cast({:upload_started, ref, metadata}, state) do 32 | state = put_in(state[:upload_tasks][ref], metadata) 33 | 34 | {:noreply, state} 35 | end 36 | 37 | @impl true 38 | def handle_cast({:upload_complete, ref, manifest}, state) do 39 | {metadata, state} = pop_in(state[:upload_tasks][ref]) 40 | 41 | if metadata == nil, do: raise("uh oh") 42 | 43 | result_manifest = 44 | ExWebRTC.Recorder.Converter.convert!(manifest, 45 | thumbnails_ctx: %{}, 46 | s3_upload_config: [bucket_name: Application.get_env(:glitchtv, :bucket_name)], 47 | only_rids: ["h", nil] 48 | ) 49 | |> Map.values() 50 | |> List.first() 51 | 52 | title = 53 | if metadata.title == nil or metadata.title == "", 54 | do: "Untitled recording", 55 | else: metadata.title 56 | 57 | description = 58 | if metadata.description == nil or metadata.description == "", 59 | do: "No description provided", 60 | else: metadata.description 61 | 62 | {:ok, _} = 63 | Glitchtv.Recordings.create_recording(%{ 64 | title: title, 65 | description: description, 66 | link: result_manifest.location |> rewrite_location(state), 67 | thumbnail_link: result_manifest.thumbnail_location |> rewrite_location(state), 68 | length_seconds: result_manifest.duration_seconds, 69 | date: metadata.started, 70 | views_count: 0 71 | }) 72 | 73 | {:noreply, state} 74 | end 75 | 76 | defp rewrite_location(location, state) do 77 | # FIXME this is workaround. Converter should return either correct url or separate parts of the url. 78 | # https://stackoverflow.com/questions/7933458/how-to-format-a-url-to-get-a-file-from-amazon-s3 79 | [bucket_name, file] = 80 | location 81 | |> String.trim_leading("s3://") 82 | |> String.split("/") 83 | 84 | "#{state.s3_config[:scheme]}#{bucket_name}.#{state.s3_config[:host]}/#{file}" 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :glitchtv, Glitchtv.Repo, 5 | database: Path.expand("../glitchtv_dev.db", __DIR__), 6 | pool_size: 5, 7 | stacktrace: true, 8 | show_sensitive_data_on_connection_error: true 9 | 10 | # For development, we disable any cache and enable 11 | # debugging and code reloading. 12 | # 13 | # The watchers configuration can be used to run external 14 | # watchers to your application. For example, we can use it 15 | # to bundle .js and .css sources. 16 | config :glitchtv, GlitchtvWeb.Endpoint, 17 | # Binding to loopback ipv4 address prevents access from other machines. 18 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 19 | http: [ip: {127, 0, 0, 1}, port: 4000], 20 | check_origin: false, 21 | code_reloader: true, 22 | debug_errors: true, 23 | secret_key_base: "hN+36WL5auhuie6jGcVQDdAjFvvAr/Kyk4uD6h6uSVK7nq+VL/L1o2JJ5swVhWrg", 24 | watchers: [ 25 | esbuild: {Esbuild, :install_and_run, [:glitchtv, ~w(--sourcemap=inline --watch)]}, 26 | tailwind: {Tailwind, :install_and_run, [:glitchtv, ~w(--watch)]} 27 | ] 28 | 29 | # ## SSL Support 30 | # 31 | # In order to use HTTPS in development, a self-signed 32 | # certificate can be generated by running the following 33 | # Mix task: 34 | # 35 | # mix phx.gen.cert 36 | # 37 | # Run `mix help phx.gen.cert` for more information. 38 | # 39 | # The `http:` config above can be replaced with: 40 | # 41 | # https: [ 42 | # port: 4001, 43 | # cipher_suite: :strong, 44 | # keyfile: "priv/cert/selfsigned_key.pem", 45 | # certfile: "priv/cert/selfsigned.pem" 46 | # ], 47 | # 48 | # If desired, both `http:` and `https:` keys can be 49 | # configured to run both http and https servers on 50 | # different ports. 51 | 52 | # Watch static and templates for browser reloading. 53 | config :glitchtv, GlitchtvWeb.Endpoint, 54 | live_reload: [ 55 | patterns: [ 56 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 57 | ~r"lib/glitchtv_web/(controllers|live|components)/.*(ex|heex)$" 58 | ] 59 | ] 60 | 61 | # Enable dev routes for dashboard and mailbox 62 | config :glitchtv, dev_routes: true 63 | 64 | # Do not include metadata nor timestamps in development logs 65 | config :logger, :console, format: "[$level] $message\n" 66 | 67 | # Set a higher stacktrace during development. Avoid configuring such 68 | # in production as building large stacktraces may be expensive. 69 | config :phoenix, :stacktrace_depth, 20 70 | 71 | # Initialize plugs at runtime for faster development compilation 72 | config :phoenix, :plug_init_mode, :runtime 73 | 74 | config :phoenix_live_view, 75 | # Include HEEx debug annotations as HTML comments in rendered markup 76 | debug_heex_annotations: true, 77 | # Enable helpful, but potentially expensive runtime checks 78 | enable_expensive_runtime_checks: true 79 | -------------------------------------------------------------------------------- /lib/glitchtv_web/live/recording_live/show.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
12 | 13 |
14 |
15 |
16 |

17 | {@recording.title} 18 |

19 |
20 |
21 | <.dropping> 22 | Recorded:  23 | 24 | {Calendar.strftime(@recording.date, "%d %b %Y")} 25 | 26 | 27 | <.dropping> 28 | Views:  29 | 30 | {@recording.views_count} 31 | 32 | 33 | <.share_button /> 34 |
35 |

36 | {@recording.description} 37 |

38 |
39 |
40 |
41 | 66 | <.link href="/recordings"> 67 | 70 | 71 |
72 |
73 | -------------------------------------------------------------------------------- /lib/glitchtv/recordings.ex: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.Recordings do 2 | @moduledoc """ 3 | The Recordings context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias Glitchtv.Repo 8 | 9 | alias Glitchtv.Recordings.Recording 10 | 11 | @doc """ 12 | Returns the list of recordings. 13 | 14 | ## Examples 15 | 16 | iex> list_recordings() 17 | [%Recording{}, ...] 18 | 19 | """ 20 | def list_recordings do 21 | Repo.all(Recording) 22 | end 23 | 24 | def list_five_recordings do 25 | query = from r in Recording, limit: 5 26 | Repo.all(query) 27 | end 28 | 29 | @doc """ 30 | Gets a single recording. 31 | 32 | Raises `Ecto.NoResultsError` if the Recording does not exist. 33 | 34 | ## Examples 35 | 36 | iex> get_recording!(123) 37 | %Recording{} 38 | 39 | iex> get_recording!(456) 40 | ** (Ecto.NoResultsError) 41 | 42 | """ 43 | def get_recording!(id), do: Repo.get!(Recording, id) 44 | 45 | def get_and_increment_views!(id) do 46 | {1, [recording]} = 47 | from(r in Recording, 48 | select: r, 49 | update: [inc: [views_count: 1]], 50 | where: r.id == ^id 51 | ) 52 | |> Repo.update_all([]) 53 | 54 | recording 55 | end 56 | 57 | @doc """ 58 | Creates a recording. 59 | 60 | ## Examples 61 | 62 | iex> create_recording(%{field: value}) 63 | {:ok, %Recording{}} 64 | 65 | iex> create_recording(%{field: bad_value}) 66 | {:error, %Ecto.Changeset{}} 67 | 68 | """ 69 | def create_recording(attrs \\ %{}) do 70 | %Recording{} 71 | |> Recording.changeset(attrs) 72 | |> Repo.insert() 73 | end 74 | 75 | @doc """ 76 | Updates a recording. 77 | 78 | ## Examples 79 | 80 | iex> update_recording(recording, %{field: new_value}) 81 | {:ok, %Recording{}} 82 | 83 | iex> update_recording(recording, %{field: bad_value}) 84 | {:error, %Ecto.Changeset{}} 85 | 86 | """ 87 | def update_recording(%Recording{} = recording, attrs) do 88 | recording 89 | |> Recording.changeset(attrs) 90 | |> Repo.update() 91 | end 92 | 93 | @doc """ 94 | Deletes a recording. 95 | 96 | ## Examples 97 | 98 | iex> delete_recording(recording) 99 | {:ok, %Recording{}} 100 | 101 | iex> delete_recording(recording) 102 | {:error, %Ecto.Changeset{}} 103 | 104 | """ 105 | def delete_recording(%Recording{} = recording) do 106 | Repo.delete(recording) 107 | end 108 | 109 | @doc """ 110 | Returns an `%Ecto.Changeset{}` for tracking recording changes. 111 | 112 | ## Examples 113 | 114 | iex> change_recording(recording) 115 | %Ecto.Changeset{data: %Recording{}} 116 | 117 | """ 118 | def change_recording(%Recording{} = recording, attrs \\ %{}) do 119 | Recording.changeset(recording, attrs) 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :glitchtv, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {Glitchtv.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.7.18"}, 36 | {:phoenix_ecto, "~> 4.5"}, 37 | {:ecto_sql, "~> 3.10"}, 38 | {:ecto_sqlite3, ">= 0.0.0"}, 39 | {:phoenix_html, "~> 4.1"}, 40 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 41 | {:phoenix_live_view, "~> 1.0.0"}, 42 | {:floki, ">= 0.30.0", only: :test}, 43 | {:phoenix_live_dashboard, "~> 0.8.3"}, 44 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 45 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, 46 | {:heroicons, 47 | github: "tailwindlabs/heroicons", 48 | tag: "v2.1.1", 49 | sparse: "optimized", 50 | app: false, 51 | compile: false, 52 | depth: 1}, 53 | {:telemetry_metrics, "~> 1.0"}, 54 | {:telemetry_poller, "~> 1.0"}, 55 | {:jason, "~> 1.2"}, 56 | {:dns_cluster, "~> 0.1.1"}, 57 | {:bandit, "~> 1.5"}, 58 | {:ex_webrtc, "~> 0.12.0"}, 59 | {:ex_webrtc_recorder, "~> 0.1.0"}, 60 | {:live_ex_webrtc, github: "elixir-webrtc/live_ex_webrtc"}, 61 | {:ex_aws, ">=0.0.0"}, 62 | {:ex_aws_s3, ">=0.0.0"}, 63 | {:sweet_xml, ">=0.0.0"}, 64 | {:hackney, ">=0.0.0"} 65 | ] 66 | end 67 | 68 | # Aliases are shortcuts or tasks specific to the current project. 69 | # For example, to install project dependencies and perform other setup tasks, run: 70 | # 71 | # $ mix setup 72 | # 73 | # See the documentation for `Mix` for more info on aliases. 74 | defp aliases do 75 | [ 76 | setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], 77 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 78 | "ecto.reset": ["ecto.drop", "ecto.setup"], 79 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 80 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], 81 | "assets.build": ["tailwind glitchtv", "esbuild glitchtv"], 82 | "assets.deploy": [ 83 | "tailwind glitchtv --minify", 84 | "esbuild glitchtv --minify", 85 | "phx.digest" 86 | ] 87 | ] 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/glitchtv_web.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use GlitchtvWeb, :controller 9 | use GlitchtvWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, 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 additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt content) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: GlitchtvWeb.Layouts] 44 | 45 | import Plug.Conn 46 | 47 | unquote(verified_routes()) 48 | end 49 | end 50 | 51 | def live_view do 52 | quote do 53 | use Phoenix.LiveView, 54 | layout: {GlitchtvWeb.Layouts, :app} 55 | 56 | unquote(html_helpers()) 57 | end 58 | end 59 | 60 | def live_component do 61 | quote do 62 | use Phoenix.LiveComponent 63 | 64 | unquote(html_helpers()) 65 | end 66 | end 67 | 68 | def html do 69 | quote do 70 | use Phoenix.Component 71 | 72 | # Import convenience functions from controllers 73 | import Phoenix.Controller, 74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 75 | 76 | # Include general helpers for rendering HTML 77 | unquote(html_helpers()) 78 | end 79 | end 80 | 81 | defp html_helpers do 82 | quote do 83 | # HTML escaping functionality 84 | import Phoenix.HTML 85 | # Core UI components 86 | import GlitchtvWeb.CoreComponents 87 | 88 | # Shortcut for generating JS commands 89 | alias Phoenix.LiveView.JS 90 | 91 | # Routes generation with the ~p sigil 92 | unquote(verified_routes()) 93 | end 94 | end 95 | 96 | def verified_routes do 97 | quote do 98 | use Phoenix.VerifiedRoutes, 99 | endpoint: GlitchtvWeb.Endpoint, 100 | router: GlitchtvWeb.Router, 101 | statics: GlitchtvWeb.static_paths() 102 | end 103 | end 104 | 105 | @doc """ 106 | When used, dispatch to the appropriate controller/live_view/etc. 107 | """ 108 | defmacro __using__(which) when is_atom(which) do 109 | apply(__MODULE__, which, []) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/glitchtv_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 10 | Elixir WebRTC logo 11 | 12 | by 13 | 14 | Software Mansion logo 15 | 20 | 21 |
22 |
23 | 44 |
45 | 51 | 61 | 67 |
68 |
69 |
70 |
71 | <.flash_group flash={@flash} /> 72 | {@inner_content} 73 |
74 |
75 | -------------------------------------------------------------------------------- /lib/glitchtv_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # Database Metrics 55 | summary("glitchtv.repo.query.total_time", 56 | unit: {:native, :millisecond}, 57 | description: "The sum of the other measurements" 58 | ), 59 | summary("glitchtv.repo.query.decode_time", 60 | unit: {:native, :millisecond}, 61 | description: "The time spent decoding the data received from the database" 62 | ), 63 | summary("glitchtv.repo.query.query_time", 64 | unit: {:native, :millisecond}, 65 | description: "The time spent executing the query" 66 | ), 67 | summary("glitchtv.repo.query.queue_time", 68 | unit: {:native, :millisecond}, 69 | description: "The time spent waiting for a database connection" 70 | ), 71 | summary("glitchtv.repo.query.idle_time", 72 | unit: {:native, :millisecond}, 73 | description: 74 | "The time the connection spent waiting before being checked out for the query" 75 | ), 76 | 77 | # VM Metrics 78 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 79 | summary("vm.total_run_queue_lengths.total"), 80 | summary("vm.total_run_queue_lengths.cpu"), 81 | summary("vm.total_run_queue_lengths.io") 82 | ] 83 | end 84 | 85 | defp periodic_measurements do 86 | [ 87 | # A module, function and arguments to be invoked periodically. 88 | # This function must call :telemetry.execute/3 and a metric must be added above. 89 | # {GlitchtvWeb, :count_users, []} 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin"); 5 | const fs = require("fs"); 6 | const path = require("path"); 7 | 8 | module.exports = { 9 | content: [ 10 | "./js/**/*.js", 11 | "../lib/glitchtv_web.ex", 12 | "../lib/glitchtv_web/**/*.*ex", 13 | "../deps/live_ex_webrtc/**/*.*ex", 14 | "../../live_ex_webrtc/**/*.*ex", 15 | ], 16 | theme: { 17 | extend: { 18 | colors: { 19 | brand: "#FD4F00", 20 | }, 21 | fontFamily: { 22 | DMSans: ["DM Sans", "sans-serif"], 23 | }, 24 | }, 25 | }, 26 | darkMode: "class", 27 | plugins: [ 28 | require("@tailwindcss/forms"), 29 | // Allows prefixing tailwind classes with LiveView classes to add rules 30 | // only when LiveView classes are applied, for example: 31 | // 32 | //
33 | // 34 | plugin(({ addVariant }) => 35 | addVariant("phx-click-loading", [ 36 | ".phx-click-loading&", 37 | ".phx-click-loading &", 38 | ]) 39 | ), 40 | plugin(({ addVariant }) => 41 | addVariant("phx-submit-loading", [ 42 | ".phx-submit-loading&", 43 | ".phx-submit-loading &", 44 | ]) 45 | ), 46 | plugin(({ addVariant }) => 47 | addVariant("phx-change-loading", [ 48 | ".phx-change-loading&", 49 | ".phx-change-loading &", 50 | ]) 51 | ), 52 | 53 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 54 | // See your `CoreComponents.icon/1` for more information. 55 | // 56 | plugin(function ({ matchComponents, theme }) { 57 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized"); 58 | let values = {}; 59 | let icons = [ 60 | ["", "/24/outline"], 61 | ["-solid", "/24/solid"], 62 | ["-mini", "/20/solid"], 63 | ["-micro", "/16/solid"], 64 | ]; 65 | icons.forEach(([suffix, dir]) => { 66 | fs.readdirSync(path.join(iconsDir, dir)).forEach((file) => { 67 | let name = path.basename(file, ".svg") + suffix; 68 | values[name] = { name, fullPath: path.join(iconsDir, dir, file) }; 69 | }); 70 | }); 71 | matchComponents( 72 | { 73 | hero: ({ name, fullPath }) => { 74 | let content = fs 75 | .readFileSync(fullPath) 76 | .toString() 77 | .replace(/\r?\n|\r/g, ""); 78 | let size = theme("spacing.6"); 79 | if (name.endsWith("-mini")) { 80 | size = theme("spacing.5"); 81 | } else if (name.endsWith("-micro")) { 82 | size = theme("spacing.4"); 83 | } 84 | return { 85 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 86 | "-webkit-mask": `var(--hero-${name})`, 87 | mask: `var(--hero-${name})`, 88 | "mask-repeat": "no-repeat", 89 | "background-color": "currentColor", 90 | "vertical-align": "middle", 91 | display: "inline-block", 92 | width: size, 93 | height: size, 94 | }; 95 | }, 96 | }, 97 | { values } 98 | ); 99 | }), 100 | ], 101 | }; 102 | -------------------------------------------------------------------------------- /test/glitchtv/recordings_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Glitchtv.RecordingsTest do 2 | use Glitchtv.DataCase 3 | 4 | alias Glitchtv.Recordings 5 | 6 | describe "recordings" do 7 | alias Glitchtv.Recordings.Recording 8 | 9 | import Glitchtv.RecordingsFixtures 10 | 11 | @invalid_attrs %{ 12 | date: nil, 13 | link: nil, 14 | description: nil, 15 | title: nil, 16 | thumbnail_link: nil, 17 | length_seconds: nil, 18 | views_count: nil 19 | } 20 | 21 | test "list_recordings/0 returns all recordings" do 22 | recording = recording_fixture() 23 | assert Recordings.list_recordings() == [recording] 24 | end 25 | 26 | test "get_recording!/1 returns the recording with given id" do 27 | recording = recording_fixture() 28 | assert Recordings.get_recording!(recording.id) == recording 29 | end 30 | 31 | test "create_recording/1 with valid data creates a recording" do 32 | valid_attrs = %{ 33 | date: ~U[2025-02-11 12:28:00Z], 34 | link: "some link", 35 | description: "some description", 36 | title: "some title", 37 | thumbnail_link: "some thumbnail_link", 38 | length_seconds: 42, 39 | views_count: 42 40 | } 41 | 42 | assert {:ok, %Recording{} = recording} = Recordings.create_recording(valid_attrs) 43 | assert recording.date == ~U[2025-02-11 12:28:00Z] 44 | assert recording.link == "some link" 45 | assert recording.description == "some description" 46 | assert recording.title == "some title" 47 | assert recording.thumbnail_link == "some thumbnail_link" 48 | assert recording.length_seconds == 42 49 | assert recording.views_count == 42 50 | end 51 | 52 | test "create_recording/1 with invalid data returns error changeset" do 53 | assert {:error, %Ecto.Changeset{}} = Recordings.create_recording(@invalid_attrs) 54 | end 55 | 56 | test "update_recording/2 with valid data updates the recording" do 57 | recording = recording_fixture() 58 | 59 | update_attrs = %{ 60 | date: ~U[2025-02-12 12:28:00Z], 61 | link: "some updated link", 62 | description: "some updated description", 63 | title: "some updated title", 64 | thumbnail_link: "some updated thumbnail_link", 65 | length_seconds: 43, 66 | views_count: 43 67 | } 68 | 69 | assert {:ok, %Recording{} = recording} = 70 | Recordings.update_recording(recording, update_attrs) 71 | 72 | assert recording.date == ~U[2025-02-12 12:28:00Z] 73 | assert recording.link == "some updated link" 74 | assert recording.description == "some updated description" 75 | assert recording.title == "some updated title" 76 | assert recording.thumbnail_link == "some updated thumbnail_link" 77 | assert recording.length_seconds == 43 78 | assert recording.views_count == 43 79 | end 80 | 81 | test "update_recording/2 with invalid data returns error changeset" do 82 | recording = recording_fixture() 83 | assert {:error, %Ecto.Changeset{}} = Recordings.update_recording(recording, @invalid_attrs) 84 | assert recording == Recordings.get_recording!(recording.id) 85 | end 86 | 87 | test "delete_recording/1 deletes the recording" do 88 | recording = recording_fixture() 89 | assert {:ok, %Recording{}} = Recordings.delete_recording(recording) 90 | assert_raise Ecto.NoResultsError, fn -> Recordings.get_recording!(recording.id) end 91 | end 92 | 93 | test "change_recording/1 returns a recording changeset" do 94 | recording = recording_fixture() 95 | assert %Ecto.Changeset{} = Recordings.change_recording(recording) 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian 2 | # instead of Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # This file is based on these images: 8 | # 9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250203-slim - for the release image 11 | # - https://pkgs.org/ - resource for finding needed packages 12 | # - Ex: hexpm/elixir:1.18.2-erlang-27.2.1-debian-bullseye-20250203-slim 13 | # 14 | ARG ELIXIR_VERSION=1.17.2 15 | ARG OTP_VERSION=27.0.1 16 | ARG DEBIAN_VERSION=bookworm-20240701-slim 17 | 18 | 19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 21 | 22 | FROM ${BUILDER_IMAGE} as builder 23 | 24 | # install build dependencies 25 | RUN apt-get update -y && apt-get install -y build-essential wget git pkg-config libssl-dev \ 26 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 27 | 28 | # Install litestream 29 | ARG LITESTREAM_VERSION=0.3.13 30 | RUN wget https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-amd64.deb \ 31 | && dpkg -i litestream-v${LITESTREAM_VERSION}-linux-amd64.deb 32 | 33 | # prepare build dir 34 | WORKDIR /app 35 | 36 | # install hex + rebar 37 | RUN mix local.hex --force && \ 38 | mix local.rebar --force 39 | 40 | # set build ENV 41 | ENV MIX_ENV="prod" 42 | 43 | # install mix dependencies 44 | COPY mix.exs mix.lock ./ 45 | RUN mix deps.get --only $MIX_ENV 46 | RUN mkdir config 47 | 48 | # copy compile-time config files before we compile dependencies 49 | # to ensure any relevant config change will trigger the dependencies 50 | # to be re-compiled. 51 | COPY config/config.exs config/${MIX_ENV}.exs config/ 52 | RUN mix deps.compile 53 | 54 | COPY priv priv 55 | 56 | COPY lib lib 57 | 58 | COPY assets assets 59 | 60 | # compile assets 61 | RUN mix assets.deploy 62 | 63 | # Compile the release 64 | RUN mix compile 65 | 66 | # Changes to config/runtime.exs don't require recompiling the code 67 | COPY config/runtime.exs config/ 68 | 69 | COPY rel rel 70 | RUN mix release 71 | 72 | # start a new build stage so that the final image will only contain 73 | # the compiled release and other runtime necessities 74 | FROM ${RUNNER_IMAGE} 75 | 76 | RUN apt-get update -y && \ 77 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates ffmpeg\ 78 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 79 | 80 | # Set the locale 81 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 82 | 83 | ENV LANG en_US.UTF-8 84 | ENV LANGUAGE en_US:en 85 | ENV LC_ALL en_US.UTF-8 86 | 87 | WORKDIR "/app" 88 | RUN chown nobody /app 89 | 90 | # set runner ENV 91 | ENV MIX_ENV="prod" 92 | 93 | # Only copy the final release from the build stage 94 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/glitchtv ./ 95 | 96 | # Copy Litestream binary from build stage 97 | COPY --from=builder /usr/bin/litestream /usr/bin/litestream 98 | COPY litestream.sh /app/bin/litestream.sh 99 | COPY config/litestream.yml /etc/litestream.yml 100 | 101 | USER nobody 102 | 103 | # If using an environment that doesn't automatically reap zombie processes, it is 104 | # advised to add an init process such as tini via `apt-get install` 105 | # above and adding an entrypoint. See https://github.com/krallin/tini for details 106 | # ENTRYPOINT ["/tini", "--"] 107 | 108 | # Run litestream script as entrypoint 109 | ENTRYPOINT ["/bin/bash", "/app/bin/litestream.sh"] 110 | 111 | CMD ["/app/bin/server"] 112 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/glitchtv start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :glitchtv, GlitchtvWeb.Endpoint, server: true 21 | end 22 | 23 | if System.get_env("FLY_APP_NAME") do 24 | config :glitchtv, ice_ip_filter: &ExWebRTC.ICE.FlyIpFilter.ip_filter/1 25 | end 26 | 27 | config :glitchtv, bucket_name: System.get_env("BUCKET_NAME") 28 | 29 | if config_env() == :prod do 30 | database_path = 31 | System.get_env("DATABASE_PATH") || 32 | raise """ 33 | environment variable DATABASE_PATH is missing. 34 | For example: /etc/glitchtv/glitchtv.db 35 | """ 36 | 37 | config :glitchtv, Glitchtv.Repo, 38 | database: database_path, 39 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5") 40 | 41 | # The secret key base is used to sign/encrypt cookies and other secrets. 42 | # A default value is used in config/dev.exs and config/test.exs but you 43 | # want to use a different value for prod and you most likely don't want 44 | # to check this value into version control, so we use an environment 45 | # variable instead. 46 | secret_key_base = 47 | System.get_env("SECRET_KEY_BASE") || 48 | raise """ 49 | environment variable SECRET_KEY_BASE is missing. 50 | You can generate one by calling: mix phx.gen.secret 51 | """ 52 | 53 | host = System.get_env("PHX_HOST") || "example.com" 54 | port = String.to_integer(System.get_env("PORT") || "4000") 55 | 56 | config :glitchtv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 57 | 58 | config :glitchtv, GlitchtvWeb.Endpoint, 59 | url: [host: host, port: 443, scheme: "https"], 60 | http: [ 61 | # Enable IPv6 and bind on all interfaces. 62 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 63 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 64 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 65 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 66 | port: port 67 | ], 68 | secret_key_base: secret_key_base 69 | 70 | # ## SSL Support 71 | # 72 | # To get SSL working, you will need to add the `https` key 73 | # to your endpoint configuration: 74 | # 75 | # config :glitchtv, GlitchtvWeb.Endpoint, 76 | # https: [ 77 | # ..., 78 | # port: 443, 79 | # cipher_suite: :strong, 80 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 81 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 82 | # ] 83 | # 84 | # The `cipher_suite` is set to `:strong` to support only the 85 | # latest and more secure SSL ciphers. This means old browsers 86 | # and clients may not be supported. You can set it to 87 | # `:compatible` for wider support. 88 | # 89 | # `:keyfile` and `:certfile` expect an absolute path to the key 90 | # and cert in disk or a relative path inside priv, for example 91 | # "priv/ssl/server.key". For all supported SSL configuration 92 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 93 | # 94 | # We also recommend setting `force_ssl` in your config/prod.exs, 95 | # ensuring no data is ever sent via http, always redirecting to https: 96 | # 97 | # config :glitchtv, GlitchtvWeb.Endpoint, 98 | # force_ssl: [hsts: true] 99 | # 100 | # Check `Plug.SSL` for all available options in `force_ssl`. 101 | end 102 | -------------------------------------------------------------------------------- /test/glitchtv_web/live/recording_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.RecordingLiveTest do 2 | use GlitchtvWeb.ConnCase 3 | 4 | import Phoenix.LiveViewTest 5 | import Glitchtv.RecordingsFixtures 6 | 7 | @create_attrs %{ 8 | date: "2025-02-11T12:28:00Z", 9 | link: "some link", 10 | description: "some description", 11 | title: "some title", 12 | thumbnail_link: "some thumbnail_link", 13 | length_seconds: 42, 14 | views_count: 42 15 | } 16 | @update_attrs %{ 17 | date: "2025-02-12T12:28:00Z", 18 | link: "some updated link", 19 | description: "some updated description", 20 | title: "some updated title", 21 | thumbnail_link: "some updated thumbnail_link", 22 | length_seconds: 43, 23 | views_count: 43 24 | } 25 | @invalid_attrs %{ 26 | date: nil, 27 | link: nil, 28 | description: nil, 29 | title: nil, 30 | thumbnail_link: nil, 31 | length_seconds: nil, 32 | views_count: nil 33 | } 34 | 35 | defp create_recording(_) do 36 | recording = recording_fixture() 37 | %{recording: recording} 38 | end 39 | 40 | describe "Index" do 41 | setup [:create_recording] 42 | 43 | test "lists all recordings", %{conn: conn, recording: recording} do 44 | {:ok, _index_live, html} = live(conn, ~p"/recordings") 45 | 46 | assert html =~ "Listing Recordings" 47 | assert html =~ recording.link 48 | end 49 | 50 | test "saves new recording", %{conn: conn} do 51 | {:ok, index_live, _html} = live(conn, ~p"/recordings") 52 | 53 | assert index_live |> element("a", "New Recording") |> render_click() =~ 54 | "New Recording" 55 | 56 | assert_patch(index_live, ~p"/recordings/new") 57 | 58 | assert index_live 59 | |> form("#recording-form", recording: @invalid_attrs) 60 | |> render_change() =~ "can't be blank" 61 | 62 | assert index_live 63 | |> form("#recording-form", recording: @create_attrs) 64 | |> render_submit() 65 | 66 | assert_patch(index_live, ~p"/recordings") 67 | 68 | html = render(index_live) 69 | assert html =~ "Recording created successfully" 70 | assert html =~ "some link" 71 | end 72 | 73 | test "updates recording in listing", %{conn: conn, recording: recording} do 74 | {:ok, index_live, _html} = live(conn, ~p"/recordings") 75 | 76 | assert index_live |> element("#recordings-#{recording.id} a", "Edit") |> render_click() =~ 77 | "Edit Recording" 78 | 79 | assert_patch(index_live, ~p"/recordings/#{recording}/edit") 80 | 81 | assert index_live 82 | |> form("#recording-form", recording: @invalid_attrs) 83 | |> render_change() =~ "can't be blank" 84 | 85 | assert index_live 86 | |> form("#recording-form", recording: @update_attrs) 87 | |> render_submit() 88 | 89 | assert_patch(index_live, ~p"/recordings") 90 | 91 | html = render(index_live) 92 | assert html =~ "Recording updated successfully" 93 | assert html =~ "some updated link" 94 | end 95 | 96 | test "deletes recording in listing", %{conn: conn, recording: recording} do 97 | {:ok, index_live, _html} = live(conn, ~p"/recordings") 98 | 99 | assert index_live |> element("#recordings-#{recording.id} a", "Delete") |> render_click() 100 | refute has_element?(index_live, "#recordings-#{recording.id}") 101 | end 102 | end 103 | 104 | describe "Show" do 105 | setup [:create_recording] 106 | 107 | test "displays recording", %{conn: conn, recording: recording} do 108 | {:ok, _show_live, html} = live(conn, ~p"/recordings/#{recording}") 109 | 110 | assert html =~ "Show Recording" 111 | assert html =~ recording.link 112 | end 113 | 114 | test "updates recording within modal", %{conn: conn, recording: recording} do 115 | {:ok, show_live, _html} = live(conn, ~p"/recordings/#{recording}") 116 | 117 | assert show_live |> element("a", "Edit") |> render_click() =~ 118 | "Edit Recording" 119 | 120 | assert_patch(show_live, ~p"/recordings/#{recording}/show/edit") 121 | 122 | assert show_live 123 | |> form("#recording-form", recording: @invalid_attrs) 124 | |> render_change() =~ "can't be blank" 125 | 126 | assert show_live 127 | |> form("#recording-form", recording: @update_attrs) 128 | |> render_submit() 129 | 130 | assert_patch(show_live, ~p"/recordings/#{recording}") 131 | 132 | html = render(show_live) 133 | assert html =~ "Recording updated successfully" 134 | assert html =~ "some updated link" 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/glitchtv_web/live/stream_viewer_live.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.StreamViewerLive do 2 | use GlitchtvWeb, :live_view 3 | 4 | alias LiveExWebRTC.Player 5 | alias Phoenix.Presence 6 | alias Phoenix.Socket.Broadcast 7 | alias GlitchtvWeb.ChatLive 8 | alias GlitchtvWeb.Presence 9 | 10 | @impl true 11 | def render(assigns) do 12 | ~H""" 13 |
14 |
15 |
16 |
17 | 18 |
19 | 20 |
21 |
22 |
23 | <%= if @stream_metadata.streaming? do %> 24 | <.live_dropping /> 25 | <% end %> 26 |

27 | {if @stream_metadata, do: @stream_metadata.title, else: "The stream is offline"} 28 |

29 |
30 |
31 | <.dropping> 32 | <%= if @stream_metadata.streaming? do %> 33 | Started:  34 | 35 | {@stream_duration} minutes ago 36 | 37 | <% else %> 38 | Stream is offline 39 | <% end %> 40 | 41 | <.dropping> 42 | 43 | {@viewers_count} viewers 44 | 45 | 46 | <.share_button /> 47 |
48 |

49 | {@stream_metadata.description} 50 |

51 |
52 |
53 |
54 | 55 |
56 |
57 | """ 58 | end 59 | 60 | defp live_dropping(assigns) do 61 | ~H""" 62 |

63 | live 64 |

65 | """ 66 | end 67 | 68 | @impl true 69 | def mount(_params, _session, socket) do 70 | if connected?(socket) do 71 | Phoenix.PubSub.subscribe(Glitchtv.PubSub, "stream_info:status") 72 | Phoenix.PubSub.subscribe(Glitchtv.PubSub, "stream_info:viewers") 73 | {:ok, _ref} = Presence.track(self(), "stream_info:viewers", inspect(self()), %{}) 74 | end 75 | 76 | metadata = Glitchtv.StreamService.get_stream_metadata() 77 | 78 | socket = 79 | Player.attach(socket, 80 | id: "player", 81 | publisher_id: "publisher", 82 | pubsub: Glitchtv.PubSub, 83 | ice_servers: [%{urls: "stun:stun.l.google.com:19302"}] 84 | # ice_ip_filter: Application.get_env(:live_broadcaster, :ice_ip_filter) 85 | ) 86 | |> assign(:page_title, "Stream") 87 | |> assign(:stream_metadata, metadata) 88 | |> assign(:viewers_count, get_viewers_count()) 89 | |> assign(:stream_duration, measure_duration(metadata.started)) 90 | 91 | {:ok, socket} 92 | end 93 | 94 | @impl Phoenix.LiveView 95 | def handle_info({:started, started}, socket) do 96 | metadata = %{socket.assigns.stream_metadata | streaming?: true, started: started} 97 | {:noreply, assign(socket, :stream_metadata, metadata)} 98 | end 99 | 100 | def handle_info({:changed, {title, description}}, socket) do 101 | metadata = %{socket.assigns.stream_metadata | title: title, description: description} 102 | {:noreply, assign(socket, :stream_metadata, metadata)} 103 | end 104 | 105 | def handle_info(:finished, socket) do 106 | metadata = %{socket.assigns.stream_metadata | streaming?: false, started: nil} 107 | {:noreply, assign(socket, :stream_metadata, metadata)} 108 | end 109 | 110 | def handle_info(:tick, socket) do 111 | socket = 112 | socket 113 | |> assign( 114 | :stream_duration, 115 | measure_duration(socket.assigns.stream_metadata.started) 116 | ) 117 | 118 | {:noreply, socket} 119 | end 120 | 121 | def handle_info(%Broadcast{event: "presence_diff"}, socket) do 122 | {:noreply, assign(socket, :viewers_count, get_viewers_count())} 123 | end 124 | 125 | def get_viewers_count() do 126 | map_size(Presence.list("stream_info:viewers")) 127 | end 128 | 129 | defp measure_duration(started_timestamp) do 130 | case started_timestamp do 131 | nil -> 132 | 0 133 | 134 | t -> 135 | DateTime.utc_now() 136 | |> DateTime.diff(t, :minute) 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /priv/static/images/elixir-webrtc-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /priv/static/images/elixir-webrtc-dark-mode-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /lib/glitchtv_web/live/chat_live.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.ChatLive do 2 | use Phoenix.LiveView 3 | 4 | attr(:socket, Phoenix.LiveView.Socket, required: true, doc: "Parent live view socket") 5 | attr(:id, :string, required: true, doc: "Component id") 6 | 7 | def live_render(assigns) do 8 | ~H""" 9 | {live_render(@socket, __MODULE__, id: @id)} 10 | """ 11 | end 12 | 13 | @impl true 14 | def render(assigns) do 15 | ~H""" 16 |
17 | 37 |
42 |
43 |
@max_msg_length - 50 && 47 | "absolute top-[-18px] right-[2px] text-xs w-full text-right text-neutral-400 dark:text-neutral-700") || 48 | "hidden" 49 | }> 50 | {String.length(@msg_body || "")}/{@max_msg_length} 51 |
52 | 62 | 63 |
64 |
65 | 66 |
67 |
68 | 69 |
70 | """ 71 | end 72 | 73 | @impl true 74 | def mount(_params, _session, socket) do 75 | if connected?(socket) do 76 | Phoenix.PubSub.subscribe(Glitchtv.PubSub, "stream_info:viewers") 77 | end 78 | 79 | socket = 80 | Publisher.attach(socket, 81 | id: "publisher", 82 | pubsub: Glitchtv.PubSub, 83 | on_connected: &on_connected/1, 84 | on_disconnected: &on_disconnected/1, 85 | on_recording_finished: &on_recording_finished/2, 86 | on_recorder_message: &on_recorder_message/2, 87 | ice_ip_filter: Application.get_env(:glitchtv, :ice_ip_filter, fn _ -> true end), 88 | ice_servers: [%{urls: "stun:stun.l.google.com:19302"}], 89 | recorder_opts: [ 90 | s3_upload_config: [bucket_name: Application.get_env(:glitchtv, :bucket_name)] 91 | ], 92 | video_codecs: @video_codecs, 93 | audio_codecs: @audio_codecs 94 | ) 95 | |> assign(:form_data, %{title: "", description: ""}) 96 | |> assign(:page_title, "Streamer Panel") 97 | |> assign(:viewers_count, StreamViewerLive.get_viewers_count()) 98 | 99 | Glitchtv.StreamService.put_stream_metadata(%{title: "", description: ""}) 100 | {:ok, socket} 101 | end 102 | 103 | @impl true 104 | def handle_event( 105 | "stream-config-update", 106 | %{"title" => title, "description" => description}, 107 | socket 108 | ) do 109 | Glitchtv.StreamService.put_stream_metadata(%{title: title, description: description}) 110 | 111 | {:noreply, socket} 112 | end 113 | 114 | def handle_event( 115 | "update-title", 116 | %{"title" => title}, 117 | socket 118 | ) do 119 | socket = 120 | socket 121 | |> assign(:form_data, %{socket.assigns.form_data | title: title}) 122 | 123 | {:noreply, socket} 124 | end 125 | 126 | def handle_event( 127 | "update-description", 128 | %{"description" => description}, 129 | socket 130 | ) do 131 | socket = 132 | socket 133 | |> assign(:form_data, %{socket.assigns.form_data | description: description}) 134 | 135 | {:noreply, socket} 136 | end 137 | 138 | defp on_connected("publisher") do 139 | Glitchtv.StreamService.stream_started() 140 | end 141 | 142 | defp on_disconnected("publisher") do 143 | Glitchtv.StreamService.stream_ended() 144 | end 145 | 146 | # Gets called before on_disconnected, so everything is OK 147 | defp on_recording_finished("publisher", {:ok, _manifest, ref}) do 148 | metadata = Glitchtv.StreamService.get_stream_metadata() 149 | 150 | if ref != nil do 151 | Glitchtv.RecordingsService.upload_started(ref, metadata) 152 | end 153 | end 154 | 155 | defp on_recorder_message( 156 | "publisher", 157 | {:ex_webrtc_recorder, _, {:upload_complete, ref, manifest}} 158 | ) do 159 | Glitchtv.RecordingsService.upload_complete(ref, manifest) 160 | end 161 | 162 | @impl Phoenix.LiveView 163 | def handle_info(%Broadcast{event: "presence_diff"}, socket) do 164 | {:noreply, assign(socket, :viewers_count, StreamViewerLive.get_viewers_count())} 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 2.0.0, 2023-02-04 4 | * https://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | currentProgress, 39 | showing, 40 | progressTimerId = null, 41 | fadeTimerId = null, 42 | delayTimerId = null, 43 | addEvent = function (elem, type, handler) { 44 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 45 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 46 | else elem["on" + type] = handler; 47 | }, 48 | options = { 49 | autoRun: true, 50 | barThickness: 3, 51 | barColors: { 52 | 0: "rgba(26, 188, 156, .9)", 53 | ".25": "rgba(52, 152, 219, .9)", 54 | ".50": "rgba(241, 196, 15, .9)", 55 | ".75": "rgba(230, 126, 34, .9)", 56 | "1.0": "rgba(211, 84, 0, .9)", 57 | }, 58 | shadowBlur: 10, 59 | shadowColor: "rgba(0, 0, 0, .6)", 60 | className: null, 61 | }, 62 | repaint = function () { 63 | canvas.width = window.innerWidth; 64 | canvas.height = options.barThickness * 5; // need space for shadow 65 | 66 | var ctx = canvas.getContext("2d"); 67 | ctx.shadowBlur = options.shadowBlur; 68 | ctx.shadowColor = options.shadowColor; 69 | 70 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 71 | for (var stop in options.barColors) 72 | lineGradient.addColorStop(stop, options.barColors[stop]); 73 | ctx.lineWidth = options.barThickness; 74 | ctx.beginPath(); 75 | ctx.moveTo(0, options.barThickness / 2); 76 | ctx.lineTo( 77 | Math.ceil(currentProgress * canvas.width), 78 | options.barThickness / 2 79 | ); 80 | ctx.strokeStyle = lineGradient; 81 | ctx.stroke(); 82 | }, 83 | createCanvas = function () { 84 | canvas = document.createElement("canvas"); 85 | var style = canvas.style; 86 | style.position = "fixed"; 87 | style.top = style.left = style.right = style.margin = style.padding = 0; 88 | style.zIndex = 100001; 89 | style.display = "none"; 90 | if (options.className) canvas.classList.add(options.className); 91 | document.body.appendChild(canvas); 92 | addEvent(window, "resize", repaint); 93 | }, 94 | topbar = { 95 | config: function (opts) { 96 | for (var key in opts) 97 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 98 | }, 99 | show: function (delay) { 100 | if (showing) return; 101 | if (delay) { 102 | if (delayTimerId) return; 103 | delayTimerId = setTimeout(() => topbar.show(), delay); 104 | } else { 105 | showing = true; 106 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 107 | if (!canvas) createCanvas(); 108 | canvas.style.opacity = 1; 109 | canvas.style.display = "block"; 110 | topbar.progress(0); 111 | if (options.autoRun) { 112 | (function loop() { 113 | progressTimerId = window.requestAnimationFrame(loop); 114 | topbar.progress( 115 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 116 | ); 117 | })(); 118 | } 119 | } 120 | }, 121 | progress: function (to) { 122 | if (typeof to === "undefined") return currentProgress; 123 | if (typeof to === "string") { 124 | to = 125 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 126 | ? currentProgress 127 | : 0) + parseFloat(to); 128 | } 129 | currentProgress = to > 1 ? 1 : to; 130 | repaint(); 131 | return currentProgress; 132 | }, 133 | hide: function () { 134 | clearTimeout(delayTimerId); 135 | delayTimerId = null; 136 | if (!showing) return; 137 | showing = false; 138 | if (progressTimerId != null) { 139 | window.cancelAnimationFrame(progressTimerId); 140 | progressTimerId = null; 141 | } 142 | (function loop() { 143 | if (topbar.progress("+.1") >= 1) { 144 | canvas.style.opacity -= 0.05; 145 | if (canvas.style.opacity <= 0.05) { 146 | canvas.style.display = "none"; 147 | fadeTimerId = null; 148 | return; 149 | } 150 | } 151 | fadeTimerId = window.requestAnimationFrame(loop); 152 | })(); 153 | }, 154 | }; 155 | 156 | if (typeof module === "object" && typeof module.exports === "object") { 157 | module.exports = topbar; 158 | } else if (typeof define === "function" && define.amd) { 159 | define(function () { 160 | return topbar; 161 | }); 162 | } else { 163 | this.topbar = topbar; 164 | } 165 | }.call(this, window, document)); 166 | -------------------------------------------------------------------------------- /priv/static/images/swm-dark-mode-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /priv/static/images/swm-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /lib/glitchtv_web/controllers/page_html/home.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 41 |
42 |
43 | 49 |

50 | Phoenix Framework 51 | 52 | v{Application.spec(:phoenix, :vsn)} 53 | 54 |

55 |

56 | Peace of mind from prototype to production. 57 |

58 |

59 | Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. 60 |

61 | 221 |
222 |
223 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, 3 | "bunch": {:hex, :bunch, "1.6.1", "5393d827a64d5f846092703441ea50e65bc09f37fd8e320878f13e63d410aec7", [:mix], [], "hexpm", "286cc3add551628b30605efbe2fca4e38cc1bea89bcd0a1a7226920b3364fe4a"}, 4 | "bunch_native": {:hex, :bunch_native, "0.5.0", "8ac1536789a597599c10b652e0b526d8833348c19e4739a0759a2bedfd924e63", [:mix], [{:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "24190c760e32b23b36edeb2dc4852515c7c5b3b8675b1a864e0715bdd1c8f80d"}, 5 | "bundlex": {:hex, :bundlex, "1.5.4", "3726acd463f4d31894a59bbc177c17f3b574634a524212f13469f41c4834a1d9", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:zarex, "~> 1.0", [hex: :zarex, repo: "hexpm", optional: false]}], "hexpm", "e745726606a560275182a8ac1c8ebd5e11a659bb7460d8abf30f397e59b4c5d2"}, 6 | "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, 7 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, 8 | "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, 9 | "crc": {:hex, :crc, "0.10.5", "ee12a7c056ac498ef2ea985ecdc9fa53c1bfb4e53a484d9f17ff94803707dfd8", [:mix, :rebar3], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3e673b6495a9525c5c641585af1accba59a1eb33de697bedf341e247012c2c7f"}, 10 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 11 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 12 | "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, 13 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 14 | "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, 15 | "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.19.0", "00030bbaba150369ff3754bbc0d2c28858e8f528ae406bf6997d1772d3a03203", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "297b16750fe229f3056fe32afd3247de308094e8b0298aef0d73a8493ce97c81"}, 16 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 17 | "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, 18 | "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"}, 19 | "ex_aws": {:hex, :ex_aws, "2.5.9", "8e2455172f0e5cbe2f56dd68de514f0dae6bb26d6b6e2f435a06434cf9dbb412", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbdb6ffb0e6c6368de05ed8641fe1376298ba23354674428e5b153a541f23359"}, 20 | "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.7", "e571424d2f345299753382f3a01b005c422b1a460a8bc3ed47659b3d3ef91e9e", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "858e51241e50181e29aa2bc128fef548873a3a9cd580471f57eda5b64dec937f"}, 21 | "ex_dtls": {:hex, :ex_dtls, "0.16.0", "3ae38025ccc77f6db573e2e391602fa9bbc02253c137d8d2d59469a66cbe806b", [:mix], [{:bundlex, "~> 1.5.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:unifex, "~> 1.0", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2a4e30d74c6ddf95cc5b796423293c06a0da295454c3823819808ff031b4b361"}, 22 | "ex_ice": {:hex, :ex_ice, "0.12.0", "b52ec3ff878d5fb632ef9facc7657dfdf59e2ff9f23e634b0918e6ce1a05af48", [:mix], [{:elixir_uuid, "~> 1.0", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}, {:ex_turn, "~> 0.2.0", [hex: :ex_turn, repo: "hexpm", optional: false]}], "hexpm", "a86024a5fbf9431082784be4bb3606d3cde9218fb325a9f208ccd6e0abfd0d73"}, 23 | "ex_libsrtp": {:hex, :ex_libsrtp, "0.7.2", "211bd89c08026943ce71f3e2c0231795b99cee748808ed3ae7b97cd8d2450b6b", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.3", [hex: :bundlex, repo: "hexpm", optional: false]}, {:membrane_precompiled_dependency_provider, "~> 0.1.0", [hex: :membrane_precompiled_dependency_provider, repo: "hexpm", optional: false]}, {:unifex, "~> 1.1", [hex: :unifex, repo: "hexpm", optional: false]}], "hexpm", "2e20645d0d739a4ecdcf8d4810a0c198120c8a2f617f2b75b2e2e704d59f492a"}, 24 | "ex_rtcp": {:hex, :ex_rtcp, "0.4.0", "f9e515462a9581798ff6413583a25174cfd2101c94a2ebee871cca7639886f0a", [:mix], [], "hexpm", "28956602cf210d692fcdaf3f60ca49681634e1deb28ace41246aee61ee22dc3b"}, 25 | "ex_rtp": {:hex, :ex_rtp, "0.4.0", "1f1b5c1440a904706011e3afbb41741f5da309ce251cb986690ce9fd82636658", [:mix], [], "hexpm", "0f72d80d5953a62057270040f0f1ee6f955c08eeae82ac659c038001d7d5a790"}, 26 | "ex_sdp": {:hex, :ex_sdp, "1.1.1", "1a7b049491e5ec02dad9251c53d960835dc5631321ae978ec331831f3e4f6d5f", [:mix], [{:bunch, "~> 1.3", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}], "hexpm", "1b13a72ac9c5c695b8824dbdffc671be8cbb4c0d1ccb4ff76a04a6826759f233"}, 27 | "ex_stun": {:hex, :ex_stun, "0.2.0", "feb1fc7db0356406655b2a617805e6c712b93308c8ea2bf0ba1197b1f0866deb", [:mix], [], "hexpm", "1e01ba8290082ccbf37acaa5190d1f69b51edd6de2026a8d6d51368b29d115d0"}, 28 | "ex_turn": {:hex, :ex_turn, "0.2.0", "4e1f9b089e9a5ee44928d12370cc9ea7a89b84b2f6256832de65271212eb80de", [:mix], [{:ex_stun, "~> 0.2.0", [hex: :ex_stun, repo: "hexpm", optional: false]}], "hexpm", "08e884f0af2c4a147e3f8cd4ffe33e3452a256389f0956e55a8c4d75bf0e74cd"}, 29 | "ex_webrtc": {:hex, :ex_webrtc, "0.12.0", "ad31fa5759c51dbdcbebe213f745de676e882145584b2f0add7d3b8c3d8a8d48", [:mix], [{:crc, "~> 0.10", [hex: :crc, repo: "hexpm", optional: false]}, {:ex_dtls, "~> 0.16.0", [hex: :ex_dtls, repo: "hexpm", optional: false]}, {:ex_ice, "~> 0.12.0", [hex: :ex_ice, repo: "hexpm", optional: false]}, {:ex_libsrtp, "~> 0.7.1", [hex: :ex_libsrtp, repo: "hexpm", optional: false]}, {:ex_rtcp, "~> 0.4.0", [hex: :ex_rtcp, repo: "hexpm", optional: false]}, {:ex_rtp, "~> 0.4.0", [hex: :ex_rtp, repo: "hexpm", optional: false]}, {:ex_sctp, "0.1.2", [hex: :ex_sctp, repo: "hexpm", optional: true]}, {:ex_sdp, "~> 1.0", [hex: :ex_sdp, repo: "hexpm", optional: false]}], "hexpm", "bb59d8593d5a2c1c4a18f78bf8b13c54951ca0255b59dcfdbaacf3a38e5f48e0"}, 30 | "ex_webrtc_recorder": {:hex, :ex_webrtc_recorder, "0.1.0", "d813fe542bb5144ac1d7f7226535dd4b0287a0730a322d4a34bc70886f60643e", [:mix], [{:ex_aws, "~> 2.5", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.5", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:ex_webrtc, "~> 0.12.0", [hex: :ex_webrtc, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "3b6183b2da994f9b55ce4a33a9440f6274eea3457983484849933c6731ecbda3"}, 31 | "exqlite": {:hex, :exqlite, "0.29.0", "e6f1de4bfe3ce6e4c4260b15fef830705fa36632218dc7eafa0a5aba3a5d6e04", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a75f8a069fcdad3e5f95dfaddccd13c2112ea3b742fdcc234b96410e9c1bde00"}, 32 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 33 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 34 | "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, 35 | "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, 36 | "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, 37 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 38 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 39 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 40 | "live_ex_webrtc": {:git, "https://github.com/elixir-webrtc/live_ex_webrtc.git", "d167b988e75fad2d03077037fa73845053b8bf32", []}, 41 | "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.1.2", "8af73b7dc15ba55c9f5fbfc0453d4a8edfb007ade54b56c37d626be0d1189aba", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "7fe3e07361510445a29bee95336adde667c4162b76b7f4c8af3aeb3415292023"}, 42 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 43 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 44 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 45 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 46 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 47 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 48 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 49 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, 50 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, 51 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, 52 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.6", "7b1f0327f54c9eb69845fd09a77accf922f488c549a7e7b8618775eb603a62c7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1681ab813ec26ca6915beb3414aa138f298e17721dc6a2bde9e6eb8a62360ff6"}, 53 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, 54 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.9", "4dc5e535832733df68df22f9de168b11c0c74bca65b27b088a10ac36dfb75d04", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1dccb04ec8544340e01608e108f32724458d0ac4b07e551406b3b920c40ba2e5"}, 55 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 56 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 57 | "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, 58 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 59 | "qex": {:hex, :qex, "0.5.1", "0d82c0f008551d24fffb99d97f8299afcb8ea9cf99582b770bd004ed5af63fd6", [:mix], [], "hexpm", "935a39fdaf2445834b95951456559e9dc2063d0a055742c558a99987b38d6bab"}, 60 | "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, 61 | "shmex": {:hex, :shmex, "0.5.1", "81dd209093416bf6608e66882cb7e676089307448a1afd4fc906c1f7e5b94cf4", [:mix], [{:bunch_native, "~> 0.5.0", [hex: :bunch_native, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "c29f8286891252f64c4e1dac40b217d960f7d58def597c4e606ff8fbe71ceb80"}, 62 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 63 | "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, 64 | "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"}, 65 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 66 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, 67 | "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, 68 | "thousand_island": {:hex, :thousand_island, "1.3.12", "590ff651a6d2a59ed7eabea398021749bdc664e2da33e0355e6c64e7e1a2ef93", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "55d0b1c868b513a7225892b8a8af0234d7c8981a51b0740369f3125f7c99a549"}, 69 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 70 | "unifex": {:hex, :unifex, "1.2.1", "6841c170a6e16509fac30b19e4e0a19937c33155a59088b50c15fc2c36251b6b", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}, {:shmex, "~> 0.5.0", [hex: :shmex, repo: "hexpm", optional: false]}], "hexpm", "8c9d2e3c48df031e9995dd16865bab3df402c0295ba3a31f38274bb5314c7d37"}, 71 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 72 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 73 | "zarex": {:hex, :zarex, "1.0.5", "58239e3ee5d75f343262bb4df5cf466555a1c689f920e5d3651a9333972f7c7e", [:mix], [], "hexpm", "9fb72ef0567c2b2742f5119a1ba8a24a2fabb21b8d09820aefbf3e592fa9a46a"}, 74 | } 75 | -------------------------------------------------------------------------------- /lib/glitchtv_web/components/core_components.ex: -------------------------------------------------------------------------------- 1 | defmodule GlitchtvWeb.CoreComponents do 2 | @moduledoc """ 3 | Provides core UI components. 4 | 5 | At first glance, this module may seem daunting, but its goal is to provide 6 | core building blocks for your application, such as modals, tables, and 7 | forms. The components consist mostly of markup and are well-documented 8 | with doc strings and declarative assigns. You may customize and style 9 | them in any way you want, based on your application growth and needs. 10 | 11 | The default components use Tailwind CSS, a utility-first CSS framework. 12 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn 13 | how to customize them or feel free to swap in another framework altogether. 14 | 15 | Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. 16 | """ 17 | use Phoenix.Component 18 | 19 | alias Phoenix.LiveView.JS 20 | 21 | @doc """ 22 | Renders a modal. 23 | 24 | ## Examples 25 | 26 | <.modal id="confirm-modal"> 27 | This is a modal. 28 | 29 | 30 | JS commands may be passed to the `:on_cancel` to configure 31 | the closing/cancel event, for example: 32 | 33 | <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> 34 | This is another modal. 35 | 36 | 37 | """ 38 | attr :id, :string, required: true 39 | attr :show, :boolean, default: false 40 | attr :on_cancel, JS, default: %JS{} 41 | slot :inner_block, required: true 42 | 43 | def modal(assigns) do 44 | ~H""" 45 | 326 | """ 327 | end 328 | 329 | def input(%{type: "select"} = assigns) do 330 | ~H""" 331 |
332 | <.label for={@id}>{@label} 333 | 343 | <.error :for={msg <- @errors}>{msg} 344 |
345 | """ 346 | end 347 | 348 | def input(%{type: "textarea"} = assigns) do 349 | ~H""" 350 |
351 | <.label for={@id}>{@label} 352 | 362 | <.error :for={msg <- @errors}>{msg} 363 |
364 | """ 365 | end 366 | 367 | # All other inputs text, datetime-local, url, password, etc. are handled here... 368 | def input(assigns) do 369 | ~H""" 370 |
371 | <.label for={@id}>{@label} 372 | 384 | <.error :for={msg <- @errors}>{msg} 385 |
386 | """ 387 | end 388 | 389 | @doc """ 390 | Renders a label. 391 | """ 392 | attr :for, :string, default: nil 393 | slot :inner_block, required: true 394 | 395 | def label(assigns) do 396 | ~H""" 397 | 400 | """ 401 | end 402 | 403 | @doc """ 404 | Generates a generic error message. 405 | """ 406 | slot :inner_block, required: true 407 | 408 | def error(assigns) do 409 | ~H""" 410 |

411 | <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> 412 | {render_slot(@inner_block)} 413 |

414 | """ 415 | end 416 | 417 | @doc """ 418 | Renders a header with title. 419 | """ 420 | attr :class, :string, default: nil 421 | 422 | slot :inner_block, required: true 423 | slot :subtitle 424 | slot :actions 425 | 426 | def header(assigns) do 427 | ~H""" 428 |
429 |
430 |

431 | {render_slot(@inner_block)} 432 |

433 |

434 | {render_slot(@subtitle)} 435 |

436 |
437 |
{render_slot(@actions)}
438 |
439 | """ 440 | end 441 | 442 | @doc ~S""" 443 | Renders a table with generic styling. 444 | 445 | ## Examples 446 | 447 | <.table id="users" rows={@users}> 448 | <:col :let={user} label="id">{user.id} 449 | <:col :let={user} label="username">{user.username} 450 | 451 | """ 452 | attr :id, :string, required: true 453 | attr :rows, :list, required: true 454 | attr :row_id, :any, default: nil, doc: "the function for generating the row id" 455 | attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" 456 | 457 | attr :row_item, :any, 458 | default: &Function.identity/1, 459 | doc: "the function for mapping each row before calling the :col and :action slots" 460 | 461 | slot :col, required: true do 462 | attr :label, :string 463 | end 464 | 465 | slot :action, doc: "the slot for showing user actions in the last table column" 466 | 467 | def table(assigns) do 468 | assigns = 469 | with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do 470 | assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) 471 | end 472 | 473 | ~H""" 474 |
475 | 476 | 477 | 478 | 479 | 482 | 483 | 484 | 489 | 490 | 502 | 513 | 514 | 515 |
{col[:label]} 480 | Actions 481 |
495 |
496 | 497 | 498 | {render_slot(col, @row_item.(row))} 499 | 500 |
501 |
503 |
504 | 505 | 509 | {render_slot(action, @row_item.(row))} 510 | 511 |
512 |
516 |
517 | """ 518 | end 519 | 520 | @doc """ 521 | Renders a data list. 522 | 523 | ## Examples 524 | 525 | <.list> 526 | <:item title="Title">{@post.title} 527 | <:item title="Views">{@post.views} 528 | 529 | """ 530 | slot :item, required: true do 531 | attr :title, :string, required: true 532 | end 533 | 534 | def list(assigns) do 535 | ~H""" 536 |
537 |
538 |
539 |
{item.title}
540 |
{render_slot(item)}
541 |
542 |
543 |
544 | """ 545 | end 546 | 547 | @doc """ 548 | Renders a back navigation link. 549 | 550 | ## Examples 551 | 552 | <.back navigate={~p"/posts"}>Back to posts 553 | """ 554 | attr :navigate, :any, required: true 555 | slot :inner_block, required: true 556 | 557 | def back(assigns) do 558 | ~H""" 559 |
560 | <.link 561 | navigate={@navigate} 562 | class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" 563 | > 564 | <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> 565 | {render_slot(@inner_block)} 566 | 567 |
568 | """ 569 | end 570 | 571 | @doc """ 572 | Renders a [Heroicon](https://heroicons.com). 573 | 574 | Heroicons come in three styles – outline, solid, and mini. 575 | By default, the outline style is used, but solid and mini may 576 | be applied by using the `-solid` and `-mini` suffix. 577 | 578 | You can customize the size and colors of the icons by setting 579 | width, height, and background color classes. 580 | 581 | Icons are extracted from the `deps/heroicons` directory and bundled within 582 | your compiled app.css by the plugin in your `assets/tailwind.config.js`. 583 | 584 | ## Examples 585 | 586 | <.icon name="hero-x-mark-solid" /> 587 | <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> 588 | """ 589 | attr :name, :string, required: true 590 | attr :class, :string, default: nil 591 | 592 | def icon(%{name: "hero-" <> _} = assigns) do 593 | ~H""" 594 | 595 | """ 596 | end 597 | 598 | ## JS Commands 599 | 600 | def show(js \\ %JS{}, selector) do 601 | JS.show(js, 602 | to: selector, 603 | time: 300, 604 | transition: 605 | {"transition-all transform ease-out duration-300", 606 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", 607 | "opacity-100 translate-y-0 sm:scale-100"} 608 | ) 609 | end 610 | 611 | def hide(js \\ %JS{}, selector) do 612 | JS.hide(js, 613 | to: selector, 614 | time: 200, 615 | transition: 616 | {"transition-all transform ease-in duration-200", 617 | "opacity-100 translate-y-0 sm:scale-100", 618 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} 619 | ) 620 | end 621 | 622 | def show_modal(js \\ %JS{}, id) when is_binary(id) do 623 | js 624 | |> JS.show(to: "##{id}") 625 | |> JS.show( 626 | to: "##{id}-bg", 627 | time: 300, 628 | transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} 629 | ) 630 | |> show("##{id}-container") 631 | |> JS.add_class("overflow-hidden", to: "body") 632 | |> JS.focus_first(to: "##{id}-content") 633 | end 634 | 635 | def hide_modal(js \\ %JS{}, id) do 636 | js 637 | |> JS.hide( 638 | to: "##{id}-bg", 639 | transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} 640 | ) 641 | |> hide("##{id}-container") 642 | |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) 643 | |> JS.remove_class("overflow-hidden", to: "body") 644 | |> JS.pop_focus() 645 | end 646 | 647 | @doc """ 648 | Translates an error message using gettext. 649 | """ 650 | def translate_error({msg, opts}) do 651 | # You can make use of gettext to translate error messages by 652 | # uncommenting and adjusting the following code: 653 | 654 | # if count = opts[:count] do 655 | # Gettext.dngettext(GlitchtvWeb.Gettext, "errors", msg, msg, count, opts) 656 | # else 657 | # Gettext.dgettext(GlitchtvWeb.Gettext, "errors", msg, opts) 658 | # end 659 | 660 | Enum.reduce(opts, msg, fn {key, value}, acc -> 661 | String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 662 | end) 663 | end 664 | 665 | @doc """ 666 | Translates the errors for a field from a keyword list of errors. 667 | """ 668 | def translate_errors(errors, field) when is_list(errors) do 669 | for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) 670 | end 671 | 672 | attr :class, :string, default: nil 673 | slot :inner_block, required: true 674 | 675 | def dropping(assigns) do 676 | ~H""" 677 |
681 | {render_slot(@inner_block)} 682 |
683 | """ 684 | end 685 | 686 | def share_button(assigns) do 687 | ~H""" 688 | 695 | """ 696 | end 697 | end 698 | --------------------------------------------------------------------------------