mirror of
https://github.com/theariatv/theariatv.github.io.git
synced 2026-08-21 05:36:09 +02:00
749 lines
36 KiB
HTML
749 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Aria | Curated IPTV Channels</title>
|
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
:root {
|
|
--bg-color: #f4f4f5;
|
|
--text-color: #18181b;
|
|
--muted-text-color: #71717a;
|
|
--border-color: #e4e4e7;
|
|
--accent-color: #6366f1;
|
|
--accent-hover: #4f46e5;
|
|
--header-bg: #ffffff;
|
|
--danger-color: #ef4444;
|
|
--success-color: #10b981;
|
|
--warning-color: #f59e0b;
|
|
--secondary-bg: #f4f4f5;
|
|
--hover-bg: #fafafa;
|
|
--card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
|
--glass-bg: rgba(255, 255, 255, 0.85);
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg-color: #09090b;
|
|
--text-color: #fafafa;
|
|
--muted-text-color: #a1a1aa;
|
|
--border-color: #27272a;
|
|
--accent-color: #818cf8;
|
|
--accent-hover: #6366f1;
|
|
--header-bg: #18181b;
|
|
--danger-color: #f87171;
|
|
--success-color: #34d399;
|
|
--warning-color: #fbbf24;
|
|
--secondary-bg: #27272a;
|
|
--hover-bg: #27272a;
|
|
--card-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
|
|
--glass-bg: rgba(24, 24, 27, 0.85);
|
|
}
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background-color: var(--bg-color);
|
|
color: var(--text-color);
|
|
transition: background-color 0.3s ease, color 0.3s ease;
|
|
line-height: 1.6;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
padding: 2rem 1.5rem;
|
|
}
|
|
|
|
header {
|
|
text-align: center;
|
|
margin-bottom: 3rem;
|
|
padding: 4rem 2rem;
|
|
background: var(--header-bg);
|
|
border-radius: 24px;
|
|
box-shadow: var(--card-shadow);
|
|
border: 1px solid var(--border-color);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
header::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0; height: 6px;
|
|
background: linear-gradient(90deg, var(--accent-color), #a855f7, #ec4899);
|
|
}
|
|
header h1 {
|
|
font-size: clamp(2.5rem, 6vw, 4rem);
|
|
font-weight: 800;
|
|
letter-spacing: -0.04em;
|
|
margin: 0 0 1rem 0;
|
|
background: linear-gradient(90deg, var(--text-color), var(--muted-text-color));
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
}
|
|
header p {
|
|
font-size: 1.1rem;
|
|
color: var(--muted-text-color);
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
}
|
|
.stats-bar {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 1.5rem;
|
|
margin-top: 2.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.stat-item {
|
|
background: var(--secondary-bg);
|
|
padding: 1rem 2rem;
|
|
border-radius: 16px;
|
|
border: 1px solid var(--border-color);
|
|
min-width: 140px;
|
|
}
|
|
.stat-number { font-size: 1.75rem; font-weight: 800; color: var(--accent-color); line-height: 1.2; }
|
|
.stat-label { font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted-text-color); }
|
|
|
|
.project-info {
|
|
background: var(--header-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 20px;
|
|
padding: 2.5rem;
|
|
margin-bottom: 3rem;
|
|
box-shadow: var(--card-shadow);
|
|
}
|
|
.project-info h2 { font-size: 1.5rem; margin-top: 0; font-weight: 700; border-bottom: 1px solid var(--border-color); padding-bottom: 1rem; margin-bottom: 1.5rem; }
|
|
.project-info h3 { font-size: 1.2rem; font-weight: 600; margin-top: 2rem; }
|
|
.project-info a.text-link { color: var(--accent-color); text-decoration: none; font-weight: 500; }
|
|
.project-info a.text-link:hover { text-decoration: underline; color: var(--accent-hover); }
|
|
|
|
.playlist-links {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
gap: 1.5rem;
|
|
margin: 2rem 0;
|
|
}
|
|
.playlist-link {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 1.5rem;
|
|
background-color: var(--secondary-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
text-decoration: none;
|
|
color: var(--text-color);
|
|
transition: all 0.2s ease;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.playlist-link:hover {
|
|
border-color: var(--accent-color);
|
|
transform: translateY(-4px);
|
|
box-shadow: var(--card-shadow);
|
|
}
|
|
.playlist-link strong { font-size: 1.1rem; color: var(--accent-color); margin-bottom: 0.25rem; display: flex; align-items: center; gap: 0.5rem; }
|
|
.playlist-link small { color: var(--muted-text-color); }
|
|
|
|
.controls-bar {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
padding: 1rem;
|
|
background: var(--glass-bg);
|
|
backdrop-filter: blur(12px);
|
|
-webkit-backdrop-filter: blur(12px);
|
|
border-radius: 16px;
|
|
border: 1px solid var(--border-color);
|
|
box-shadow: var(--card-shadow);
|
|
position: sticky;
|
|
top: 1rem;
|
|
z-index: 100;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
.search-container {
|
|
flex: 1;
|
|
min-width: 250px;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 1rem;
|
|
color: var(--muted-text-color);
|
|
pointer-events: none;
|
|
}
|
|
#search-input {
|
|
width: 100%;
|
|
padding: 0.875rem 1rem 0.875rem 3rem;
|
|
font-size: 1rem;
|
|
font-family: inherit;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border-color);
|
|
background-color: var(--bg-color);
|
|
color: var(--text-color);
|
|
transition: all 0.2s ease;
|
|
}
|
|
#search-input:focus { outline: none; border-color: var(--accent-color); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); }
|
|
|
|
.view-toggle {
|
|
display: flex;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
padding: 0.25rem;
|
|
}
|
|
.view-button {
|
|
padding: 0.5rem 1rem;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: var(--muted-text-color);
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
font-family: inherit;
|
|
}
|
|
.view-button.active { background: var(--header-bg); color: var(--text-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
|
|
|
.action-button {
|
|
padding: 0.6rem 1rem;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 10px;
|
|
background: var(--bg-color);
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
font-family: inherit;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.action-button:hover { background: var(--border-color); }
|
|
|
|
.category-folder {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
margin-bottom: 1rem;
|
|
background-color: var(--header-bg);
|
|
overflow: hidden;
|
|
transition: box-shadow 0.2s ease;
|
|
}
|
|
.category-folder:hover { box-shadow: var(--card-shadow); }
|
|
.category-header {
|
|
padding: 1.25rem 1.5rem;
|
|
cursor: pointer;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
background: var(--header-bg);
|
|
user-select: none;
|
|
}
|
|
.category-header:hover { background: var(--hover-bg); }
|
|
.category-title { display: flex; align-items: center; gap: 0.75rem; }
|
|
.channel-count {
|
|
background: var(--secondary-bg);
|
|
color: var(--muted-text-color);
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 99px;
|
|
font-size: 0.8rem;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
.expand-icon { transition: transform 0.3s ease; color: var(--muted-text-color); }
|
|
.expand-icon.expanded { transform: rotate(180deg); }
|
|
|
|
.channel-table { width: 100%; border-collapse: collapse; }
|
|
.channel-table th, .channel-table td {
|
|
padding: 1rem 1.5rem;
|
|
border-top: 1px solid var(--border-color);
|
|
text-align: left;
|
|
vertical-align: middle;
|
|
}
|
|
.channel-table th { font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted-text-color); background-color: var(--secondary-bg); }
|
|
.channel-table tr:hover { background-color: var(--hover-bg); }
|
|
|
|
.channel-name { display: flex; align-items: center; gap: 1rem; }
|
|
.channel-logo {
|
|
width: 48px;
|
|
height: 32px;
|
|
object-fit: contain;
|
|
border-radius: 6px;
|
|
background-color: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
padding: 2px;
|
|
}
|
|
.logo-fallback {
|
|
width: 48px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--border-color);
|
|
border-radius: 6px;
|
|
color: var(--text-color);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
padding: 0.2rem 0.6rem;
|
|
border-radius: 99px;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--muted-text-color);
|
|
}
|
|
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
.status-dot.stable { background-color: var(--success-color); box-shadow: 0 0 6px var(--success-color); }
|
|
.status-dot.unstable { background-color: var(--warning-color); box-shadow: 0 0 6px var(--warning-color); }
|
|
.status-dot.not-working { background-color: var(--danger-color); box-shadow: 0 0 6px var(--danger-color); }
|
|
|
|
.link-group { display: flex; gap: 0.5rem; align-items: center; }
|
|
|
|
.icon-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
color: var(--muted-text-color);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
.icon-btn:hover { background: var(--header-bg); border-color: var(--text-color); color: var(--text-color); }
|
|
.icon-btn.success { color: var(--success-color); border-color: var(--success-color); background: rgba(16, 185, 129, 0.1); }
|
|
|
|
.play-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: var(--text-color);
|
|
color: var(--bg-color);
|
|
padding: 0 1rem;
|
|
height: 36px;
|
|
border-radius: 8px;
|
|
text-decoration: none;
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
transition: all 0.2s ease;
|
|
border: 1px solid transparent;
|
|
}
|
|
.play-btn:hover { opacity: 0.9; transform: translateY(-1px); box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
|
|
|
.report-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 32px;
|
|
padding: 0 0.75rem;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-color);
|
|
color: var(--muted-text-color);
|
|
font-size: 0.8rem;
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
transition: all 0.2s;
|
|
}
|
|
.report-btn:hover { background: rgba(239, 68, 68, 0.1); border-color: var(--danger-color); color: var(--danger-color); }
|
|
.grid-view { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.5rem; }
|
|
.grid-category {
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 16px;
|
|
background: var(--header-bg);
|
|
padding: 1.25rem;
|
|
box-shadow: var(--card-shadow);
|
|
}
|
|
.grid-category h3 {
|
|
margin: 0 0 1rem 0;
|
|
font-size: 1.1rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding-bottom: 0.75rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.grid-channels { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
.grid-channel {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 12px;
|
|
background: var(--bg-color);
|
|
transition: all 0.2s ease;
|
|
}
|
|
.grid-channel:hover { border-color: var(--accent-color); background: var(--header-bg); }
|
|
.grid-channel-info { display: flex; align-items: center; gap: 0.75rem; font-weight: 500; font-size: 0.95rem; }
|
|
.grid-channel-logo { width: 32px; height: 24px; object-fit: contain; border-radius: 4px; background: var(--border-color); }
|
|
.grid-logo-fallback {
|
|
width: 32px; height: 24px; display: flex; align-items: center; justify-content: center;
|
|
background: var(--border-color); border-radius: 4px; font-size: 9px; font-weight: 700; color: var(--text-color);
|
|
}
|
|
|
|
.spinner { width: 40px; height: 40px; border: 4px solid var(--border-color); border-top: 4px solid var(--accent-color); border-radius: 50%; animation: spin 1s linear infinite; margin: 3rem auto; }
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
.no-results { text-align: center; padding: 4rem; color: var(--muted-text-color); background: var(--header-bg); border-radius: 16px; border: 1px solid var(--border-color); }
|
|
|
|
@media (max-width: 768px) {
|
|
.controls-bar { flex-direction: column; align-items: stretch; }
|
|
.channel-table th:nth-child(n+3), .channel-table td:nth-child(n+3) { display: none; }
|
|
.stats-bar { gap: 1rem; }
|
|
}
|
|
|
|
.fallback-ui { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 80vh; text-align: center; padding: 2rem; }
|
|
.fallback-card { background: var(--header-bg); border: 1px solid var(--border-color); padding: 2.5rem; border-radius: 20px; max-width: 500px; box-shadow: var(--card-shadow); }
|
|
</style>
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js" crossorigin></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js" crossorigin></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.6/babel.min.js" crossorigin></script>
|
|
</head>
|
|
<body>
|
|
<div id="root">
|
|
<div class="fallback-ui">
|
|
<div class="fallback-card">
|
|
<h2>Loading Application...</h2>
|
|
<div class="spinner"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="text/babel">
|
|
const { useState, useEffect, useMemo } = React;
|
|
|
|
const Icons = {
|
|
Search: () => <svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>,
|
|
ChevronDown: ({ className }) => <svg className={className} viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>,
|
|
Copy: () => <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>,
|
|
Check: () => <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>,
|
|
Play: () => <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>,
|
|
Download: () => <svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>,
|
|
Warning: () => <svg viewBox="0 0 24 24" width="14" height="14" stroke="var(--warning-color)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
|
|
};
|
|
|
|
const useDebounce = (value, delay) => {
|
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
useEffect(() => {
|
|
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
|
return () => clearTimeout(handler);
|
|
}, [value, delay]);
|
|
return debouncedValue;
|
|
};
|
|
|
|
const parseM3U = (m3uContent, isStable) => {
|
|
const lines = m3uContent.split('\n');
|
|
const channels = {};
|
|
const regex = /group-title="([^"]+)"/;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (line.startsWith('#EXTINF:-1')) {
|
|
try {
|
|
const nextLine = lines[i + 1];
|
|
if (!nextLine || nextLine.startsWith('#')) continue;
|
|
|
|
const countryMatch = line.match(regex);
|
|
if (!countryMatch) continue;
|
|
|
|
const country = countryMatch[1].trim();
|
|
const channelName = line.split(',').pop().trim();
|
|
const streamUrl = nextLine.trim();
|
|
const logoMatch = line.match(/tvg-logo="([^"]+)"/);
|
|
const statusMatch = line.match(/tvg-status="([^"]+)"/);
|
|
const status = statusMatch ? statusMatch[1] : (isStable ? 'stable' : 'unstable');
|
|
|
|
if (!channels[country]) channels[country] = {};
|
|
if (!channels[country][channelName]) {
|
|
channels[country][channelName] = {
|
|
name: channelName,
|
|
stable: isStable,
|
|
status: status,
|
|
url: streamUrl,
|
|
country: country,
|
|
logo: logoMatch ? logoMatch[1] : null
|
|
};
|
|
}
|
|
} catch (e) { console.error("Parse error:", e); }
|
|
}
|
|
}
|
|
return channels;
|
|
};
|
|
|
|
const ChannelLogo = ({ channel, className = "channel-logo" }) => {
|
|
const [error, setError] = useState(false);
|
|
if (!channel.logo || error) {
|
|
const init = channel.name.split(' ').map(w => w[0]).join('').substring(0, 2);
|
|
return <div className={className.includes('grid') ? 'grid-logo-fallback' : 'logo-fallback'}>{init}</div>;
|
|
}
|
|
return <img src={channel.logo} alt="" className={className} onError={() => setError(true)} />;
|
|
};
|
|
|
|
const Category = ({ country, channels, forceOpen, viewMode }) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [copiedUrl, setCopiedUrl] = useState(null);
|
|
|
|
useEffect(() => setIsOpen(forceOpen), [forceOpen]);
|
|
|
|
const handleCopy = (url) => {
|
|
navigator.clipboard.writeText(url);
|
|
setCopiedUrl(url);
|
|
setTimeout(() => setCopiedUrl(null), 2000);
|
|
};
|
|
|
|
const reportUrl = (channel) => {
|
|
const body = `**Channel:** ${channel.name}\n**URL:** \`${channel.url}\`\n**Problem:** `;
|
|
return `https://github.com/theariatv/theariatv.github.io/issues/new?title=${encodeURIComponent(`Broken Stream: ${channel.name}`)}&body=${encodeURIComponent(body)}`;
|
|
};
|
|
|
|
if (viewMode === 'grid') {
|
|
return (
|
|
<div className="grid-category">
|
|
<h3>
|
|
{country}
|
|
<span className="channel-count">{channels.length}</span>
|
|
</h3>
|
|
<div className="grid-channels">
|
|
{channels.slice(0, 6).map(ch => (
|
|
<div key={ch.name} className="grid-channel">
|
|
<div className="grid-channel-info">
|
|
<ChannelLogo channel={ch} className="grid-channel-logo" />
|
|
<div style={{display:'flex', flexDirection:'column'}}>
|
|
<span style={{lineHeight: 1.2}}>{ch.name}</span>
|
|
<span style={{fontSize: '0.7rem', color: 'var(--muted-text-color)', display:'flex', alignItems:'center', gap:'4px'}}>
|
|
<div className={`status-dot ${ch.status === 'not-working' ? 'not-working' : (ch.stable ? 'stable' : 'unstable')}`}></div>
|
|
{ch.status === 'not-working' ? 'Not Working' : (ch.stable ? 'Stable' : 'Unstable')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="link-group">
|
|
<button className={`icon-btn ${copiedUrl === ch.url ? 'success' : ''}`} onClick={() => handleCopy(ch.url)} title="Copy URL">
|
|
{copiedUrl === ch.url ? <Icons.Check /> : <Icons.Copy />}
|
|
</button>
|
|
<a href={ch.url} target="_blank" className="icon-btn play-btn" style={{padding:0, width:'36px'}}>
|
|
<Icons.Play />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{channels.length > 6 && (
|
|
<div style={{textAlign: 'center', color: 'var(--muted-text-color)', fontSize: '0.85rem', padding:'0.5rem'}}>
|
|
+{channels.length - 6} more channels
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="category-folder">
|
|
<div className="category-header" onClick={() => setIsOpen(!isOpen)}>
|
|
<div className="category-title">
|
|
<span>{country}</span>
|
|
<span className="channel-count">{channels.length}</span>
|
|
</div>
|
|
<Icons.ChevronDown className={`expand-icon ${isOpen ? 'expanded' : ''}`} />
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<table className="channel-table">
|
|
<thead><tr><th>Channel</th><th>Stream Access</th><th>Status</th></tr></thead>
|
|
<tbody>
|
|
{channels.map(ch => (
|
|
<tr key={ch.name}>
|
|
<td>
|
|
<div className="channel-name">
|
|
<ChannelLogo channel={ch} />
|
|
<div>
|
|
<div style={{fontWeight: '600', color:'var(--text-color)'}}>{ch.name}</div>
|
|
<div className="status-badge" style={{marginTop:'4px'}}>
|
|
<div className={`status-dot ${ch.status === 'not-working' ? 'not-working' : (ch.stable ? 'stable' : 'unstable')}`}></div>
|
|
{ch.status === 'not-working' ? 'Not Working' : (ch.stable ? 'Official Stream' : 'Community Stream')}
|
|
{(!ch.stable || ch.status === 'not-working') && <Icons.Warning />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div className="link-group">
|
|
<a href={ch.url} target="_blank" className="play-btn">
|
|
<Icons.Play /> Play
|
|
</a>
|
|
<button className={`icon-btn ${copiedUrl === ch.url ? 'success' : ''}`} onClick={() => handleCopy(ch.url)} title="Copy M3U8 URL">
|
|
{copiedUrl === ch.url ? <Icons.Check /> : <Icons.Copy />}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<a href={reportUrl(ch)} target="_blank" className="report-btn">Report Broken</a>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const App = () => {
|
|
const [allData, setAllData] = useState([]);
|
|
const [filtered, setFiltered] = useState([]);
|
|
const [search, setSearch] = useState('');
|
|
const debouncedSearch = useDebounce(search, 300);
|
|
const [loading, setLoading] = useState(true);
|
|
const [view, setView] = useState('list');
|
|
const [expandAll, setExpandAll] = useState(false);
|
|
|
|
const stats = useMemo(() => {
|
|
return {
|
|
total: allData.reduce((s, c) => s + c.channels.length, 0),
|
|
stable: allData.reduce((s, c) => s + c.channels.filter(ch => ch.stable).length, 0),
|
|
countries: allData.length
|
|
};
|
|
}, [allData]);
|
|
|
|
useEffect(() => {
|
|
Promise.all([fetch('aria.m3u').then(r=>r.text()).catch(()=>''), fetch('aria+.m3u').then(r=>r.text()).catch(()=>'')])
|
|
.then(([stable, unstable]) => {
|
|
const parsed1 = parseM3U(stable, true);
|
|
const parsed2 = parseM3U(unstable, false);
|
|
const merged = { ...parsed1 };
|
|
|
|
for (const c in parsed2) {
|
|
if (!merged[c]) merged[c] = {};
|
|
for (const ch in parsed2[c]) {
|
|
if (!merged[c][ch]) merged[c][ch] = parsed2[c][ch];
|
|
}
|
|
}
|
|
|
|
const formatted = Object.keys(merged).sort().map(c => ({
|
|
country: c,
|
|
channels: Object.values(merged[c]).sort((a, b) => a.name.localeCompare(b.name))
|
|
}));
|
|
|
|
setAllData(formatted);
|
|
setFiltered(formatted);
|
|
setLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!debouncedSearch) return setFiltered(allData);
|
|
const q = debouncedSearch.toLowerCase();
|
|
setFiltered(allData.map(c => ({
|
|
...c, channels: c.channels.filter(ch => ch.name.toLowerCase().includes(q) || ch.country.toLowerCase().includes(q))
|
|
})).filter(c => c.channels.length > 0));
|
|
}, [debouncedSearch, allData]);
|
|
|
|
return (
|
|
<div className="container">
|
|
<header>
|
|
<h1>Aria</h1>
|
|
<p>A curated, high-quality collection of IPTV channels from around the world.</p>
|
|
<div className="stats-bar">
|
|
<div className="stat-item">
|
|
<div className="stat-number">{stats.total || '-'}</div>
|
|
<div className="stat-label">Total Channels</div>
|
|
</div>
|
|
<div className="stat-item">
|
|
<div className="stat-number">{stats.stable || '-'}</div>
|
|
<div className="stat-label">Stable Streams</div>
|
|
</div>
|
|
<div className="stat-item">
|
|
<div className="stat-number">{stats.countries || '-'}</div>
|
|
<div className="stat-label">Regions</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<div className="project-info">
|
|
<h2>About The Project</h2>
|
|
<p style={{color: 'var(--muted-text-color)', fontSize: '1.05rem'}}>
|
|
Aria provides a curated collection of IPTV channels organized by country. Unlike our predecessor Mystique, this git strictly focuses on official streams, tvheadends, and astra control panels for higher reliability.
|
|
</p>
|
|
|
|
<div className="playlist-links">
|
|
<a href="./aria.m3u" download className="playlist-link">
|
|
<strong><Icons.Download /> aria.m3u</strong>
|
|
<small>Official & Stable Streams Only</small>
|
|
</a>
|
|
<a href="./aria+.m3u" download className="playlist-link">
|
|
<strong><Icons.Download /> aria+.m3u</strong>
|
|
<small>Includes Community/Unstable Streams</small>
|
|
</a>
|
|
</div>
|
|
|
|
<p style={{fontSize: '0.9rem', color: 'var(--muted-text-color)', marginTop: '2rem'}}>
|
|
Found a broken stream? Request an update in our <a href="https://github.com/theariatv/theariatv.github.io/issues" target="_blank" className="text-link">GitHub Issues</a>.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="controls-bar">
|
|
<div className="search-container">
|
|
<span className="search-icon"><Icons.Search /></span>
|
|
<input
|
|
id="search-input"
|
|
type="search"
|
|
placeholder="Search channels or regions..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="view-toggle">
|
|
<button className={`view-button ${view === 'list' ? 'active' : ''}`} onClick={() => setView('list')}>List</button>
|
|
<button className={`view-button ${view === 'grid' ? 'active' : ''}`} onClick={() => setView('grid')}>Grid</button>
|
|
</div>
|
|
{view === 'list' && (
|
|
<div style={{display:'flex', gap:'0.5rem'}}>
|
|
<button className="action-button" onClick={() => setExpandAll(true)}>Expand All</button>
|
|
<button className="action-button" onClick={() => setExpandAll(false)}>Collapse</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{loading ? <div className="spinner"></div> :
|
|
filtered.length === 0 ? (
|
|
<div className="no-results">
|
|
<h3>No channels found</h3>
|
|
<p>We couldn't find any streams matching "{search}".</p>
|
|
</div>
|
|
) : (
|
|
<div className={view === 'grid' ? 'grid-view' : ''}>
|
|
{filtered.map(c => (
|
|
<Category key={c.country} country={c.country} channels={c.channels} forceOpen={debouncedSearch.length > 0 || expandAll} viewMode={view} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(<App />);
|
|
</script>
|
|
</body>
|
|
</html>
|