/movie and /tv is either an HLS stream (.m3u8) or an MP4 file — your player code must handle both. HLS goes through HLS.js; MP4 sets video.src directly. The detection logic is the same everywhere.
Source Type Detection
This is the single utility everything below depends on. A URL is MP4 if the proxy path ends in.mp4 or the upstream served a video content-type that isn’t HLS.
// lib/sourceType.ts
export function isMp4(url: string): boolean {
// The proxy wraps the real URL as ?url=<encoded>
// Decode and inspect the inner URL for the file extension
try {
const inner = new URL(url).searchParams.get('url') ?? url;
return /\.mp4(\?|$)/i.test(inner) || /\.mkv(\?|$)/i.test(inner);
} catch {
return /\.mp4(\?|$)/i.test(url);
}
}
Installation
npm install hls.js
How the Stream Works
/movie and /tv are Server-Sent Events — not JSON. Each response is a live stream of data: lines. Three event types arrive in sequence:
| Event | When | Contains |
|---|---|---|
meta | Immediately, before any provider resolves | TMDB metadata + all subtitle tracks |
source | Once per working provider, as each resolves | Proxied URL (HLS or MP4) + provider label |
done | After all providers finish or time out | Total working source count |
source event — don’t wait for done. Queue every subsequent source as a fallback.
Minimal HTML Player
Zero dependencies beyond HLS.js. Handles both HLS and MP4 correctly.<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
</head>
<body>
<video id="player" controls style="width: 100%; max-width: 900px;"></video>
<div id="status">Loading…</div>
<script>
const BASE = 'https://missourimonster-vyla.hf.space';
const video = document.getElementById('player');
const status = document.getElementById('status');
let hlsInstance = null;
function isMp4(url) {
try {
const inner = new URL(url).searchParams.get('url') ?? url;
return /\.(mp4|mkv)(\?|$)/i.test(inner);
} catch { return /\.(mp4|mkv)(\?|$)/i.test(url); }
}
function attachSource(url, label) {
status.textContent = `Loading ${label}…`;
hlsInstance?.destroy();
hlsInstance = null;
if (isMp4(url)) {
// MP4 — set directly, no HLS.js needed
video.src = url;
video.play().catch(() => {});
status.textContent = `Playing ${label} (MP4)`;
return;
}
if (Hls.isSupported()) {
hlsInstance = new Hls();
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {});
status.textContent = `Playing ${label}`;
});
return;
}
// Safari native HLS
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
video.play().catch(() => {});
status.textContent = `Playing ${label}`;
return;
}
status.textContent = 'HLS not supported in this browser.';
}
async function play(tmdbId) {
const res = await fetch(`${BASE}/movie?id=${tmdbId}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let started = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'source' && !started) {
started = true;
attachSource(event.source.url, event.source.label);
}
if (event.type === 'done' && !started) {
status.textContent = 'No sources available.';
}
}
}
}
play(550);
</script>
</body>
</html>
HTML Player — Full Featured
Source switching, fallback queue, subtitle tracks, quality selector. All source types handled.<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
<style>
* { box-sizing: border-box; }
body { font-family: sans-serif; background: #0f0f0f; color: #fff; padding: 1rem; margin: 0; }
video { width: 100%; max-width: 960px; display: block; background: #000; border-radius: 6px; }
#controls { max-width: 960px; margin-top: 0.5rem; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
#sources { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.4rem; max-width: 960px; }
button { padding: 0.35rem 0.75rem; background: #1e1e1e; color: #ccc; border: 1px solid #333;
border-radius: 4px; cursor: pointer; font-size: 0.8rem; }
button:hover { background: #2a2a2a; }
button.active { background: #fff; color: #111; border-color: #fff; }
select { background: #1e1e1e; color: #ccc; border: 1px solid #333; border-radius: 4px;
padding: 0.35rem 0.5rem; font-size: 0.8rem; }
#status { font-size: 0.8rem; color: #666; margin-top: 0.4rem; max-width: 960px; }
.badge { font-size: 0.65rem; background: #333; color: #aaa; padding: 1px 5px;
border-radius: 3px; margin-left: 4px; vertical-align: middle; }
</style>
</head>
<body>
<video id="player" controls></video>
<div id="controls">
<select id="quality" style="display:none"><option value="-1">Quality: Auto</option></select>
</div>
<div id="sources"></div>
<div id="status">Connecting…</div>
<script>
const BASE = 'https://missourimonster-vyla.hf.space';
const video = document.getElementById('player');
const statusEl = document.getElementById('status');
const sourcesEl = document.getElementById('sources');
const qualitySel = document.getElementById('quality');
let hlsInstance = null;
let allSources = [];
let activeUrl = null;
function isMp4(url) {
try {
const inner = new URL(url).searchParams.get('url') ?? url;
return /\.(mp4|mkv)(\?|$)/i.test(inner);
} catch { return /\.(mp4|mkv)(\?|$)/i.test(url); }
}
function setActive(url) {
activeUrl = url;
sourcesEl.querySelectorAll('button').forEach(b => {
b.classList.toggle('active', b.dataset.url === url);
});
}
function switchSource(url, label) {
setActive(url);
statusEl.textContent = `Loading ${label}…`;
qualitySel.style.display = 'none';
qualitySel.innerHTML = '<option value="-1">Quality: Auto</option>';
hlsInstance?.destroy();
hlsInstance = null;
if (isMp4(url)) {
video.src = url;
video.play().catch(() => {});
statusEl.textContent = `Playing ${label} · MP4`;
return;
}
if (Hls.isSupported()) {
hlsInstance = new Hls({ startLevel: -1 });
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
video.play().catch(() => {});
statusEl.textContent = `Playing ${label} · HLS`;
if (data.levels.length > 1) {
data.levels.forEach((level, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = level.height ? `${level.height}p` : `Level ${i}`;
qualitySel.appendChild(opt);
});
qualitySel.style.display = '';
}
});
hlsInstance.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
qualitySel.value = hlsInstance.autoLevelEnabled ? '-1' : data.level;
});
hlsInstance.on(Hls.Events.ERROR, (_, err) => {
if (err.fatal) {
statusEl.textContent = `${label} failed — trying next…`;
tryFallback(url);
}
});
return;
}
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
video.play().catch(() => {});
statusEl.textContent = `Playing ${label} · HLS`;
return;
}
statusEl.textContent = 'HLS not supported — try a different browser.';
}
function tryFallback(failedUrl) {
const next = allSources.find(s => s.url !== failedUrl && s.url !== activeUrl);
if (next) switchSource(next.url, next.label);
else statusEl.textContent = 'All sources failed.';
}
function addSourceButton(source) {
allSources.push(source);
const { url, label } = source;
const btn = document.createElement('button');
btn.dataset.url = url;
btn.innerHTML = label + (isMp4(url) ? '<span class="badge">MP4</span>' : '<span class="badge">HLS</span>');
btn.onclick = () => switchSource(url, label);
sourcesEl.appendChild(btn);
}
qualitySel.onchange = () => {
if (hlsInstance) hlsInstance.currentLevel = parseInt(qualitySel.value);
};
async function loadContent(tmdbId, season, episode) {
const url = (season && episode)
? `${BASE}/tv?id=${tmdbId}&season=${season}&episode=${episode}`
: `${BASE}/movie?id=${tmdbId}`;
allSources = [];
sourcesEl.innerHTML = '';
statusEl.textContent = 'Connecting…';
let started = false;
const res = await fetch(url);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'meta') {
statusEl.textContent = `Loading ${event.meta?.title ?? event.meta?.name ?? ''}…`;
// Clear and reattach subtitle tracks
Array.from(video.querySelectorAll('track')).forEach(t => t.remove());
(event.subtitles ?? []).forEach((sub, i) => {
const track = Object.assign(document.createElement('track'), {
kind: 'subtitles', label: sub.label, src: sub.file,
});
if (i === 0) track.default = true;
video.appendChild(track);
});
}
if (event.type === 'source') {
addSourceButton(event.source);
if (!started) {
started = true;
switchSource(event.source.url, event.source.label);
}
}
if (event.type === 'done' && !started) {
statusEl.textContent = 'No sources available.';
}
}
}
}
// Usage: movie
loadContent(550);
// Usage: TV episode
// loadContent(1396, 1, 1);
</script>
</body>
</html>
React
useStream Hook
Handles SSE parsing, abort on unmount, and re-fetch on param change. Drop-in for any framework.
// hooks/useStream.ts
import { useState, useEffect, useRef } from 'react';
const BASE = 'https://missourimonster-vyla.hf.space';
export interface StreamSource {
source: string;
label: string;
url: string;
}
export interface StreamSubtitle {
label: string;
file: string;
type: string;
source: string;
}
export interface StreamState {
sources: StreamSource[];
subtitles: StreamSubtitle[];
meta: Record<string, unknown> | null;
loading: boolean;
done: boolean;
error: string | null;
}
export function useStream(
tmdbId: number | null,
season?: number | null,
episode?: number | null,
): StreamState {
const [state, setState] = useState<StreamState>({
sources: [], subtitles: [], meta: null, loading: false, done: false, error: null,
});
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (!tmdbId) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const url = season && episode
? `${BASE}/tv?id=${tmdbId}&season=${season}&episode=${episode}`
: `${BASE}/movie?id=${tmdbId}`;
setState({ sources: [], subtitles: [], meta: null, loading: true, done: false, error: null });
(async () => {
try {
const res = await fetch(url, { signal: controller.signal });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'meta')
setState(s => ({ ...s, meta: event.meta ?? null, subtitles: event.subtitles ?? [] }));
if (event.type === 'source')
setState(s => ({ ...s, sources: [...s.sources, event.source] }));
if (event.type === 'done')
setState(s => ({ ...s, loading: false, done: true }));
}
}
} catch (err: any) {
if (err.name !== 'AbortError')
setState(s => ({ ...s, loading: false, error: err.message }));
}
})();
return () => controller.abort();
}, [tmdbId, season, episode]);
return state;
}
attachSource Utility
Centralises HLS vs MP4 detection so every component shares one path.
// lib/attachSource.ts
import Hls from 'hls.js';
export function isMp4(url: string): boolean {
try {
const inner = new URL(url).searchParams.get('url') ?? url;
return /\.(mp4|mkv)(\?|$)/i.test(inner);
} catch { return /\.(mp4|mkv)(\?|$)/i.test(url); }
}
export interface AttachResult {
hls: Hls | null;
type: 'hls' | 'mp4' | 'native-hls';
}
export function attachSource(
videoEl: HTMLVideoElement,
url: string,
prevHls?: Hls | null,
onLevels?: (levels: Hls.Level[]) => void,
onError?: () => void,
): AttachResult {
prevHls?.destroy();
if (isMp4(url)) {
videoEl.src = url;
videoEl.play().catch(() => {});
return { hls: null, type: 'mp4' };
}
if (Hls.isSupported()) {
const hls = new Hls({ startLevel: -1 });
hls.loadSource(url);
hls.attachMedia(videoEl);
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
videoEl.play().catch(() => {});
onLevels?.(data.levels);
});
hls.on(Hls.Events.ERROR, (_, err) => {
if (err.fatal) onError?.();
});
return { hls, type: 'hls' };
}
if (videoEl.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.src = url;
videoEl.play().catch(() => {});
return { hls: null, type: 'native-hls' };
}
return { hls: null, type: 'mp4' }; // last resort — let the browser try
}
StreamPlayer Component
Full-featured player: auto-starts on first source, queues fallbacks, source switcher, quality selector, subtitle tracks. Works for movies and TV.
// components/StreamPlayer.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';
import { useStream, StreamSource } from '@/hooks/useStream';
import { attachSource, isMp4 } from '@/lib/attachSource';
interface Props {
tmdbId: number;
season?: number;
episode?: number;
}
export function StreamPlayer({ tmdbId, season, episode }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const queueRef = useRef<StreamSource[]>([]);
const startedRef = useRef(false);
const activeRef = useRef<string>('');
const { sources, subtitles, meta, loading, done, error } = useStream(tmdbId, season, episode);
const [activeSource, setActiveSource] = useState<string>('');
const [levels, setLevels] = useState<Hls.Level[]>([]);
const [quality, setQuality] = useState(-1);
const [status, setStatus] = useState('Connecting…');
// Reset when content changes
useEffect(() => {
startedRef.current = false;
queueRef.current = [];
activeRef.current = '';
setActiveSource('');
setLevels([]);
setQuality(-1);
setStatus('Connecting…');
}, [tmdbId, season, episode]);
function playSource(src: StreamSource) {
if (!videoRef.current) return;
activeRef.current = src.url;
setActiveSource(src.source);
setLevels([]);
setQuality(-1);
setStatus(`Loading ${src.label}…`);
const { hls } = attachSource(
videoRef.current,
src.url,
hlsRef.current,
(newLevels) => {
setLevels(newLevels);
setStatus(`Playing via ${src.label}${isMp4(src.url) ? ' · MP4' : ' · HLS'}`);
},
() => {
// Fatal HLS error — try next in queue
const next = queueRef.current.shift();
if (next) playSource(next);
else setStatus('All sources failed.');
},
);
hlsRef.current = hls;
if (isMp4(src.url))
setStatus(`Playing via ${src.label} · MP4`);
}
// Start on first source, queue the rest as fallbacks
useEffect(() => {
if (!sources.length) return;
const latest = sources[sources.length - 1];
if (!startedRef.current) {
startedRef.current = true;
playSource(latest);
} else {
queueRef.current.push(latest);
}
}, [sources]);
// Sync quality level to HLS.js
useEffect(() => {
if (hlsRef.current) hlsRef.current.currentLevel = quality;
}, [quality]);
// Cleanup on unmount
useEffect(() => () => { hlsRef.current?.destroy(); }, []);
if (error) return (
<div style={{ padding: '1rem', color: '#f87171' }}>
Stream error: {error}
</div>
);
return (
<div style={{ width: '100%' }}>
{/* Video element */}
<video
ref={videoRef}
controls
style={{ width: '100%', background: '#000', display: 'block', borderRadius: 6 }}
>
{subtitles.map(sub => (
<track
key={`${sub.label}-${sub.source}`}
kind="subtitles"
label={sub.label}
src={sub.file}
srcLang={sub.label.slice(0, 2).toLowerCase()}
/>
))}
</video>
{/* Status bar */}
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: 4 }}>
{loading && !sources.length ? 'Connecting to providers…' : status}
{done && ` · ${sources.length} source${sources.length !== 1 ? 's' : ''} found`}
</div>
{/* Controls row */}
{(sources.length > 0 || levels.length > 1) && (
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.5rem', alignItems: 'center' }}>
{/* Quality selector — only shown for HLS with multiple levels */}
{levels.length > 1 && (
<select
value={quality}
onChange={e => setQuality(Number(e.target.value))}
style={{ fontSize: '0.8rem', padding: '0.3rem 0.5rem', borderRadius: 4,
background: '#1e1e1e', color: '#ccc', border: '1px solid #333' }}
>
<option value={-1}>Quality: Auto</option>
{levels.map((l, i) => (
<option key={i} value={i}>
{l.height ? `${l.height}p` : `Level ${i}`}
</option>
))}
</select>
)}
{/* Source switcher */}
{sources.length > 1 && sources.map(src => (
<button
key={src.source}
onClick={() => playSource(src)}
style={{
fontSize: '0.75rem', padding: '0.3rem 0.65rem', borderRadius: 4, cursor: 'pointer',
background: activeSource === src.source ? '#fff' : '#1e1e1e',
color: activeSource === src.source ? '#111' : '#aaa',
border: `1px solid ${activeSource === src.source ? '#fff' : '#333'}`,
}}
>
{src.label}
<span style={{ fontSize: '0.6rem', marginLeft: 4, opacity: 0.6 }}>
{isMp4(src.url) ? 'MP4' : 'HLS'}
</span>
</button>
))}
</div>
)}
</div>
);
}
Episode Selector
Renders an episode list and mounts the player only when an episode is selected — no streams open until the user picks something.// components/EpisodeSelector.tsx
'use client';
import { useState } from 'react';
import { StreamPlayer } from './StreamPlayer';
interface Episode {
episode_number: number;
name: string;
}
interface Props {
seriesId: number;
season: number;
episodes: Episode[];
}
export function EpisodeSelector({ seriesId, season, episodes }: Props) {
const [selected, setSelected] = useState<number | null>(null);
return (
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: '1rem' }}>
<aside style={{ overflowY: 'auto', maxHeight: 480, borderRight: '1px solid #222', paddingRight: '0.75rem' }}>
{episodes.map(ep => (
<button
key={ep.episode_number}
onClick={() => setSelected(ep.episode_number)}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '0.5rem 0.6rem', marginBottom: 2, borderRadius: 4, cursor: 'pointer',
background: selected === ep.episode_number ? '#fff' : 'transparent',
color: selected === ep.episode_number ? '#111' : '#aaa',
border: 'none', fontSize: '0.82rem',
}}
>
<span style={{ opacity: 0.5, marginRight: 6 }}>E{ep.episode_number}</span>
{ep.name}
</button>
))}
</aside>
<section>
{selected === null
? <div style={{ color: '#555', paddingTop: '2rem' }}>Select an episode to watch</div>
: <StreamPlayer tmdbId={seriesId} season={season} episode={selected} />
}
</section>
</div>
);
}
Next.js App Router
Movie Page
// app/movie/[id]/page.tsx
import { StreamPlayer } from '@/components/StreamPlayer';
export default function MoviePage({ params }: { params: { id: string } }) {
return (
<main style={{ maxWidth: 960, margin: '0 auto', padding: '1.5rem 1rem' }}>
<StreamPlayer tmdbId={Number(params.id)} />
</main>
);
}
TV Episode Page
// app/tv/[id]/[season]/[episode]/page.tsx
import { StreamPlayer } from '@/components/StreamPlayer';
export default function EpisodePage({
params,
}: {
params: { id: string; season: string; episode: string };
}) {
return (
<main style={{ maxWidth: 960, margin: '0 auto', padding: '1.5rem 1rem' }}>
<StreamPlayer
tmdbId={Number(params.id)}
season={Number(params.season)}
episode={Number(params.episode)}
/>
</main>
);
}
Dynamic HLS.js Import (SSR-safe)
HLS.js accesseswindow and will crash during SSR. Either mark the component 'use client' (already done above) or use a dynamic import to exclude it from the server bundle entirely:
// If you need to import StreamPlayer in a server component:
import dynamic from 'next/dynamic';
const StreamPlayer = dynamic(
() => import('@/components/StreamPlayer').then(m => m.StreamPlayer),
{ ssr: false, loading: () => <div style={{ aspectRatio: '16/9', background: '#111', borderRadius: 6 }} /> }
);
Never import
hls.js at the top level of a file that can run on the server. Always use 'use client' or a dynamic import with ssr: false.Vue 3
<!-- components/StreamPlayer.vue -->
<template>
<div>
<video ref="videoEl" controls style="width:100%; background:#000; border-radius:6px; display:block">
<track
v-for="sub in subtitles"
:key="sub.label + sub.source"
kind="subtitles"
:label="sub.label"
:src="sub.file"
/>
</video>
<div style="font-size:0.75rem; color:#666; margin-top:4px">{{ status }}</div>
<div v-if="sources.length > 1" style="display:flex; gap:0.4rem; flex-wrap:wrap; margin-top:0.5rem">
<button
v-for="src in sources"
:key="src.source"
@click="playSource(src)"
:style="{
fontSize: '0.75rem', padding: '0.3rem 0.65rem', borderRadius: '4px', cursor: 'pointer',
background: activeSource === src.source ? '#fff' : '#1e1e1e',
color: activeSource === src.source ? '#111' : '#aaa',
border: `1px solid ${activeSource === src.source ? '#fff' : '#333'}`,
}"
>
{{ src.label }}
<span style="font-size:0.6rem; margin-left:4px; opacity:0.6">
{{ isMp4(src.url) ? 'MP4' : 'HLS' }}
</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue';
import Hls from 'hls.js';
const props = defineProps<{
tmdbId: number;
season?: number;
episode?: number;
}>();
const BASE = 'https://missourimonster-vyla.hf.space';
interface Source { source: string; label: string; url: string; }
interface Subtitle { label: string; file: string; type: string; source: string; }
const videoEl = ref<HTMLVideoElement | null>(null);
const sources = ref<Source[]>([]);
const subtitles = ref<Subtitle[]>([]);
const status = ref('Connecting…');
const activeSource = ref('');
let hlsInstance: Hls | null = null;
let abortController: AbortController | null = null;
let started = false;
let queue: Source[] = [];
function isMp4(url: string): boolean {
try {
const inner = new URL(url).searchParams.get('url') ?? url;
return /\.(mp4|mkv)(\?|$)/i.test(inner);
} catch { return /\.(mp4|mkv)(\?|$)/i.test(url); }
}
function playSource(src: Source) {
if (!videoEl.value) return;
hlsInstance?.destroy();
hlsInstance = null;
activeSource.value = src.source;
if (isMp4(src.url)) {
videoEl.value.src = src.url;
videoEl.value.play().catch(() => {});
status.value = `Playing ${src.label} · MP4`;
return;
}
if (Hls.isSupported()) {
hlsInstance = new Hls({ startLevel: -1 });
hlsInstance.loadSource(src.url);
hlsInstance.attachMedia(videoEl.value);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
videoEl.value?.play().catch(() => {});
status.value = `Playing ${src.label} · HLS`;
});
hlsInstance.on(Hls.Events.ERROR, (_, err) => {
if (err.fatal) {
const next = queue.shift();
if (next) playSource(next);
else status.value = 'All sources failed.';
}
});
return;
}
if (videoEl.value.canPlayType('application/vnd.apple.mpegurl')) {
videoEl.value.src = src.url;
videoEl.value.play().catch(() => {});
status.value = `Playing ${src.label} · HLS`;
}
}
async function loadStream() {
abortController?.abort();
abortController = new AbortController();
sources.value = [];
subtitles.value = [];
status.value = 'Connecting…';
started = false;
queue = [];
const url = props.season && props.episode
? `${BASE}/tv?id=${props.tmdbId}&season=${props.season}&episode=${props.episode}`
: `${BASE}/movie?id=${props.tmdbId}`;
try {
const res = await fetch(url, { signal: abortController.signal });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'meta') {
subtitles.value = event.subtitles ?? [];
status.value = `Loading ${event.meta?.title ?? event.meta?.name ?? ''}…`;
}
if (event.type === 'source') {
sources.value = [...sources.value, event.source];
if (!started) { started = true; playSource(event.source); }
else queue.push(event.source);
}
if (event.type === 'done' && !started) {
status.value = 'No sources available.';
}
}
}
} catch (err: any) {
if (err.name !== 'AbortError') status.value = `Error: ${err.message}`;
}
}
watch(() => [props.tmdbId, props.season, props.episode], loadStream, { immediate: true });
onUnmounted(() => { hlsInstance?.destroy(); abortController?.abort(); });
</script>
Android (Kotlin + ExoPlayer)
ExoPlayer handles both HLS and MP4 natively — just pass the URL and it auto-detects the type.// StreamActivity.kt
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.*
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.URL
class StreamActivity : AppCompatActivity() {
private val BASE = "https://missourimonster-vyla.hf.space"
private lateinit var player: ExoPlayer
private val scope = CoroutineScope(Dispatchers.Main + Job())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
player = ExoPlayer.Builder(this).build()
binding.playerView.player = player
val tmdbId = intent.getIntExtra("tmdb_id", 550)
loadStream(tmdbId)
}
private fun loadStream(tmdbId: Int, season: Int? = null, episode: Int? = null) {
scope.launch {
val url = if (season != null && episode != null)
"$BASE/tv?id=$tmdbId&season=$season&episode=$episode"
else
"$BASE/movie?id=$tmdbId"
withContext(Dispatchers.IO) {
val conn = URL(url).openConnection()
val reader = BufferedReader(InputStreamReader(conn.inputStream))
reader.forEachLine { line ->
if (!line.startsWith("data: ")) return@forEachLine
val json = JSONObject(line.substring(6))
when (json.getString("type")) {
"source" -> {
val source = json.getJSONObject("source")
val streamUrl = source.getString("url")
// ExoPlayer auto-detects HLS vs MP4 from the URL and Content-Type
withContext(Dispatchers.Main) {
if (!player.isPlaying) {
player.setMediaItem(MediaItem.fromUri(streamUrl))
player.prepare()
player.play()
}
}
}
}
}
}
}
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
player.release()
}
}
ExoPlayer resolves HLS and MP4 automatically from the
Content-Type returned by the proxy. No manual type detection needed on Android.iOS (Swift + AVPlayer)
AVPlayer handles both HLS and MP4 natively. Same SSE parsing, different player attachment.// StreamViewController.swift
import UIKit
import AVKit
class StreamViewController: UIViewController {
let BASE = "https://missourimonster-vyla.hf.space"
var player: AVPlayer?
var playerLayer: AVPlayerLayer?
var streamTask: URLSessionDataTask?
var started = false
override func viewDidLoad() {
super.viewDidLoad()
loadStream(tmdbId: 550)
}
func loadStream(tmdbId: Int, season: Int? = nil, episode: Int? = nil) {
let path: String
if let s = season, let e = episode {
path = "\(BASE)/tv?id=\(tmdbId)&season=\(s)&episode=\(e)"
} else {
path = "\(BASE)/movie?id=\(tmdbId)"
}
guard let url = URL(string: path) else { return }
// SSE via URLSession data delegate
class SSEDelegate: NSObject, URLSessionDataDelegate {
var buffer = ""
var onSource: (String) -> Void
init(_ onSource: @escaping (String) -> Void) { self.onSource = onSource }
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard let text = String(data: data, encoding: .utf8) else { return }
buffer += text
while let range = buffer.range(of: "\n") {
let line = String(buffer[..<range.lowerBound])
buffer.removeSubrange(..<range.upperBound)
guard line.hasPrefix("data: "),
let jsonData = line.dropFirst(6).data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
let type = json["type"] as? String else { continue }
if type == "source",
let source = json["source"] as? [String: Any],
let streamUrl = source["url"] as? String {
onSource(streamUrl)
}
}
}
}
let delegate = SSEDelegate { [weak self] streamUrl in
guard let self, !self.started else { return }
self.started = true
DispatchQueue.main.async {
// AVPlayer handles HLS and MP4 automatically
guard let url = URL(string: streamUrl) else { return }
let player = AVPlayer(url: url)
self.player = player
let layer = AVPlayerLayer(player: player)
layer.frame = self.view.bounds
layer.videoGravity = .resizeAspect
self.view.layer.addSublayer(layer)
self.playerLayer = layer
player.play()
}
}
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
streamTask = session.dataTask(with: url)
streamTask?.resume()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
streamTask?.cancel()
player?.pause()
}
}
AVPlayer resolves HLS via
application/vnd.apple.mpegurl and MP4 via video/mp4 content-type returned by the proxy — no manual detection needed on iOS either.Python (mpv)
For desktop apps or scripts.mpv handles HLS and MP4 natively via its own demuxer.
import requests
import json
import subprocess
BASE = 'https://missourimonster-vyla.hf.space'
def stream(tmdb_id: int, season: int = None, episode: int = None):
url = (
f'{BASE}/tv?id={tmdb_id}&season={season}&episode={episode}'
if season and episode else
f'{BASE}/movie?id={tmdb_id}'
)
with requests.get(url, stream=True) as res:
for line in res.iter_lines():
if not line or not line.startswith(b'data: '):
continue
event = json.loads(line[6:])
if event['type'] == 'source':
stream_url = event['source']['url']
label = event['source']['label']
print(f'Playing via {label}: {stream_url}')
# mpv handles HLS and MP4 automatically
subprocess.run(['mpv', stream_url])
return # play first working source
if event['type'] == 'done' and not any(True for _ in []):
print('No sources available.')
stream(550)
# stream(1396, season=1, episode=1)

