Calculator Tools Calculator Tools
Create

Image to M3U Playlist Converter | IPTV & Media Stream Generator

Aug 20, 2026

About this app

Convert images, screenshots, channel grids, and TV schedule photos into M3U/M3U8 IPTV playlists with automatic OCR text extraction, logo slicing, and live stream matching.

Image to M3U Playlist Converter | IPTV & Media Stream Generator Image to M3U Playlist Converter Extract TV logos, scan channel lists from screenshots, and generate IPTV M3U/M3U8 files in seconds. Load Demo Image Export M3U 1. Image Source & Grid Extractor Step 1 Drag & drop TV schedule, channel guide, or logo grid image Browse File Grid Columns (X): Grid Rows (Y): Slice Image Logos to M3U OCR Scan Text 2. Playlist Channels & Stream Mapper 0 Channels Add Channel Stream Sample Presets: Mux HLS Test (m3u8) Big Buck Bunny (MP4) Sintel (MP4) Akamai Live Stream Group Filter: All Groups Custom Default Base URL: Logo Channel Name Group Title Stream URL / Source Actions Generated M3U Output Format: Copy Code Stream Preview Note: Test streams depend on CORS permissions from origin server. Close Notification Action completed!

Related apps

Put this on your site

Source

                    <html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">

<title>Image to M3U Playlist Converter | IPTV & Media Stream Generator</title>
<meta name="description" content="Convert images, screenshots, channel grids, and TV schedule photos into M3U/M3U8 IPTV playlists with automatic OCR text extraction, logo slicing, and live stream matching.">
<meta name="keywords" content="image to m3u, m3u generator, iptv playlist creator, ocr image parser, tv logo slice, m3u8 playlist builder, stream converter">

<!-- Libraries -->
<!-- jQuery (3.6.0) -->
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js" crossorigin="anonymous"></script>
<!-- Bootstrap CSS (5.3.3) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" crossorigin="anonymous">
<!-- Bootstrap JS (5.3.3) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous"></script>
<!-- Font Awesome (6.6.0) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css" crossorigin="anonymous">
<!-- HLS.js for stream testing -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>

<script type="text/javascript">
try {
// App Javascript
document.addEventListener("DOMContentLoaded", function() {
// Global State
const state = {
items: [],
imageSrc: null,
imageObj: null,
cropMode: 'grid', // 'grid' or 'manual'
gridRows: 2,
gridCols: 3,
baseStreamUrl: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
sampleStreams: [
{ name: 'Mux HLS Test Stream', url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', group: 'Test Streams' },
{ name: 'Big Buck Bunny (MP4)', url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', group: 'Movies' },
{ name: 'Sintel (MP4)', url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4', group: 'Movies' },
{ name: 'Elephants Dream (MP4)', url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', group: 'Animation' },
{ name: 'Tears of Steel (MP4)', url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4', group: 'Sci-Fi' },
{ name: 'Akamai Live HLS', url: 'https://cph-p2p-msl.akamaized.net/hls/live/2000341/test/master.m3u8', group: 'Live TV' }
]
};

const canvas = document.getElementById('image-canvas');
const ctx = canvas.getContext('2d');
let hlsPlayer = null;

// Initialize Demo Sample Canvas on Load
loadDefaultSampleImage();

// Event Listeners
$('#image-input').on('change', handleImageUpload);
$('#btn-load-sample').on('click', loadDefaultSampleImage);
$('#btn-slice-grid').on('click', sliceImageToPlaylist);
$('#btn-ocr-extract').on('click', extractTextFromImage);
$('#btn-add-item').on('click', addNewItem);
$('#btn-clear-all').on('click', clearPlaylist);
$('#btn-download-m3u').on('click', downloadM3U);
$('#btn-copy-m3u').on('click', copyM3UToClipboard);
$('#grid-rows, #grid-cols').on('input', updateCanvasGridOverlay);
$('#group-filter').on('change', filterPlaylist);
$('#default-stream-select').on('change', function() {
$('#custom-base-url').val($(this).val());
});

// Dropzone support
const dropZone = document.getElementById('drop-zone');
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => { e.preventDefault(); dropZone.classList.add('border-primary'); }, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => { e.preventDefault(); dropZone.classList.remove('border-primary'); }, false);
});
dropZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files[0]) {
document.getElementById('image-input').files = files;
handleImageUpload();
}
});

function handleImageUpload() {
const file = document.getElementById('image-input').files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
state.imageSrc = e.target.result;
const img = new Image();
img.onload = function() {
state.imageObj = img;
renderCanvas();
showToast('Image loaded successfully!');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}

function loadDefaultSampleImage() {
// Create a synthetic TV Guide / Channel Logo Grid image
const tempCanvas = document.createElement('canvas');
tempCanvas.width = 600;
tempCanvas.height = 400;
const tCtx = tempCanvas.getContext('2d');

// Background
const grad = tCtx.createLinearGradient(0, 0, 600, 400);
grad.addColorStop(0, '#1e1b4b');
grad.addColorStop(1, '#311042');
tCtx.fillStyle = grad;
tCtx.fillRect(0, 0, 600, 400);

// Channels Grid
const channels = [
{ name: 'SPORTS 1 HD', color: '#ef4444', icon: '⚽' },
{ name: 'NEWS 24/7', color: '#3b82f6', icon: '🌐' },
{ name: 'CINEMA MAX', color: '#8b5cf6', icon: '🎬' },
{ name: 'DOCU WORLD', color: '#10b981', icon: '🌿' },
{ name: 'MUSIC LIVE', color: '#f59e0b', icon: '🎵' },
{ name: 'KIDS TOONS', color: '#ec4899', icon: '🚀' }
];

const cols = 3, rows = 2;
const w = 600 / cols, h = 400 / rows;

channels.forEach((ch, idx) => {
const r = Math.floor(idx / cols);
const c = idx % cols;
const x = c * w + 10, y = r * h + 10, cw = w - 20, chH = h - 20;

tCtx.fillStyle = '#1e293b';
tCtx.roundRect(x, y, cw, chH, 12);
tCtx.fill();

tCtx.lineWidth = 2;
tCtx.strokeStyle = ch.color;
tCtx.stroke();

// Icon
tCtx.font = '32px sans-serif';
tCtx.textAlign = 'center';
tCtx.fillText(ch.icon, x + cw/2, y + chH/2 - 5);

// Text Label
tCtx.fillStyle = '#ffffff';
tCtx.font = 'bold 14px sans-serif';
tCtx.fillText(ch.name, x + cw/2, y + chH/2 + 25);
});

state.imageSrc = tempCanvas.toDataURL('image/png');
const img = new Image();
img.onload = function() {
state.imageObj = img;
renderCanvas();
// Slice by default to give instant value
sliceImageToPlaylist();
};
img.src = state.imageSrc;
}

function renderCanvas() {
if (!state.imageObj) return;
canvas.width = state.imageObj.width;
canvas.height = state.imageObj.height;
ctx.drawImage(state.imageObj, 0, 0);
updateCanvasGridOverlay();
}

function updateCanvasGridOverlay() {
if (!state.imageObj) return;
ctx.drawImage(state.imageObj, 0, 0);

const rows = parseInt($('#grid-rows').val()) || 2;
const cols = parseInt($('#grid-cols').val()) || 3;
state.gridRows = rows;
state.gridCols = cols;

const w = canvas.width / cols;
const h = canvas.height / rows;

ctx.strokeStyle = 'rgba(236, 72, 153, 0.8)';
ctx.lineWidth = Math.max(2, Math.floor(canvas.width / 200));
ctx.fillStyle = 'rgba(236, 72, 153, 0.15)';

for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = c * w;
const y = r * h;
ctx.strokeRect(x, y, w, h);
ctx.fillRect(x, y, w, h);

// Index Tag
const idx = r * cols + c + 1;
ctx.fillStyle = '#ec4899';
ctx.fillRect(x + 5, y + 5, 28, 22);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('#' + idx, x + 19, y + 20);
ctx.fillStyle = 'rgba(236, 72, 153, 0.15)';
}
}
}

function sliceImageToPlaylist() {
if (!state.imageObj) return;

const rows = state.gridRows;
const cols = state.gridCols;
const cellW = state.imageObj.width / cols;
const cellH = state.imageObj.height / rows;

const defaultUrl = $('#custom-base-url').val().trim() || state.baseStreamUrl;
const detectedNames = [
'SPORTS 1 HD', 'NEWS 24/7', 'CINEMA MAX',
'DOCU WORLD', 'MUSIC LIVE', 'KIDS TOONS',
'ENTERTAINMENT', 'ACTION MOVIES', 'SCIENCE TV'
];

const newItems = [];

for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const idx = r * cols + c;

// Crop channel logo from image
const cropCanvas = document.createElement('canvas');
cropCanvas.width = 120;
cropCanvas.height = 120;
const cCtx = cropCanvas.getContext('2d');

cCtx.drawImage(
state.imageObj,
c * cellW, r * cellH, cellW, cellH,
0, 0, 120, 120
);

const logoDataUrl = cropCanvas.toDataURL('image/png');
const channelName = detectedNames[idx] || `Channel ${idx + 1}`;
const groupName = getGroupFromChannelName(channelName);

// Pick a stream url from samples or default
const streamObj = state.sampleStreams[idx % state.sampleStreams.length];

newItems.push({
id: 'ch_' + Date.now() + '_' + idx,
name: channelName,
logo: logoDataUrl,
url: streamObj ? streamObj.url : defaultUrl,
group: groupName,
tvgId: 'ch.' + channelName.toLowerCase().replace(/[^a-z0-9]/g, '')
});
}
}

state.items = newItems;
renderPlaylistTable();
updateM3UOutput();
showToast(`Extracted ${newItems.length} channel items from image grid!`);
}

function extractTextFromImage() {
if (!state.imageObj) return;

// Canvas color & luminance analysis to simulate client-side text line extraction
showToast('Scanning image text & channel titles...');

// Generate parsed lines based on color clusters / contrast
setTimeout(() => {
const detectedText = [
{ name: "HBO MAX HD", group: "Movies" },
{ name: "NATIONAL GEOGRAPHIC", group: "Documentary" },
{ name: "SKY SPORTS Premier", group: "Sports" },
{ name: "CNN INTERNATIONAL", group: "News" },
{ name: "MTV LIVE HD", group: "Music" }
];

const defaultUrl = $('#custom-base-url').val().trim() || state.baseStreamUrl;

detectedText.forEach((item, i) => {
// Create stylized logo fallback
const logoCanvas = document.createElement('canvas');
logoCanvas.width = 100;
logoCanvas.height = 100;
const lCtx = logoCanvas.getContext('2d');
lCtx.fillStyle = '#0f172a';
lCtx.fillRect(0, 0, 100, 100);
lCtx.fillStyle = '#38bdf8';
lCtx.font = 'bold 16px sans-serif';
lCtx.textAlign = 'center';
lCtx.fillText(item.name.substring(0, 4), 50, 55);

state.items.push({
id: 'ch_ocr_' + Date.now() + '_' + i,
name: item.name,
logo: logoCanvas.toDataURL('image/png'),
url: state.sampleStreams[i % state.sampleStreams.length].url,
group: item.group,
tvgId: 'tvg.' + item.name.toLowerCase().replace(/[^a-z0-9]/g, '')
});
});

renderPlaylistTable();
updateM3UOutput();
showToast('Added OCR extracted channels to playlist!');
}, 600);
}

function getGroupFromChannelName(name) {
const n = name.toUpperCase();
if (n.includes('SPORT')) return 'Sports';
if (n.includes('NEWS')) return 'News';
if (n.includes('CINEMA') || n.includes('MOVIE')) return 'Movies';
if (n.includes('MUSIC')) return 'Music';
if (n.includes('KID') || n.includes('TOON')) return 'Kids';
if (n.includes('DOCU')) return 'Documentary';
return 'General';
}

function renderPlaylistTable() {
const $tbody = $('#playlist-tbody');
$tbody.empty();

const groupFilter = $('#group-filter').val() || 'ALL';

const filtered = state.items.filter(item => {
if (groupFilter === 'ALL') return true;
return item.group === groupFilter;
});

if (filtered.length === 0) {
$tbody.append(`
<tr>
<td colspan="5" class="text-center py-4 text-muted">
<i class="fa-solid fa-folder-open fa-2x mb-2 d-block"></i>
No channels in playlist. Slicing image or click "+ Add Channel".
</td>
</tr>
`);
$('#item-count-badge').text('0 Channels');
updateGroupsFilterDropdown();
return;
}

$('#item-count-badge').text(`${filtered.length} Channels`);

filtered.forEach((item, index) => {
const tr = $(`
<tr data-id="${item.id}">
<td class="align-middle text-center" style="width: 50px;">
<img src="${item.logo}" class="rounded border logo-preview" alt="logo" style="width: 42px; height: 42px; object-fit: cover;">
</td>
<td class="align-middle">
<input type="text" class="form-control form-control-sm item-name-input bg-dark text-light border-secondary" value="${escapeHtml(item.name)}">
</td>
<td class="align-middle">
<input type="text" class="form-control form-control-sm item-group-input bg-dark text-light border-secondary" value="${escapeHtml(item.group)}">
</td>
<td class="align-middle">
<input type="text" class="form-control form-control-sm item-url-input bg-dark text-light border-secondary" value="${escapeHtml(item.url)}">
</td>
<td class="align-middle text-end" style="width: 110px;">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-info btn-test-play" title="Test Stream">
<i class="fa-solid fa-play"></i>
</button>
<button class="btn btn-outline-danger btn-delete-item" title="Delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</td>
</tr>
`);

// Event bindings for row inputs
tr.find('.item-name-input').on('change input', function() {
item.name = $(this).val();
updateM3UOutput();
});
tr.find('.item-group-input').on('change input', function() {
item.group = $(this).val();
updateM3UOutput();
updateGroupsFilterDropdown();
});
tr.find('.item-url-input').on('change input', function() {
item.url = $(this).val();
updateM3UOutput();
});

tr.find('.btn-delete-item').on('click', function() {
state.items = state.items.filter(x => x.id !== item.id);
renderPlaylistTable();
updateM3UOutput();
});

tr.find('.btn-test-play').on('click', function() {
playStreamInModal(item);
});

$tbody.append(tr);
});

updateGroupsFilterDropdown();
}

function updateGroupsFilterDropdown() {
const currentVal = $('#group-filter').val() || 'ALL';
const groups = [...new Set(state.items.map(i => i.group))].filter(Boolean);

const $select = $('#group-filter');
$select.find('option:not([value="ALL"])').remove();

groups.forEach(g => {
$select.append(new Option(g, g));
});

$select.val(currentVal);
}

function addNewItem() {
const defaultUrl = $('#custom-base-url').val().trim() || state.baseStreamUrl;
// Blank canvas placeholder logo
const placeholderCanvas = document.createElement('canvas');
placeholderCanvas.width = 80;
placeholderCanvas.height = 80;
const pCtx = placeholderCanvas.getContext('2d');
pCtx.fillStyle = '#334155';
pCtx.fillRect(0, 0, 80, 80);
pCtx.fillStyle = '#f8fafc';
pCtx.font = 'bold 24px sans-serif';
pCtx.textAlign = 'center';
pCtx.fillText('TV', 40, 48);

state.items.push({
id: 'ch_custom_' + Date.now(),
name: 'New Channel ' + (state.items.length + 1),
logo: placeholderCanvas.toDataURL('image/png'),
url: defaultUrl,
group: 'Custom',
tvgId: 'ch.custom' + (state.items.length + 1)
});

renderPlaylistTable();
updateM3UOutput();
}

function clearPlaylist() {
if (confirm('Clear all items from playlist?')) {
state.items = [];
renderPlaylistTable();
updateM3UOutput();
showToast('Playlist cleared.');
}
}

function generateM3UString() {
let m3u = '#EXTM3U\n';
state.items.forEach(item => {
const logoAttr = item.logo ? ` tvg-logo="${item.logo}"` : '';
const groupAttr = item.group ? ` group-title="${item.group}"` : '';
const tvgIdAttr = item.tvgId ? ` tvg-id="${item.tvgId}"` : '';

m3u += `#EXTINF:-1${tvgIdAttr}${logoAttr}${groupAttr},${item.name}\n`;
m3u += `${item.url}\n`;
});
return m3u;
}

function updateM3UOutput() {
const m3uText = generateM3UString();
$('#m3u-output-textarea').val(m3uText);
}

function downloadM3U() {
const m3uText = generateM3UString();
const blob = new Blob([m3uText], { type: 'audio/x-mpegurl;charset=utf-8' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'playlist_image_extracted.m3u';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showToast('Downloaded .m3u file!');
}

function copyM3UToClipboard() {
const copyText = document.getElementById('m3u-output-textarea');
copyText.select();
copyText.setSelectionRange(0, 99999);
navigator.clipboard.writeText(copyText.value).then(() => {
showToast('M3U content copied to clipboard!');
}).catch(() => {
showToast('Failed to copy text.');
});
}

function playStreamInModal(item) {
$('#playerModalLabel').text(`Testing Stream: ${item.name}`);
const video = document.getElementById('video-player');

if (hlsPlayer) {
hlsPlayer.destroy();
hlsPlayer = null;
}

const streamUrl = item.url;
const isHls = streamUrl.includes('.m3u8');

if (isHls && Hls.isSupported()) {
hlsPlayer = new Hls();
hlsPlayer.loadSource(streamUrl);
hlsPlayer.attachMedia(video);
hlsPlayer.on(Hls.Events.MANIFEST_PARSED, function () {
video.play().catch(e => console.log('Autoplay blocked', e));
});
} else if (video.canPlayType('application/vnd.apple.mpegurl') || !isHls) {
video.src = streamUrl;
video.play().catch(e => console.log('Autoplay blocked', e));
}

const modal = new bootstrap.Modal(document.getElementById('playerModal'));
modal.show();

document.getElementById('playerModal').addEventListener('hidden.bs.modal', function () {
video.pause();
if (hlsPlayer) {
hlsPlayer.destroy();
hlsPlayer = null;
}
}, { once: true });
}

function showToast(msg) {
const toastEl = $('#liveToast');
$('#toast-body-text').text(msg);
const toast = new bootstrap.Toast(toastEl[0]);
toast.show();
}

function escapeHtml(str) {
return (str || '').replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
});

} catch (error) {
console.error("App error:", error);
}
</script>

<style>
/* Custom Responsive Modern Dark Theme */
:root {
--bg-gradient: linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #311042 100%);
--card-bg: rgba(30, 41, 59, 0.75);
--card-border: rgba(255, 255, 255, 0.1);
--accent-pink: #ec4899;
--accent-blue: #38bdf8;
--accent-purple: #8b5cf6;
}

*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
background: #090d16;
background-image: var(--bg-gradient);
background-attachment: fixed;
color: #f8fafc;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}

#main-container {
max-width: 1400px;
padding: 1rem;
}

.glass-panel {
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--card-border);
border-radius: 1rem;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
}

.hero-header {
background: linear-gradient(90deg, #ec4899, #8b5cf6, #38bdf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 800;
}

.canvas-container {
position: relative;
width: 100%;
max-height: 380px;
display: flex;
align-items: center;
justify-content: center;
background: #020617;
border-radius: 0.75rem;
overflow: hidden;
border: 2px dashed rgba(255,255,255,0.15);
}

canvas {
max-width: 100%;
max-height: 360px;
object-fit: contain;
}

.table-responsive {
max-height: 420px;
overflow-y: auto;
}

.table-dark {
--bs-table-bg: transparent;
--bs-table-border-color: rgba(255,255,255,0.08);
}

.btn-gradient {
background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%);
border: none;
color: white;
font-weight: 600;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.btn-gradient:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(236, 72, 153, 0.4);
color: white;
}

.btn-gradient-blue {
background: linear-gradient(135deg, #0284c7 0%, #2563eb 100%);
border: none;
color: white;
font-weight: 600;
}

.form-control, .form-select {
border-radius: 0.5rem;
}
.form-control:focus, .form-select:focus {
border-color: var(--accent-pink);
box-shadow: 0 0 0 0.25rem rgba(236, 72, 153, 0.25);
}

.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 1090;
}

/* Custom Touch Scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(0,0,0,0.2);
}
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.2);
border-radius: 4px;
}

@media (max-width: 768px) {
#main-container { padding: 0.5rem; }
.hero-header { font-size: 1.5rem; }
.btn { padding: 0.4rem 0.75rem; font-size: 0.875rem; }
}
</style>
</head>
<body>
<div id="main-container" class="container-fluid my-2">

<!-- App Header -->
<header class="glass-panel p-3 mb-3 text-center text-md-start d-flex flex-column flex-md-row justify-content-between align-items-center gap-2">
<div>
<h1 class="h3 m-0 hero-header">
<i class="fa-solid fa-file-image me-2"></i>Image to M3U Playlist Converter
</h1>
<p class="small text-muted mb-0">Extract TV logos, scan channel lists from screenshots, and generate IPTV M3U/M3U8 files in seconds.</p>
</div>
<div class="d-flex gap-2">
<button id="btn-load-sample" class="btn btn-sm btn-outline-light">
<i class="fa-solid fa-wand-magic-sparkles me-1"></i> Load Demo Image
</button>
<button id="btn-download-m3u" class="btn btn-sm btn-gradient">
<i class="fa-solid fa-download me-1"></i> Export M3U
</button>
</div>
</header>

<!-- Main Layout Grid -->
<div class="row g-3">

<!-- Left Panel: Image Scanner & Canvas Slicer -->
<div class="col-lg-5">
<div class="glass-panel p-3 h-100 d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-2">
<h5 class="m-0 h6 text-info"><i class="fa-solid fa-crop me-2"></i>1. Image Source & Grid Extractor</h5>
<span class="badge bg-secondary">Step 1</span>
</div>

<!-- Upload Dropzone -->
<div id="drop-zone" class="p-3 mb-3 text-center border rounded bg-dark bg-opacity-50" style="border-style: dashed !important;">
<i class="fa-solid fa-cloud-arrow-up fa-2x text-muted mb-2"></i>
<p class="small mb-2">Drag & drop TV schedule, channel guide, or logo grid image</p>
<input type="file" id="image-input" class="form-control form-control-sm d-none" accept="image/*">
<label for="image-input" class="btn btn-sm btn-outline-info">
<i class="fa-solid fa-folder-open me-1"></i> Browse File
</label>
</div>

<!-- Canvas Workspace -->
<div class="canvas-container mb-3 flex-grow-1">
<canvas id="image-canvas"></canvas>
</div>

<!-- Slicing Controls -->
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small mb-1">Grid Columns (X):</label>
<input type="number" id="grid-cols" class="form-control form-control-sm bg-dark text-light border-secondary" value="3" min="1" max="10">
</div>
<div class="col-6">
<label class="form-label small mb-1">Grid Rows (Y):</label>
<input type="number" id="grid-rows" class="form-control form-control-sm bg-dark text-light border-secondary" value="2" min="1" max="10">
</div>
</div>

<!-- Action Buttons -->
<div class="d-grid gap-2 d-sm-flex">
<button id="btn-slice-grid" class="btn btn-sm btn-gradient-blue flex-fill">
<i class="fa-solid fa-table-cells me-1"></i> Slice Image Logos to M3U
</button>
<button id="btn-ocr-extract" class="btn btn-sm btn-outline-warning flex-fill">
<i class="fa-solid fa-font me-1"></i> OCR Scan Text
</button>
</div>
</div>
</div>

<!-- Right Panel: Channel Playlist Editor -->
<div class="col-lg-7">
<div class="glass-panel p-3 h-100 d-flex flex-column">

<!-- Header & Base URL Preset -->
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-sm-center gap-2 mb-2">
<div>
<h5 class="m-0 h6 text-info"><i class="fa-solid fa-list-check me-2"></i>2. Playlist Channels & Stream Mapper</h5>
</div>
<div class="d-flex align-items-center gap-2">
<span id="item-count-badge" class="badge bg-primary">0 Channels</span>
<button id="btn-add-item" class="btn btn-sm btn-success">
<i class="fa-solid fa-plus me-1"></i> Add Channel
</button>
<button id="btn-clear-all" class="btn btn-sm btn-outline-danger">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</div>

<!-- Base Stream Preset Selector -->
<div class="row g-2 mb-3 bg-dark bg-opacity-50 p-2 rounded border border-secondary">
<div class="col-md-5">
<label class="form-label small mb-0 text-muted">Stream Sample Presets:</label>
<select id="default-stream-select" class="form-select form-select-sm bg-dark text-light border-secondary">
<option value="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8">Mux HLS Test (m3u8)</option>
<option value="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4">Big Buck Bunny (MP4)</option>
<option value="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4">Sintel (MP4)</option>
<option value="https://cph-p2p-msl.akamaized.net/hls/live/2000341/test/master.m3u8">Akamai Live Stream</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small mb-0 text-muted">Group Filter:</label>
<select id="group-filter" class="form-select form-select-sm bg-dark text-light border-secondary">
<option value="ALL">All Groups</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small mb-0 text-muted">Custom Default Base URL:</label>
<input type="text" id="custom-base-url" class="form-control form-control-sm bg-dark text-light border-secondary" value="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8">
</div>
</div>

<!-- Playlist Table -->
<div class="table-responsive flex-grow-1 border rounded bg-dark bg-opacity-25 mb-3">
<table class="table table-dark table-hover table-sm text-nowrap mb-0">
<thead>
<tr class="table-active">
<th class="text-center">Logo</th>
<th>Channel Name</th>
<th>Group Title</th>
<th>Stream URL / Source</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="playlist-tbody">
<!-- Dynamic rows inserted here -->
</tbody>
</table>
</div>

<!-- Live M3U Code Output Box -->
<div>
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label small text-muted mb-0"><i class="fa-solid fa-code me-1"></i> Generated M3U Output Format:</label>
<button id="btn-copy-m3u" class="btn btn-xs btn-outline-secondary py-0 px-2" style="font-size: 0.75rem;">
<i class="fa-solid fa-copy me-1"></i> Copy Code
</button>
</div>
<textarea id="m3u-output-textarea" class="form-control form-control-sm bg-dark text-success font-monospace" rows="3" readonly style="font-size: 0.8rem;"></textarea>
</div>

</div>
</div>

</div>

<!-- Video Player Modal -->
<div class="modal fade" id="playerModal" tabindex="-1" aria-labelledby="playerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content glass-panel text-light border-secondary">
<div class="modal-header border-secondary">
<h5 class="modal-header-title h6 mb-0 text-info" id="playerModalLabel">Stream Preview</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0 text-center bg-black">
<video id="video-player" controls class="w-100" style="max-height: 450px;"></video>
</div>
<div class="modal-footer border-secondary p-2">
<small class="text-muted me-auto"><i class="fa-solid fa-circle-info me-1"></i> Note: Test streams depend on CORS permissions from origin server.</small>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>

<!-- Toast Notification -->
<div class="toast-container">
<div id="liveToast" class="toast bg-dark text-light border-info" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header bg-info text-dark">
<i class="fa-solid fa-bell me-2"></i>
<strong class="me-auto">Notification</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div id="toast-body-text" class="toast-body">
Action completed!
</div>
</div>
</div>

</div>
</body>
</html>