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