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 new file mode 100644 index 00000000..9ddb4ae3 --- /dev/null +++ b/netlify-arvio-tv-site/companion/app.js @@ -0,0 +1,1096 @@ +// ── 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); + +// ── i18n ────────────────────────────────────────────────────────────────── +const STRINGS = { + en: { + nav_dashboard: 'Dashboard', nav_profiles: 'Profiles', nav_addons: 'Add-ons', + nav_iptv: 'IPTV', nav_plugins: 'Plugins', nav_history: 'History', + nav_watchlist: 'Watchlist', nav_ai: 'AI Subtitles', nav_settings: 'Settings', + sign_out: 'Sign out', user_fallback: 'User', loading: 'Loading...', + saved: 'Saved ✓', save_err: 'Failed to save', no_sync: 'No sync data', + // auth + auth_sub: 'Manage your ARVIO account — profiles, add-ons, history & AI subtitles', + auth_google: 'Continue with Google', + // dashboard + dash_sub: 'A quick overview of your ARVIO account', + stat_profiles: 'Profiles', stat_addons: 'Add-ons', + stat_history: 'Watch History', stat_watchlist: 'Watchlist', + recent_activity: 'Recent Activity', no_activity: 'No recent activity', + type_movie: '🎬 Movie', type_series: '📺 Series', + // profiles + profiles_title: 'Profiles', + profiles_sub: (n) => `${n} profile${n !== 1 ? 's' : ''} in account`, + profiles_empty: 'No profiles found — open ARVIO on your TV to create one', + profiles_tip: '💡 Profile management (create, delete, rename) is available in ARVIO on your TV.
Changes sync automatically to the cloud.', + badge_active: 'Active', badge_kids: 'Kids', badge_locked: '🔒 Locked', + // addons + addons_title: 'Add-ons', + addons_sub: (a, t) => `${a} active of ${t}`, + addons_empty: 'No add-ons installed', + addons_tip: '💡 To add or remove add-ons, go to Settings in ARVIO on your TV. Data syncs automatically.', + badge_official: 'Official', badge_subtitles: 'Subtitles', + badge_metadata: 'Metadata', badge_community: 'Community', + badge_telegram: 'Telegram', badge_enabled: 'Enabled', badge_disabled: 'Disabled', + stremio_label: (n) => `Stremio Add-ons (${n})`, + telegram_label: (n) => `Telegram Sources (${n})`, + // iptv + iptv_sub: (n) => `${n} active playlist${n !== 1 ? 's' : ''}`, + iptv_empty: 'No IPTV playlists configured — go to ARVIO → Settings → IPTV', + iptv_m3u: 'M3U Playlists', iptv_active: 'Active', iptv_disabled: 'Disabled', + fav_groups: (n) => `Favourite Groups (${n})`, + fav_channels: (n) => `Favourite Channels (${n})`, + iptv_more: (n) => `+${n} more`, + iptv_tip: '💡 To add M3U playlists and manage channels — go to IPTV Settings in ARVIO. Changes sync automatically to the cloud.', + // plugins + plugins_title: 'Plugins (Sideload)', + plugins_sub: (r, s) => `${r} repositor${r !== 1 ? 'ies' : 'y'} · ${s} scrapers`, + plugins_status_on: 'Plugins enabled', plugins_status_off: 'Plugins disabled', + plugins_empty: 'No plugin repositories — add one in ARVIO → Settings → Plugins', + repos_label: (n) => `Repositories (${n})`, + repo_updated: 'updated', repo_never: 'never', + scrapers_label: (n) => `Scrapers (${n})`, + plugin_active: 'Active', plugin_disabled: 'Disabled', + plugins_tip: '💡 Plugins sync to the cloud automatically. Each scraper\'s JS code stays local on your TV only and is never uploaded to the cloud.', + // history + history_title: 'Watch History', + tab_movies: 'Movies', tab_tv: 'TV Shows', tab_all: 'All', + history_empty: 'No watch history', history_err: 'Failed to load', + delete_err: 'Failed to delete', deleted_history: 'Removed from history ✓', + // watchlist + watchlist_title: 'Watchlist', + watchlist_sub: (n) => `${n} item${n !== 1 ? 's' : ''}`, + watchlist_empty: 'Your watchlist is empty', + wl_movies: '🎬 Movies', wl_tv: '📺 TV Shows', + wl_remove: 'Remove', wl_remove_err: 'Failed to remove', + wl_removed: 'Removed from watchlist ✓', + // ai + ai_title: 'AI Subtitle Translation', + ai_sub: 'Configure real-time AI subtitle translation', + ai_banner_title: '🔑 API Key — set directly on your TV', + ai_banner_sub: 'For security, your API key is stored locally on your TV only.
Open ARVIO → Settings → Subtitles → AI Translation', + ai_settings_title: 'Translation Settings', + ai_enable: 'Enable AI Translation', ai_enable_desc: 'Automatically translate subtitles while watching', + ai_auto: 'Auto-select', ai_auto_desc: 'Automatically pick subtitles for translation', + ai_hi: 'Remove Hearing Impaired (HI)', ai_hi_desc: 'Strip audio descriptions from subtitles [SDH]', + ai_model_title: 'Select AI Model', + ai_groq_desc: 'Fastest · Free · Great quality for subtitles', + ai_gemini_desc: 'Google Gemini, high quality, very fast', + ai_key_title: 'How to get a free API key?', + ai_key_desc: '🔹 Groq (recommended): go to console.groq.com → Create account → API Keys → Create Key
🔹 Gemini: go to aistudio.google.com → Get API Key', + ai_recommended: 'Recommended', + // settings + settings_title: 'Settings', settings_sub: 'General account settings', + appearance: 'Appearance', + oled_label: 'OLED Black Background', oled_desc: 'Pure black background to save battery on OLED screens', + layout_label: 'Media Card Layout', layout_landscape: 'Landscape', layout_portrait: 'Portrait', + lang_label: 'App Language', + profile_section: 'Profile', + skip_label: 'Skip Profile Selection', skip_desc: 'Launch directly into the app with the active profile', + account_section: 'Account', user_id_label: 'User ID', + // time + just_now: 'just now', m_ago: (m) => `${m}m ago`, h_ago: (h) => `${h}h ago`, d_ago: (d) => `${d}d ago`, + }, + he: { + nav_dashboard: 'דשבורד', nav_profiles: 'פרופילים', nav_addons: 'הרחבות', + nav_iptv: 'IPTV', nav_plugins: 'פלאגינים', nav_history: 'היסטוריה', + nav_watchlist: 'רשימת צפייה', nav_ai: 'כתוביות AI', nav_settings: 'הגדרות', + sign_out: 'התנתק', user_fallback: 'משתמש', loading: 'טוען...', + saved: 'נשמר ✓', save_err: 'שגיאה בשמירה', no_sync: 'אין נתוני סנכרון', + // auth + auth_sub: 'נהל את חשבון ה-ARVIO שלך — פרופילים, הרחבות, היסטוריה ותרגום AI', + auth_google: 'המשך עם Google', + // dashboard + dash_sub: 'סקירה מהירה של חשבון ה-ARVIO שלך', + stat_profiles: 'פרופילים', stat_addons: 'הרחבות', + stat_history: 'היסטוריית צפייה', stat_watchlist: 'רשימת צפייה', + recent_activity: 'פעילות אחרונה', no_activity: 'אין פעילות אחרונה', + type_movie: '🎬 סרט', type_series: '📺 סדרה', + // profiles + profiles_title: 'פרופילים', + profiles_sub: (n) => `${n} פרופילים בחשבון`, + profiles_empty: 'אין פרופילים — פתח את ARVIO בטלוויזיה ליצירת פרופיל', + profiles_tip: '💡 ניהול פרופילים (יצירה, מחיקה, שינוי שם) זמין ב-ARVIO על הטלוויזיה.
שינויים מסונכרנים אוטומטית עם הענן.', + badge_active: 'פעיל', badge_kids: 'ילדים', badge_locked: '🔒 נעול', + // addons + addons_title: 'הרחבות', + addons_sub: (a, total) => `${a} פעילות מתוך ${total}`, + addons_empty: 'אין הרחבות מותקנות', + addons_tip: '💡 להוספה/הסרה של הרחבות — גש להגדרות ב-ARVIO. הנתונים מסונכרנים אוטומטית.', + badge_official: 'רשמי', badge_subtitles: 'כתוביות', + badge_metadata: 'מטאדאטה', badge_community: 'קהילה', + badge_telegram: 'Telegram', badge_enabled: 'פעיל', badge_disabled: 'כבוי', + stremio_label: (n) => `הרחבות Stremio (${n})`, + telegram_label: (n) => `מקורות Telegram (${n})`, + // iptv + iptv_sub: (n) => `${n} רשימות פעילות`, + iptv_empty: 'אין רשימות IPTV מוגדרות — הגדר ב-ARVIO ← הגדרות ← IPTV', + iptv_m3u: 'רשימות M3U', iptv_active: 'פעיל', iptv_disabled: 'כבוי', + fav_groups: (n) => `קבוצות מועדפות (${n})`, + fav_channels: (n) => `ערוצים מועדפים (${n})`, + iptv_more: (n) => `+${n} נוספים`, + iptv_tip: '💡 להוספת רשימות M3U ולניהול ערוצים — גש להגדרות IPTV ב-ARVIO. השינויים מסונכרנים אוטומטית לענן.', + // plugins + plugins_title: 'פלאגינים (Sideload)', + plugins_sub: (r, s) => `${r} מאגרים · ${s} scrapers`, + plugins_status_on: 'פלאגינים פעילים', plugins_status_off: 'פלאגינים כבויים', + plugins_empty: 'אין מאגרי פלאגינים — הוסף ב-ARVIO ← הגדרות ← פלאגינים', + repos_label: (n) => `מאגרים (${n})`, + repo_updated: 'עודכן', repo_never: 'אף פעם', + scrapers_label: (n) => `Scrapers (${n})`, + plugin_active: 'פעיל', plugin_disabled: 'כבוי', + plugins_tip: '💡 הפלאגינים מסונכרנים לענן אוטומטית. קוד ה-JS של כל scraper נשמר מקומית בטלוויזיה בלבד ולא עולה לענן.', + // history + history_title: 'היסטוריית צפייה', + tab_movies: 'סרטים', tab_tv: 'סדרות', tab_all: 'הכל', + history_empty: 'אין היסטוריית צפייה', history_err: 'שגיאה בטעינה', + delete_err: 'שגיאה במחיקה', deleted_history: 'הוסר מההיסטוריה ✓', + // watchlist + watchlist_title: 'רשימת צפייה', + watchlist_sub: (n) => `${n} פריטים`, + watchlist_empty: 'רשימת הצפייה ריקה', + wl_movies: '🎬 סרטים', wl_tv: '📺 סדרות', + wl_remove: 'הסר', wl_remove_err: 'שגיאה במחיקה', + wl_removed: 'הוסר מהרשימה ✓', + // ai + ai_title: 'תרגום כתוביות AI', + ai_sub: 'הגדר תרגום כתוביות בזמן אמת על ידי AI', + ai_banner_title: '🔑 מפתח API — הגדר ישירות בטלוויזיה', + ai_banner_sub: 'מטעמי אבטחה, מפתח ה-API נשמר רק מקומית בטלוויזיה.
פתח את ARVIO ← הגדרות ← כתוביות ← תרגום AI', + ai_settings_title: 'הגדרות תרגום', + ai_enable: 'הפעל תרגום AI', ai_enable_desc: 'תרגם כתוביות אוטומטית בזמן צפייה', + ai_auto: 'בחירה אוטומטית', ai_auto_desc: 'בחר אוטומטית כתוביות לתרגום', + ai_hi: 'הסר כתוביות לכבדי שמיעה', ai_hi_desc: 'הסר תיאורי קול מהכתוביות [SDH]', + ai_model_title: 'בחר מודל AI', + ai_groq_desc: 'מהיר ביותר, חינמי, מומלץ לתרגום כתוביות', + ai_gemini_desc: 'Google Gemini, איכות גבוהה, מהיר מאוד', + ai_key_title: 'איך לקבל מפתח API חינמי?', + ai_key_desc: '🔹 Groq (מומלץ): גש ל-console.groq.com ← צור חשבון ← API Keys ← Create Key
🔹 Gemini: גש ל-aistudio.google.com ← Get API Key', + ai_recommended: 'מומלץ', + // settings + settings_title: 'הגדרות', settings_sub: 'הגדרות כלליות לחשבון', + appearance: 'מראה', + oled_label: 'רקע OLED שחור', oled_desc: 'רקע שחור לחלוטין לחיסכון בסוללה', + layout_label: 'פריסת כרטיסי מדיה', layout_landscape: 'Landscape (רוחב)', layout_portrait: 'Portrait (אנכי)', + lang_label: 'שפת ממשק', + profile_section: 'פרופיל', + skip_label: 'דלג על בחירת פרופיל', skip_desc: 'עבור ישירות לאפליקציה עם הפרופיל הפעיל', + account_section: 'חשבון', user_id_label: 'מזהה משתמש', + // time + just_now: 'עכשיו', m_ago: (m) => `לפני ${m} דק׳`, h_ago: (h) => `לפני ${h} שע׳`, d_ago: (d) => `לפני ${d} ימים`, + }, +}; + +let currentLang = localStorage.getItem('arvio_lang') || 'en'; + +function t(key, ...args) { + const s = STRINGS[currentLang][key]; + return typeof s === 'function' ? s(...args) : (s ?? key); +} + +function setLang(lang) { + currentLang = lang; + localStorage.setItem('arvio_lang', lang); + const isRTL = lang === 'he'; + document.documentElement.lang = lang; + document.documentElement.dir = isRTL ? 'rtl' : 'ltr'; + // Re-render auth text if on auth screen + const authSub = document.querySelector('.auth-sub'); + if (authSub) authSub.textContent = t('auth_sub'); + const authBtn = document.querySelector('.btn-google'); + if (authBtn) authBtn.childNodes[authBtn.childNodes.length - 1].textContent = ' ' + t('auth_google'); + // Re-render app if logged in + if (state.session) { + buildShell(state.session.user); + renderSection(); + } +} + +// ── 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); + // Read existing wrapper so we only overwrite our two keys and preserve any others + const { data: existing } = await db.from('profiles').select('addons').eq('id', state.userId).single(); + let wrapper = {}; + try { wrapper = JSON.parse(existing?.addons ?? '{}'); } catch {} + wrapper.__arvioAccountSyncPayload = raw; + wrapper.__arvioAccountSyncUpdatedAt = new Date().toISOString(); + const { error } = await db + .from('profiles') + .update({ addons: JSON.stringify(wrapper) }) + .eq('id', state.userId); + if (error) throw error; + state.syncPayload = payload; +} + +// ── Sections ────────────────────────────────────────────────────────────── +const sections = { + dashboard: renderDashboard, + profiles: renderProfiles, + addons: renderAddons, + iptv: renderIPTV, + plugins: renderPlugins, + 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 = `
${t('loading')}
`; + 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 = ` +
+
+
${t('nav_dashboard')}
+
${t('dash_sub')}
+
+
+
+
${t('stat_profiles')}
${profiles.length || 1}
+
${t('stat_addons')}
${addons.filter(a => a.isEnabled).length}
+
${t('stat_history')}
${histRes.count ?? '—'}
+
${t('stat_watchlist')}
${wlRes.count ?? '—'}
+
+
+
${t('recent_activity')}
+
+
+ `; + + 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(t('no_activity')); + return; + } + ra.innerHTML = recent.map(r => ` +
+ +
+
${escapeHtml(r.title) || '—'}
+
${r.media_type === 'movie' ? t('type_movie') : t('type_series')} · ${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 = /^[0-9a-fA-F]{6}$/.test((profile.avatarColor?.toString(16).padStart(8,'0').slice(2) || '')) + ? '#' + profile.avatarColor.toString(16).padStart(8,'0').slice(2) + : '#F5C442'; + const letter = escapeHtml((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 = ` +
${t('profiles_title')}
+ ${emptyState(t('profiles_empty'))}`; + return; + } + + main.innerHTML = ` +
+
${t('profiles_title')}
${t('profiles_sub', profiles.length)}
+
+
+ ${profiles.map(p => ` +
+ ${renderProfileAvatar(p)} +
${escapeHtml(p.name)}
+
+ ${p.id === activeId ? `${t('badge_active')}` : ''} + ${p.isKidsProfile ? `${t('badge_kids')}` : ''} + ${p.pin ? `${t('badge_locked')}` : ''} +
+
+ `).join('')} +
+
+
${t('profiles_tip')}
+
+ `; +} + +// ── 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 = ` +
${t('addons_title')}
+ ${emptyState(t('addons_empty'))}`; + return; + } + + const byType = { + STREMIO: addons.filter(a => a.runtimeKind !== 'TELEGRAM'), + TELEGRAM: addons.filter(a => a.runtimeKind === 'TELEGRAM'), + }; + + function addonIcon(a) { + const logo = safeUrl(a.manifest?.logo || a.logo); + if (logo) return ``; + return ``; + } + + function addonTypeBadge(a) { + const t = a.type || 'COMMUNITY'; + if (t === 'OFFICIAL') return `${STRINGS[currentLang].badge_official}`; + if (t === 'SUBTITLE') return `${STRINGS[currentLang].badge_subtitles}`; + if (t === 'METADATA') return `${STRINGS[currentLang].badge_metadata}`; + if (a.runtimeKind === 'TELEGRAM') return `${STRINGS[currentLang].badge_telegram}`; + return `${STRINGS[currentLang].badge_community}`; + } + + function renderAddonList(list, label) { + if (!list.length) return ''; + return ` +
${label}
+
+ ${list.map(a => ` +
+
${addonIcon(a)}
+
+
${escapeHtml(a.name || a.id)}
+
${escapeHtml(a.manifest?.description || a.description || a.id)}
+
+
+ ${addonTypeBadge(a)} + ${a.isEnabled ? t('badge_enabled') : t('badge_disabled')} +
+
+ `).join('')} +
+ `; + } + + main.innerHTML = ` +
+
${t('addons_title')}
${t('addons_sub', addons.filter(a=>a.isEnabled).length, addons.length)}
+
+ ${renderAddonList(byType.STREMIO, t('stremio_label', byType.STREMIO.length))} + ${renderAddonList(byType.TELEGRAM, t('telegram_label', byType.TELEGRAM.length))} +
+
${t('addons_tip')}
+
+ `; +} + +// ── 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
${t('iptv_sub', allPlaylists.length)}
+
+ + ${allPlaylists.length === 0 ? emptyState(t('iptv_empty')) : ` +
${t('iptv_m3u')}
+
+ ${allPlaylists.map(pl => ` +
+
+ +
+
+
${escapeHtml(pl.name) || 'M3U Playlist'}
+
${escapeHtml(pl.m3uUrl)}
+ ${pl.epgUrl ? `
EPG: ${escapeHtml(pl.epgUrl)}
` : ''} +
+
+ ${pl.enabled !== false ? t('iptv_active') : t('iptv_disabled')} + ${escapeHtml(pl._profileName)} +
+
+ `).join('')} +
`} + + ${favGroups.length > 0 ? ` +
${t('fav_groups', favGroups.length)}
+
+ ${favGroups.map(g => `⭐ ${escapeHtml(g)}`).join('')} +
` : ''} + + ${favChannels.length > 0 ? ` +
${t('fav_channels', favChannels.length)}
+
+ ${favChannels.slice(0, 50).map(c => `📺 ${escapeHtml(c)}`).join('')} + ${favChannels.length > 50 ? `${t('iptv_more', favChannels.length - 50)}` : ''} +
` : ''} + +
+
${t('iptv_tip')}
+
+ `; +} + +// ── 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 = ` +
+
${t('plugins_title')}
${t('plugins_sub', repos.length, scrapers.length)}
+
+ ${pluginsEnabled ? t('plugins_status_on') : t('plugins_status_off')} + +
+
+ + ${repos.length === 0 ? emptyState(t('plugins_empty')) : ` +
${t('repos_label', repos.length)}
+
+ ${repos.map(r => ` +
+
+ +
+
+
${escapeHtml(r.name)}
+
${escapeHtml(r.url)}
+
${r.scraperCount ?? 0} scrapers · ${t('repo_updated')} ${r.lastUpdated ? timeAgo(new Date(r.lastUpdated).toISOString()) : t('repo_never')}
+
+ ${r.enabled ? t('plugin_active') : t('plugin_disabled')} +
+ `).join('')} +
`} + + ${scrapers.length > 0 ? ` +
${t('scrapers_label', scrapers.length)}
+
+ ${scrapers.map(s => ` +
+
+ ${safeUrl(s.logo) ? `` : ``} +
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.description)} · v${escapeHtml(s.version)}
+
+ ${(s.supportedTypes ?? []).map(st => `${escapeHtml(st)}`).join('')} + ${(s.contentLanguage ?? []).map(l => `${escapeHtml(l)}`).join('')} +
+
+ ${s.enabled && s.manifestEnabled ? t('plugin_active') : t('plugin_disabled')} +
+ `).join('')} +
` : ''} + +
+
${t('plugins_tip')}
+
+ `; +} + +// ── Watch History ───────────────────────────────────────────────────────── +async function renderHistory() { + const main = document.getElementById('main-content'); + + main.innerHTML = ` +
+
${t('history_title')}
+
+
+ + + +
+
+ `; + await renderHistoryContent(); +} + +async function renderHistoryContent() { + 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'); + 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 = `
${t('history_err')}
`; return; } + if (!data?.length) { el.innerHTML = emptyState(t('history_empty')); return; } + + el.innerHTML = `
+ ${data.map(r => ` +
+ +
+
${escapeHtml(r.title) || '—'}${r.season ? ` S${String(Number(r.season)||0).padStart(2,'0')}E${String(Number(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(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 = `
${t('watchlist_title')}
`; + + const { data, error } = await db.from('watchlist') + .select('*') + .eq('user_id', state.userId) + .order('added_at', { ascending: false }); + + if (error || !data?.length) { + main.innerHTML = `
${t('watchlist_title')}
${emptyState(t('watchlist_empty'))}`; + 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 = ` +
+
${t('watchlist_title')}
${t('watchlist_sub', data.length)}
+
+ ${renderWLSection(movies, t('wl_movies'))} + ${renderWLSection(shows, t('wl_tv'))} + `; +} + +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(t('wl_remove_err'), 'err'); btn.disabled = false; return; } + toast(t('wl_removed')); + 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 = ` +
+
${t('ai_title')}
${t('ai_sub')}
+
+ +
+ + + + +
+
${t('ai_banner_title')}
+
${t('ai_banner_sub')}
+
+
+ +
+
${t('ai_settings_title')}
+ +
+
+
+
${t('ai_enable')}
+
${t('ai_enable_desc')}
+
+ +
+
+ +
+
+
+
${t('ai_auto')}
+
${t('ai_auto_desc')}
+
+ +
+
+ +
+
+
+
${t('ai_hi')}
+
${t('ai_hi_desc')}
+
+ +
+
+
+ +
+
${t('ai_model_title')}
+
+ + +
+ +
+
${t('ai_key_title')}
+
${t('ai_key_desc')}
+
+
+ `; +} + +async function updateAISetting(key, value) { + if (!state.syncPayload) { toast(t('no_sync'), 'err'); return; } + state.syncPayload[key] = value; + try { + await saveSyncPayload(state.syncPayload); + toast(t('saved')); + if (key === 'subtitleAiModel') { + document.querySelectorAll('.model-card').forEach(card => { + const inp = card.querySelector('input'); + card.classList.toggle('selected', inp?.value === value); + }); + } + } catch (e) { + toast(t('save_err'), '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 = ` +
+
${t('settings_title')}
${t('settings_sub')}
+
+ +
+
${t('appearance')}
+ +
+
+
+
${t('oled_label')}
+
${t('oled_desc')}
+
+ +
+
+ +
+ + +
+ +
+ + +
+
+ +
+
${t('profile_section')}
+
+
+
+
${t('skip_label')}
+
${t('skip_desc')}
+
+ +
+
+
+ +
+
${t('account_section')}
+
+
+
${t('user_id_label')}
+
${escapeHtml(state.userId)}
+
+
+ +
+ `; +} + +async function updateSetting(key, value) { + if (!state.syncPayload) { toast(t('no_sync'), 'err'); return; } + state.syncPayload[key] = value; + try { + await saveSyncPayload(state.syncPayload); + toast(t('saved')); + } catch (e) { + toast(t('save_err'), 'err'); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────── +function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function safeUrl(url) { + if (!url) return ''; + try { + const u = new URL(url); + return (u.protocol === 'https:' || u.protocol === 'http:') ? escapeHtml(u.href) : ''; + } catch { return ''; } +} + +function timeAgo(iso) { + if (!iso) return ''; + const d = Date.now() - new Date(iso).getTime(); + const m = Math.floor(d / 60000); + if (m < 1) return t('just_now'); + if (m < 60) return t('m_ago', m); + const h = Math.floor(m / 60); + if (h < 24) return t('h_ago', h); + const days = Math.floor(h / 24); + if (days < 30) return t('d_ago', days); + return new Date(iso).toLocaleDateString(currentLang === 'he' ? 'he-IL' : 'en-US'); +} + +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 || t('user_fallback'); + const avatar = safeUrl(user.user_metadata?.avatar_url || ''); + const email = user.email || ''; + const initial = escapeHtml(name[0].toUpperCase()); + + const navItems = [ + { id: 'dashboard', label: t('nav_dashboard'), icon: '' }, + { id: 'profiles', label: t('nav_profiles'), icon: '' }, + { id: 'addons', label: t('nav_addons'), icon: '' }, + { id: 'iptv', label: 'IPTV', icon: '' }, + { id: 'plugins', label: t('nav_plugins'), icon: '' }, + { id: 'history', label: t('nav_history'), icon: '' }, + { id: 'watchlist', label: t('nav_watchlist'), icon: '' }, + { id: 'ai', label: t('nav_ai'), icon: '' }, + { id: 'settings', label: t('nav_settings'), 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..a225509f --- /dev/null +++ b/netlify-arvio-tv-site/companion/index.html @@ -0,0 +1,41 @@ + + + + + + ARVIO Companion + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/netlify-arvio-tv-site/companion/mock-preview.html b/netlify-arvio-tv-site/companion/mock-preview.html new file mode 100644 index 00000000..5da8ed19 --- /dev/null +++ b/netlify-arvio-tv-site/companion/mock-preview.html @@ -0,0 +1,485 @@ + + + + + +ARVIO Companion — Preview + + + + + + + + + + +
+ + +
+
+ + Dashboard +
+ +
+
+
🎬
+
47
+
Movies watched
+
+
+
📺
+
312
+
Episodes watched
+
+
+
🔖
+
8
+
Watchlist
+
+
+
🧩
+
5
+
Active add-ons
+
+
+ +

Recent Activity

+
+
+
🎬
+
+
Inception
+
Movie · 1d ago
+
+
+ Completed +
+
+
📺
+
+
Breaking Bad
+
S05E14 · 2d ago
+
+
+ 72% +
+
+
🎬
+
+
Oppenheimer
+
Movie · 4d ago
+
+
+ 55% +
+
+
+ +
+ + +
+
+ + Profiles +
+
+
+
+
A
+
Alex
+
● Active
+
+
+
S
+
Sarah
+
🔒 Locked
+
+
+
K
+
Kids
+
👶 Kids
+
+
+
+ +
+ + +
+
+ + Stremio Add-ons +
+
+
+
+ Torrentio + +
+
torrentio.strem.fun/manifest.json
+
+
+
+ Cinemeta + +
+
v3-cinemeta.strem.io/manifest.json
+
+
+
+ OpenSubtitles + +
+
opensubtitles-v3.strem.io/manifest.json
+
+
+

Telegram

+
+
+ ARVIO Bot + ● Active +
+
@arvio_updates
+
+
+ +
+ + +
+
+ + IPTV — Alex +
+
+
+
+
ישראל HD
+
https://example.m3u
+
EPG: https://example.com/epg.xml
+
+
+
142
+
ערוצים
+
+
+
+
+
+
+
Sports Pack
+
https://sports.m3u
+
ללא EPG
+
+
+
38
+
ערוצים
+
+
+
+

Favourite Groups

+
+
Israel
+
Sports
+
Movies
+
+

Favourite Channels

+
+
Channel 12
+
Channel 13
+
HOT Sport 1
+
Sport 5
+
+
+ +
+ + +
+
+ + Plugins +
+
+
+
+
Plugins enabled
+
Enable or disable the scraper engine
+
+ +
+
+

Repositories

+
+
+
📦
+
+
ARVIO Plugins
+
raw.githubusercontent.com/arvio/plugins/main/repo.json
+
+ 12 scrapers +
+
+
📦
+
+
Community Repo
+
community.arvio.tv/repo.json
+
+ 7 scrapers +
+
+

Scrapers

+
+
+
+
YesMovies v2.1.0
+
Movies · Series · Hebrew · English
+
+ Active +
+
+
+
+
PrimeWire v1.4.2
+
Movies · English
+
+ Active +
+
+
+
+
SolarMovie v1.0.1
+
Movies · Series · English
+
+ Disabled +
+
+ +
+ + +
+
+ + AI Subtitle Translation +
+
+
+
Enable AI Translation
Automatically translate subtitles while watching
+ +
+
+
Auto-select
Automatically pick subtitles for translation
+ +
+
+
Remove HI
Strip hearing-impaired audio descriptions [SDH]
+ +
+
+

Select AI Model

+
+
+
Groq — Llama 3.3 70B Recommended
+
Fastest · Free · Great quality for subtitles
+
+
+
Google — Gemini Flash 2.5
+
Fast · Free · Excellent multilingual support
+
+
+
+ +
+ + +
+
+ + Settings +
+
+
+
App Language
+ English +
+
+
OLED Mode
Pure black background to save battery
+ +
+
+
Skip Profile Selection
Launch directly with the active profile
+ +
+
+
+ +
+
+ + + 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; } +}