From 9404470a3627cbfb8781a7d19bfc817d88b9d0f8 Mon Sep 17 00:00:00 2001 From: koby455 Date: Tue, 16 Jun 2026 20:21:52 +0300 Subject: [PATCH 1/6] Add ARVIO Companion web app at /companion/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vanilla JS + Supabase single-page app served alongside the existing arvio.tv marketing site. No build step required — just static files. Features: - Google OAuth login (via Supabase GoTrue) - Dashboard with watch stats and recent activity - Profiles view — shows all ARVIO sub-profiles with active/kids badges - Addons manager — lists all Stremio + Telegram addons with enabled status - Watch History — paginated by movie/tv/all, delete individual entries - Watchlist — view and remove items - AI Subtitle Translation — toggle on/off, select model (Groq Llama 70B / Gemini Flash 2.5), configure auto-select and hearing-impaired removal; settings saved directly to cloud sync payload - Settings — card layout, language, OLED mode, skip profile selection Data layer: reads/writes the cloud sync payload stored in profiles.addons.__arvioAccountSyncPayload (same format the TV app uses), and queries watch_history/watchlist tables directly via Supabase REST. Co-Authored-By: koby455 --- netlify-arvio-tv-site/companion/app.js | 725 +++++++++++++++++++++ netlify-arvio-tv-site/companion/index.html | 41 ++ netlify-arvio-tv-site/companion/style.css | 464 +++++++++++++ 3 files changed, 1230 insertions(+) create mode 100644 netlify-arvio-tv-site/companion/app.js create mode 100644 netlify-arvio-tv-site/companion/index.html create mode 100644 netlify-arvio-tv-site/companion/style.css diff --git a/netlify-arvio-tv-site/companion/app.js b/netlify-arvio-tv-site/companion/app.js new file mode 100644 index 00000000..76226611 --- /dev/null +++ b/netlify-arvio-tv-site/companion/app.js @@ -0,0 +1,725 @@ +// ── Supabase Config ─────────────────────────────────────────────────────── +const SUPABASE_URL = 'https://zrdwvortcfnoykltzuqf.supabase.co'; +const SUPABASE_ANON = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpyZHd2b3J0Y2Zub3lrbHR6dXFmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjY3NDU4NzMsImV4cCI6MjA4MjMyMTg3M30.YfKZbSwxGs6_xMd6jkDtn1PKkfuyOHo9qVhUvFRddGU'; +const TMDB_IMG = 'https://image.tmdb.org/t/p/w92'; + +const { createClient } = supabase; +const db = createClient(SUPABASE_URL, SUPABASE_ANON); + +// ── State ───────────────────────────────────────────────────────────────── +let state = { + session: null, + userId: null, + syncPayload: null, + activeSection: 'dashboard', + historyTab: 'movies', +}; + +// ── Toast ───────────────────────────────────────────────────────────────── +function toast(msg, type = 'ok') { + const el = document.getElementById('toast'); + el.textContent = msg; + el.className = `show toast-${type}`; + clearTimeout(el._t); + el._t = setTimeout(() => { el.className = ''; }, 3000); +} + +// ── Auth ────────────────────────────────────────────────────────────────── +async function signInGoogle() { + const { error } = await db.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: window.location.href }, + }); + if (error) toast(error.message, 'err'); +} + +async function signOut() { + await db.auth.signOut(); + location.reload(); +} + +// ── Cloud Sync Payload ──────────────────────────────────────────────────── +async function loadSyncPayload() { + try { + const { data, error } = await db + .from('profiles') + .select('addons') + .eq('id', state.userId) + .single(); + if (error || !data?.addons) return null; + const wrapper = JSON.parse(data.addons); + const raw = wrapper['__arvioAccountSyncPayload']; + if (!raw) return null; + return JSON.parse(raw); + } catch { return null; } +} + +async function saveSyncPayload(payload) { + const raw = JSON.stringify(payload); + const wrapper = JSON.stringify({ + __arvioAccountSyncPayload: raw, + __arvioAccountSyncUpdatedAt: new Date().toISOString(), + }); + const { error } = await db + .from('profiles') + .update({ addons: wrapper }) + .eq('id', state.userId); + if (error) throw error; + state.syncPayload = payload; +} + +// ── Sections ────────────────────────────────────────────────────────────── +const sections = { + dashboard: renderDashboard, + profiles: renderProfiles, + addons: renderAddons, + history: renderHistory, + watchlist: renderWatchlist, + ai: renderAI, + settings: renderSettings, +}; + +function navigate(id) { + state.activeSection = id; + document.querySelectorAll('.nav-item').forEach(el => { + el.classList.toggle('active', el.dataset.section === id); + }); + renderSection(); +} + +async function renderSection() { + const main = document.getElementById('main-content'); + main.innerHTML = `
טוען...
`; + await sections[state.activeSection]?.(); +} + +// ── Dashboard ───────────────────────────────────────────────────────────── +async function renderDashboard() { + const main = document.getElementById('main-content'); + + const [histRes, wlRes] = await Promise.all([ + db.from('watch_history').select('id, media_type', { count: 'exact', head: false }) + .eq('user_id', state.userId).limit(1), + db.from('watchlist').select('tmdb_id', { count: 'exact', head: false }) + .eq('user_id', state.userId).limit(1), + ]); + + const payload = state.syncPayload; + const profiles = payload?.profiles ?? []; + const addons = getAddons(payload); + + main.innerHTML = ` +
+
+
Dashboard
+
סקירה מהירה של חשבון ה-ARVIO שלך
+
+
+
+
פרופילים
${profiles.length || 1}
+
הרחבות
${addons.filter(a => a.isEnabled).length}
+
היסטוריית צפייה
${histRes.count ?? '—'}
+
רשימת צפייה
${wlRes.count ?? '—'}
+
+
+
פעילות אחרונה
+
+
+ `; + + const { data: recent } = await db.from('watch_history') + .select('title, media_type, progress, updated_at, poster_path, backdrop_path') + .eq('user_id', state.userId) + .order('updated_at', { ascending: false }) + .limit(6); + + const ra = document.getElementById('recent-activity'); + if (!recent?.length) { + ra.innerHTML = emptyState('אין פעילות אחרונה'); + return; + } + ra.innerHTML = recent.map(r => ` +
+ +
+
${r.title || '—'}
+
${r.media_type === 'movie' ? '🎬 סרט' : '📺 סדרה'} · ${timeAgo(r.updated_at)}
+
+
+ ${Math.round((r.progress||0)*100)}% +
+ `).join(''); +} + +// ── Profiles ────────────────────────────────────────────────────────────── +function getProfileColors() { + return ['#E53935','#8E24AA','#1E88E5','#00897B','#F4511E','#6D4C41','#3949AB','#039BE5']; +} + +function renderProfileAvatar(profile) { + const color = '#' + (profile.avatarColor?.toString(16).padStart(8,'0').slice(2) || 'F5C442'); + const letter = (profile.name || '?')[0].toUpperCase(); + return `
${letter}
`; +} + +async function renderProfiles() { + const main = document.getElementById('main-content'); + const payload = state.syncPayload; + const profiles = payload?.profiles ?? []; + const activeId = payload?.activeProfileId; + + if (!profiles.length) { + main.innerHTML = ` +
פרופילים
+ ${emptyState('אין פרופילים — פתח את ARVIO בטלוויזיה ליצירת פרופיל')}`; + return; + } + + main.innerHTML = ` +
+
פרופילים
${profiles.length} פרופילים בחשבון
+
+
+ ${profiles.map(p => ` +
+ ${renderProfileAvatar(p)} +
${p.name}
+
+ ${p.id === activeId ? 'פעיל' : ''} + ${p.isKidsProfile ? 'ילדים' : ''} + ${p.pin ? '🔒 נעול' : ''} +
+
+ `).join('')} +
+
+
💡 ניהול פרופילים (יצירה, מחיקה, שינוי שם) זמין ב-ARVIO על הטלוויזיה.
שינויים מסונכרנים אוטומטית עם הענן.
+
+ `; +} + +// ── Addons ──────────────────────────────────────────────────────────────── +function getAddons(payload) { + if (!payload) return []; + const shared = payload.addons ?? []; + const byProfile = payload.addonsByProfile ?? {}; + const allIds = new Set(); + const all = [...shared]; + Object.values(byProfile).forEach(arr => { + (arr || []).forEach(a => { if (!allIds.has(a.id)) { allIds.add(a.id); all.push(a); } }); + }); + return all; +} + +async function renderAddons() { + const main = document.getElementById('main-content'); + const addons = getAddons(state.syncPayload); + + if (!addons.length) { + main.innerHTML = ` +
הרחבות
+ ${emptyState('אין הרחבות מותקנות')}`; + return; + } + + const byType = { + STREMIO: addons.filter(a => a.runtimeKind !== 'TELEGRAM'), + TELEGRAM: addons.filter(a => a.runtimeKind === 'TELEGRAM'), + }; + + function addonIcon(a) { + if (a.manifest?.logo || a.logo) { + return ``; + } + return ``; + } + + function addonTypeBadge(a) { + const t = a.type || 'COMMUNITY'; + if (t === 'OFFICIAL') return 'רשמי'; + if (t === 'SUBTITLE') return 'כתוביות'; + if (t === 'METADATA') return 'מטאדאטה'; + if (a.runtimeKind === 'TELEGRAM') return 'Telegram'; + return 'קהילה'; + } + + function renderAddonList(list, label) { + if (!list.length) return ''; + return ` +
${label}
+
+ ${list.map(a => ` +
+
${addonIcon(a)}
+
+
${a.name || a.id}
+
${a.manifest?.description || a.description || a.id}
+
+
+ ${addonTypeBadge(a)} + ${a.isEnabled ? 'פעיל' : 'כבוי'} +
+
+ `).join('')} +
+ `; + } + + main.innerHTML = ` +
+
הרחבות
${addons.filter(a=>a.isEnabled).length} פעילות מתוך ${addons.length}
+
+ ${renderAddonList(byType.STREMIO, `Stremio הרחבות (${byType.STREMIO.length})`)} + ${renderAddonList(byType.TELEGRAM, `Telegram מקורות (${byType.TELEGRAM.length})`)} +
+
💡 להוספה/הסרה של הרחבות — גש להגדרות ב-ARVIO. הנתונים מסונכרנים אוטומטית.
+
+ `; +} + +// ── Watch History ───────────────────────────────────────────────────────── +async function renderHistory() { + const main = document.getElementById('main-content'); + + main.innerHTML = ` +
+
היסטוריית צפייה
+
+
+ + + +
+
+ `; + await renderHistoryContent(); +} + +async function renderHistoryContent() { + document.querySelectorAll('.tab').forEach(t => { + const map = { movies: 'סרטים', tv: 'סדרות', all: 'הכל' }; + t.classList.toggle('active', t.textContent.trim() === map[state.historyTab]); + }); + + const el = document.getElementById('history-content'); + if (!el) return; + el.innerHTML = `
`; + + let q = db.from('watch_history') + .select('*') + .eq('user_id', state.userId) + .order('updated_at', { ascending: false }) + .limit(50); + + if (state.historyTab === 'movies') q = q.eq('media_type', 'movie'); + if (state.historyTab === 'tv') q = q.eq('media_type', 'tv'); + + const { data, error } = await q; + if (error) { el.innerHTML = `
שגיאה בטעינה
`; return; } + if (!data?.length) { el.innerHTML = emptyState('אין היסטוריית צפייה'); return; } + + el.innerHTML = `
+ ${data.map(r => ` +
+ +
+
${r.title || '—'}${r.season ? ` S${String(r.season).padStart(2,'0')}E${String(r.episode||0).padStart(2,'0')}` : ''}
+
${timeAgo(r.updated_at)} · ${formatDuration(r.position_seconds)} / ${formatDuration(r.duration_seconds)}
+
+
+
+ ${Math.round((r.progress||0)*100)}% + +
+
+ `).join('')} +
`; +} + +async function deleteHistory(id, btn) { + btn.disabled = true; + const { error } = await db.from('watch_history').delete().eq('id', id).eq('user_id', state.userId); + if (error) { toast('שגיאה במחיקה', 'err'); btn.disabled = false; return; } + toast('הוסר מההיסטוריה ✓'); + btn.closest('.list-item').remove(); +} + +// ── Watchlist ───────────────────────────────────────────────────────────── +async function renderWatchlist() { + const main = document.getElementById('main-content'); + main.innerHTML = `
רשימת צפייה
`; + + const { data, error } = await db.from('watchlist') + .select('*') + .eq('user_id', state.userId) + .order('added_at', { ascending: false }); + + if (error || !data?.length) { + main.innerHTML = `
רשימת צפייה
${emptyState('רשימת הצפייה ריקה')}`; + return; + } + + const movies = data.filter(i => i.media_type === 'movie'); + const shows = data.filter(i => i.media_type === 'tv'); + + function renderWLSection(items, label) { + if (!items.length) return ''; + return ` +
${label} (${items.length})
+
+ ${items.map(i => ` +
+
+ ${i.media_type==='movie'?'':''} + +
+
+
TMDB #${i.tmdb_id}
+
נוסף ${timeAgo(i.added_at)}
+
+ +
+ `).join('')} +
`; + } + + main.innerHTML = ` +
+
רשימת צפייה
${data.length} פריטים
+
+ ${renderWLSection(movies, '🎬 סרטים')} + ${renderWLSection(shows, '📺 סדרות')} + `; +} + +async function removeWatchlist(tmdbId, mediaType, btn) { + btn.disabled = true; + const { error } = await db.from('watchlist').delete() + .eq('user_id', state.userId).eq('tmdb_id', tmdbId).eq('media_type', mediaType); + if (error) { toast('שגיאה במחיקה', 'err'); btn.disabled = false; return; } + toast('הוסר מהרשימה ✓'); + document.getElementById(`wl-${tmdbId}-${mediaType}`)?.remove(); +} + +// ── AI Subtitle Translation ──────────────────────────────────────────────── +async function renderAI() { + const main = document.getElementById('main-content'); + const payload = state.syncPayload; + const enabled = payload?.subtitleAiEnabled ?? false; + const autoSelect = payload?.subtitleAiAutoSelect ?? false; + const model = payload?.subtitleAiModel ?? 'GROQ_LLAMA_70B'; + const removeHI = payload?.subtitleRemoveHearingImpaired ?? false; + + main.innerHTML = ` +
+
תרגום כתוביות AI
הגדר תרגום כתוביות בזמן אמת על ידי AI
+
+ +
+ + + + +
+
🔑 מפתח API — הגדר ישירות בטלוויזיה
+
מטעמי אבטחה, מפתח ה-API נשמר רק מקומית בטלוויזיה.
פתח את ARVIO ← הגדרות ← כתוביות ← תרגום AI
+
+
+ +
+
הגדרות תרגום
+ +
+
+
+
הפעל תרגום AI
+
תרגם כתוביות אוטומטית בזמן צפייה
+
+ +
+
+ +
+
+
+
בחירה אוטומטית
+
בחר אוטומטית כתוביות לתרגום
+
+ +
+
+ +
+
+
+
הסר כתוביות לכבדי שמיעה
+
הסר תיאורי קול מהכתוביות [SDH]
+
+ +
+
+
+ +
+
בחר מודל AI
+
+ + +
+ +
+
איך לקבל מפתח API חינמי?
+
+ 🔹 Groq (מומלץ): גש ל-console.groq.com ← צור חשבון ← API Keys ← Create Key
+ 🔹 Gemini: גש ל-aistudio.google.com ← Get API Key +
+
+
+ `; +} + +async function updateAISetting(key, value) { + if (!state.syncPayload) { toast('אין נתוני סנכרון', 'err'); return; } + state.syncPayload[key] = value; + try { + await saveSyncPayload(state.syncPayload); + toast('נשמר ✓'); + // Update model card UI + if (key === 'subtitleAiModel') { + document.querySelectorAll('.model-card').forEach(card => { + const inp = card.querySelector('input'); + card.classList.toggle('selected', inp?.value === value); + }); + } + } catch (e) { + toast('שגיאה בשמירה', 'err'); + } +} + +// ── Settings ────────────────────────────────────────────────────────────── +async function renderSettings() { + const main = document.getElementById('main-content'); + const p = state.syncPayload; + + const cardLayout = p?.cardLayoutMode ?? 'landscape'; + const accentColor = p?.accentColor ?? '#F5C442'; + const oled = p?.oledBlackBackground ?? true; + const skipProfile = p?.skipProfileSelection ?? false; + const lang = p?.lastAppLanguage ?? 'en'; + + main.innerHTML = ` +
+
הגדרות
הגדרות כלליות לחשבון
+
+ +
+
מראה
+ +
+
+
+
רקע OLED שחור
+
רקע שחור לחלוטין לחיסכון בסוללה
+
+ +
+
+ +
+ + +
+ +
+ + +
+
+ +
+
פרופיל
+
+
+
+
דלג על בחירת פרופיל
+
עבור ישירות לאפליקציה עם הפרופיל הפעיל
+
+ +
+
+
+ +
+
חשבון
+
+
+
מזהה משתמש
+
${state.userId}
+
+
+ +
+ `; +} + +async function updateSetting(key, value) { + if (!state.syncPayload) { toast('אין נתוני סנכרון', 'err'); return; } + state.syncPayload[key] = value; + try { + await saveSyncPayload(state.syncPayload); + toast('נשמר ✓'); + } catch (e) { + toast('שגיאה בשמירה', 'err'); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────── +function timeAgo(iso) { + if (!iso) return ''; + const d = Date.now() - new Date(iso).getTime(); + const m = Math.floor(d / 60000); + if (m < 1) return 'עכשיו'; + if (m < 60) return `לפני ${m} דק׳`; + const h = Math.floor(m / 60); + if (h < 24) return `לפני ${h} שע׳`; + const days = Math.floor(h / 24); + if (days < 30) return `לפני ${days} ימים`; + return new Date(iso).toLocaleDateString('he-IL'); +} + +function formatDuration(secs) { + if (!secs) return '0:00'; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = Math.floor(secs % 60); + if (h) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; + return `${m}:${String(s).padStart(2,'0')}`; +} + +function emptyState(msg) { + return `
+ +
${msg}
+
`; +} + +// ── Shell ───────────────────────────────────────────────────────────────── +function buildShell(user) { + const name = user.user_metadata?.full_name || user.email || 'משתמש'; + const avatar = user.user_metadata?.avatar_url || ''; + const email = user.email || ''; + const initial = name[0].toUpperCase(); + + const navItems = [ + { id: 'dashboard', label: 'Dashboard', icon: '' }, + { id: 'profiles', label: 'פרופילים', icon: '' }, + { id: 'addons', label: 'הרחבות', icon: '' }, + { id: 'history', label: 'היסטוריה', icon: '' }, + { id: 'watchlist', label: 'רשימת צפייה', icon: '' }, + { id: 'ai', label: 'תרגום AI', icon: '' }, + { id: 'settings', label: 'הגדרות', icon: '' }, + ]; + + document.getElementById('app-screen').innerHTML = ` + +
+ `; +} + +// ── Boot ────────────────────────────────────────────────────────────────── +async function boot() { + const { data: { session } } = await db.auth.getSession(); + + if (!session) { + document.getElementById('auth-screen').style.display = 'flex'; + document.getElementById('app-screen').style.display = 'none'; + return; + } + + state.session = session; + state.userId = session.user.id; + + document.getElementById('auth-screen').style.display = 'none'; + document.getElementById('app-screen').style.display = 'flex'; + + buildShell(session.user); + + state.syncPayload = await loadSyncPayload(); + + await renderSection(); + + db.auth.onAuthStateChange((_e, s) => { + if (!s) location.reload(); + }); +} + +boot(); diff --git a/netlify-arvio-tv-site/companion/index.html b/netlify-arvio-tv-site/companion/index.html new file mode 100644 index 00000000..73405fd6 --- /dev/null +++ b/netlify-arvio-tv-site/companion/index.html @@ -0,0 +1,41 @@ + + + + + + ARVIO Companion + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/netlify-arvio-tv-site/companion/style.css b/netlify-arvio-tv-site/companion/style.css new file mode 100644 index 00000000..6ebd184b --- /dev/null +++ b/netlify-arvio-tv-site/companion/style.css @@ -0,0 +1,464 @@ +:root { + --bg: #000; + --bg-card: #111; + --bg-card2: #1a1a1a; + --bg-hover: #222; + --border: #2a2a2a; + --gold: #f5c442; + --gold-dim: rgba(245,196,66,0.12); + --text: #f0f0f0; + --text-muted: #888; + --text-dim: #555; + --green: #4ade80; + --red: #f87171; + --blue: #60a5fa; + --radius: 12px; + --sidebar-w: 220px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; +} + +/* ── Auth Screen ─────────────────────────────── */ +#auth-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + gap: 32px; + padding: 24px; +} + +.auth-logo { width: 56px; height: 56px; } +.auth-title { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; } +.auth-sub { color: var(--text-muted); font-size: 15px; text-align: center; max-width: 320px; } + +.btn-google { + display: flex; + align-items: center; + gap: 12px; + background: #fff; + color: #111; + border: none; + border-radius: 10px; + padding: 14px 28px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: opacity .15s; +} +.btn-google:hover { opacity: .85; } +.btn-google svg { width: 20px; height: 20px; } + +/* ── App Shell ───────────────────────────────── */ +#app-screen { display: flex; min-height: 100vh; } + +/* Sidebar */ +.sidebar { + width: var(--sidebar-w); + flex-shrink: 0; + background: var(--bg-card); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; left: 0; bottom: 0; + z-index: 10; + overflow-y: auto; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 18px 16px; + border-bottom: 1px solid var(--border); +} +.sidebar-logo img { width: 32px; height: 32px; } +.sidebar-logo span { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; } + +nav { flex: 1; padding: 12px 8px; } + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + color: var(--text-muted); + font-size: 14px; + font-weight: 500; + transition: all .15s; + border: none; + background: none; + width: 100%; + text-align: left; +} +.nav-item:hover { background: var(--bg-hover); color: var(--text); } +.nav-item.active { background: var(--gold-dim); color: var(--gold); } +.nav-item svg { width: 18px; height: 18px; flex-shrink: 0; } + +.sidebar-user { + padding: 14px 18px; + border-top: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; +} +.user-avatar { + width: 32px; height: 32px; + border-radius: 50%; + background: var(--gold-dim); + border: 1px solid var(--gold); + display: flex; align-items: center; justify-content: center; + font-size: 13px; font-weight: 600; color: var(--gold); + flex-shrink: 0; + overflow: hidden; +} +.user-avatar img { width: 100%; height: 100%; object-fit: cover; } +.user-info { flex: 1; min-width: 0; } +.user-name { font-size: 13px; font-weight: 600; truncate: clip; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.user-email { font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.btn-logout { + background: none; border: none; color: var(--text-dim); + cursor: pointer; padding: 4px; + display: flex; align-items: center; + border-radius: 6px; + transition: color .15s; +} +.btn-logout:hover { color: var(--red); } +.btn-logout svg { width: 16px; height: 16px; } + +/* Main Content */ +.main { + margin-left: var(--sidebar-w); + flex: 1; + padding: 32px; + max-width: 960px; +} + +/* ── Section Header ──────────────────────────── */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} +.section-title { font-size: 22px; font-weight: 700; letter-spacing: -0.3px; } +.section-sub { font-size: 14px; color: var(--text-muted); margin-top: 2px; } + +/* ── Cards ───────────────────────────────────── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 16px; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 14px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} +.stat-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; } +.stat-value { font-size: 28px; font-weight: 700; color: var(--text); } +.stat-value.gold { color: var(--gold); } + +/* ── Profile Cards ───────────────────────────── */ +.profiles-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 14px; +} + +.profile-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + cursor: default; + transition: border-color .15s; +} +.profile-card.active-profile { border-color: var(--gold); } +.profile-avatar-big { + width: 56px; height: 56px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 22px; font-weight: 700; + flex-shrink: 0; +} +.profile-name { font-size: 14px; font-weight: 600; text-align: center; } +.profile-badge { + font-size: 10px; padding: 2px 8px; border-radius: 20px; + background: var(--gold-dim); color: var(--gold); font-weight: 600; +} +.profile-badge.active-badge { background: var(--gold); color: #000; } +.kids-badge { background: rgba(96,165,250,0.15); color: var(--blue); } + +/* ── List Items ──────────────────────────────── */ +.list-item { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} +.list-item:last-child { border-bottom: none; } + +.item-icon { + width: 40px; height: 40px; + border-radius: 8px; + background: var(--bg-card2); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + overflow: hidden; +} +.item-icon img { width: 100%; height: 100%; object-fit: cover; border-radius: 8px; } +.item-icon svg { width: 20px; height: 20px; color: var(--text-muted); } + +.item-info { flex: 1; min-width: 0; } +.item-title { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.item-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; } + +.item-actions { display: flex; gap: 8px; flex-shrink: 0; } + +/* ── Toggle Switch ───────────────────────────── */ +.toggle { + position: relative; + width: 38px; height: 22px; + flex-shrink: 0; +} +.toggle input { opacity: 0; width: 0; height: 0; } +.toggle-slider { + position: absolute; + inset: 0; + background: var(--bg-card2); + border: 1px solid var(--border); + border-radius: 22px; + cursor: pointer; + transition: background .2s, border-color .2s; +} +.toggle-slider::before { + content: ''; + position: absolute; + width: 16px; height: 16px; + left: 2px; top: 2px; + background: var(--text-muted); + border-radius: 50%; + transition: transform .2s, background .2s; +} +.toggle input:checked + .toggle-slider { background: var(--gold-dim); border-color: var(--gold); } +.toggle input:checked + .toggle-slider::before { transform: translateX(16px); background: var(--gold); } + +/* ── Badges ──────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; +} +.badge-gold { background: var(--gold-dim); color: var(--gold); } +.badge-green { background: rgba(74,222,128,.12); color: var(--green); } +.badge-red { background: rgba(248,113,113,.12); color: var(--red); } +.badge-blue { background: rgba(96,165,250,.12); color: var(--blue); } +.badge-gray { background: var(--bg-card2); color: var(--text-muted); } + +/* ── Progress Bar ────────────────────────────── */ +.progress-bar { + height: 3px; + background: var(--border); + border-radius: 2px; + margin-top: 6px; + overflow: hidden; +} +.progress-fill { + height: 100%; + background: var(--gold); + border-radius: 2px; + transition: width .3s; +} + +/* ── Buttons ─────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 8px; + border: none; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity .15s; +} +.btn:hover { opacity: .8; } +.btn-primary { background: var(--gold); color: #000; } +.btn-ghost { background: var(--bg-card2); color: var(--text); border: 1px solid var(--border); } +.btn-danger { background: rgba(248,113,113,.12); color: var(--red); border: 1px solid rgba(248,113,113,.2); } +.btn svg { width: 14px; height: 14px; } +.btn:disabled { opacity: .4; cursor: not-allowed; } + +/* ── Form Elements ───────────────────────────── */ +.form-group { margin-bottom: 20px; } +.form-label { display: block; font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: .4px; } +.form-input, .form-select { + width: 100%; + background: var(--bg-card2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + padding: 10px 14px; + font-size: 14px; + outline: none; + transition: border-color .15s; + font-family: inherit; +} +.form-input:focus, .form-select:focus { border-color: var(--gold); } +.form-input::placeholder { color: var(--text-dim); } +.form-hint { font-size: 12px; color: var(--text-muted); margin-top: 6px; } + +/* ── AI Config Special ───────────────────────── */ +.model-card { + background: var(--bg-card2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + cursor: pointer; + transition: border-color .15s; + display: flex; + gap: 12px; + align-items: flex-start; +} +.model-card.selected { border-color: var(--gold); background: var(--gold-dim); } +.model-card input[type=radio] { margin-top: 3px; accent-color: var(--gold); flex-shrink: 0; } +.model-name { font-size: 14px; font-weight: 600; } +.model-desc { font-size: 12px; color: var(--text-muted); margin-top: 3px; } +.models-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 20px; } + +/* ── Watch History ───────────────────────────── */ +.history-poster { + width: 42px; height: 60px; + border-radius: 6px; + object-fit: cover; + background: var(--bg-card2); + flex-shrink: 0; +} + +/* ── Empty State ─────────────────────────────── */ +.empty { + text-align: center; + padding: 48px 24px; + color: var(--text-muted); +} +.empty svg { width: 48px; height: 48px; margin: 0 auto 16px; display: block; opacity: .3; } +.empty-title { font-size: 16px; font-weight: 600; color: var(--text); margin-bottom: 6px; } + +/* ── Loading ─────────────────────────────────── */ +.loading { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-muted); + padding: 32px; + justify-content: center; +} +.spinner { + width: 20px; height: 20px; + border: 2px solid var(--border); + border-top-color: var(--gold); + border-radius: 50%; + animation: spin .7s linear infinite; + flex-shrink: 0; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Toast ───────────────────────────────────── */ +#toast { + position: fixed; + bottom: 24px; right: 24px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 20px; + font-size: 14px; + font-weight: 500; + opacity: 0; + transform: translateY(8px); + transition: opacity .2s, transform .2s; + z-index: 100; + max-width: 320px; +} +#toast.show { opacity: 1; transform: translateY(0); } +#toast.toast-ok { border-color: rgba(74,222,128,.3); color: var(--green); } +#toast.toast-err { border-color: rgba(248,113,113,.3); color: var(--red); } + +/* ── Tabs ────────────────────────────────────── */ +.tabs { display: flex; gap: 4px; margin-bottom: 20px; } +.tab { + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + border: none; + background: none; + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + transition: all .15s; +} +.tab.active { background: var(--gold-dim); color: var(--gold); } +.tab:hover:not(.active) { background: var(--bg-card2); color: var(--text); } + +/* ── TV Connect Banner ───────────────────────── */ +.tv-banner { + background: var(--gold-dim); + border: 1px solid rgba(245,196,66,.25); + border-radius: var(--radius); + padding: 16px 20px; + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 24px; +} +.tv-banner svg { width: 28px; height: 28px; color: var(--gold); flex-shrink: 0; } +.tv-banner-title { font-size: 14px; font-weight: 600; color: var(--gold); } +.tv-banner-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; } + +/* ── Scrollbar ───────────────────────────────── */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +/* ── Responsive ──────────────────────────────── */ +@media (max-width: 700px) { + :root { --sidebar-w: 0px; } + .sidebar { display: none; } + .main { margin-left: 0; padding: 20px 16px; } + .card-grid { grid-template-columns: 1fr 1fr; } + .models-grid { grid-template-columns: 1fr; } +} From 28c6a63bbc49b973b26b57513f97cd0cba7b3df9 Mon Sep 17 00:00:00 2001 From: koby455 Date: Tue, 16 Jun 2026 20:42:09 +0300 Subject: [PATCH 2/6] Add IPTV + plugins cloud sync and companion web sections Android TV app (CloudSyncRepository.kt): - Inject PluginDataStore into CloudSyncRepository - buildCloudPayload: export pluginRepositories, pluginScrapers, pluginsEnabled into __arvioAccountSyncPayload so plugins survive device wipes / multi-device - applyCloudPayload: restore repositories + scrapers from cloud on first launch (scraper JS code stays local-only for security; only metadata is synced) Companion web app (app.js): - Add IPTV section: shows all M3U playlists per profile with M3U/EPG URLs, enabled status, favourite groups and favourite channels - Add Plugins section: shows all plugin repositories (name, URL, scraper count, last updated) and individual scrapers (name, version, supported types, content languages, enabled status) with global plugins toggle - Add both to sidebar navigation Co-Authored-By: koby455 --- .../tv/data/repository/CloudSyncRepository.kt | 28 +++- netlify-arvio-tv-site/companion/app.js | 146 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt index 868b2bab..15ed5841 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt @@ -91,7 +91,8 @@ class CloudSyncRepository @Inject constructor( private val watchHistoryRepository: WatchHistoryRepository, private val watchlistRepository: WatchlistRepository, private val profileAvatarImageManager: ProfileAvatarImageManager, - private val invalidationBus: CloudSyncInvalidationBus + private val invalidationBus: CloudSyncInvalidationBus, + private val pluginDataStore: com.arflix.tv.data.local.PluginDataStore ) { private val TAG = "CloudSync" private val gson = Gson() @@ -624,6 +625,16 @@ class CloudSyncRepository @Inject constructor( root.put("iptvFavoriteGroups", JSONArray(gson.toJson(iptvRepository.observeFavoriteGroups().first()))) root.put("iptvFavoriteChannels", JSONArray(gson.toJson(iptvRepository.observeFavoriteChannels().first()))) + // Plugin repositories and scrapers (sideload flavor) + runCatching { + val pluginRepos = pluginDataStore.repositories.first() + val pluginScrapers = pluginDataStore.scrapers.first() + val pluginsEnabled = pluginDataStore.pluginsEnabled.first() + root.put("pluginRepositories", JSONArray(gson.toJson(pluginRepos))) + root.put("pluginScrapers", JSONArray(gson.toJson(pluginScrapers))) + root.put("pluginsEnabled", pluginsEnabled) + } + // Informational val isTraktLinked = traktRepository.hasTrakt() root.put("traktLinked", isTraktLinked) @@ -1443,6 +1454,21 @@ class CloudSyncRepository @Inject constructor( traktRepository.clearAllProfileCaches() watchHistoryRepository.clearProfileCaches() + // Restore plugin repositories and scrapers + runCatching { + root.optJSONArray("pluginRepositories")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> + val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, com.arflix.tv.domain.model.PluginRepository::class.java).type + val repos: List = gson.fromJson(json, type) ?: emptyList() + if (repos.isNotEmpty()) pluginDataStore.saveRepositories(repos) + } + root.optJSONArray("pluginScrapers")?.toString()?.takeIf { it.isNotBlank() }?.let { json -> + val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, com.arflix.tv.domain.model.ScraperInfo::class.java).type + val scrapers: List = gson.fromJson(json, type) ?: emptyList() + if (scrapers.isNotEmpty()) pluginDataStore.saveScrapers(scrapers) + } + if (root.has("pluginsEnabled")) pluginDataStore.setPluginsEnabled(root.optBoolean("pluginsEnabled", false)) + }.onFailure { AppLogger.recordException(it, mapOf("error_area" to "CloudSync", "cloud_flow" to "apply_plugins")) } + System.err.println("[CLOUD-SYNC] Full cloud restore applied successfully") } } diff --git a/netlify-arvio-tv-site/companion/app.js b/netlify-arvio-tv-site/companion/app.js index 76226611..50a520a1 100644 --- a/netlify-arvio-tv-site/companion/app.js +++ b/netlify-arvio-tv-site/companion/app.js @@ -73,6 +73,8 @@ const sections = { dashboard: renderDashboard, profiles: renderProfiles, addons: renderAddons, + iptv: renderIPTV, + plugins: renderPlugins, history: renderHistory, watchlist: renderWatchlist, ai: renderAI, @@ -277,6 +279,148 @@ async function renderAddons() { `; } +// ── IPTV ────────────────────────────────────────────────────────────────── +async function renderIPTV() { + const main = document.getElementById('main-content'); + const payload = state.syncPayload; + const profiles = payload?.profiles ?? []; + const activeId = payload?.activeProfileId; + const iptvByProfile = payload?.iptvByProfile ?? {}; + + // Collect all playlists across profiles + const allPlaylists = []; + const seenUrls = new Set(); + for (const [profileId, iptvState] of Object.entries(iptvByProfile)) { + const profile = profiles.find(p => p.id === profileId); + const pName = profile?.name ?? profileId.slice(0, 8); + (iptvState.playlists ?? []).forEach(pl => { + if (!seenUrls.has(pl.m3uUrl)) { + seenUrls.add(pl.m3uUrl); + allPlaylists.push({ ...pl, _profileName: pName, _profileId: profileId }); + } + }); + // Legacy single M3U + if (iptvState.m3uUrl && !seenUrls.has(iptvState.m3uUrl)) { + seenUrls.add(iptvState.m3uUrl); + allPlaylists.push({ id: 'legacy', name: 'M3U', m3uUrl: iptvState.m3uUrl, epgUrl: iptvState.epgUrl, enabled: true, _profileName: pName, _profileId: profileId }); + } + } + + // Collect favourite channels/groups from active profile + const activeIPTV = iptvByProfile[activeId] ?? Object.values(iptvByProfile)[0] ?? {}; + const favGroups = activeIPTV.favoriteGroups ?? []; + const favChannels = activeIPTV.favoriteChannels ?? []; + + main.innerHTML = ` +
+
IPTV
${allPlaylists.length} רשימות פעילות
+
+ + ${allPlaylists.length === 0 ? emptyState('אין רשימות IPTV מוגדרות — הגדר ב-ARVIO ← הגדרות ← IPTV') : ` +
רשימות M3U
+
+ ${allPlaylists.map(pl => ` +
+
+ +
+
+
${pl.name || 'M3U Playlist'}
+
${pl.m3uUrl}
+ ${pl.epgUrl ? `
EPG: ${pl.epgUrl}
` : ''} +
+
+ ${pl.enabled !== false ? 'פעיל' : 'כבוי'} + ${pl._profileName} +
+
+ `).join('')} +
`} + + ${favGroups.length > 0 ? ` +
קבוצות מועדפות (${favGroups.length})
+
+ ${favGroups.map(g => `⭐ ${g}`).join('')} +
` : ''} + + ${favChannels.length > 0 ? ` +
ערוצים מועדפים (${favChannels.length})
+
+ ${favChannels.slice(0, 50).map(c => `📺 ${c}`).join('')} + ${favChannels.length > 50 ? `+${favChannels.length - 50} נוספים` : ''} +
` : ''} + +
+
💡 להוספת רשימות M3U ולניהול ערוצים — גש להגדרות IPTV ב-ARVIO. השינויים מסונכרנים אוטומטית לענן.
+
+ `; +} + +// ── Plugins ─────────────────────────────────────────────────────────────── +async function renderPlugins() { + const main = document.getElementById('main-content'); + const payload = state.syncPayload; + const repos = payload?.pluginRepositories ?? []; + const scrapers = payload?.pluginScrapers ?? []; + const pluginsEnabled = payload?.pluginsEnabled ?? false; + + main.innerHTML = ` +
+
פלאגינים (Sideload)
${repos.length} מאגרים · ${scrapers.length} scrapers
+
+ פלאגינים ${pluginsEnabled ? 'פעילים' : 'כבויים'} + +
+
+ + ${repos.length === 0 ? emptyState('אין מאגרי פלאגינים — הוסף ב-ARVIO ← הגדרות ← פלאגינים') : ` +
מאגרים (${repos.length})
+
+ ${repos.map(r => ` +
+
+ +
+
+
${r.name}
+
${r.url}
+
${r.scraperCount ?? 0} scrapers · עודכן ${r.lastUpdated ? timeAgo(new Date(r.lastUpdated).toISOString()) : 'אף פעם'}
+
+ ${r.enabled ? 'פעיל' : 'כבוי'} +
+ `).join('')} +
`} + + ${scrapers.length > 0 ? ` +
Scrapers (${scrapers.length})
+
+ ${scrapers.map(s => ` +
+
+ ${s.logo ? `` : ``} +
+
+
${s.name}
+
${s.description || ''} · v${s.version}
+
+ ${(s.supportedTypes ?? []).map(t => `${t}`).join('')} + ${(s.contentLanguage ?? []).map(l => `${l}`).join('')} +
+
+ ${s.enabled && s.manifestEnabled ? 'פעיל' : 'כבוי'} +
+ `).join('')} +
` : ''} + +
+
💡 הפלאגינים מסונכרנים לענן אוטומטית. קוד ה-JS של כל scraper נשמר מקומית בטלוויזיה בלבד ולא עולה לענן.
+
+ `; +} + // ── Watch History ───────────────────────────────────────────────────────── async function renderHistory() { const main = document.getElementById('main-content'); @@ -658,6 +802,8 @@ function buildShell(user) { { id: 'dashboard', label: 'Dashboard', icon: '' }, { id: 'profiles', label: 'פרופילים', icon: '' }, { id: 'addons', label: 'הרחבות', icon: '' }, + { id: 'iptv', label: 'IPTV', icon: '' }, + { id: 'plugins', label: 'פלאגינים', icon: '' }, { id: 'history', label: 'היסטוריה', icon: '' }, { id: 'watchlist', label: 'רשימת צפייה', icon: '' }, { id: 'ai', label: 'תרגום AI', icon: '' }, From ebc9d45ef6444256ec70c4fb7b1c4df24190563c Mon Sep 17 00:00:00 2001 From: koby455 Date: Wed, 17 Jun 2026 06:27:58 +0300 Subject: [PATCH 3/6] Translate companion web app UI to English (default language) - All navigation labels, section titles, badges, toasts, and helper text translated to English in app.js and index.html - Auth screen subtitle and Google button label now in English - timeAgo() helper uses English relative-time strings - Hebrew remains fully supported as a selectable app language (setting stored in cloud sync payload as before) - Add mock-preview.html: standalone demo page with generic sample data for screenshots / PR previews (no Supabase dependency) Co-Authored-By: Claude Sonnet 4.6 --- netlify-arvio-tv-site/companion/app.js | 216 ++++---- netlify-arvio-tv-site/companion/index.html | 8 +- .../companion/mock-preview.html | 485 ++++++++++++++++++ 3 files changed, 597 insertions(+), 112 deletions(-) create mode 100644 netlify-arvio-tv-site/companion/mock-preview.html diff --git a/netlify-arvio-tv-site/companion/app.js b/netlify-arvio-tv-site/companion/app.js index 50a520a1..ad9ad9f1 100644 --- a/netlify-arvio-tv-site/companion/app.js +++ b/netlify-arvio-tv-site/companion/app.js @@ -91,7 +91,7 @@ function navigate(id) { async function renderSection() { const main = document.getElementById('main-content'); - main.innerHTML = `
טוען...
`; + main.innerHTML = `
Loading...
`; await sections[state.activeSection]?.(); } @@ -114,17 +114,17 @@ async function renderDashboard() {
Dashboard
-
סקירה מהירה של חשבון ה-ARVIO שלך
+
A quick overview of your ARVIO account
-
פרופילים
${profiles.length || 1}
-
הרחבות
${addons.filter(a => a.isEnabled).length}
-
היסטוריית צפייה
${histRes.count ?? '—'}
-
רשימת צפייה
${wlRes.count ?? '—'}
+
Profiles
${profiles.length || 1}
+
Add-ons
${addons.filter(a => a.isEnabled).length}
+
Watch History
${histRes.count ?? '—'}
+
Watchlist
${wlRes.count ?? '—'}
-
פעילות אחרונה
+
Recent Activity
`; @@ -137,7 +137,7 @@ async function renderDashboard() { const ra = document.getElementById('recent-activity'); if (!recent?.length) { - ra.innerHTML = emptyState('אין פעילות אחרונה'); + ra.innerHTML = emptyState('No recent activity'); return; } ra.innerHTML = recent.map(r => ` @@ -145,7 +145,7 @@ async function renderDashboard() {
${r.title || '—'}
-
${r.media_type === 'movie' ? '🎬 סרט' : '📺 סדרה'} · ${timeAgo(r.updated_at)}
+
${r.media_type === 'movie' ? '🎬 Movie' : '📺 Series'} · ${timeAgo(r.updated_at)}
${Math.round((r.progress||0)*100)}% @@ -172,14 +172,14 @@ async function renderProfiles() { if (!profiles.length) { main.innerHTML = ` -
פרופילים
- ${emptyState('אין פרופילים — פתח את ARVIO בטלוויזיה ליצירת פרופיל')}`; +
Profiles
+ ${emptyState('No profiles found — open ARVIO on your TV to create one')}`; return; } main.innerHTML = `
-
פרופילים
${profiles.length} פרופילים בחשבון
+
Profiles
${profiles.length} profile${profiles.length !== 1 ? 's' : ''} in account
${profiles.map(p => ` @@ -187,15 +187,15 @@ async function renderProfiles() { ${renderProfileAvatar(p)}
${p.name}
- ${p.id === activeId ? 'פעיל' : ''} - ${p.isKidsProfile ? 'ילדים' : ''} - ${p.pin ? '🔒 נעול' : ''} + ${p.id === activeId ? 'Active' : ''} + ${p.isKidsProfile ? 'Kids' : ''} + ${p.pin ? '🔒 Locked' : ''}
`).join('')}
-
💡 ניהול פרופילים (יצירה, מחיקה, שינוי שם) זמין ב-ARVIO על הטלוויזיה.
שינויים מסונכרנים אוטומטית עם הענן.
+
💡 Profile management (create, delete, rename) is available in ARVIO on your TV.
Changes sync automatically to the cloud.
`; } @@ -219,8 +219,8 @@ async function renderAddons() { if (!addons.length) { main.innerHTML = ` -
הרחבות
- ${emptyState('אין הרחבות מותקנות')}`; +
Add-ons
+ ${emptyState('No add-ons installed')}`; return; } @@ -238,11 +238,11 @@ async function renderAddons() { function addonTypeBadge(a) { const t = a.type || 'COMMUNITY'; - if (t === 'OFFICIAL') return 'רשמי'; - if (t === 'SUBTITLE') return 'כתוביות'; - if (t === 'METADATA') return 'מטאדאטה'; + if (t === 'OFFICIAL') return 'Official'; + if (t === 'SUBTITLE') return 'Subtitles'; + if (t === 'METADATA') return 'Metadata'; if (a.runtimeKind === 'TELEGRAM') return 'Telegram'; - return 'קהילה'; + return 'Community'; } function renderAddonList(list, label) { @@ -259,7 +259,7 @@ async function renderAddons() {
${addonTypeBadge(a)} - ${a.isEnabled ? 'פעיל' : 'כבוי'} + ${a.isEnabled ? 'Enabled' : 'Disabled'}
`).join('')} @@ -269,12 +269,12 @@ async function renderAddons() { main.innerHTML = `
-
הרחבות
${addons.filter(a=>a.isEnabled).length} פעילות מתוך ${addons.length}
+
Add-ons
${addons.filter(a=>a.isEnabled).length} active of ${addons.length}
- ${renderAddonList(byType.STREMIO, `Stremio הרחבות (${byType.STREMIO.length})`)} - ${renderAddonList(byType.TELEGRAM, `Telegram מקורות (${byType.TELEGRAM.length})`)} + ${renderAddonList(byType.STREMIO, `Stremio Add-ons (${byType.STREMIO.length})`)} + ${renderAddonList(byType.TELEGRAM, `Telegram Sources (${byType.TELEGRAM.length})`)}
-
💡 להוספה/הסרה של הרחבות — גש להגדרות ב-ARVIO. הנתונים מסונכרנים אוטומטית.
+
💡 To add or remove add-ons, go to Settings in ARVIO on your TV. Data syncs automatically.
`; } @@ -313,11 +313,11 @@ async function renderIPTV() { main.innerHTML = `
-
IPTV
${allPlaylists.length} רשימות פעילות
+
IPTV
${allPlaylists.length} active playlist${allPlaylists.length !== 1 ? 's' : ''}
- ${allPlaylists.length === 0 ? emptyState('אין רשימות IPTV מוגדרות — הגדר ב-ARVIO ← הגדרות ← IPTV') : ` -
רשימות M3U
+ ${allPlaylists.length === 0 ? emptyState('No IPTV playlists configured — go to ARVIO → Settings → IPTV') : ` +
M3U Playlists
${allPlaylists.map(pl => `
@@ -330,7 +330,7 @@ async function renderIPTV() { ${pl.epgUrl ? `
EPG: ${pl.epgUrl}
` : ''}
- ${pl.enabled !== false ? 'פעיל' : 'כבוי'} + ${pl.enabled !== false ? 'Active' : 'Disabled'} ${pl._profileName}
@@ -338,16 +338,16 @@ async function renderIPTV() { `} ${favGroups.length > 0 ? ` -
קבוצות מועדפות (${favGroups.length})
+
Favourite Groups (${favGroups.length})
${favGroups.map(g => `⭐ ${g}`).join('')}
` : ''} ${favChannels.length > 0 ? ` -
ערוצים מועדפים (${favChannels.length})
+
Favourite Channels (${favChannels.length})
${favChannels.slice(0, 50).map(c => `📺 ${c}`).join('')} - ${favChannels.length > 50 ? `+${favChannels.length - 50} נוספים` : ''} + ${favChannels.length > 50 ? `+${favChannels.length - 50} more` : ''}
` : ''}
@@ -366,9 +366,9 @@ async function renderPlugins() { main.innerHTML = `
-
פלאגינים (Sideload)
${repos.length} מאגרים · ${scrapers.length} scrapers
+
Plugins (Sideload)
${repos.length} repositor${repos.length !== 1 ? 'ies' : 'y'} · ${scrapers.length} scrapers
- פלאגינים ${pluginsEnabled ? 'פעילים' : 'כבויים'} + Plugins ${pluginsEnabled ? 'enabled' : 'disabled'}
- ${repos.length === 0 ? emptyState('אין מאגרי פלאגינים — הוסף ב-ARVIO ← הגדרות ← פלאגינים') : ` -
מאגרים (${repos.length})
+ ${repos.length === 0 ? emptyState('No plugin repositories — add one in ARVIO → Settings → Plugins') : ` +
Repositories (${repos.length})
${repos.map(r => `
@@ -387,9 +387,9 @@ async function renderPlugins() {
${r.name}
${r.url}
-
${r.scraperCount ?? 0} scrapers · עודכן ${r.lastUpdated ? timeAgo(new Date(r.lastUpdated).toISOString()) : 'אף פעם'}
+
${r.scraperCount ?? 0} scrapers · updated ${r.lastUpdated ? timeAgo(new Date(r.lastUpdated).toISOString()) : 'never'}
- ${r.enabled ? 'פעיל' : 'כבוי'} + ${r.enabled ? 'Active' : 'Disabled'}
`).join('')}
`} @@ -410,13 +410,13 @@ async function renderPlugins() { ${(s.contentLanguage ?? []).map(l => `${l}`).join('')}
- ${s.enabled && s.manifestEnabled ? 'פעיל' : 'כבוי'} + ${s.enabled && s.manifestEnabled ? 'Active' : 'Disabled'} `).join('')} ` : ''}
-
💡 הפלאגינים מסונכרנים לענן אוטומטית. קוד ה-JS של כל scraper נשמר מקומית בטלוויזיה בלבד ולא עולה לענן.
+
💡 Plugins sync to the cloud automatically. Each scraper's JS code stays local on your TV only and is never uploaded to the cloud.
`; } @@ -430,9 +430,9 @@ async function renderHistory() {
היסטוריית צפייה
- - - + + +
`; @@ -441,7 +441,7 @@ async function renderHistory() { async function renderHistoryContent() { document.querySelectorAll('.tab').forEach(t => { - const map = { movies: 'סרטים', tv: 'סדרות', all: 'הכל' }; + const map = { movies: 'Movies', tv: 'TV Shows', all: 'All' }; t.classList.toggle('active', t.textContent.trim() === map[state.historyTab]); }); @@ -459,8 +459,8 @@ async function renderHistoryContent() { if (state.historyTab === 'tv') q = q.eq('media_type', 'tv'); const { data, error } = await q; - if (error) { el.innerHTML = `
שגיאה בטעינה
`; return; } - if (!data?.length) { el.innerHTML = emptyState('אין היסטוריית צפייה'); return; } + if (error) { el.innerHTML = `
Failed to load
`; return; } + if (!data?.length) { el.innerHTML = emptyState('No watch history'); return; } el.innerHTML = `
${data.map(r => ` @@ -485,15 +485,15 @@ async function renderHistoryContent() { async function deleteHistory(id, btn) { btn.disabled = true; const { error } = await db.from('watch_history').delete().eq('id', id).eq('user_id', state.userId); - if (error) { toast('שגיאה במחיקה', 'err'); btn.disabled = false; return; } - toast('הוסר מההיסטוריה ✓'); + if (error) { toast('Failed to delete', 'err'); btn.disabled = false; return; } + toast('Removed from history ✓'); btn.closest('.list-item').remove(); } // ── Watchlist ───────────────────────────────────────────────────────────── async function renderWatchlist() { const main = document.getElementById('main-content'); - main.innerHTML = `
רשימת צפייה
`; + main.innerHTML = `
Watchlist
`; const { data, error } = await db.from('watchlist') .select('*') @@ -501,7 +501,7 @@ async function renderWatchlist() { .order('added_at', { ascending: false }); if (error || !data?.length) { - main.innerHTML = `
רשימת צפייה
${emptyState('רשימת הצפייה ריקה')}`; + main.innerHTML = `
Watchlist
${emptyState('Your watchlist is empty')}`; return; } @@ -523,7 +523,7 @@ async function renderWatchlist() {
TMDB #${i.tmdb_id}
נוסף ${timeAgo(i.added_at)}
- + `).join('')} `; @@ -531,10 +531,10 @@ async function renderWatchlist() { main.innerHTML = `
-
רשימת צפייה
${data.length} פריטים
+
Watchlist
${data.length} item${data.length !== 1 ? 's' : ''}
- ${renderWLSection(movies, '🎬 סרטים')} - ${renderWLSection(shows, '📺 סדרות')} + ${renderWLSection(movies, '🎬 Movies')} + ${renderWLSection(shows, '📺 TV Shows')} `; } @@ -542,8 +542,8 @@ async function removeWatchlist(tmdbId, mediaType, btn) { btn.disabled = true; const { error } = await db.from('watchlist').delete() .eq('user_id', state.userId).eq('tmdb_id', tmdbId).eq('media_type', mediaType); - if (error) { toast('שגיאה במחיקה', 'err'); btn.disabled = false; return; } - toast('הוסר מהרשימה ✓'); + if (error) { toast('Failed to remove', 'err'); btn.disabled = false; return; } + toast('Removed from watchlist ✓'); document.getElementById(`wl-${tmdbId}-${mediaType}`)?.remove(); } @@ -558,7 +558,7 @@ async function renderAI() { main.innerHTML = `
-
תרגום כתוביות AI
הגדר תרגום כתוביות בזמן אמת על ידי AI
+
AI Subtitle Translation
Configure real-time AI subtitle translation
@@ -567,19 +567,19 @@ async function renderAI() {
-
🔑 מפתח API — הגדר ישירות בטלוויזיה
-
מטעמי אבטחה, מפתח ה-API נשמר רק מקומית בטלוויזיה.
פתח את ARVIO ← הגדרות ← כתוביות ← תרגום AI
+
🔑 API Key — set directly on your TV
+
For security, your API key is stored locally on your TV only.
Open ARVIO → Settings → Subtitles → AI Translation
-
הגדרות תרגום
+
Translation Settings
-
הפעל תרגום AI
-
תרגם כתוביות אוטומטית בזמן צפייה
+
Enable AI Translation
+
Automatically translate subtitles while watching
`} ${favGroups.length > 0 ? ` -
Favourite Groups (${favGroups.length})
+
${t('fav_groups', favGroups.length)}
${favGroups.map(g => `⭐ ${g}`).join('')}
` : ''} ${favChannels.length > 0 ? ` -
Favourite Channels (${favChannels.length})
+
${t('fav_channels', favChannels.length)}
${favChannels.slice(0, 50).map(c => `📺 ${c}`).join('')} - ${favChannels.length > 50 ? `+${favChannels.length - 50} more` : ''} + ${favChannels.length > 50 ? `${t('iptv_more', favChannels.length - 50)}` : ''}
` : ''}
-
💡 להוספת רשימות M3U ולניהול ערוצים — גש להגדרות IPTV ב-ARVIO. השינויים מסונכרנים אוטומטית לענן.
+
${t('iptv_tip')}
`; } @@ -366,9 +571,9 @@ async function renderPlugins() { main.innerHTML = `
-
Plugins (Sideload)
${repos.length} repositor${repos.length !== 1 ? 'ies' : 'y'} · ${scrapers.length} scrapers
+
${t('plugins_title')}
${t('plugins_sub', repos.length, scrapers.length)}
- Plugins ${pluginsEnabled ? 'enabled' : 'disabled'} + ${pluginsEnabled ? t('plugins_status_on') : t('plugins_status_off')}
- ${repos.length === 0 ? emptyState('No plugin repositories — add one in ARVIO → Settings → Plugins') : ` -
Repositories (${repos.length})
+ ${repos.length === 0 ? emptyState(t('plugins_empty')) : ` +
${t('repos_label', repos.length)}
${repos.map(r => `
@@ -387,15 +592,15 @@ async function renderPlugins() {
${r.name}
${r.url}
-
${r.scraperCount ?? 0} scrapers · updated ${r.lastUpdated ? timeAgo(new Date(r.lastUpdated).toISOString()) : 'never'}
+
${r.scraperCount ?? 0} scrapers · ${t('repo_updated')} ${r.lastUpdated ? timeAgo(new Date(r.lastUpdated).toISOString()) : t('repo_never')}
- ${r.enabled ? 'Active' : 'Disabled'} + ${r.enabled ? t('plugin_active') : t('plugin_disabled')}
`).join('')}
`} ${scrapers.length > 0 ? ` -
Scrapers (${scrapers.length})
+
${t('scrapers_label', scrapers.length)}
${scrapers.map(s => `
@@ -406,17 +611,17 @@ async function renderPlugins() {
${s.name}
${s.description || ''} · v${s.version}
- ${(s.supportedTypes ?? []).map(t => `${t}`).join('')} + ${(s.supportedTypes ?? []).map(st => `${st}`).join('')} ${(s.contentLanguage ?? []).map(l => `${l}`).join('')}
- ${s.enabled && s.manifestEnabled ? 'Active' : 'Disabled'} + ${s.enabled && s.manifestEnabled ? t('plugin_active') : t('plugin_disabled')}
`).join('')}
` : ''}
-
💡 Plugins sync to the cloud automatically. Each scraper's JS code stays local on your TV only and is never uploaded to the cloud.
+
${t('plugins_tip')}
`; } @@ -427,12 +632,12 @@ async function renderHistory() { main.innerHTML = `
-
היסטוריית צפייה
+
${t('history_title')}
- - - + + +
`; @@ -440,9 +645,9 @@ async function renderHistory() { } async function renderHistoryContent() { - document.querySelectorAll('.tab').forEach(t => { - const map = { movies: 'Movies', tv: 'TV Shows', all: 'All' }; - t.classList.toggle('active', t.textContent.trim() === map[state.historyTab]); + document.querySelectorAll('.tab').forEach(tab => { + const map = { movies: t('tab_movies'), tv: t('tab_tv'), all: t('tab_all') }; + tab.classList.toggle('active', tab.textContent.trim() === map[state.historyTab]); }); const el = document.getElementById('history-content'); @@ -459,8 +664,8 @@ async function renderHistoryContent() { if (state.historyTab === 'tv') q = q.eq('media_type', 'tv'); const { data, error } = await q; - if (error) { el.innerHTML = `
Failed to load
`; return; } - if (!data?.length) { el.innerHTML = emptyState('No watch history'); return; } + if (error) { el.innerHTML = `
${t('history_err')}
`; return; } + if (!data?.length) { el.innerHTML = emptyState(t('history_empty')); return; } el.innerHTML = `
${data.map(r => ` @@ -485,15 +690,15 @@ async function renderHistoryContent() { async function deleteHistory(id, btn) { btn.disabled = true; const { error } = await db.from('watch_history').delete().eq('id', id).eq('user_id', state.userId); - if (error) { toast('Failed to delete', 'err'); btn.disabled = false; return; } - toast('Removed from history ✓'); + if (error) { toast(t('delete_err'), 'err'); btn.disabled = false; return; } + toast(t('deleted_history')); btn.closest('.list-item').remove(); } // ── Watchlist ───────────────────────────────────────────────────────────── async function renderWatchlist() { const main = document.getElementById('main-content'); - main.innerHTML = `
Watchlist
`; + main.innerHTML = `
${t('watchlist_title')}
`; const { data, error } = await db.from('watchlist') .select('*') @@ -501,7 +706,7 @@ async function renderWatchlist() { .order('added_at', { ascending: false }); if (error || !data?.length) { - main.innerHTML = `
Watchlist
${emptyState('Your watchlist is empty')}`; + main.innerHTML = `
${t('watchlist_title')}
${emptyState(t('watchlist_empty'))}`; return; } @@ -521,9 +726,9 @@ async function renderWatchlist() {
TMDB #${i.tmdb_id}
-
נוסף ${timeAgo(i.added_at)}
+
${timeAgo(i.added_at)}
- +
`).join('')} `; @@ -531,10 +736,10 @@ async function renderWatchlist() { main.innerHTML = `
-
Watchlist
${data.length} item${data.length !== 1 ? 's' : ''}
+
${t('watchlist_title')}
${t('watchlist_sub', data.length)}
- ${renderWLSection(movies, '🎬 Movies')} - ${renderWLSection(shows, '📺 TV Shows')} + ${renderWLSection(movies, t('wl_movies'))} + ${renderWLSection(shows, t('wl_tv'))} `; } @@ -542,8 +747,8 @@ async function removeWatchlist(tmdbId, mediaType, btn) { btn.disabled = true; const { error } = await db.from('watchlist').delete() .eq('user_id', state.userId).eq('tmdb_id', tmdbId).eq('media_type', mediaType); - if (error) { toast('Failed to remove', 'err'); btn.disabled = false; return; } - toast('Removed from watchlist ✓'); + if (error) { toast(t('wl_remove_err'), 'err'); btn.disabled = false; return; } + toast(t('wl_removed')); document.getElementById(`wl-${tmdbId}-${mediaType}`)?.remove(); } @@ -558,7 +763,7 @@ async function renderAI() { main.innerHTML = `
-
AI Subtitle Translation
Configure real-time AI subtitle translation
+
${t('ai_title')}
${t('ai_sub')}
@@ -567,19 +772,19 @@ async function renderAI() {
-
🔑 API Key — set directly on your TV
-
For security, your API key is stored locally on your TV only.
Open ARVIO → Settings → Subtitles → AI Translation
+
${t('ai_banner_title')}
+
${t('ai_banner_sub')}
-
Translation Settings
+
${t('ai_settings_title')}
-
Enable AI Translation
-
Automatically translate subtitles while watching
+
${t('ai_enable')}
+
${t('ai_enable_desc')}