This commit is contained in:
commit
ae3224c18b
30 changed files with 2455 additions and 0 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
@ -0,0 +1,12 @@
|
|||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
5
.env.example
Normal file
5
.env.example
Normal file
|
@ -0,0 +1,5 @@
|
|||
# NODE_ENV=development
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
SRS_URL=
|
24
.forgejo/workflows/biomejs.yml
Normal file
24
.forgejo/workflows/biomejs.yml
Normal file
|
@ -0,0 +1,24 @@
|
|||
name: Code quality checks
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
biome:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Bun
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
export BUN_INSTALL="$HOME/.bun"
|
||||
echo "$BUN_INSTALL/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install Dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Run Biome with verbose output
|
||||
run: bunx biome ci . --verbose
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* text=auto eol=lf
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/node_modules
|
||||
bun.lock
|
||||
logs
|
||||
public/custom
|
||||
.env
|
28
LICENSE
Normal file
28
LICENSE
Normal file
|
@ -0,0 +1,28 @@
|
|||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2025, creations.works
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
50
biome.json
Normal file
50
biome.json
Normal file
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"ignore": ["dist", "public/js/flv.min.js", "public/js/flv.min.js.map"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"css": {
|
||||
"formatter": {
|
||||
"indentStyle": "tab",
|
||||
"lineEnding": "lf"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"noUnusedImports": "error",
|
||||
"noUnusedVariables": "error"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "error",
|
||||
"noVar": "error"
|
||||
}
|
||||
},
|
||||
"ignore": ["types"]
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"indentStyle": "tab",
|
||||
"lineEnding": "lf",
|
||||
"jsxQuoteStyle": "double",
|
||||
"semicolons": "always"
|
||||
}
|
||||
}
|
||||
}
|
30
config/index.ts
Normal file
30
config/index.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { echo } from "@atums/echo";
|
||||
|
||||
const environment: Environment = {
|
||||
port: Number.parseInt(process.env.PORT || "8080", 10),
|
||||
host: process.env.HOST || "0.0.0.0",
|
||||
development:
|
||||
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
|
||||
};
|
||||
|
||||
const srsUrl = process.env.SRS_URL;
|
||||
|
||||
function verifyRequiredVariables(): void {
|
||||
const requiredVariables = ["HOST", "PORT", "SRS_URL"];
|
||||
|
||||
let hasError = false;
|
||||
|
||||
for (const key of requiredVariables) {
|
||||
const value = process.env[key];
|
||||
if (value === undefined || value.trim() === "") {
|
||||
echo.error(`Missing or empty environment variable: ${key}`);
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export { environment, verifyRequiredVariables, srsUrl };
|
39
logger.json
Normal file
39
logger.json
Normal file
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"directory": "logs",
|
||||
"level": "debug",
|
||||
"disableFile": false,
|
||||
|
||||
"rotate": true,
|
||||
"maxFiles": 3,
|
||||
|
||||
"console": true,
|
||||
"consoleColor": true,
|
||||
|
||||
"dateFormat": "yyyy-MM-dd HH:mm:ss.SSS",
|
||||
"timezone": "local",
|
||||
|
||||
"silent": false,
|
||||
|
||||
"pattern": "{color:gray}{pretty-timestamp}{reset} {color:levelColor}[{level-name}]{reset} {color:gray}({reset}{file-name}:{color:blue}{line}{reset}:{color:blue}{column}{color:gray}){reset} {data}",
|
||||
"levelColor": {
|
||||
"debug": "blue",
|
||||
"info": "green",
|
||||
"warn": "yellow",
|
||||
"error": "red",
|
||||
"fatal": "red"
|
||||
},
|
||||
|
||||
"customPattern": "{color:gray}{pretty-timestamp}{reset} {color:tagColor}[{tag}]{reset} {color:contextColor}({context}){reset} {data}",
|
||||
"customColors": {
|
||||
"GET": "green",
|
||||
"POST": "blue",
|
||||
"PUT": "yellow",
|
||||
"DELETE": "red",
|
||||
"PATCH": "cyan",
|
||||
"HEAD": "magenta",
|
||||
"OPTIONS": "white",
|
||||
"TRACE": "gray"
|
||||
},
|
||||
|
||||
"prettyPrint": true
|
||||
}
|
22
package.json
Normal file
22
package.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "bun_frontend_template",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/index.ts",
|
||||
"dev": "bun run --hot src/index.ts --dev",
|
||||
"lint": "bunx biome check",
|
||||
"lint:fix": "bunx biome check --fix",
|
||||
"cleanup": "rm -rf logs node_modules bun.lockdb"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@biomejs/biome": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atums/echo": "latest"
|
||||
}
|
||||
}
|
BIN
public/assets/favicon.ico
Normal file
BIN
public/assets/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
327
public/css/style.css
Normal file
327
public/css/style.css
Normal file
|
@ -0,0 +1,327 @@
|
|||
:root[data-theme="dark"] {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #1f2937;
|
||||
--bg-card: rgba(22, 27, 34, 0.95);
|
||||
--bg-card-hover: rgba(31, 38, 49, 0.95);
|
||||
--bg-input: rgba(22, 27, 34, 0.85);
|
||||
--bg-stat: rgba(31, 38, 49, 0.75);
|
||||
|
||||
--border-primary: #30363d;
|
||||
--border-secondary: #444c56;
|
||||
--border-accent: #58a6ff;
|
||||
|
||||
--text-primary: #c9d1d9;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--text-accent: #58a6ff;
|
||||
|
||||
--accent-blue: #58a6ff;
|
||||
--accent-purple: #a371f7;
|
||||
--accent-green: #3fb950;
|
||||
--accent-red: #f85149;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 10px 10px -5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-blue);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid var(--border-primary);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: var(--bg-stat);
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--text-accent);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-primary);
|
||||
border-top: 4px solid var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
color: var(--accent-red);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: var(--accent-blue);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.streams-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stream-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(15px);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stream-card:hover {
|
||||
transform: translateY(-4px);
|
||||
background: var(--bg-card-hover);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-color: var(--border-accent);
|
||||
}
|
||||
|
||||
.stream-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stream-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.watch-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
background: var(--accent-green);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 0.375rem;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.watch-btn:hover {
|
||||
opacity: 1;
|
||||
background: #16a34a;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.stream-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.stream-info {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.stream-url {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
font-family: "SF Mono", "Monaco", "Menlo", "Ubuntu Mono", monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #93c5fd;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stream-watch-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stream-url:hover {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.stream-url:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.streams-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stream-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stream-header-actions {
|
||||
margin-left: 0;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
346
public/css/watch.css
Normal file
346
public/css/watch.css
Normal file
|
@ -0,0 +1,346 @@
|
|||
.watch-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.watch-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.watch-header h1 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stream-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.loading-overlay,
|
||||
.error-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(15px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-content svg {
|
||||
color: var(--accent-red);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-content h3 {
|
||||
color: var(--accent-red);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-content p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stream-status-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 2rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stream-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 9999px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stream-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: .5px solid var(--border-primary);
|
||||
border-radius: 1rem;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stream-info-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(15px);
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.url-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.url-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.url-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.url-value {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
font-family: "SF Mono", "Monaco", "Menlo", "Ubuntu Mono", monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #93c5fd;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.url-value:hover {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.url-value:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.watch-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.watch-header h1 {
|
||||
font-size: 1.5rem;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
aspect-ratio: 16 / 10;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stream-status-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stream-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.stream-info-panel {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stream-controls {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stream-stats {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
51
public/index.html
Normal file
51
public/index.html
Normal file
|
@ -0,0 +1,51 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stream Dashboard</title>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<link rel="stylesheet" href="/public/css/style.css" />
|
||||
<script type="module" src="/public/js/index.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1 class="title">Live Streams</h1>
|
||||
<div class="search-container">
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="Search streams..." />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Streams:</span>
|
||||
<span id="totalStreams" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Online:</span>
|
||||
<span id="onlineStreams" class="stat-value">0</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="loadingState" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading streams...</p>
|
||||
</section>
|
||||
|
||||
<section id="errorState" class="error-state" style="display: none;">
|
||||
<p>Failed to load streams. Please try again.</p>
|
||||
<button id="retryBtn" class="retry-btn">Retry</button>
|
||||
</section>
|
||||
|
||||
<section id="streamsContainer" class="streams-grid" style="display: none;"></section>
|
||||
|
||||
<section id="noResults" class="no-results" style="display: none;">
|
||||
<p>No streams found matching your search.</p>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
10
public/js/flv.min.js
vendored
Normal file
10
public/js/flv.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/flv.min.js.map
Normal file
1
public/js/flv.min.js.map
Normal file
File diff suppressed because one or more lines are too long
226
public/js/index.js
Normal file
226
public/js/index.js
Normal file
|
@ -0,0 +1,226 @@
|
|||
class StreamDashboard {
|
||||
constructor() {
|
||||
this.streams = [];
|
||||
this.filteredStreams = [];
|
||||
this.searchTimeout = null;
|
||||
this.refreshInterval = null;
|
||||
|
||||
this.initializeElements();
|
||||
this.bindEvents();
|
||||
this.loadStreams();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
initializeElements() {
|
||||
this.elements = {
|
||||
searchInput: document.getElementById("searchInput"),
|
||||
totalStreams: document.getElementById("totalStreams"),
|
||||
onlineStreams: document.getElementById("onlineStreams"),
|
||||
loadingState: document.getElementById("loadingState"),
|
||||
errorState: document.getElementById("errorState"),
|
||||
streamsContainer: document.getElementById("streamsContainer"),
|
||||
noResults: document.getElementById("noResults"),
|
||||
retryBtn: document.getElementById("retryBtn"),
|
||||
};
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.elements.searchInput.addEventListener("input", (e) => {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.filterStreams(e.target.value);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
this.elements.retryBtn.addEventListener("click", () => {
|
||||
this.loadStreams();
|
||||
});
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
this.stopAutoRefresh();
|
||||
} else {
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.loadStreams(true);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadStreams(silentRefresh = false) {
|
||||
if (!silentRefresh) {
|
||||
this.showLoading();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/streams");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to load streams");
|
||||
}
|
||||
|
||||
const allStreams = result.data.streams || [];
|
||||
this.streams = allStreams.filter(
|
||||
(stream) => stream.publish?.active === true,
|
||||
);
|
||||
this.filteredStreams = [...this.streams];
|
||||
|
||||
this.updateStats();
|
||||
this.renderStreams();
|
||||
|
||||
if (!silentRefresh) {
|
||||
this.showStreams();
|
||||
}
|
||||
} catch {
|
||||
if (!silentRefresh) {
|
||||
this.showError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filterStreams(searchTerm) {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
|
||||
if (!term) {
|
||||
this.filteredStreams = [...this.streams];
|
||||
} else {
|
||||
this.filteredStreams = this.streams.filter(
|
||||
(stream) =>
|
||||
stream.name?.toLowerCase().includes(term) ||
|
||||
stream.url?.toLowerCase().includes(term) ||
|
||||
stream.app?.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
this.renderStreams();
|
||||
|
||||
if (this.filteredStreams.length === 0 && term) {
|
||||
this.showNoResults();
|
||||
} else {
|
||||
this.showStreams();
|
||||
}
|
||||
}
|
||||
|
||||
updateStats() {
|
||||
const total = this.streams.length;
|
||||
|
||||
this.elements.totalStreams.textContent = total;
|
||||
this.elements.onlineStreams.textContent = total;
|
||||
}
|
||||
|
||||
renderStreams() {
|
||||
this.elements.streamsContainer.innerHTML = "";
|
||||
|
||||
for (const stream of this.filteredStreams) {
|
||||
const streamCard = this.createStreamCard(stream);
|
||||
this.elements.streamsContainer.appendChild(streamCard);
|
||||
}
|
||||
}
|
||||
|
||||
createStreamCard(stream) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "stream-card";
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (!bytes) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const watchName = stream.name?.replace(/\/live\//, "/");
|
||||
const watchUrl = watchName
|
||||
? `${window.location.origin}/watch/${encodeURIComponent(watchName)}`
|
||||
: "Unknown Stream";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="stream-header">
|
||||
<h3 class="stream-name">${this.escapeHtml(stream.name || "Unknown Stream")}</h3>
|
||||
</div>
|
||||
|
||||
<div class="stream-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Clients:</span>
|
||||
<span class="info-value">${stream.clients || 0}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Bitrate:</span>
|
||||
<span class="info-value">${formatBytes((stream.kbps?.recv_30s || 0) * 1024)}/s</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Video:</span>
|
||||
<span class="info-value">${stream.video?.codec || "N/A"} ${stream.video?.profile || ""}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Audio:</span>
|
||||
<span class="info-value">${stream.audio?.codec || "N/A"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="${watchUrl}" class="stream-watch-link">
|
||||
<div class="stream-url" onclick="navigator.clipboard.writeText('${this.escapeHtml(watchUrl)}').then(() => this.style.background = 'rgba(34, 197, 94, 0.2)').catch(() => {})" title="Click to copy">
|
||||
<span class="stream-url-value">${this.escapeHtml(watchUrl)}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.elements.loadingState.style.display = "block";
|
||||
this.elements.errorState.style.display = "none";
|
||||
this.elements.streamsContainer.style.display = "none";
|
||||
this.elements.noResults.style.display = "none";
|
||||
}
|
||||
|
||||
showError() {
|
||||
this.elements.loadingState.style.display = "none";
|
||||
this.elements.errorState.style.display = "block";
|
||||
this.elements.streamsContainer.style.display = "none";
|
||||
this.elements.noResults.style.display = "none";
|
||||
}
|
||||
|
||||
showStreams() {
|
||||
this.elements.loadingState.style.display = "none";
|
||||
this.elements.errorState.style.display = "none";
|
||||
this.elements.streamsContainer.style.display = "grid";
|
||||
this.elements.noResults.style.display = "none";
|
||||
}
|
||||
|
||||
showNoResults() {
|
||||
this.elements.loadingState.style.display = "none";
|
||||
this.elements.errorState.style.display = "none";
|
||||
this.elements.streamsContainer.style.display = "none";
|
||||
this.elements.noResults.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
new StreamDashboard();
|
631
public/js/watch.js
Normal file
631
public/js/watch.js
Normal file
|
@ -0,0 +1,631 @@
|
|||
class StreamWatcher {
|
||||
constructor() {
|
||||
this.streamId = this.getStreamId();
|
||||
this.srsUrl = this.getSrsUrl();
|
||||
this.streamData = null;
|
||||
this.refreshInterval = null;
|
||||
this.isFullscreen = false;
|
||||
this.player = null;
|
||||
this.isDestroyed = false;
|
||||
this.retryCount = 0;
|
||||
this.maxRetries = 3;
|
||||
this.userInteracted = false;
|
||||
this.pendingPlay = false;
|
||||
|
||||
this.initializeElements();
|
||||
this.bindEvents();
|
||||
this.setupUserInteractionDetection();
|
||||
this.loadStreamData();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
getStreamId() {
|
||||
const metaTag = document.querySelector('meta[name="stream-id"]');
|
||||
if (!metaTag) {
|
||||
throw new Error("Stream ID not found in meta tags");
|
||||
}
|
||||
return metaTag.content;
|
||||
}
|
||||
|
||||
getSrsUrl() {
|
||||
const metaTag = document.querySelector('meta[name="srs-url"]');
|
||||
if (!metaTag) {
|
||||
throw new Error("SRS URL not found in meta tags");
|
||||
}
|
||||
return metaTag.content;
|
||||
}
|
||||
|
||||
initializeElements() {
|
||||
this.elements = {
|
||||
streamTitle: document.getElementById("streamTitle"),
|
||||
loadingState: document.getElementById("loadingState"),
|
||||
errorState: document.getElementById("errorState"),
|
||||
errorMessage: document.getElementById("errorMessage"),
|
||||
retryBtn: document.getElementById("retryBtn"),
|
||||
videoPlayer: document.getElementById("videoPlayer"),
|
||||
streamStatus: document.getElementById("streamStatus"),
|
||||
streamStats: document.getElementById("streamStats"),
|
||||
viewerCount: document.getElementById("viewerCount"),
|
||||
bitrate: document.getElementById("bitrate"),
|
||||
quality: document.getElementById("quality"),
|
||||
streamId: document.getElementById("streamId"),
|
||||
streamApp: document.getElementById("streamApp"),
|
||||
streamDuration: document.getElementById("streamDuration"),
|
||||
videoCodec: document.getElementById("videoCodec"),
|
||||
audioCodec: document.getElementById("audioCodec"),
|
||||
resolution: document.getElementById("resolution"),
|
||||
rtmpUrl: document.getElementById("rtmpUrl"),
|
||||
hlsUrl: document.getElementById("hlsUrl"),
|
||||
flvUrl: document.getElementById("flvUrl"),
|
||||
webrtcUrl: document.getElementById("webrtcUrl"),
|
||||
videoContainer: document.querySelector(".video-container"),
|
||||
};
|
||||
}
|
||||
|
||||
setupUserInteractionDetection() {
|
||||
const events = ["click", "touchstart", "keydown", "scroll"];
|
||||
const handleInteraction = () => {
|
||||
this.userInteracted = true;
|
||||
if (this.pendingPlay && this.elements.videoPlayer) {
|
||||
this.attemptAutoplay();
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
document.removeEventListener(event, handleInteraction, {
|
||||
once: true,
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
document.addEventListener(event, handleInteraction, {
|
||||
once: true,
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.elements.retryBtn.addEventListener("click", () => {
|
||||
this.retryCount = 0;
|
||||
this.userInteracted = true;
|
||||
this.loadStreamData();
|
||||
});
|
||||
|
||||
this.elements.videoContainer.addEventListener("click", () => {
|
||||
this.userInteracted = true;
|
||||
if (this.elements.videoPlayer?.paused) {
|
||||
this.attemptAutoplay();
|
||||
}
|
||||
});
|
||||
|
||||
const urlElements = [
|
||||
this.elements.rtmpUrl,
|
||||
this.elements.hlsUrl,
|
||||
this.elements.flvUrl,
|
||||
this.elements.webrtcUrl,
|
||||
];
|
||||
|
||||
for (const element of urlElements) {
|
||||
element.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const url = element.textContent.trim();
|
||||
if (url !== "-") {
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
element.style.background = "rgba(34, 197, 94, 0.2)";
|
||||
setTimeout(() => {
|
||||
element.style.background = "";
|
||||
}, 1000);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
this.stopAutoRefresh();
|
||||
} else {
|
||||
this.startAutoRefresh();
|
||||
setTimeout(() => this.loadStreamData(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("fullscreenchange", () => {
|
||||
this.isFullscreen = !!document.fullscreenElement;
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
this.cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
async loadStreamData() {
|
||||
const wasPlaying =
|
||||
this.elements.videoPlayer && !this.elements.videoPlayer.paused;
|
||||
|
||||
if (!wasPlaying) {
|
||||
this.showLoading();
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const response = await fetch("/api/streams", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Failed to load streams");
|
||||
}
|
||||
|
||||
const streams = result.data.streams || [];
|
||||
const newStreamData = streams.find(
|
||||
(stream) =>
|
||||
stream.name === this.streamId || stream.id === this.streamId,
|
||||
);
|
||||
|
||||
if (!newStreamData) {
|
||||
this.elements.streamTitle.textContent = `No stream found for: ${this.streamId}`;
|
||||
throw new Error("Stream not found");
|
||||
}
|
||||
|
||||
const wasOnline = this.streamData?.publish?.active === true;
|
||||
const isOnline = newStreamData.publish?.active === true;
|
||||
|
||||
this.streamData = newStreamData;
|
||||
this.updateStreamInfo();
|
||||
|
||||
if (wasOnline !== isOnline || !this.player) {
|
||||
await this.setupVideoPlayer();
|
||||
}
|
||||
|
||||
this.retryCount = 0;
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") {
|
||||
this.showError("Request timeout - please check your connection");
|
||||
} else {
|
||||
this.showError(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateStreamInfo() {
|
||||
const stream = this.streamData;
|
||||
const isOnline = stream.publish?.active === true;
|
||||
|
||||
this.elements.streamTitle.textContent =
|
||||
stream.name || `Stream ${this.streamId}`;
|
||||
|
||||
this.elements.streamStats.style.display = isOnline ? "flex" : "none";
|
||||
|
||||
if (isOnline) {
|
||||
this.elements.viewerCount.textContent = stream.clients || 0;
|
||||
this.elements.bitrate.textContent = this.formatBitrate(
|
||||
stream.kbps?.recv_30s || 0,
|
||||
);
|
||||
this.elements.quality.textContent = this.getQualityString(stream);
|
||||
}
|
||||
|
||||
this.elements.streamId.textContent = stream.name || this.streamId;
|
||||
this.elements.streamApp.textContent = stream.app || "-";
|
||||
this.elements.streamDuration.textContent = isOnline
|
||||
? this.formatDuration(stream.publish?.duration)
|
||||
: "-";
|
||||
this.elements.videoCodec.textContent = stream.video
|
||||
? `${stream.video.codec} ${stream.video.profile || ""}`.trim()
|
||||
: "-";
|
||||
this.elements.audioCodec.textContent = stream.audio?.codec || "-";
|
||||
this.elements.resolution.textContent = stream.video
|
||||
? `${stream.video.width || "?"}x${stream.video.height || "?"}`
|
||||
: "-";
|
||||
|
||||
this.updateStreamUrls(stream);
|
||||
}
|
||||
|
||||
updateStreamUrls(stream) {
|
||||
const srsHost = new URL(this.srsUrl).host;
|
||||
const streamPath = `${stream.app}/${stream.name}`;
|
||||
|
||||
this.elements.rtmpUrl.textContent = `rtmp://${srsHost}/${streamPath}`;
|
||||
this.elements.hlsUrl.textContent = `${this.srsUrl}/${streamPath}.m3u8`;
|
||||
this.elements.flvUrl.textContent = `${this.srsUrl}/${streamPath}.flv`;
|
||||
this.elements.webrtcUrl.textContent = `webrtc://${srsHost}/${streamPath}`;
|
||||
}
|
||||
|
||||
async setupVideoPlayer() {
|
||||
const stream = this.streamData;
|
||||
|
||||
if (!stream.publish?.active) {
|
||||
this.showError("Stream is currently offline");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.player &&
|
||||
this.elements.videoPlayer &&
|
||||
!this.elements.videoPlayer.paused
|
||||
) {
|
||||
this.showVideo();
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupPlayer();
|
||||
|
||||
const videoElement = this.elements.videoPlayer;
|
||||
const hlsUrl = `${this.srsUrl}/${stream.app}/${stream.name}.m3u8`;
|
||||
const flvUrl = `${this.srsUrl}/${stream.app}/${stream.name}.flv`;
|
||||
|
||||
const hasFlvjs = typeof flvjs !== "undefined";
|
||||
if (hasFlvjs && flvjs.isSupported()) {
|
||||
await this.setupFlvjsPlayer(videoElement, flvUrl, hlsUrl);
|
||||
} else {
|
||||
await this.setupNativePlayer(videoElement, hlsUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async setupFlvjsPlayer(videoElement, flvUrl, hlsUrl) {
|
||||
try {
|
||||
this.player = flvjs.createPlayer(
|
||||
{
|
||||
type: "flv",
|
||||
url: flvUrl,
|
||||
isLive: true,
|
||||
cors: true,
|
||||
},
|
||||
{
|
||||
enableWorker: false,
|
||||
enableStashBuffer: false,
|
||||
stashInitialSize: 128,
|
||||
isLive: true,
|
||||
lazyLoad: false,
|
||||
lazyLoadMaxDuration: 0,
|
||||
lazyLoadRecoverDuration: 0,
|
||||
deferLoadAfterSourceOpen: false,
|
||||
autoCleanupMaxBackwardDuration: 2,
|
||||
autoCleanupMinBackwardDuration: 1,
|
||||
statisticsInfoReportInterval: 1000,
|
||||
fixAudioTimestampGap: true,
|
||||
accurateSeek: false,
|
||||
seekType: "range",
|
||||
liveBufferLatencyChasing: true,
|
||||
liveBufferLatencyMaxLatency: 1.5,
|
||||
liveBufferLatencyMinRemain: 0.3,
|
||||
},
|
||||
);
|
||||
|
||||
const newVideoElement = this.setupVideoEvents(videoElement);
|
||||
|
||||
this.player.on(flvjs.Events.ERROR, (errorType, errorDetail) => {
|
||||
console.warn(
|
||||
"FLV.js error, falling back to HLS:",
|
||||
errorType,
|
||||
errorDetail,
|
||||
);
|
||||
this.cleanupPlayer();
|
||||
setTimeout(() => this.setupNativePlayer(newVideoElement, hlsUrl), 100);
|
||||
});
|
||||
|
||||
this.player.attachMediaElement(newVideoElement);
|
||||
|
||||
const loadPromise = new Promise((resolve, reject) => {
|
||||
const loadTimeout = setTimeout(() => {
|
||||
reject(new Error("FLV load timeout"));
|
||||
}, 3000);
|
||||
|
||||
this.player.on(flvjs.Events.MEDIA_INFO, () => {
|
||||
clearTimeout(loadTimeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.player.load();
|
||||
|
||||
try {
|
||||
await loadPromise;
|
||||
} catch {
|
||||
console.warn("FLV.js load timeout, falling back to HLS");
|
||||
this.cleanupPlayer();
|
||||
await this.setupNativePlayer(newVideoElement, hlsUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("FLV.js setup failed, using native player:", error);
|
||||
await this.setupNativePlayer(videoElement, hlsUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async setupNativePlayer(videoElement, url) {
|
||||
try {
|
||||
const newVideoElement = this.setupVideoEvents(videoElement);
|
||||
|
||||
newVideoElement.crossOrigin = "anonymous";
|
||||
newVideoElement.preload = "metadata";
|
||||
newVideoElement.autoplay = true;
|
||||
newVideoElement.muted = true;
|
||||
newVideoElement.playsInline = true;
|
||||
newVideoElement.controls = false;
|
||||
|
||||
if (newVideoElement.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
newVideoElement.setAttribute("playsinline", "");
|
||||
newVideoElement.setAttribute("webkit-playsinline", "");
|
||||
}
|
||||
|
||||
newVideoElement.src = url;
|
||||
newVideoElement.load();
|
||||
} catch (error) {
|
||||
console.error("Native player setup failed:", error);
|
||||
this.showError("Failed to load video stream");
|
||||
}
|
||||
}
|
||||
|
||||
setupVideoEvents(videoElement) {
|
||||
const newVideoElement = videoElement.cloneNode(true);
|
||||
videoElement.parentNode.replaceChild(newVideoElement, videoElement);
|
||||
this.elements.videoPlayer = newVideoElement;
|
||||
|
||||
newVideoElement.removeAttribute("src");
|
||||
newVideoElement.load();
|
||||
|
||||
newVideoElement.addEventListener("loadstart", () => {
|
||||
this.showLoading();
|
||||
});
|
||||
|
||||
newVideoElement.addEventListener("loadedmetadata", () => {
|
||||
this.attemptAutoplay();
|
||||
});
|
||||
|
||||
newVideoElement.addEventListener("canplay", () => {
|
||||
this.showVideo();
|
||||
this.attemptAutoplay();
|
||||
});
|
||||
|
||||
newVideoElement.addEventListener("playing", () => {
|
||||
this.showVideo();
|
||||
this.pendingPlay = false;
|
||||
});
|
||||
|
||||
newVideoElement.addEventListener("waiting", () => {
|
||||
setTimeout(() => {
|
||||
if (newVideoElement.readyState < 3) {
|
||||
this.showLoading();
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
newVideoElement.addEventListener("error", () => {
|
||||
const error = newVideoElement.error;
|
||||
console.error("Video error:", error);
|
||||
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
this.retryCount++;
|
||||
setTimeout(
|
||||
() => {
|
||||
if (!this.isDestroyed) {
|
||||
console.log(`Retrying video load (attempt ${this.retryCount})`);
|
||||
const stream = this.streamData;
|
||||
const hlsUrl = `${this.srsUrl}/${stream.app}/${stream.name}.m3u8`;
|
||||
this.cleanupPlayer();
|
||||
this.setupNativePlayer(newVideoElement, hlsUrl);
|
||||
}
|
||||
},
|
||||
Math.min(1000 * this.retryCount, 5000),
|
||||
);
|
||||
} else {
|
||||
this.showError(
|
||||
`Video failed to load: ${error?.message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
newVideoElement.addEventListener("pause", () => {
|
||||
if (!this.isDestroyed && this.streamData?.publish?.active) {
|
||||
setTimeout(() => {
|
||||
if (newVideoElement.paused && !this.isDestroyed) {
|
||||
this.attemptAutoplay();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
return newVideoElement;
|
||||
}
|
||||
|
||||
async attemptAutoplay() {
|
||||
if (!this.elements.videoPlayer || this.isDestroyed) return;
|
||||
|
||||
try {
|
||||
this.elements.videoPlayer.muted = true;
|
||||
|
||||
const playPromise = this.elements.videoPlayer.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
await playPromise;
|
||||
console.log("Autoplay successful");
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Autoplay failed:", error);
|
||||
|
||||
if (!this.userInteracted) {
|
||||
this.pendingPlay = true;
|
||||
this.showPlayButton();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!this.elements.videoPlayer.paused) return;
|
||||
this.elements.videoPlayer.play().catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showPlayButton() {
|
||||
if (!document.getElementById("playButtonOverlay")) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "playButtonOverlay";
|
||||
overlay.style.cssText = `
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
`;
|
||||
overlay.innerHTML = "▶";
|
||||
|
||||
overlay.addEventListener("click", () => {
|
||||
this.userInteracted = true;
|
||||
this.attemptAutoplay();
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
this.elements.videoContainer.style.position = "relative";
|
||||
this.elements.videoContainer.appendChild(overlay);
|
||||
}
|
||||
}
|
||||
|
||||
cleanupPlayer() {
|
||||
if (this.player) {
|
||||
try {
|
||||
this.player.pause();
|
||||
this.player.unload();
|
||||
this.player.detachMediaElement();
|
||||
this.player.destroy();
|
||||
} catch (e) {
|
||||
console.warn("Error cleaning up player:", e);
|
||||
}
|
||||
this.player = null;
|
||||
}
|
||||
|
||||
if (this.elements.videoPlayer) {
|
||||
this.elements.videoPlayer.pause();
|
||||
this.elements.videoPlayer.src = "";
|
||||
this.elements.videoPlayer.removeAttribute("src");
|
||||
this.elements.videoPlayer.load();
|
||||
}
|
||||
|
||||
const overlay = document.getElementById("playButtonOverlay");
|
||||
if (overlay) {
|
||||
overlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.isDestroyed = true;
|
||||
this.stopAutoRefresh();
|
||||
this.cleanupPlayer();
|
||||
}
|
||||
|
||||
formatBitrate(kbps) {
|
||||
if (kbps >= 1000) {
|
||||
return `${(kbps / 1000).toFixed(1)} Mbps`;
|
||||
}
|
||||
return `${kbps} kbps`;
|
||||
}
|
||||
|
||||
formatDuration(ms) {
|
||||
if (!ms) return "0s";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
getQualityString(stream) {
|
||||
if (!stream.video) return "Unknown";
|
||||
const width = stream.video.width;
|
||||
|
||||
if (width >= 1920) return "1080p";
|
||||
if (width >= 1280) return "720p";
|
||||
if (width >= 854) return "480p";
|
||||
if (width >= 640) return "360p";
|
||||
return `${width}x${stream.video.height}`;
|
||||
}
|
||||
|
||||
toggleFullscreen() {
|
||||
if (!this.isFullscreen) {
|
||||
this.elements.videoContainer.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
|
||||
this.refreshInterval = setInterval(() => {
|
||||
if (!this.isDestroyed) {
|
||||
this.loadStreamData();
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.elements.loadingState.style.display = "flex";
|
||||
this.elements.errorState.style.display = "none";
|
||||
this.elements.videoPlayer.style.display = "none";
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.elements.loadingState.style.display = "none";
|
||||
this.elements.errorState.style.display = "flex";
|
||||
this.elements.videoPlayer.style.display = "none";
|
||||
this.elements.errorMessage.textContent = message;
|
||||
}
|
||||
|
||||
showVideo() {
|
||||
this.elements.loadingState.style.display = "none";
|
||||
this.elements.errorState.style.display = "none";
|
||||
this.elements.videoPlayer.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
new StreamWatcher();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize StreamWatcher:", error);
|
||||
}
|
129
public/watch.html
Normal file
129
public/watch.html
Normal file
|
@ -0,0 +1,129 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Watch Stream</title>
|
||||
|
||||
<script src="/public/js/flv.min.js"></script>
|
||||
<script type="module" src="/public/js/watch.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="/public/css/style.css">
|
||||
<link rel="stylesheet" href="/public/css/watch.css">
|
||||
|
||||
<meta name="color-scheme" content="dark">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="watch-container">
|
||||
<header class="watch-header">
|
||||
<div class="header-content">
|
||||
<a href="/" class="back-btn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
<h1 id="streamTitle">Loading Stream...</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="video-container">
|
||||
<div id="loadingState" class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading stream...</p>
|
||||
</div>
|
||||
|
||||
<div id="errorState" class="error-overlay" style="display: none;">
|
||||
<div class="error-content">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
<h3>Stream Unavailable</h3>
|
||||
<p id="errorMessage">The stream is currently offline or unavailable.</p>
|
||||
<button id="retryBtn" class="retry-btn">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<video id="videoPlayer" class="video-player" controls autoplay muted style="display: none;">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="stream-status-container">
|
||||
<div class="stream-stats" id="streamStats" style="display: none;">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Viewers</span>
|
||||
<span class="stat-value" id="viewerCount">0</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Bitrate</span>
|
||||
<span class="stat-value" id="bitrate">0 kbps</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Quality</span>
|
||||
<span class="stat-value" id="quality">Unknown</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stream-info-panel">
|
||||
<div class="info-section">
|
||||
<h3>Stream Information</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Stream ID:</span>
|
||||
<span class="info-value" id="streamId">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Application:</span>
|
||||
<span class="info-value" id="streamApp">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Duration:</span>
|
||||
<span class="info-value" id="streamDuration">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Video Codec:</span>
|
||||
<span class="info-value" id="videoCodec">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Audio Codec:</span>
|
||||
<span class="info-value" id="audioCodec">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Resolution:</span>
|
||||
<span class="info-value" id="resolution">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h3>Stream URLs</h3>
|
||||
<div class="url-list">
|
||||
<div class="url-item">
|
||||
<span class="url-label">RTMP:</span>
|
||||
<div class="url-value" id="rtmpUrl" title="Click to copy">-</div>
|
||||
</div>
|
||||
<div class="url-item">
|
||||
<span class="url-label">HLS:</span>
|
||||
<div class="url-value" id="hlsUrl" title="Click to copy">-</div>
|
||||
</div>
|
||||
<div class="url-item">
|
||||
<span class="url-label">HTTP-FLV:</span>
|
||||
<div class="url-value" id="flvUrl" title="Click to copy">-</div>
|
||||
</div>
|
||||
<div class="url-item">
|
||||
<span class="url-label">WebRTC:</span>
|
||||
<div class="url-value" id="webrtcUrl" title="Click to copy">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
15
src/index.ts
Normal file
15
src/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { echo } from "@atums/echo";
|
||||
|
||||
import { verifyRequiredVariables } from "@config";
|
||||
import { serverHandler } from "@server";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
verifyRequiredVariables();
|
||||
|
||||
serverHandler.initialize();
|
||||
}
|
||||
|
||||
main().catch((error: Error) => {
|
||||
echo.error({ message: "Error initializing the server:", error });
|
||||
process.exit(1);
|
||||
});
|
42
src/routes/api/streams.ts
Normal file
42
src/routes/api/streams.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { srsUrl } from "@config";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "application/json",
|
||||
};
|
||||
|
||||
async function handler(): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(`${srsUrl}/api/v1/streams`);
|
||||
|
||||
if (!response.ok) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: response.status,
|
||||
error: `SRS API error: ${response.statusText}`,
|
||||
},
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 500,
|
||||
error: "Failed to fetch streams from SRS server",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
20
src/routes/index.ts
Normal file
20
src/routes/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { resolve } from "node:path";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "text/html",
|
||||
};
|
||||
|
||||
async function handler(): Promise<Response> {
|
||||
const filePath = resolve("public", "index.html");
|
||||
const file = Bun.file(filePath);
|
||||
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
40
src/routes/watch/[id].ts
Normal file
40
src/routes/watch/[id].ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { resolve } from "node:path";
|
||||
import { srsUrl } from "@config";
|
||||
|
||||
const routeDef: RouteDef = {
|
||||
method: "GET",
|
||||
accepts: "*/*",
|
||||
returns: "text/html",
|
||||
};
|
||||
|
||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||
const { id } = request.params;
|
||||
|
||||
if (!id) {
|
||||
return new Response("Stream ID is required", { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = resolve("public", "watch.html");
|
||||
const bunFile = Bun.file(filePath);
|
||||
|
||||
const html = new HTMLRewriter()
|
||||
.on("head", {
|
||||
element(head) {
|
||||
head.append(`<meta name="srs-url" content="${srsUrl}">`, {
|
||||
html: true,
|
||||
});
|
||||
head.append(`<meta name="stream-id" content="${id}">`, {
|
||||
html: true,
|
||||
});
|
||||
},
|
||||
})
|
||||
.transform(await bunFile.text());
|
||||
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { handler, routeDef };
|
305
src/server.ts
Normal file
305
src/server.ts
Normal file
|
@ -0,0 +1,305 @@
|
|||
import { resolve } from "node:path";
|
||||
import { Echo, echo } from "@atums/echo";
|
||||
import { environment } from "@config";
|
||||
import {
|
||||
type BunFile,
|
||||
FileSystemRouter,
|
||||
type MatchedRoute,
|
||||
type Server,
|
||||
} from "bun";
|
||||
|
||||
import { webSocketHandler } from "@websocket";
|
||||
|
||||
class ServerHandler {
|
||||
private router: FileSystemRouter;
|
||||
|
||||
constructor(
|
||||
private port: number,
|
||||
private host: string,
|
||||
) {
|
||||
this.router = new FileSystemRouter({
|
||||
style: "nextjs",
|
||||
dir: resolve("src", "routes"),
|
||||
fileExtensions: [".ts"],
|
||||
origin: `http://${this.host}:${this.port}`,
|
||||
});
|
||||
}
|
||||
|
||||
public initialize(): void {
|
||||
const server: Server = Bun.serve({
|
||||
port: this.port,
|
||||
hostname: this.host,
|
||||
fetch: this.handleRequest.bind(this),
|
||||
websocket: {
|
||||
open: webSocketHandler.handleOpen.bind(webSocketHandler),
|
||||
message: webSocketHandler.handleMessage.bind(webSocketHandler),
|
||||
close: webSocketHandler.handleClose.bind(webSocketHandler),
|
||||
},
|
||||
});
|
||||
|
||||
const echoChild = new Echo({ disableFile: true });
|
||||
|
||||
echoChild.info(
|
||||
`Server running at http://${server.hostname}:${server.port}`,
|
||||
);
|
||||
this.logRoutes(echoChild);
|
||||
}
|
||||
|
||||
private logRoutes(echo: Echo): void {
|
||||
echo.info("Available routes:");
|
||||
|
||||
const sortedRoutes: [string, string][] = Object.entries(
|
||||
this.router.routes,
|
||||
).sort(([pathA]: [string, string], [pathB]: [string, string]) =>
|
||||
pathA.localeCompare(pathB),
|
||||
);
|
||||
|
||||
for (const [path, filePath] of sortedRoutes) {
|
||||
echo.info(`Route: ${path}, File: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async serveStaticFile(
|
||||
request: ExtendedRequest,
|
||||
pathname: string,
|
||||
ip: string,
|
||||
): Promise<Response> {
|
||||
let filePath: string;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
if (pathname === "/favicon.ico") {
|
||||
filePath = resolve("public", "assets", "favicon.ico");
|
||||
} else {
|
||||
filePath = resolve(`.${pathname}`);
|
||||
}
|
||||
|
||||
const file: BunFile = Bun.file(filePath);
|
||||
|
||||
if (await file.exists()) {
|
||||
const fileContent: ArrayBuffer = await file.arrayBuffer();
|
||||
const contentType: string = file.type ?? "application/octet-stream";
|
||||
|
||||
response = new Response(fileContent, {
|
||||
headers: { "Content-Type": contentType },
|
||||
});
|
||||
} else {
|
||||
echo.warn(`File not found: ${filePath}`);
|
||||
response = new Response("Not Found", { status: 404 });
|
||||
}
|
||||
} catch (error) {
|
||||
echo.error({
|
||||
message: `Error serving static file: ${pathname}`,
|
||||
error: error as Error,
|
||||
});
|
||||
response = new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
|
||||
this.logRequest(request, response, ip);
|
||||
return response;
|
||||
}
|
||||
|
||||
private logRequest(
|
||||
request: ExtendedRequest,
|
||||
response: Response,
|
||||
ip: string | undefined,
|
||||
): void {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
|
||||
const ignoredStartsWith: string[] = ["/public"];
|
||||
const ignoredPaths: string[] = ["/favicon.ico"];
|
||||
|
||||
if (
|
||||
ignoredStartsWith.some((prefix) => pathname.startsWith(prefix)) ||
|
||||
ignoredPaths.includes(pathname)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
echo.custom(`${request.method}`, `${response.status}`, [
|
||||
request.url,
|
||||
`${(performance.now() - request.startPerf).toFixed(2)}ms`,
|
||||
ip || "unknown",
|
||||
]);
|
||||
}
|
||||
|
||||
private async handleRequest(
|
||||
request: Request,
|
||||
server: Server,
|
||||
): Promise<Response> {
|
||||
const extendedRequest: ExtendedRequest = request as ExtendedRequest;
|
||||
extendedRequest.startPerf = performance.now();
|
||||
|
||||
const headers = request.headers;
|
||||
let ip = server.requestIP(request)?.address;
|
||||
let response: Response;
|
||||
|
||||
if (!ip || ip.startsWith("172.") || ip === "127.0.0.1") {
|
||||
ip =
|
||||
headers.get("CF-Connecting-IP")?.trim() ||
|
||||
headers.get("X-Real-IP")?.trim() ||
|
||||
headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ||
|
||||
"unknown";
|
||||
}
|
||||
|
||||
const pathname: string = new URL(request.url).pathname;
|
||||
|
||||
const baseDir = resolve("public", "custom");
|
||||
const customPath = resolve(baseDir, pathname.slice(1));
|
||||
|
||||
if (!customPath.startsWith(baseDir)) {
|
||||
response = new Response("Forbidden", { status: 403 });
|
||||
this.logRequest(extendedRequest, response, ip);
|
||||
return response;
|
||||
}
|
||||
|
||||
const customFile = Bun.file(customPath);
|
||||
if (await customFile.exists()) {
|
||||
const content = await customFile.arrayBuffer();
|
||||
const type: string = customFile.type ?? "application/octet-stream";
|
||||
response = new Response(content, {
|
||||
headers: { "Content-Type": type },
|
||||
});
|
||||
this.logRequest(extendedRequest, response, ip);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/public") || pathname === "/favicon.ico") {
|
||||
return await this.serveStaticFile(extendedRequest, pathname, ip);
|
||||
}
|
||||
|
||||
const match: MatchedRoute | null = this.router.match(request);
|
||||
let requestBody: unknown = {};
|
||||
|
||||
if (match) {
|
||||
const { filePath, params, query } = match;
|
||||
|
||||
try {
|
||||
const routeModule: RouteModule = await import(filePath);
|
||||
const contentType: string | null = request.headers.get("Content-Type");
|
||||
const actualContentType: string | null = contentType
|
||||
? (contentType.split(";")[0]?.trim() ?? null)
|
||||
: null;
|
||||
|
||||
if (
|
||||
routeModule.routeDef.needsBody === "json" &&
|
||||
actualContentType === "application/json"
|
||||
) {
|
||||
try {
|
||||
requestBody = await request.json();
|
||||
} catch {
|
||||
requestBody = {};
|
||||
}
|
||||
} else if (
|
||||
routeModule.routeDef.needsBody === "multipart" &&
|
||||
actualContentType === "multipart/form-data"
|
||||
) {
|
||||
try {
|
||||
requestBody = await request.formData();
|
||||
} catch {
|
||||
requestBody = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(Array.isArray(routeModule.routeDef.method) &&
|
||||
!routeModule.routeDef.method.includes(request.method)) ||
|
||||
(!Array.isArray(routeModule.routeDef.method) &&
|
||||
routeModule.routeDef.method !== request.method)
|
||||
) {
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 405,
|
||||
error: `Method ${request.method} Not Allowed, expected ${
|
||||
Array.isArray(routeModule.routeDef.method)
|
||||
? routeModule.routeDef.method.join(", ")
|
||||
: routeModule.routeDef.method
|
||||
}`,
|
||||
},
|
||||
{ status: 405 },
|
||||
);
|
||||
} else {
|
||||
const expectedContentType: string | string[] | null =
|
||||
routeModule.routeDef.accepts;
|
||||
|
||||
let matchesAccepts: boolean;
|
||||
|
||||
if (Array.isArray(expectedContentType)) {
|
||||
matchesAccepts =
|
||||
expectedContentType.includes("*/*") ||
|
||||
expectedContentType.includes(actualContentType || "");
|
||||
} else {
|
||||
matchesAccepts =
|
||||
expectedContentType === "*/*" ||
|
||||
actualContentType === expectedContentType;
|
||||
}
|
||||
|
||||
if (!matchesAccepts) {
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 406,
|
||||
error: `Content-Type ${actualContentType} Not Acceptable, expected ${
|
||||
Array.isArray(expectedContentType)
|
||||
? expectedContentType.join(", ")
|
||||
: expectedContentType
|
||||
}`,
|
||||
},
|
||||
{ status: 406 },
|
||||
);
|
||||
} else {
|
||||
extendedRequest.params = params;
|
||||
extendedRequest.query = query;
|
||||
|
||||
response = await routeModule.handler(
|
||||
extendedRequest,
|
||||
requestBody,
|
||||
server,
|
||||
);
|
||||
|
||||
if (routeModule.routeDef.returns !== "*/*") {
|
||||
response.headers.set(
|
||||
"Content-Type",
|
||||
routeModule.routeDef.returns,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
echo.error({
|
||||
message: `Error handling route ${request.url}`,
|
||||
error: error,
|
||||
});
|
||||
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 500,
|
||||
error: "Internal Server Error",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
response = Response.json(
|
||||
{
|
||||
success: false,
|
||||
code: 404,
|
||||
error: "Not Found",
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
this.logRequest(extendedRequest, response, ip);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
const serverHandler: ServerHandler = new ServerHandler(
|
||||
environment.port,
|
||||
environment.host,
|
||||
);
|
||||
|
||||
export { serverHandler };
|
29
src/websocket.ts
Normal file
29
src/websocket.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { echo } from "@atums/echo";
|
||||
import type { ServerWebSocket } from "bun";
|
||||
|
||||
class WebSocketHandler {
|
||||
public handleMessage(ws: ServerWebSocket, message: string): void {
|
||||
echo.info(`WebSocket received: ${message}`);
|
||||
try {
|
||||
ws.send(`You said: ${message}`);
|
||||
} catch (error) {
|
||||
echo.error({ message: "WebSocket send error", error });
|
||||
}
|
||||
}
|
||||
|
||||
public handleOpen(ws: ServerWebSocket): void {
|
||||
echo.info("WebSocket connection opened.");
|
||||
try {
|
||||
ws.send("Welcome to the WebSocket server!");
|
||||
} catch (error) {
|
||||
echo.error({ message: "WebSocket send error", error });
|
||||
}
|
||||
}
|
||||
|
||||
public handleClose(_ws: ServerWebSocket, code: number, reason: string): void {
|
||||
echo.warn(`WebSocket closed with code ${code}, reason: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
const webSocketHandler: WebSocketHandler = new WebSocketHandler();
|
||||
export { webSocketHandler, WebSocketHandler };
|
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@*": ["src/*"],
|
||||
"@config": ["config/index.ts"],
|
||||
"@config/*": ["config/*"],
|
||||
"@types/*": ["types/*"]
|
||||
},
|
||||
"typeRoots": ["./types", "./node_modules/@types"],
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"allowJs": false,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"include": ["src", "types"]
|
||||
}
|
5
types/config.d.ts
vendored
Normal file
5
types/config.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
type Environment = {
|
||||
port: number;
|
||||
host: string;
|
||||
development: boolean;
|
||||
};
|
9
types/logger.d.ts
vendored
Normal file
9
types/logger.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
type ILogMessagePart = { value: string; color: string };
|
||||
|
||||
type ILogMessageParts = {
|
||||
level: ILogMessagePart;
|
||||
filename: ILogMessagePart;
|
||||
readableTimestamp: ILogMessagePart;
|
||||
message: ILogMessagePart;
|
||||
[key: string]: ILogMessagePart;
|
||||
};
|
15
types/routes.d.ts
vendored
Normal file
15
types/routes.d.ts
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
type RouteDef = {
|
||||
method: string | string[];
|
||||
accepts: string | null | string[];
|
||||
returns: string;
|
||||
needsBody?: "multipart" | "json";
|
||||
};
|
||||
|
||||
type RouteModule = {
|
||||
handler: (
|
||||
request: Request | ExtendedRequest,
|
||||
requestBody: unknown,
|
||||
server: Server,
|
||||
) => Promise<Response> | Response;
|
||||
routeDef: RouteDef;
|
||||
};
|
8
types/server.d.ts
vendored
Normal file
8
types/server.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
type Query = Record<string, string>;
|
||||
type Params = Record<string, string>;
|
||||
|
||||
interface ExtendedRequest extends Request {
|
||||
startPerf: number;
|
||||
query: Query;
|
||||
params: Params;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue