├── StepDaddyLiveHD
├── __init__.py
├── components
│ ├── __init__.py
│ ├── media_player.py
│ ├── card.py
│ └── navbar.py
├── pages
│ ├── __init__.py
│ ├── schedule.py
│ ├── playlist.py
│ └── watch.py
├── StepDaddyLiveHD.py
├── utils.py
├── backend.py
└── step_daddy.py
├── .dockerignore
├── assets
├── favicon.ico
└── player.jsx
├── .gitignore
├── requirements.txt
├── .env
├── Caddyfile
├── rxconfig.py
├── docker-compose.yml
├── Dockerfile
└── README.md
/StepDaddyLiveHD/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .web
2 | !.web/bun.lockb
3 | !.web/package.json
4 | .states
5 | .venv
--------------------------------------------------------------------------------
/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gookie-dev/StepDaddyLiveHD/HEAD/assets/favicon.ico
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .states
2 | *.db
3 | assets/external/
4 | *.py[cod]
5 | .web
6 | .venv
7 | __pycache__/
8 | /.idea/
9 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | reflex==0.8.13
2 | curl-cffi==0.13.0
3 | httpx[http2]==0.28.1
4 | python-dateutil==2.9.0
5 | fastapi==0.118.0
--------------------------------------------------------------------------------
/StepDaddyLiveHD/components/__init__.py:
--------------------------------------------------------------------------------
1 | from .navbar import navbar
2 | from .card import card
3 | from .media_player import MediaPlayer
4 |
5 | __all__ = ["navbar", "card", "MediaPlayer"]
6 |
--------------------------------------------------------------------------------
/StepDaddyLiveHD/pages/__init__.py:
--------------------------------------------------------------------------------
1 | from .watch import watch
2 | from .playlist import playlist
3 | from .schedule import schedule
4 |
5 |
6 | __all__ = ["watch", "playlist", "schedule"]
7 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | PORT="3000"
2 | API_URL="http://localhost:3000" # Replace with your local IP or domain if you want to access it across the network
3 | PROXY_CONTENT="TRUE" # Set to FALSE if you don't want to proxy content
4 | SOCKS5="" # Add SOCKS5 proxy details for DLHD request if needed "user:password@proxy.example.com:1080"
--------------------------------------------------------------------------------
/Caddyfile:
--------------------------------------------------------------------------------
1 | :{$PORT}
2 |
3 | encode gzip
4 |
5 | @backend_routes path /_event/* /ping /_upload /_upload/* /stream/* /key/* /content/* /playlist.m3u8 /logo/*
6 | handle @backend_routes {
7 | reverse_proxy localhost:8000
8 | }
9 |
10 | handle {
11 | root * /srv
12 | route {
13 | try_files {path} {path}/ /404.html
14 | file_server
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/StepDaddyLiveHD/components/media_player.py:
--------------------------------------------------------------------------------
1 | import reflex as rx
2 | from reflex.components.component import NoSSRComponent
3 |
4 |
5 | class MediaPlayer(NoSSRComponent):
6 | library = "$/public/player"
7 | lib_dependencies: list[str] = ["@vidstack/react@next"]
8 | tag = "Player"
9 | title: rx.Var[str]
10 | src: rx.Var[str]
11 | autoplay: bool = True
12 |
--------------------------------------------------------------------------------
/rxconfig.py:
--------------------------------------------------------------------------------
1 | import reflex as rx
2 | import os
3 |
4 |
5 | proxy_content = os.environ.get("PROXY_CONTENT", "TRUE").upper() == "TRUE"
6 | socks5 = os.environ.get("SOCKS5", "")
7 |
8 | print(f"PROXY_CONTENT: {proxy_content}\nSOCKS5: {socks5}")
9 |
10 | config = rx.Config(
11 | app_name="StepDaddyLiveHD",
12 | proxy_content=proxy_content,
13 | socks5=socks5,
14 | show_built_with_reflex=False,
15 | plugins=[
16 | rx.plugins.SitemapPlugin(),
17 | rx.plugins.TailwindV4Plugin(),
18 | ],
19 | )
20 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | step-daddy-live-hd:
3 | build:
4 | context: .
5 | dockerfile: Dockerfile
6 | args:
7 | PORT: ${PORT}
8 | API_URL: ${API_URL}
9 | PROXY_CONTENT: ${PROXY_CONTENT}
10 | SOCKS5: ${SOCKS5}
11 | environment:
12 | - PORT=${PORT}
13 | - API_URL=${API_URL}
14 | - PROXY_CONTENT=${PROXY_CONTENT}
15 | - SOCKS5=${SOCKS5}
16 | ports:
17 | - "${PORT}:${PORT}"
18 | restart: unless-stopped
19 | env_file:
20 | - .env
--------------------------------------------------------------------------------
/assets/player.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import '@vidstack/react/player/styles/default/theme.css';
3 | import '@vidstack/react/player/styles/default/layouts/audio.css';
4 | import '@vidstack/react/player/styles/default/layouts/video.css';
5 | import { MediaPlayer, MediaProvider, Poster, Captions } from "@vidstack/react"
6 | import { DefaultVideoLayout, defaultLayoutIcons } from '@vidstack/react/player/layouts/default';
7 |
8 |
9 | function InjectCSS() {
10 | const css = `
11 | .media-player[data-view-type="video"] {
12 | aspect-ratio: 16 / 9;
13 | }
14 |
15 | .vds-video-layout {
16 | --video-brand: hsl(0, 0%, 96%);
17 | }
18 |
19 | .vds-audio-layout {
20 | --audio-brand: hsl(0, 0%, 96%);
21 | }
22 |
23 | .plyr {
24 | --plyr-color-main: hsl(198, 100%, 50%);
25 | }
26 |
27 | .vds-slider-chapters {
28 | display: none;
29 | }
30 |
31 | .rt-Container {
32 | align-self: center;
33 | }
34 | `;
35 |
36 | return ;
37 | }
38 |
39 | export function Player({ title, src }) {
40 | return (
41 | <>
42 |
43 |
53 |
54 |
55 |
56 |
59 |
60 |
61 | >
62 | );
63 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG PORT=3000
2 | ARG PROXY_CONTENT=TRUE
3 | ARG SOCKS5
4 |
5 | # Only set for local/direct access. When TLS is used, the API_URL is assumed to be the same as the frontend.
6 | ARG API_URL
7 |
8 | # It uses a reverse proxy to serve the frontend statically and proxy to backend
9 | # from a single exposed port, expecting TLS termination to be handled at the
10 | # edge by the given platform.
11 | FROM python:3.13 AS builder
12 |
13 | RUN mkdir -p /app/.web
14 | RUN python -m venv /app/.venv
15 | ENV PATH="/app/.venv/bin:$PATH"
16 |
17 | WORKDIR /app
18 |
19 | # Install python app requirements and reflex in the container
20 | COPY requirements.txt .
21 | RUN pip install -r requirements.txt
22 |
23 | # Install reflex helper utilities like bun/node
24 | COPY rxconfig.py ./
25 | RUN reflex init
26 |
27 | # Copy local context to `/app` inside container (see .dockerignore)
28 | COPY . .
29 |
30 | ARG PORT API_URL PROXY_CONTENT SOCKS5
31 | # Download other npm dependencies and compile frontend
32 | RUN REFLEX_API_URL=${API_URL:-http://localhost:$PORT} reflex export --loglevel debug --frontend-only --no-zip && mv .web/build/client/* /srv/ && rm -rf .web
33 |
34 |
35 | # Final image with only necessary files
36 | FROM python:3.13-slim
37 |
38 | # Install Caddy and redis server inside image
39 | RUN apt-get update -y && apt-get install -y caddy redis-server && rm -rf /var/lib/apt/lists/*
40 |
41 | ARG PORT API_URL
42 | ENV PATH="/app/.venv/bin:$PATH" PORT=$PORT REFLEX_API_URL=${API_URL:-http://localhost:$PORT} REDIS_URL=redis://localhost PYTHONUNBUFFERED=1 PROXY_CONTENT=${PROXY_CONTENT:-TRUE} SOCKS5=${SOCKS5:-""}
43 |
44 | WORKDIR /app
45 | COPY --from=builder /app /app
46 | COPY --from=builder /srv /srv
47 |
48 | # Needed until Reflex properly passes SIGTERM on backend.
49 | STOPSIGNAL SIGKILL
50 |
51 | EXPOSE $PORT
52 |
53 | # Starting the backend.
54 | CMD caddy start && \
55 | redis-server --daemonize yes && \
56 | exec reflex run --env prod --backend-only
--------------------------------------------------------------------------------
/StepDaddyLiveHD/StepDaddyLiveHD.py:
--------------------------------------------------------------------------------
1 | import reflex as rx
2 | import StepDaddyLiveHD.pages
3 | from typing import List
4 | from StepDaddyLiveHD import backend
5 | from StepDaddyLiveHD.components import navbar, card
6 | from StepDaddyLiveHD.step_daddy import Channel
7 |
8 |
9 | class State(rx.State):
10 | channels: List[Channel] = []
11 | search_query: str = ""
12 |
13 | @rx.var
14 | def filtered_channels(self) -> List[Channel]:
15 | if not self.search_query:
16 | return self.channels
17 | return [ch for ch in self.channels if self.search_query.lower() in ch.name.lower()]
18 |
19 | async def on_load(self):
20 | self.channels = backend.get_channels()
21 |
22 | @rx.event
23 | def set_search_query(self, value: str):
24 | self.search_query = value
25 |
26 |
27 | @rx.page("/", on_load=State.on_load)
28 | def index() -> rx.Component:
29 | return rx.box(
30 | navbar(
31 | rx.box(
32 | rx.input(
33 | rx.input.slot(
34 | rx.icon("search"),
35 | ),
36 | placeholder="Search channels...",
37 | on_change=State.set_search_query,
38 | value=State.search_query,
39 | width="100%",
40 | max_width="25rem",
41 | size="3",
42 | ),
43 | ),
44 | ),
45 | rx.center(
46 | rx.cond(
47 | State.channels,
48 | rx.grid(
49 | rx.foreach(
50 | State.filtered_channels,
51 | lambda channel: card(channel),
52 | ),
53 | grid_template_columns="repeat(auto-fill, minmax(250px, 1fr))",
54 | spacing=rx.breakpoints(
55 | initial="4",
56 | sm="6",
57 | lg="9"
58 | ),
59 | width="100%",
60 | ),
61 | rx.center(
62 | rx.spinner(),
63 | height="50vh",
64 | ),
65 | ),
66 | padding="1rem",
67 | padding_top="10rem",
68 | ),
69 | )
70 |
71 |
72 | app = rx.App(
73 | theme=rx.theme(
74 | appearance="dark",
75 | accent_color="red",
76 | ),
77 | api_transformer=backend.fastapi_app,
78 | )
79 |
80 | app.register_lifespan_task(backend.update_channels)
81 |
--------------------------------------------------------------------------------
/StepDaddyLiveHD/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import base64
4 | import json
5 |
6 | key_bytes = os.urandom(64)
7 |
8 |
9 | def encrypt(input_string: str):
10 | input_bytes = input_string.encode()
11 | result = xor(input_bytes)
12 | return base64.urlsafe_b64encode(result).decode().rstrip('=')
13 |
14 |
15 | def decrypt(input_string: str):
16 | padding_needed = 4 - (len(input_string) % 4)
17 | if padding_needed:
18 | input_string += '=' * padding_needed
19 | input_bytes = base64.urlsafe_b64decode(input_string)
20 | result = xor(input_bytes)
21 | return result.decode()
22 |
23 |
24 | def xor(input_bytes):
25 | return bytes([input_bytes[i] ^ key_bytes[i % len(key_bytes)] for i in range(len(input_bytes))])
26 |
27 |
28 | def urlsafe_base64(input_string: str) -> str:
29 | input_bytes = input_string.encode("utf-8")
30 | base64_bytes = base64.urlsafe_b64encode(input_bytes)
31 | base64_string = base64_bytes.decode("utf-8")
32 | return base64_string
33 |
34 |
35 | def urlsafe_base64_decode(base64_string: str) -> str:
36 | padding = '=' * (-len(base64_string) % 4)
37 | base64_string_padded = base64_string + padding
38 | base64_bytes = base64_string_padded.encode("utf-8")
39 | decoded_bytes = base64.urlsafe_b64decode(base64_bytes)
40 | return decoded_bytes.decode("utf-8")
41 |
42 |
43 | def extract_and_decode_var(var_name: str, response: str) -> str:
44 | pattern = rf'var\s+{re.escape(var_name)}\s*=\s*atob\("([^"]+)"\);'
45 | matches = re.findall(pattern, response)
46 | if not matches:
47 | raise ValueError(f"Variable '{var_name}' not found in response")
48 | b64 = matches[-1]
49 | return base64.b64decode(b64).decode("utf-8")
50 |
51 |
52 | def decode_bundle(response_text: str) -> dict:
53 | candidates = set()
54 | candidates.update(re.findall(r'JSON\.parse\s*\(\s*atob\s*\(\s*["\']([^"\']{40,})["\']\s*\)\s*\)', response_text))
55 | candidates.update(re.findall(r'atob\s*\(\s*["\'](eyJ[A-Za-z0-9+/=]{40,})["\']\s*\)', response_text))
56 | candidates.update(re.findall(r'(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*["\'](eyJ[A-Za-z0-9+/=]{40,})["\']', response_text))
57 | candidates.update(re.findall(r'["\'](eyJ[A-Za-z0-9+/=]{40,})["\']', response_text))
58 | candidates.update(re.findall(r'["\']([A-Za-z0-9+/=]{80,})["\']', response_text))
59 |
60 | for candidate in candidates:
61 | try:
62 | decoded_candidate = base64.b64decode(candidate).decode("utf-8")
63 | data = json.loads(decoded_candidate)
64 | if not all(key in data for key in ['b_ts', 'b_sig', 'b_rnd', 'b_host']):
65 | continue
66 | decoded = {}
67 | for k, v in data.items():
68 | if isinstance(v, str):
69 | try:
70 | pad = '=' * (-len(v) % 4)
71 | decoded[k] = base64.b64decode(v + pad).decode("utf-8")
72 | except Exception:
73 | decoded[k] = v
74 | else:
75 | decoded[k] = v
76 | return decoded
77 | except Exception:
78 | continue
79 | return {}
80 |
--------------------------------------------------------------------------------
/StepDaddyLiveHD/components/card.py:
--------------------------------------------------------------------------------
1 | import reflex as rx
2 | from StepDaddyLiveHD.step_daddy import Channel
3 |
4 |
5 | def card(channel: Channel) -> rx.Component:
6 | return rx.link(
7 | rx.box(
8 | rx.image(
9 | src=channel.logo,
10 | position="absolute",
11 | width="100%",
12 | height="100%",
13 | object_fit="cover",
14 | filter="blur(10px)",
15 | opacity="0.4",
16 | z_index="0",
17 | padding="1rem",
18 | loading="lazy",
19 | ),
20 | rx.card(
21 | rx.box(
22 | rx.separator(
23 | position="absolute",
24 | top="32px",
25 | left="0",
26 | width="calc(50% - 35px)",
27 | ),
28 | rx.separator(
29 | position="absolute",
30 | top="32px",
31 | right="0",
32 | width="calc(50% - 35px)",
33 | ),
34 | rx.center(
35 | rx.image(
36 | src=channel.logo,
37 | width="64px",
38 | height="64px",
39 | object_fit="contain",
40 | position="relative",
41 | border_radius="8px",
42 | loading="lazy",
43 | ),
44 | ),
45 | position="relative",
46 | ),
47 | rx.center(
48 | rx.box(
49 | rx.heading(
50 | channel.name,
51 | color="white",
52 | align="center",
53 | ),
54 | padding_top="0.7rem",
55 | padding_bottom="3rem",
56 | overflow="hidden",
57 | text_overflow="ellipsis",
58 | white_space="nowrap",
59 | width="100%",
60 | ),
61 | rx.flex(
62 | rx.cond(
63 | channel.tags,
64 | rx.foreach(
65 | channel.tags,
66 | lambda tag: rx.badge(tag, variant="surface", color_scheme="gray")
67 | ),
68 | ),
69 | spacing=rx.breakpoints(
70 | initial="2",
71 | sm="1",
72 | lg="3",
73 | ),
74 | position="absolute",
75 | bottom="8px",
76 | ),
77 | position="relative",
78 | width="100%",
79 | ),
80 | z_index="1",
81 | position="relative",
82 | background="rgba(26, 25, 27, 0.8)",
83 | border="2px solid transparent",
84 | style={
85 | "_hover": {
86 | "border": "2px solid #fa5252",
87 | }
88 | },
89 | ),
90 | position="relative",
91 | ),
92 | href=f"/watch/{channel.id}",
93 | )
94 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # StepDaddyLiveHD 🚀
2 |
3 | A self-hosted IPTV proxy built with [Reflex](https://reflex.dev), enabling you to watch over 1,000 📺 TV channels and search for live events or sports matches ⚽🏀. Stream directly in your browser 🌐 or through any media player client 🎶. You can also download the entire playlist (`playlist.m3u8`) and integrate it with platforms like Jellyfin 🍇 or other IPTV media players.
4 |
5 | ---
6 |
7 | ## ✨ Features
8 |
9 | - **📱 Stream Anywhere**: Watch TV channels on any device via the web or media players.
10 | - **🔎 Event Search**: Quickly find the right channel for live events or sports.
11 | - **📄 Playlist Integration**: Download the `playlist.m3u8` and use it with Jellyfin or any IPTV client.
12 | - **⚙️ Customizable Hosting**: Host the application locally or deploy it via Docker with various configuration options.
13 |
14 | ---
15 |
16 | ## 🐳 Docker Installation (Recommended)
17 |
18 | > ⚠️ **Important:** If you plan to use this application across your local network (LAN), you must set `API_URL` to the **local IP address** of the device hosting the server in `.env`.
19 |
20 | 1. Make sure you have Docker and Docker Compose installed on your system.
21 | 2. Clone the repository and navigate into the project directory:
22 | 3. Run the following command to start the application:
23 | ```bash
24 | docker compose up -d
25 | ```
26 |
27 | Plain Docker:
28 | ```bash
29 | docker build -t step-daddy-live-hd .
30 | docker run -p 3000:3000 step-daddy-live-hd
31 | ```
32 |
33 | ---
34 |
35 | ## 🖥️ Local Installation
36 |
37 | 1. Install Python 🐍 (tested with version 3.12).
38 | 2. Clone the repository and navigate into the project directory:
39 | ```bash
40 | git clone https://github.com/gookie-dev/StepDaddyLiveHD
41 | cd StepDaddyLiveHD
42 | ```
43 | 3. Create and activate a virtual environment:
44 | ```bash
45 | python -m venv venv
46 | source venv/bin/activate # On Windows: venv\Scripts\activate
47 | ```
48 | 4. Install the dependencies:
49 | ```bash
50 | pip install -r requirements.txt
51 | ```
52 | 5. Initialize Reflex:
53 | ```bash
54 | reflex init
55 | ```
56 | 6. Run the application in production mode:
57 | ```bash
58 | reflex run --env prod
59 | ```
60 |
61 | ---
62 |
63 | ## ⚙️ Configuration
64 |
65 | ### Environment Variables
66 |
67 | - **PORT**: Set a custom port for the server.
68 | - **API_URL**: Set the domain or IP where the server is reachable.
69 | - **SOCKS5**: Proxy DLHD traffic through a SOCKS5 server if needed.
70 | - **PROXY_CONTENT**: Proxy video content itself through your server (optional).
71 |
72 | Edit the `.env` for docker compose.
73 |
74 | ### Example Docker Command
75 | ```bash
76 | docker build --build-arg PROXY_CONTENT=FALSE --build-arg API_URL=https://example.com --build-arg SOCKS5=user:password@proxy.example.com:1080 -t step-daddy-live-hd .
77 | docker run -e PROXY_CONTENT=FALSE -e API_URL=https://example.com -e SOCKS5=user:password@proxy.example.com:1080 -p 3000:3000 step-daddy-live-hd
78 | ```
79 |
80 | ---
81 |
82 | ## 🗺️ Site Map
83 |
84 | ### Pages Overview:
85 |
86 | - **🏠 Home**: Browse and search for TV channels.
87 | - **📺 Live Events**: Quickly find channels broadcasting live events and sports.
88 | - **📥 Playlist Download**: Download the `playlist.m3u8` file for integration with media players.
89 |
90 | ---
91 |
92 | ## 📸 Screenshots
93 |
94 | **Home Page**
95 |
96 |
97 | **Watch Page**
98 |
99 |
100 | **Live Events**
101 |
102 |
103 | ---
104 |
105 | ## 📚 Hosting Options
106 |
107 | Check out the [official Reflex hosting documentation](https://reflex.dev/docs/hosting/self-hosting/) for more advanced self-hosting setups!
--------------------------------------------------------------------------------
/StepDaddyLiveHD/backend.py:
--------------------------------------------------------------------------------
1 | import os
2 | import asyncio
3 | import httpx
4 | from StepDaddyLiveHD.step_daddy import StepDaddy, Channel
5 | from fastapi import Response, status, FastAPI
6 | from fastapi.responses import JSONResponse, StreamingResponse, FileResponse
7 | from .utils import urlsafe_base64_decode
8 |
9 |
10 | fastapi_app = FastAPI()
11 | step_daddy = StepDaddy()
12 | client = httpx.AsyncClient(http2=True, timeout=None, verify=False)
13 |
14 |
15 | @fastapi_app.get("/stream/{channel_id}.m3u8")
16 | async def stream(channel_id: str):
17 | try:
18 | return Response(
19 | content=await step_daddy.stream(channel_id),
20 | media_type="application/vnd.apple.mpegurl",
21 | headers={f"Content-Disposition": f"attachment; filename={channel_id}.m3u8"}
22 | )
23 | except IndexError:
24 | return JSONResponse(content={"error": "Stream not found"}, status_code=status.HTTP_404_NOT_FOUND)
25 | except Exception as e:
26 | return JSONResponse(content={"error": str(e)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
27 |
28 |
29 | @fastapi_app.get("/key/{url}/{host}")
30 | async def key(url: str, host: str):
31 | try:
32 | return Response(
33 | content=await step_daddy.key(url, host),
34 | media_type="application/octet-stream",
35 | headers={"Content-Disposition": "attachment; filename=key"}
36 | )
37 | except Exception as e:
38 | return JSONResponse(content={"error": str(e)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
39 |
40 |
41 | @fastapi_app.get("/content/{path}")
42 | async def content(path: str):
43 | try:
44 | async def proxy_stream():
45 | async with client.stream("GET", step_daddy.content_url(path), timeout=60) as response:
46 | async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
47 | yield chunk
48 | return StreamingResponse(proxy_stream(), media_type="application/octet-stream")
49 | except Exception as e:
50 | return JSONResponse(content={"error": str(e)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
51 |
52 |
53 | async def update_channels():
54 | while True:
55 | try:
56 | await step_daddy.load_channels()
57 | await asyncio.sleep(300)
58 | except asyncio.CancelledError:
59 | continue
60 |
61 |
62 | def get_channels():
63 | return step_daddy.channels
64 |
65 |
66 | def get_channel(channel_id) -> Channel | None:
67 | if not channel_id or channel_id == "":
68 | return None
69 | return next((channel for channel in step_daddy.channels if channel.id == channel_id), None)
70 |
71 |
72 | @fastapi_app.get("/playlist.m3u8")
73 | def playlist():
74 | return Response(content=step_daddy.playlist(), media_type="application/vnd.apple.mpegurl", headers={"Content-Disposition": "attachment; filename=playlist.m3u8"})
75 |
76 |
77 | async def get_schedule():
78 | return await step_daddy.schedule()
79 |
80 |
81 | @fastapi_app.get("/logo/{logo}")
82 | async def logo(logo: str):
83 | url = urlsafe_base64_decode(logo)
84 | file = url.split("/")[-1]
85 | if not os.path.exists("./logo-cache"):
86 | os.makedirs("./logo-cache")
87 | if os.path.exists(f"./logo-cache/{file}"):
88 | return FileResponse(f"./logo-cache/{file}")
89 | try:
90 | response = await client.get(url, headers={"user-agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0"})
91 | if response.status_code == 200:
92 | with open(f"./logo-cache/{file}", "wb") as f:
93 | f.write(response.content)
94 | return FileResponse(f"./logo-cache/{file}")
95 | else:
96 | return JSONResponse(content={"error": "Logo not found"}, status_code=status.HTTP_404_NOT_FOUND)
97 | except httpx.ConnectTimeout:
98 | return JSONResponse(content={"error": "Request timed out"}, status_code=status.HTTP_504_GATEWAY_TIMEOUT)
99 | except Exception as e:
100 | return JSONResponse(content={"error": str(e)}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
101 |
102 |
--------------------------------------------------------------------------------
/StepDaddyLiveHD/components/navbar.py:
--------------------------------------------------------------------------------
1 | from pyexpat.errors import XML_ERROR_INVALID_ARGUMENT
2 |
3 | import reflex as rx
4 | from pygments.styles.dracula import background
5 |
6 | from rxconfig import config
7 |
8 |
9 | def navbar_icons_item(text: str, icon: str, url: str, external: bool = False) -> rx.Component:
10 | return rx.link(
11 | rx.hstack(
12 | rx.icon(icon, color="white"),
13 | rx.text(text, size="4", weight="medium", color="white"),
14 | ),
15 | href=url,
16 | is_external=external,
17 | )
18 |
19 |
20 | def navbar_icons_menu_item(text: str, icon: str, url: str, external: bool = False) -> rx.Component:
21 | return rx.link(
22 | rx.hstack(
23 | rx.icon(icon, size=24, color="white"),
24 | rx.text(text, size="3", weight="medium", color="white"),
25 | ),
26 | href=url,
27 | is_external=external,
28 | padding="0.5em",
29 | )
30 |
31 |
32 | def navbar(search=None) -> rx.Component:
33 | return rx.box(
34 | rx.card(
35 | rx.desktop_only(
36 | rx.hstack(
37 | rx.vstack(
38 | rx.text(
39 | config.app_name, size="8", weight="bold"
40 | ),
41 | rx.box(
42 | background_color="#fa5252",
43 | width="100%",
44 | padding="2.5px",
45 | ),
46 | align_items="center",
47 | gap="0",
48 | on_click=rx.redirect("/")
49 | ),
50 | rx.cond(
51 | search,
52 | search,
53 | rx.text(
54 | "Watch ",
55 | rx.code("live"),
56 | " TV channels",
57 | align="center",
58 | size="4",
59 | padding="5px",
60 | ),
61 | ),
62 | rx.hstack(
63 | navbar_icons_item("Schedule", "calendar-sync", "/schedule"),
64 | navbar_icons_item("playlist.m3u8", "file-down", "/playlist"),
65 | navbar_icons_item("Github", "github", "https://github.com/gookie-dev/StepDaddyLiveHD", True),
66 | spacing="6",
67 | ),
68 | justify=rx.breakpoints(initial="between"),
69 | align_items="center",
70 | ),
71 | ),
72 | rx.mobile_and_tablet(
73 | rx.vstack(
74 | rx.hstack(
75 | rx.vstack(
76 | rx.text(
77 | config.app_name, size="7", weight="bold"
78 | ),
79 | rx.box(
80 | background_color="#fa5252",
81 | width="100%",
82 | padding="2.5px",
83 | ),
84 | align_items="center",
85 | gap="0",
86 | on_click=rx.redirect("/")
87 | ),
88 | rx.tablet_only(
89 | rx.cond(
90 | search,
91 | search,
92 | rx.fragment(),
93 | ),
94 | ),
95 | rx.menu.root(
96 | rx.menu.trigger(
97 | rx.icon("menu", size=30)
98 | ),
99 | rx.menu.content(
100 | navbar_icons_menu_item("Schedule", "calendar-sync", "/schedule"),
101 | navbar_icons_menu_item("playlist.m3u8", "file-down", "/playlist"),
102 | navbar_icons_menu_item("Github", "github", "https://github.com/gookie-dev/StepDaddyLiveHD", True),
103 | ),
104 | justify="end",
105 | ),
106 | justify=rx.breakpoints(initial="between"),
107 | align_items="center",
108 | width="100%",
109 | ),
110 | rx.cond(
111 | search,
112 | rx.mobile_only(
113 | rx.box(
114 | search,
115 | width="100%",
116 | ),
117 | width="100%",
118 | ),
119 | rx.fragment(),
120 | ),
121 | ),
122 | ),
123 | padding="1em",
124 | width="100%",
125 | ),
126 | padding="1rem",
127 | position="fixed",
128 | top="0px",
129 | z_index="2",
130 | width="100%",
131 | )
132 |
--------------------------------------------------------------------------------
/StepDaddyLiveHD/step_daddy.py:
--------------------------------------------------------------------------------
1 | import json
2 | import re
3 | import reflex as rx
4 | from urllib.parse import quote, urlparse
5 | from curl_cffi import AsyncSession
6 | from typing import List
7 | from .utils import encrypt, decrypt, urlsafe_base64, decode_bundle
8 | from rxconfig import config
9 | import html
10 |
11 |
12 | class Channel(rx.Base):
13 | id: str
14 | name: str
15 | tags: List[str]
16 | logo: str | None
17 |
18 |
19 | class StepDaddy:
20 | def __init__(self):
21 | socks5 = config.socks5
22 | if socks5 != "":
23 | self._session = AsyncSession(proxy="socks5://" + socks5)
24 | else:
25 | self._session = AsyncSession()
26 | self._base_url = "https://dlhd.dad"
27 | self.channels = []
28 | with open("StepDaddyLiveHD/meta.json", "r") as f:
29 | self._meta = json.load(f)
30 |
31 | def _headers(self, referer: str = None, origin: str = None):
32 | if referer is None:
33 | referer = self._base_url
34 | headers = {
35 | "Referer": referer,
36 | "user-agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:137.0) Gecko/20100101 Firefox/137.0",
37 | }
38 | if origin:
39 | headers["Origin"] = origin
40 | return headers
41 |
42 | async def load_channels(self):
43 | channels = []
44 | try:
45 | response = await self._session.get(f"{self._base_url}/24-7-channels.php", headers=self._headers())
46 | matches = re.findall(
47 | r']*>\s*(.*?)
',
48 | response.text,
49 | re.DOTALL
50 | )
51 | for channel_id, channel_name in matches:
52 | channel_name = html.unescape(channel_name.strip()).replace("#", "")
53 | meta = self._meta.get("18+" if channel_name.startswith("18+") else channel_name, {})
54 | logo = meta.get("logo", "")
55 | if logo:
56 | logo = f"{config.api_url}/logo/{urlsafe_base64(logo)}"
57 | channels.append(Channel(id=channel_id, name=channel_name, tags=meta.get("tags", []), logo=logo))
58 | finally:
59 | self.channels = sorted(channels, key=lambda channel: (channel.name.startswith("18"), channel.name))
60 |
61 | async def stream(self, channel_id: str):
62 | key = "CHANNEL_KEY"
63 | url = f"{self._base_url}/stream/stream-{channel_id}.php"
64 | response = await self._session.get(url, headers=self._headers())
65 | matches = re.compile("iframe src=\"(.*)\" width").findall(response.text)
66 | if matches:
67 | source_url = matches[0]
68 | source_response = await self._session.get(source_url, headers=self._headers(url))
69 | else:
70 | raise ValueError("Failed to find source URL for channel")
71 |
72 | channel_key = re.compile(rf"const\s+{re.escape(key)}\s*=\s*\"(.*?)\";").findall(source_response.text)[-1]
73 |
74 | data = decode_bundle(source_response.text)
75 | auth_ts = data.get("b_ts", "")
76 | auth_sig = data.get("b_sig", "")
77 | auth_rnd = data.get("b_rnd", "")
78 | auth_url = data.get("b_host", "")
79 | auth_request_url = f"{auth_url}auth.php?channel_id={channel_key}&ts={auth_ts}&rnd={auth_rnd}&sig={auth_sig}"
80 | auth_response = await self._session.get(auth_request_url, headers=self._headers(source_url))
81 | if auth_response.status_code != 200:
82 | raise ValueError("Failed to get auth response")
83 | key_url = urlparse(source_url)
84 | key_url = f"{key_url.scheme}://{key_url.netloc}/server_lookup.php?channel_id={channel_key}"
85 | key_response = await self._session.get(key_url, headers=self._headers(source_url))
86 | server_key = key_response.json().get("server_key")
87 | if not server_key:
88 | raise ValueError("No server key found in response")
89 | if server_key == "top1/cdn":
90 | server_url = f"https://top1.newkso.ru/top1/cdn/{channel_key}/mono.m3u8"
91 | else:
92 | server_url = f"https://{server_key}new.newkso.ru/{server_key}/{channel_key}/mono.m3u8"
93 | m3u8 = await self._session.get(server_url, headers=self._headers(quote(str(source_url))))
94 | m3u8_data = ""
95 | for line in m3u8.text.split("\n"):
96 | if line.startswith("#EXT-X-KEY:"):
97 | original_url = re.search(r'URI="(.*?)"', line).group(1)
98 | line = line.replace(original_url, f"{config.api_url}/key/{encrypt(original_url)}/{encrypt(urlparse(source_url).netloc)}")
99 | elif line.startswith("http") and config.proxy_content:
100 | line = f"{config.api_url}/content/{encrypt(line)}"
101 | m3u8_data += line + "\n"
102 | return m3u8_data
103 |
104 | async def key(self, url: str, host: str):
105 | url = decrypt(url)
106 | host = decrypt(host)
107 | response = await self._session.get(url, headers=self._headers(f"{host}/", host), timeout=60)
108 | if response.status_code != 200:
109 | raise Exception(f"Failed to get key")
110 | return response.content
111 |
112 | @staticmethod
113 | def content_url(path: str):
114 | return decrypt(path)
115 |
116 | def playlist(self):
117 | data = "#EXTM3U\n"
118 | for channel in self.channels:
119 | entry = f" tvg-logo=\"{channel.logo}\",{channel.name}" if channel.logo else f",{channel.name}"
120 | data += f"#EXTINF:-1{entry}\n{config.api_url}/stream/{channel.id}.m3u8\n"
121 | return data
122 |
123 | async def schedule(self):
124 | response = await self._session.get(f"{self._base_url}/schedule/schedule-generated.php", headers=self._headers())
125 | return response.json()
126 |
--------------------------------------------------------------------------------
/StepDaddyLiveHD/pages/schedule.py:
--------------------------------------------------------------------------------
1 | import reflex as rx
2 | from typing import Dict, List, TypedDict
3 | from zoneinfo import ZoneInfo
4 | from datetime import datetime, timedelta
5 | from dateutil import parser
6 | from StepDaddyLiveHD import backend
7 | from StepDaddyLiveHD.components import navbar
8 |
9 |
10 | class ChannelItem(TypedDict):
11 | name: str
12 | id: str
13 |
14 |
15 | class EventItem(TypedDict):
16 | name: str
17 | time: str
18 | dt: datetime
19 | category: str
20 | channels: List[ChannelItem]
21 |
22 |
23 | class ScheduleState(rx.State):
24 | events: List[EventItem] = []
25 | categories: Dict[str, bool] = {}
26 | switch: bool = True
27 | search_query: str = ""
28 |
29 | @staticmethod
30 | def get_channels(channels: dict) -> List[ChannelItem]:
31 | channel_list = []
32 | if isinstance(channels, list):
33 | for channel in channels:
34 | try:
35 | channel_list.append(ChannelItem(name=channel["channel_name"], id=channel["channel_id"]))
36 | except:
37 | continue
38 | elif isinstance(channels, dict):
39 | for channel_dic in channels:
40 | try:
41 | channel_list.append(ChannelItem(name=channels[channel_dic]["channel_name"], id=channels[channel_dic]["channel_id"]))
42 | except:
43 | continue
44 | return channel_list
45 |
46 | def toggle_category(self, category):
47 | self.categories[category] = not self.categories.get(category, False)
48 |
49 | def double_category(self, category):
50 | for cat in self.categories:
51 | if cat != category:
52 | self.categories[cat] = False
53 | else:
54 | self.categories[cat] = True
55 |
56 | async def on_load(self):
57 | self.events = []
58 | categories = {}
59 | days = await backend.get_schedule()
60 | for day in days:
61 | name = day.split(" - ")[0]
62 | dt = parser.parse(name, dayfirst=True)
63 | for category in days[day]:
64 | categories[category] = True
65 | for event in days[day][category]:
66 | time = event["time"]
67 | hour, minute = map(int, time.split(":"))
68 | event_dt = dt.replace(hour=hour, minute=minute).replace(tzinfo=ZoneInfo("UTC"))
69 | channels = self.get_channels(event.get("channels"))
70 | channels.extend(self.get_channels(event.get("channels2")))
71 | channels.sort(key=lambda channel: channel["name"])
72 | self.events.append(EventItem(name=event["event"], time=time, dt=event_dt, category=category, channels=channels))
73 | self.categories = dict(sorted(categories.items()))
74 | self.events.sort(key=lambda event: event["dt"])
75 |
76 | @rx.event
77 | def set_switch(self, value: bool):
78 | self.switch = value
79 |
80 | @rx.event
81 | def set_search_query(self, value: str):
82 | self.search_query = value
83 |
84 | @rx.var
85 | def filtered_events(self) -> List[EventItem]:
86 | now = datetime.now(ZoneInfo("UTC")) - timedelta(minutes=30)
87 | query = self.search_query.strip().lower()
88 |
89 | return [
90 | event for event in self.events
91 | if self.categories.get(event["category"], False)
92 | and (not self.switch or event["dt"] > now)
93 | and (query == "" or query in event["name"].lower())
94 | ]
95 |
96 |
97 | def event_card(event: EventItem) -> rx.Component:
98 | return rx.card(
99 | rx.heading(event["name"]),
100 | rx.hstack(
101 | rx.moment(event["dt"], format="HH:mm", local=True),
102 | rx.moment(event["dt"], format="ddd MMM DD YYYY", local=True),
103 | rx.badge(event["category"], margin_top="0.2rem"),
104 | ),
105 | rx.hstack(
106 | rx.foreach(
107 | event["channels"],
108 | lambda channel: rx.button(channel["name"], variant="surface", color_scheme="gray", size="1", on_click=rx.redirect(f"/watch/{channel['id']}")),
109 | ),
110 | wrap="wrap",
111 | margin_top="0.5rem",
112 | ),
113 | width="100%",
114 | )
115 |
116 |
117 | def category_badge(category) -> rx.Component:
118 | return rx.badge(
119 | category[0],
120 | color_scheme=rx.cond(
121 | category[1],
122 | "red",
123 | "gray",
124 | ),
125 | _hover={"color": "white"},
126 | style={"cursor": "pointer"},
127 | on_click=lambda: ScheduleState.toggle_category(category[0]),
128 | on_double_click=lambda: ScheduleState.double_category(category[0]),
129 | size="2",
130 | )
131 |
132 |
133 | @rx.page("/schedule", on_load=ScheduleState.on_load)
134 | def schedule() -> rx.Component:
135 | return rx.box(
136 | navbar(),
137 | rx.container(
138 | rx.center(
139 | rx.vstack(
140 | rx.cond(
141 | ScheduleState.categories,
142 | rx.card(
143 | rx.input(
144 | placeholder="Search events...",
145 | on_change=ScheduleState.set_search_query,
146 | value=ScheduleState.search_query,
147 | width="100%",
148 | size="3",
149 | ),
150 | rx.hstack(
151 | rx.text("Filter by tag:"),
152 | rx.foreach(ScheduleState.categories, category_badge),
153 | spacing="2",
154 | wrap="wrap",
155 | margin_top="0.7rem",
156 | ),
157 | rx.hstack(
158 | rx.text("Hide past events"),
159 | rx.switch(
160 | on_change=ScheduleState.set_switch,
161 | checked=ScheduleState.switch,
162 | margin_top="0.2rem"
163 | ),
164 | margin_top="0.5rem",
165 | ),
166 | ),
167 | rx.spinner(size="3"),
168 | ),
169 | rx.foreach(ScheduleState.filtered_events, event_card),
170 | ),
171 | ),
172 | padding_top="10rem",
173 | ),
174 | )
175 |
--------------------------------------------------------------------------------
/StepDaddyLiveHD/pages/playlist.py:
--------------------------------------------------------------------------------
1 | import reflex as rx
2 | from rxconfig import config
3 | from StepDaddyLiveHD.components import navbar
4 |
5 |
6 | @rx.page("/playlist")
7 | def playlist() -> rx.Component:
8 | return rx.box(
9 | navbar(),
10 | rx.container(
11 | rx.center(
12 | rx.card(
13 | rx.vstack(
14 | rx.cond(
15 | config.proxy_content,
16 | rx.fragment(),
17 | rx.card(
18 | rx.hstack(
19 | rx.icon(
20 | "info",
21 | ),
22 | rx.text(
23 | "Proxy content is disabled on this instance. Some clients may not work.",
24 | ),
25 | ),
26 | width="100%",
27 | background_color=rx.color("accent", 7),
28 | ),
29 | ),
30 | rx.heading("Welcome to StepDaddyLiveHD", size="7", margin_bottom="1rem"),
31 | rx.text(
32 | "StepDaddyLiveHD allows you to watch various TV channels via IPTV. "
33 | "You can download the playlist file below and use it with your favorite media player.",
34 | ),
35 |
36 | rx.divider(margin_y="1.5rem"),
37 |
38 | rx.heading("How to Use", size="5", margin_bottom="0.5rem"),
39 | rx.text(
40 | "1. Copy the link below or download the playlist file",
41 | margin_bottom="0.5rem",
42 | font_weight="medium",
43 | ),
44 | rx.text(
45 | "2. Open it with your preferred media player or IPTV app",
46 | margin_bottom="1.5rem",
47 | font_weight="medium",
48 | ),
49 |
50 | rx.hstack(
51 | rx.button(
52 | "Download Playlist",
53 | rx.icon("download", margin_right="0.5rem"),
54 | on_click=rx.redirect(f"{config.api_url}/playlist.m3u8", is_external=True),
55 | size="3",
56 | ),
57 | rx.button(
58 | "Copy Link",
59 | rx.icon("clipboard", margin_right="0.5rem"),
60 | on_click=[
61 | rx.set_clipboard(f"{config.api_url}/playlist.m3u8"),
62 | rx.toast("Playlist URL copied to clipboard!"),
63 | ],
64 | size="3",
65 | # variant="soft",
66 | color_scheme="gray",
67 | ),
68 | width="100%",
69 | justify="center",
70 | spacing="4",
71 | margin_bottom="1rem",
72 | ),
73 |
74 | rx.box(
75 | rx.text(
76 | f"{config.api_url}/playlist.m3u8",
77 | font_family="mono",
78 | font_size="sm",
79 | ),
80 | padding="0.75rem",
81 | background="gray.100",
82 | border_radius="md",
83 | width="100%",
84 | text_align="center",
85 | ),
86 |
87 | rx.divider(margin_y="1rem"),
88 |
89 | rx.heading("Compatible Players", size="5", margin_bottom="1rem"),
90 | rx.text(
91 | "You can use the m3u8 playlist with most media players and IPTV applications:",
92 | margin_bottom="1rem",
93 | ),
94 | rx.card(
95 | rx.vstack(
96 | rx.heading("VLC Media Player", size="6"),
97 | rx.text("Popular free and open-source media player"),
98 | rx.spacer(),
99 | rx.link(
100 | "Download",
101 | href="https://www.videolan.org/vlc/",
102 | target="_blank",
103 | color="blue.500",
104 | ),
105 | height="100%",
106 | justify="between",
107 | align="center",
108 | ),
109 | padding="1rem",
110 | width="100%",
111 | ),
112 |
113 | rx.card(
114 | rx.vstack(
115 | rx.heading("IPTVnator", size="6"),
116 | rx.text("Cross-platform IPTV player application"),
117 | rx.spacer(),
118 | rx.link(
119 | "Download",
120 | href="https://github.com/4gray/iptvnator",
121 | target="_blank",
122 | color="blue.500",
123 | ),
124 | height="100%",
125 | justify="between",
126 | align="center",
127 | ),
128 | padding="1rem",
129 | width="100%",
130 | ),
131 |
132 | rx.card(
133 | rx.vstack(
134 | rx.heading("Jellyfin", size="6"),
135 | rx.text("Free media system to manage your media"),
136 | rx.spacer(),
137 | rx.link(
138 | "Download",
139 | href="https://jellyfin.org/",
140 | target="_blank",
141 | color="blue.500",
142 | ),
143 | height="100%",
144 | justify="between",
145 | align="center",
146 | ),
147 | padding="1rem",
148 | width="100%",
149 | ),
150 |
151 | rx.divider(margin_y="1rem"),
152 |
153 | rx.text(
154 | "Need help? Most media players allow you to open network streams or IPTV playlists. "
155 | "Simply paste the m3u8 URL above or import the downloaded playlist file.",
156 | font_style="italic",
157 | color="gray.600",
158 | text_align="center",
159 | ),
160 | spacing="4",
161 | width="100%",
162 | ),
163 | padding="2rem",
164 | width="100%",
165 | max_width="800px",
166 | border_radius="xl",
167 | box_shadow="lg",
168 | ),
169 | padding_y="3rem",
170 | ),
171 | padding_top="7rem",
172 | ),
173 | )
174 |
--------------------------------------------------------------------------------
/StepDaddyLiveHD/pages/watch.py:
--------------------------------------------------------------------------------
1 | import reflex as rx
2 | from rxconfig import config
3 | from StepDaddyLiveHD import backend
4 | from StepDaddyLiveHD.components import navbar, MediaPlayer
5 | from StepDaddyLiveHD.step_daddy import Channel
6 |
7 | media_player = MediaPlayer.create
8 |
9 |
10 | class WatchState(rx.State):
11 | is_loaded: bool = False
12 |
13 | @rx.var
14 | def channel(self) -> Channel | None:
15 | self.is_loaded = False
16 | channel = backend.get_channel(str(self.channel_id))
17 | self.is_loaded = True
18 | return channel
19 |
20 | @rx.var
21 | def url(self) -> str:
22 | return f"{config.api_url}/stream/{self.channel_id}.m3u8"
23 |
24 |
25 | def uri_card() -> rx.Component:
26 | return rx.card(
27 | rx.hstack(
28 | rx.button(
29 | rx.text(WatchState.url),
30 | rx.icon("link-2", size=20),
31 | on_click=[
32 | rx.set_clipboard(WatchState.url),
33 | rx.toast("Copied to clipboard!"),
34 | ],
35 | size="1",
36 | variant="surface",
37 | radius="full",
38 | color_scheme="gray"
39 | ),
40 | rx.button(
41 | rx.text("VLC"),
42 | rx.icon("external-link", size=15),
43 | on_click=rx.redirect(f"vlc://{WatchState.url}", is_external=True),
44 | size="1",
45 | color_scheme="orange",
46 | variant="soft",
47 | high_contrast=True,
48 | ),
49 | rx.button(
50 | rx.text("MPV"),
51 | rx.icon("external-link", size=15),
52 | on_click=rx.redirect(f"mpv://{WatchState.url}", is_external=True),
53 | size="1",
54 | color_scheme="purple",
55 | variant="soft",
56 | high_contrast=True,
57 | ),
58 | rx.button(
59 | rx.text("Pot"),
60 | rx.icon("external-link", size=15),
61 | on_click=rx.redirect(f"potplayer://{WatchState.url}", is_external=True),
62 | size="1",
63 | color_scheme="yellow",
64 | variant="soft",
65 | high_contrast=True,
66 | ),
67 | # width="100%",
68 | wrap="wrap",
69 | ),
70 | margin_top="1rem",
71 | )
72 |
73 |
74 | @rx.page("/watch/[channel_id]")
75 | def watch() -> rx.Component:
76 | return rx.box(
77 | navbar(),
78 | rx.container(
79 | rx.cond(
80 | config.proxy_content,
81 | rx.fragment(),
82 | rx.card(
83 | rx.hstack(
84 | rx.icon(
85 | "info",
86 | ),
87 | rx.text(
88 | "Proxy content is disabled on this instance. Web Player won't work due to CORS.",
89 | ),
90 | ),
91 | width="100%",
92 | margin_bottom="1rem",
93 | background_color=rx.color("accent", 7),
94 | ),
95 | ),
96 | rx.center(
97 | rx.card(
98 | rx.cond(
99 | WatchState.channel.name,
100 | rx.hstack(
101 | rx.box(
102 | rx.hstack(
103 | rx.card(
104 | rx.image(
105 | src=WatchState.channel.logo,
106 | width="60px",
107 | height="60px",
108 | object_fit="contain",
109 | ),
110 | padding="0",
111 | ),
112 | rx.box(
113 | rx.heading(WatchState.channel.name, margin_bottom="0.3rem", padding_top="0.2rem"),
114 | rx.box(
115 | rx.hstack(
116 | rx.cond(
117 | WatchState.channel.tags,
118 | rx.foreach(
119 | WatchState.channel.tags,
120 | lambda tag: rx.badge(tag, variant="surface", color_scheme="gray")
121 | ),
122 | ),
123 | ),
124 | ),
125 | overflow="hidden",
126 | text_overflow="ellipsis",
127 | white_space="nowrap",
128 | ),
129 | ),
130 | ),
131 | rx.tablet_and_desktop(
132 | rx.box(
133 | rx.vstack(
134 | rx.button(
135 | rx.text(
136 | WatchState.url,
137 | overflow="hidden",
138 | text_overflow="ellipsis",
139 | white_space="nowrap",
140 | ),
141 | rx.icon("link-2", size=20),
142 | on_click=[
143 | rx.set_clipboard(WatchState.url),
144 | rx.toast("Copied to clipboard!"),
145 | ],
146 | size="1",
147 | variant="surface",
148 | radius="full",
149 | color_scheme="gray"
150 | ),
151 | rx.hstack(
152 | rx.button(
153 | rx.text("VLC"),
154 | rx.icon("external-link", size=15),
155 | on_click=rx.redirect(f"vlc://{WatchState.url}", is_external=True),
156 | size="1",
157 | color_scheme="orange",
158 | variant="soft",
159 | high_contrast=True,
160 | ),
161 | rx.button(
162 | rx.text("MPV"),
163 | rx.icon("external-link", size=15),
164 | on_click=rx.redirect(f"mpv://{WatchState.url}", is_external=True),
165 | size="1",
166 | color_scheme="purple",
167 | variant="soft",
168 | high_contrast=True,
169 | ),
170 | rx.button(
171 | rx.text("Pot"),
172 | rx.icon("external-link", size=15),
173 | on_click=rx.redirect(f"potplayer://{WatchState.url}", is_external=True),
174 | size="1",
175 | color_scheme="yellow",
176 | variant="soft",
177 | high_contrast=True,
178 | ),
179 | justify="end",
180 | width="100%",
181 | ),
182 | ),
183 | ),
184 | ),
185 | justify="between",
186 | padding_bottom="0.5rem",
187 | ),
188 | ),
189 | rx.box(
190 | rx.cond(
191 | WatchState.channel_id != "",
192 | media_player(
193 | title=WatchState.channel.name,
194 | src=WatchState.url,
195 | ),
196 | rx.center(
197 | rx.spinner(size="3"),
198 | ),
199 | ),
200 | width="100%",
201 | ),
202 | padding_bottom="0.3rem",
203 | width="100%",
204 | ),
205 | ),
206 | rx.fragment(
207 | rx.mobile_only(
208 | uri_card(),
209 | ),
210 | rx.cond(
211 | WatchState.is_loaded & ~WatchState.channel.name,
212 | rx.tablet_and_desktop(
213 | uri_card(),
214 | ),
215 | ),
216 | ),
217 | size="4",
218 | padding_top="10rem",
219 | ),
220 | )
221 |
--------------------------------------------------------------------------------