This commit is contained in:
Devin Bot
2026-10-25 14:53:50 -07:00
parent 6080df1456
commit e31ae53518
41 changed files with 442 additions and 431 deletions

View File

@@ -271,6 +271,8 @@
@media (max-width: 900px) { @media (max-width: 900px) {
#header-container { #header-container {
border-radius: 100px; border-radius: 100px;
top: max(6px, env(safe-area-inset-top));
width: 90%;
} }
} }
@@ -331,7 +333,7 @@
<div class="header-content"> <div class="header-content">
<div class="header-logo-badge"> <div class="header-logo-badge">
<img src="/assets/images/logo.png" alt="Vyla" /> <img src="/src/assets/images/logo.png" alt="Vyla" />
</div> </div>
<h1 class="header-title">Vyla Entertainment</h1> <h1 class="header-title">Vyla Entertainment</h1>
<p class="header-desc">A community-built media project focused on freedom, privacy, and giving people <p class="header-desc">A community-built media project focused on freedom, privacy, and giving people

View File

@@ -23,7 +23,7 @@
content="A community-built media ecosystem focused on freedom, privacy, and control. Featuring native Desktop, Android, and TV apps backed by modular developer tools." /> content="A community-built media ecosystem focused on freedom, privacy, and control. Featuring native Desktop, Android, and TV apps backed by modular developer tools." />
<meta property="og:url" content="https://vyla.cc/" /> <meta property="og:url" content="https://vyla.cc/" />
<meta property="og:site_name" content="Vyla Entertainment" /> <meta property="og:site_name" content="Vyla Entertainment" />
<meta property="og:image" content="/assets/images/devices.png" /> <meta property="og:image" content="/src/assets/images/devices.png" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Vyla Entertainment Ecosystem" /> <meta property="og:image:alt" content="Vyla Entertainment Ecosystem" />
@@ -32,7 +32,7 @@
<meta name="twitter:title" content="Vyla Entertainment - Free & Decentralized Media Platform" /> <meta name="twitter:title" content="Vyla Entertainment - Free & Decentralized Media Platform" />
<meta name="twitter:description" <meta name="twitter:description"
content="Explore Vyla Home, Go, TV, and the developer SDK. Built for privacy, performance, and complete streaming control." /> content="Explore Vyla Home, Go, TV, and the developer SDK. Built for privacy, performance, and complete streaming control." />
<meta name="twitter:image" content="/assets/images/devices.png" /> <meta name="twitter:image" content="/src/assets/images/devices.png" />
<script type="application/ld+json"> <script type="application/ld+json">
{ {
@@ -53,7 +53,7 @@
"@id": "/#organization", "@id": "/#organization",
"name": "Vyla Entertainment", "name": "Vyla Entertainment",
"url": "/", "url": "/",
"logo": "/assets/images/logo.png", "logo": "/src/assets/images/logo.png",
"sameAs": [ "sameAs": [
"https://gitlab.com/vyla-entertainment/", "https://gitlab.com/vyla-entertainment/",
"https://discord.gg/5eqRhKeywG" "https://discord.gg/5eqRhKeywG"
@@ -75,16 +75,16 @@
} }
</script> </script>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="preload" href="/assets/styling/roots.css" as="style" /> <link rel="preload" href="/src/assets/css/roots.css" as="style" />
<link rel="stylesheet" href="/assets/styling/roots.css" /> <link rel="stylesheet" href="/src/assets/css/roots.css" />
<link rel="stylesheet" href="/assets/styling/layout.css" /> <link rel="stylesheet" href="/src/assets/css/layout.css" />
<link rel="stylesheet" href="/assets/styling/prose.css" /> <link rel="stylesheet" href="/src/assets/css/prose.css" />
<link rel="stylesheet" href="/assets/styling/components.css" /> <link rel="stylesheet" href="/src/assets/css/components.css" />
<link rel="stylesheet" href="/assets/styling/themes.css" /> <link rel="stylesheet" href="/src/assets/css/themes.css" />
<link rel="stylesheet" href="/assets/styling/overlay.css" /> <link rel="stylesheet" href="/src/assets/css/overlay.css" />
<link rel="stylesheet" href="/assets/styling/features.css" /> <link rel="stylesheet" href="/src/assets/css/features.css" />
<link rel="stylesheet" href="/assets/styling/highlight.css" /> <link rel="stylesheet" href="/src/assets/css/highlight.css" />
<link rel="stylesheet" href="/assets/styling/404.css" /> <link rel="stylesheet" href="/src/assets/css/404.css" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="ard" href="/.well-known/ard.json" /> <link rel="ard" href="/.well-known/ard.json" />
@@ -106,10 +106,10 @@
<body> <body>
<div class="cobweb cobweb-top-left" aria-hidden="true"> <div class="cobweb cobweb-top-left" aria-hidden="true">
<img class="cobweb-media" src="/assets/images/themes/halloween/cobweb.png" alt="" /> <img class="cobweb-media" src="/src/assets/images/themes/halloween/cobweb.png" alt="" />
</div> </div>
<div class="cobweb cobweb-top-right" aria-hidden="true"> <div class="cobweb cobweb-top-right" aria-hidden="true">
<img class="cobweb-media" src="/assets/images/themes/halloween/cobweb.png" alt="" /> <img class="cobweb-media" src="/src/assets/images/themes/halloween/cobweb.png" alt="" />
</div> </div>
<div class="spider-container" aria-hidden="true"> <div class="spider-container" aria-hidden="true">
<div class="spider-thread"></div> <div class="spider-thread"></div>
@@ -130,7 +130,7 @@
<div id="header-container"> <div id="header-container">
<header id="navbar"> <header id="navbar">
<div class="nav-top"> <div class="nav-top">
<a class="brand" href="/"><img src="/assets/images/logo.png" alt="Vyla" id="brand-logo" width="28" <a class="brand" href="/"><img src="/src/assets/images/logo.png" alt="Vyla" id="brand-logo" width="28"
height="28" decoding="async" /></a> height="28" decoding="async" /></a>
<button id="search-trigger" type="button" onclick="openSearch()" aria-label="Search"></button> <button id="search-trigger" type="button" onclick="openSearch()" aria-label="Search"></button>
<div class="links"> <div class="links">
@@ -194,7 +194,12 @@
</aside> </aside>
</div> </div>
</div> </div>
<script src="/assets/app.js"></script> <script src="/src/assets/js/main.js"></script>
<script src="/src/assets/js/api/contribute.js"></script>
<script src="/src/assets/js/api/download.js"></script>
<script src="/src/assets/js/api/release.js"></script>
<script src="/src/assets/js/main/settings.js"></script>
</body> </body>
</html> </html>

View File

@@ -15,13 +15,13 @@
], ],
"icons": [ "icons": [
{ {
"src": "/assets/images/android-chrome-192x192.png", "src": "/src/assets/images/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
}, },
{ {
"src": "/assets/images/android-chrome-512x512.png", "src": "/src/assets/images/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"

View File

@@ -2,7 +2,7 @@
<p class="page-description">The Android control center for running the Vyla media stack locally.</p> <p class="page-description">The Android control center for running the Vyla media stack locally.</p>
<img class="vyla-banner" src="/assets/images/android/site_preview.png" alt="A collage of films and shows available through a Vyla experience" loading="lazy" fetchpriority="low" /> <img class="vyla-banner" src="/src/assets/images/android/site_preview.png" alt="A collage of films and shows available through a Vyla experience" loading="lazy" fetchpriority="low" />
**Vyla Go** is the Android application in the Vyla ecosystem. It prepares and runs the local services that power your media experience, then opens the interface in a secure embedded window. It is designed for people who want Vyla on their Android device without managing a separate server process or service lifecycle by hand. **Vyla Go** is the Android application in the Vyla ecosystem. It prepares and runs the local services that power your media experience, then opens the interface in a secure embedded window. It is designed for people who want Vyla on their Android device without managing a separate server process or service lifecycle by hand.

View File

@@ -2,7 +2,7 @@
<p class="page-description">The desktop control center for running the Vyla media stack locally.</p> <p class="page-description">The desktop control center for running the Vyla media stack locally.</p>
<img class="vyla-banner" src="/assets/images/desktop/site_preview.png" alt="Vyla Home desktop application preview" loading="lazy" fetchpriority="low" /> <img class="vyla-banner" src="/src/assets/images/desktop/site_preview.png" alt="Vyla Home desktop application preview" loading="lazy" fetchpriority="low" />
## Vyla Home ## Vyla Home

View File

@@ -2,7 +2,7 @@
<p class="page-description">The Android TV application for running the Vyla media experience on your television.</p> <p class="page-description">The Android TV application for running the Vyla media experience on your television.</p>
<img class="vyla-banner" src="/assets/images/android-tv/site-preview.png" alt="A collage of films and shows available through a Vyla experience" loading="lazy" fetchpriority="low" /> <img class="vyla-banner" src="/src/assets/images/android-tv/site-preview.png" alt="A collage of films and shows available through a Vyla experience" loading="lazy" fetchpriority="low" />
## Vyla TV ## Vyla TV

View File

@@ -815,11 +815,9 @@ body.is-home #mobile-bar {
} }
#header-container { #header-container {
width: 90%; width: 100%;
border-radius: 30px; border-radius: 0px;
backdrop-filter: blur(15px) saturate(1.2); top: 0px;
-webkit-backdrop-filter: blur(15px) saturate(1.2);
top: max(6px, env(safe-area-inset-top));
} }
#navbar .tabs, #navbar .tabs,

View File

@@ -16,7 +16,7 @@
--code-text: #abb2bf; --code-text: #abb2bf;
--bg-menu: #000000; --bg-menu: #000000;
--bg-modal: rgba(10, 10, 10, 0.35); --bg-modal: rgba(10, 10, 10, 0.35);
--hover-bg: rgba(255, 255, 255, 0.08); --hover-bg: rgba(120, 120, 120, 0.05);
--search-bg: rgba(50, 50, 50, 0.1); --search-bg: rgba(50, 50, 50, 0.1);
--search-panel-bg: rgba(10, 10, 10, 0.6); --search-panel-bg: rgba(10, 10, 10, 0.6);
--code-group-bg: #0d0d0d; --code-group-bg: #0d0d0d;

View File

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

Before

Width:  |  Height:  |  Size: 1016 KiB

After

Width:  |  Height:  |  Size: 1016 KiB

View File

Before

Width:  |  Height:  |  Size: 441 KiB

After

Width:  |  Height:  |  Size: 441 KiB

View File

Before

Width:  |  Height:  |  Size: 360 B

After

Width:  |  Height:  |  Size: 360 B

View File

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 3.9 MiB

View File

Before

Width:  |  Height:  |  Size: 698 KiB

After

Width:  |  Height:  |  Size: 698 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

View File

@@ -0,0 +1,84 @@
function renderChangelog() {
var box = document.getElementById("changelog");
if (!box) return;
box.innerHTML = '<p class="muted-line">Loading releases...</p>';
Promise.all(PROJECTS.map(function (p) {
return fetchProjectReleases(p).then(function (list) {
return list.map(function (r) { return { project: p, rel: r }; });
});
})).then(function (groups) {
var all = [].concat.apply([], groups).sort(function (a, b) {
return new Date(b.rel.released_at || b.rel.created_at) - new Date(a.rel.released_at || a.rel.created_at);
}).slice(0, 15);
if (!all.length) {
box.innerHTML = '<p class="muted-line">No releases found yet.</p>';
return;
}
box.innerHTML = all.map(function (entry) {
var r = entry.rel;
var d = new Date(r.released_at || r.created_at);
var date = isNaN(d) ? "" : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
var notes = String(r.description || "").replace(/[#*`>_\[\]]/g, "").replace(/\s+/g, " ").trim().slice(0, 280);
var url = r._links && typeof r._links.self === "string" && /^https?:\/\//.test(r._links.self) ? r._links.self : "";
return '<article class="cl-item"><div class="cl-meta"><span class="cl-tag">' + esc(PROJECT_LABELS[entry.project] || entry.project) + "</span><span>" + esc(r.tag_name || "") + "</span><span>" + esc(date) + "</span></div>" +
(r.name && r.name !== r.tag_name ? '<div class="cl-title">' + esc(r.name) + "</div>" : "") +
(notes ? '<p class="cl-notes">' + esc(notes) + "</p>" : "") +
(url ? '<a class="cl-link" href="' + esc(url) + '" target="_blank" rel="noopener">View on GitLab</a>' : "") +
"</article>";
}).join("");
});
}
function fetchContributors(project) {
var url = "https://gitlab.com/api/v4/projects/vyla-entertainment%2F" + encodeURIComponent(project) + "/repository/contributors?order_by=commits&sort=desc&per_page=100";
return fetch(url)
.then(function (res) {
if (!res.ok) throw new Error();
return res.json();
})
.then(function (data) { return Array.isArray(data) ? data : []; })
.catch(function () { return []; });
}
function renderContributors() {
var box = document.getElementById("contributors");
if (!box) return;
box.innerHTML = '<p class="muted-line">Loading contributors...</p>';
Promise.all(PROJECTS.map(fetchContributors)).then(function (groups) {
var totals = {};
[].concat.apply([], groups).forEach(function (c) {
var name = String(c.name || "").trim();
if (!name || name.toLowerCase() === "devin bot") return;
var key = name.toLowerCase();
if (!totals[key]) totals[key] = { name: name, commits: 0 };
totals[key].commits += Number(c.commits) || 0;
});
var people = Object.keys(totals)
.map(function (k) { return totals[k]; })
.sort(function (a, b) { return b.commits - a.commits; });
if (!people.length) {
box.innerHTML = '<p class="muted-line">Contributors will show up here.</p>';
return;
}
box.innerHTML = people.map(function (p) {
var avatar;
if (p.name.toLowerCase() === "alwaysmesmerizingyou") {
avatar = '<img class="ct-avatar-img" src="https://secure.gravatar.com/avatar/f7a5dfd997d0aa0e81331fd1804d962099056d0b01d60d686e02deb3f8d9751c?s=80&d=identicon" alt="' + esc(p.name) + '" />';
} else if (p.name.toLowerCase() === "zstrike") {
avatar = '<img class="ct-avatar-img" src="https://secure.gravatar.com/avatar/50dccff77795ed47ea2afe0d67823fbde6e0a6c39d7b6cc3af41a08bd159611d?s=1600&d=identicon" alt="' + esc(p.name) + '" />';
} else {
avatar = '<span class="ct-avatar">' + esc(p.name.charAt(0).toUpperCase()) + "</span>";
}
return '<span class="ct-chip">' + avatar + esc(p.name) + " <em>" + p.commits + "</em></span>";
}).join("");
});
}

View File

@@ -0,0 +1,50 @@
function resolveLatestDownloads() {
var path = window.location.pathname;
var project = null;
if (path.indexOf("/products/home") !== -1) project = "home";
else if (path.indexOf("/products/go") !== -1) project = "go";
else if (path.indexOf("/products/tv") !== -1) project = "TV";
if (!project) return;
fetchProjectReleases(project).then(function (releases) {
markRecommended(project);
if (!releases.length) return;
var links = document.querySelectorAll("#main-content a");
links.forEach(function (a) {
var text = a.textContent.toLowerCase();
var href = a.getAttribute("href") || "";
if (project === "home") {
if (text.indexOf(".exe") !== -1 || text.indexOf("windows") !== -1) {
var matchExe = findAssetInReleases(releases, "exe");
if (matchExe) {
a.href = matchExe.url;
a.textContent = "Download: " + matchExe.tag + " (.exe)";
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
}
} else if (text.indexOf(".dmg") !== -1 || text.indexOf("macos") !== -1) {
var matchDmg = findAssetInReleases(releases, "dmg");
if (matchDmg) {
a.href = matchDmg.url;
a.textContent = "Download: " + matchDmg.tag + " (.dmg)";
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
}
}
} else if (project === "go" || project === "TV") {
if (text.indexOf(".apk") !== -1 || href.indexOf("releases") !== -1) {
var matchApk = findAssetInReleases(releases, "apk");
if (matchApk) {
a.href = matchApk.url;
a.textContent = "Download: " + matchApk.tag + " (.apk)";
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
}
}
}
});
});
}

View File

@@ -0,0 +1,42 @@
var RELEASE_CACHE = {};
function fetchProjectReleases(project) {
if (RELEASE_CACHE[project]) return Promise.resolve(RELEASE_CACHE[project]);
var url = "https://gitlab.com/api/v4/projects/vyla-entertainment%2F" + encodeURIComponent(project) + "/releases";
return fetch(url)
.then(function (res) {
if (!res.ok) throw new Error();
return res.json();
})
.then(function (data) {
RELEASE_CACHE[project] = Array.isArray(data) ? data : [];
return RELEASE_CACHE[project];
})
.catch(function () {
return [];
});
}
function findAssetInReleases(releases, ext) {
for (var r = 0; r < releases.length; r++) {
var rel = releases[r];
var tag = rel.tag_name || "";
var links = (rel.assets && rel.assets.links) || [];
for (var i = 0; i < links.length; i++) {
var l = links[i];
var name = (l.name || "").toLowerCase();
var url = (l.url || "").toLowerCase();
if (name.indexOf(ext) !== -1 || url.indexOf(ext) !== -1) {
return { tag: tag, url: l.direct_asset_url || l.url };
}
}
if (rel.description) {
var reg = new RegExp("\\[([^\\]]+)\\]\\((https?:[^)]+\\." + ext + "[^)]*)\\)", "i");
var match = rel.description.match(reg);
if (match) return { tag: tag, url: match[2] };
var rawMatch = rel.description.match(new RegExp("(https?:\\S+\\." + ext + ")", "i"));
if (rawMatch) return { tag: tag, url: rawMatch[1] };
}
}
return null;
}

View File

@@ -176,7 +176,7 @@ function applyTheme() {
var theme = resolveTheme(mode); var theme = resolveTheme(mode);
document.documentElement.setAttribute("data-theme", theme); document.documentElement.setAttribute("data-theme", theme);
var logo = document.getElementById("brand-logo"); var logo = document.getElementById("brand-logo");
if (logo) logo.src = theme === "light" ? "/assets/images/dark_logo.png" : "/assets/images/logo.png"; if (logo) logo.src = theme === "light" ? "/src/assets/images/dark_logo.png" : "/src/assets/images/logo.png";
applyPreferences(); applyPreferences();
var targetSrc = THEME_VIDEOS[theme] || THEME_VIDEOS.halloween; var targetSrc = THEME_VIDEOS[theme] || THEME_VIDEOS.halloween;
var videosOff = getPref("video", "on") === "off"; var videosOff = getPref("video", "on") === "off";
@@ -561,80 +561,6 @@ function markRecommended(project) {
}); });
} }
function renderChangelog() {
var box = document.getElementById("changelog");
if (!box) return;
box.innerHTML = '<p class="muted-line">Loading releases...</p>';
Promise.all(PROJECTS.map(function (p) {
return fetchProjectReleases(p).then(function (list) {
return list.map(function (r) { return { project: p, rel: r }; });
});
})).then(function (groups) {
var all = [].concat.apply([], groups).sort(function (a, b) {
return new Date(b.rel.released_at || b.rel.created_at) - new Date(a.rel.released_at || a.rel.created_at);
}).slice(0, 15);
if (!all.length) {
box.innerHTML = '<p class="muted-line">No releases found yet.</p>';
return;
}
box.innerHTML = all.map(function (entry) {
var r = entry.rel;
var d = new Date(r.released_at || r.created_at);
var date = isNaN(d) ? "" : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
var notes = String(r.description || "").replace(/[#*`>_\[\]]/g, "").replace(/\s+/g, " ").trim().slice(0, 280);
var url = r._links && typeof r._links.self === "string" && /^https?:\/\//.test(r._links.self) ? r._links.self : "";
return '<article class="cl-item"><div class="cl-meta"><span class="cl-tag">' + esc(PROJECT_LABELS[entry.project] || entry.project) + "</span><span>" + esc(r.tag_name || "") + "</span><span>" + esc(date) + "</span></div>" +
(r.name && r.name !== r.tag_name ? '<div class="cl-title">' + esc(r.name) + "</div>" : "") +
(notes ? '<p class="cl-notes">' + esc(notes) + "</p>" : "") +
(url ? '<a class="cl-link" href="' + esc(url) + '" target="_blank" rel="noopener">View on GitLab</a>' : "") +
"</article>";
}).join("");
});
}
function fetchContributors(project) {
var url = "https://gitlab.com/api/v4/projects/vyla-entertainment%2F" + encodeURIComponent(project) + "/repository/contributors?order_by=commits&sort=desc&per_page=100";
return fetch(url)
.then(function (res) {
if (!res.ok) throw new Error();
return res.json();
})
.then(function (data) { return Array.isArray(data) ? data : []; })
.catch(function () { return []; });
}
function renderContributors() {
var box = document.getElementById("contributors");
if (!box) return;
box.innerHTML = '<p class="muted-line">Loading contributors...</p>';
Promise.all(PROJECTS.map(fetchContributors)).then(function (groups) {
var totals = {};
[].concat.apply([], groups).forEach(function (c) {
var name = String(c.name || "").trim();
if (!name) return;
var key = name.toLowerCase();
if (!totals[key]) totals[key] = { name: name, commits: 0 };
totals[key].commits += Number(c.commits) || 0;
});
var people = Object.keys(totals).map(function (k) { return totals[k]; }).sort(function (a, b) { return b.commits - a.commits; });
if (!people.length) {
box.innerHTML = '<p class="muted-line">Contributors will show up here.</p>';
return;
}
box.innerHTML = people.map(function (p) {
var avatar;
if (p.name.toLowerCase() === "alwaysmesmerizingyou") {
avatar = '<img class="ct-avatar-img" src="https://secure.gravatar.com/avatar/f7a5dfd997d0aa0e81331fd1804d962099056d0b01d60d686e02deb3f8d9751c?s=80&d=identicon" alt="' + esc(p.name) + '" />';
} else if (p.name.toLowerCase() === "zstrike") {
avatar = '<img class="ct-avatar-img" src="https://secure.gravatar.com/avatar/50dccff77795ed47ea2afe0d67823fbde6e0a6c39d7b6cc3af41a08bd159611d?s=1600&d=identicon" alt="' + esc(p.name) + '" />';
} else {
avatar = '<span class="ct-avatar">' + esc(p.name.charAt(0).toUpperCase()) + "</span>";
}
return '<span class="ct-chip">' + avatar + esc(p.name) + " <em>" + p.commits + "</em></span>";
}).join("");
});
}
function burstConfetti() { function burstConfetti() {
if (document.body.classList.contains("no-motion")) return; if (document.body.classList.contains("no-motion")) return;
var copy = THEME_COPY[resolveTheme(getThemeMode())] || THEME_COPY.base; var copy = THEME_COPY[resolveTheme(getThemeMode())] || THEME_COPY.base;
@@ -713,7 +639,6 @@ function initEasterEggs() {
} }
}); });
try { try {
console.log("%cVyla", "font:700 22px Inter,sans-serif;color:#ff7518", "\nYou found the console. Vyla is built by its community. Come help: https://gitlab.com/vyla-entertainment/");
} catch (e) { } } catch (e) { }
} }
@@ -751,237 +676,6 @@ function switchRow(id, title, hint) {
'<span class="st-switch"><input id="' + id + '" type="checkbox"><span class="st-track"></span></span></label>'; '<span class="st-switch"><input id="' + id + '" type="checkbox"><span class="st-track"></span></span></label>';
} }
function syncSettingsUI() {
var m = settingsEl("settings-modal");
if (!m) return;
settingsEl("set-name").value = getPref("name", "");
settingsEl("set-api").value = getPref("api", "");
settingsEl("set-api").classList.remove("bad");
setApiHint("");
var accent = String(getPref("accent", "") || "").toLowerCase();
var validAccent = /^#[0-9a-f]{6}$/.test(accent);
m.querySelectorAll(".st-swatch[data-color]").forEach(function (b) {
b.classList.toggle("on", validAccent && b.getAttribute("data-color") === accent);
});
m.querySelector(".st-swatch.reset").classList.toggle("on", !validAccent);
m.querySelector(".st-swatch.custom").classList.toggle("on", validAccent && ACCENT_PRESETS.indexOf(accent) === -1);
settingsEl("set-accent").value = validAccent ? accent : "#ffffff";
var size = String(parseInt(getPref("text", "100"), 10) || 100);
m.querySelectorAll(".st-seg button").forEach(function (b) {
b.classList.toggle("on", b.getAttribute("data-v") === size);
});
settingsEl("set-video").checked = getPref("video", "on") !== "off";
settingsEl("set-motion").checked = getPref("motion", "on") !== "off";
settingsEl("set-deco").checked = getPref("deco", "on") !== "off";
}
var saveTimer = null;
function measureSettings() {
var m = settingsEl("settings-modal");
if (!m) return;
var panel = m.querySelector(".sheet-panel");
var top = m.querySelector(".st-top");
var bottom = m.querySelector(".st-bottom");
if (top) panel.style.setProperty("--st-top", Math.max(0, top.offsetHeight - 10) + "px");
if (bottom) panel.style.setProperty("--st-bottom", Math.max(0, bottom.offsetHeight - 10) + "px");
}
function openSettings() {
var m = settingsEl("settings-modal");
if (!m) return;
syncSettingsUI();
m.querySelector(".sheet-panel").style.transform = "";
m.classList.add("active");
measureSettings();
document.body.style.overflow = "hidden";
}
function closeSettings() {
var m = settingsEl("settings-modal");
if (!m || !m.classList.contains("active")) return;
m.querySelector(".sheet-panel").style.transform = "";
m.classList.remove("active");
document.body.style.overflow = "";
}
function buildSettings() {
var trigger = settingsEl("settings-trigger");
if (trigger) trigger.innerHTML = icon("sliders");
var modal = document.createElement("div");
modal.id = "settings-modal";
modal.className = "sheet-overlay settings-overlay";
var swatches = ACCENT_PRESETS.map(function (c) {
return '<button type="button" class="st-swatch" data-color="' + c + '" style="--sw:' + c + '" aria-label="Accent color ' + c + '"></button>';
}).join("");
var sizes = TEXT_STEPS.map(function (s) {
return '<button type="button" data-v="' + s.v + '">' + s.l + "</button>";
}).join("");
modal.innerHTML =
'<div class="sheet-backdrop"></div>' +
'<div class="sheet-panel settings-panel" role="dialog" aria-modal="true" aria-label="Personalize">' +
'<div class="st-top"><div class="sheet-handle-bar"><div class="sheet-handle"></div></div>' +
'<h3 class="sheet-title">Make it yours</h3>' +
'<p class="sheet-desc">Saved only in this browser. Nothing leaves your device.</p></div>' +
'<div class="st-scroll">' +
'<section class="st-group"><h4 class="st-heading">Theme</h4><div class="st-card st-themes">' +
'<div class="st-sub">General</div><div class="st-theme-grid">' + themeChips(THEMES_GENERAL) + "</div>" +
'<div class="st-sub">Seasonal</div><div class="st-theme-grid">' + themeChips(THEMES_SEASONAL) + "</div>" +
"</div></section>" +
'<section class="st-group"><h4 class="st-heading">You</h4><div class="st-card">' +
'<label class="st-field"><span class="st-label">Your name</span><input id="set-name" class="st-input" type="text" maxlength="24" placeholder="Shown in the home greeting" autocomplete="off"></label>' +
'<label class="st-field"><span class="st-label">Stream API URL</span><input id="set-api" class="st-input" type="url" placeholder="https://your-server.example.com" autocomplete="off" spellcheck="false"><span id="set-api-hint" class="st-hint"></span></label>' +
"</div></section>" +
'<section class="st-group"><h4 class="st-heading">Appearance</h4><div class="st-card">' +
'<div class="st-row stack"><span class="st-text"><span class="st-label">Accent color</span><span class="st-hint">Headings, buttons and highlights.</span></span>' +
'<span class="st-swatches"><button type="button" class="st-swatch reset" aria-label="Use theme color" title="Theme default"></button>' + swatches +
'<label class="st-swatch custom" title="Custom color"><input id="set-accent" type="color" value="#ffffff" aria-label="Custom accent color"></label></span></div>' +
'<div class="st-row stack"><span class="st-text"><span class="st-label">Text size</span><span class="st-hint">Applies to documentation pages.</span></span>' +
'<div class="st-seg">' + sizes + "</div></div>" +
"</div></section>" +
'<section class="st-group"><h4 class="st-heading">Experience</h4><div class="st-card">' +
switchRow("set-video", "Background video", "Looping video behind each theme.") +
switchRow("set-motion", "Animations", "Transitions, falling decorations and effects.") +
switchRow("set-deco", "Decorations", "Spiders, snow, leaves and lights.") +
"</div></section>" +
"</div>" +
'<div class="st-bottom"><button type="button" class="sheet-btn-never st-reset">Reset</button><button type="button" class="sheet-btn-primary st-save">Save preferences</button></div>' +
"</div>";
document.body.appendChild(modal);
refreshThemeUI();
measureSettings();
if (window.ResizeObserver) {
var settingsRO = new ResizeObserver(measureSettings);
settingsRO.observe(modal.querySelector(".st-top"));
settingsRO.observe(modal.querySelector(".st-bottom"));
}
var panel = modal.querySelector(".sheet-panel");
var handle = modal.querySelector(".st-top");
modal.addEventListener("click", function (e) {
if (e.target.closest(".sheet-backdrop")) {
closeSettings();
return;
}
var saveBtn = e.target.closest(".st-save");
if (saveBtn) {
var apiBox = settingsEl("set-api");
var apiRaw = apiBox.value.trim();
commitApi();
if (apiRaw && !cleanApiUrl(apiRaw)) {
apiBox.focus();
return;
}
saveBtn.textContent = "Saved";
saveBtn.classList.add("done");
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
closeSettings();
saveBtn.textContent = "Save preferences";
saveBtn.classList.remove("done");
}, 450);
return;
}
var tb = e.target.closest(".st-theme");
if (tb) {
setTheme(tb.getAttribute("data-mode"));
return;
}
var sw = e.target.closest(".st-swatch[data-color]");
if (sw) {
var color = sw.getAttribute("data-color");
setPref("accent", color);
applyAccent(color);
syncSettingsUI();
return;
}
if (e.target.closest(".st-swatch.reset")) {
setPref("accent", null);
applyAccent("");
syncSettingsUI();
return;
}
var seg = e.target.closest(".st-seg button");
if (seg) {
var v = seg.getAttribute("data-v");
setPref("text", v === "100" ? null : v);
applyPreferences();
syncSettingsUI();
return;
}
if (e.target.closest(".st-reset")) {
Object.keys(PREF_KEYS).forEach(function (k) { setPref(k, null); });
applyTheme();
syncSettingsUI();
refreshPersonalPages();
}
});
settingsEl("set-name").addEventListener("input", function () {
setPref("name", this.value.trim());
});
function commitApi() {
var input = settingsEl("set-api");
var raw = input.value.trim();
var clean = cleanApiUrl(raw);
if (raw && !clean) {
input.classList.add("bad");
setApiHint("Enter a full URL starting with http:// or https://", true);
return;
}
input.classList.remove("bad");
setPref("api", clean);
input.value = clean;
setApiHint(clean ? "Saved. Docs examples now use your server." : "");
refreshPersonalPages();
}
settingsEl("set-api").addEventListener("change", commitApi);
settingsEl("set-api").addEventListener("keydown", function (e) {
if (e.key === "Enter") commitApi();
});
settingsEl("set-accent").addEventListener("input", function () {
var color = this.value.toLowerCase();
setPref("accent", color);
applyAccent(color);
syncSettingsUI();
});
[["set-video", "video"], ["set-motion", "motion"], ["set-deco", "deco"]].forEach(function (pair) {
settingsEl(pair[0]).addEventListener("change", function () {
setPref(pair[1], this.checked ? null : "off");
applyTheme();
});
});
var drag = null;
handle.addEventListener("touchstart", function (e) {
if (window.innerWidth > 900) return;
drag = { y: e.touches[0].clientY, d: 0 };
panel.classList.add("dragging");
}, { passive: true });
window.addEventListener("touchmove", function (e) {
if (!drag) return;
drag.d = Math.max(0, e.touches[0].clientY - drag.y);
panel.style.transform = "translateY(" + drag.d + "px)";
}, { passive: true });
window.addEventListener("touchend", function () {
if (!drag) return;
var d = drag.d;
drag = null;
panel.classList.remove("dragging");
if (d > 110) closeSettings();
else panel.style.transform = "";
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && modal.classList.contains("active")) closeSettings();
});
}
function parseMarkdown(md) { function parseMarkdown(md) {
var lines = md.replace(/\r\n/g, "\n").split("\n"); var lines = md.replace(/\r\n/g, "\n").split("\n");
var out = []; var out = [];
@@ -1790,100 +1484,6 @@ function loadPage() {
} }
} }
var RELEASE_CACHE = {};
function fetchProjectReleases(project) {
if (RELEASE_CACHE[project]) return Promise.resolve(RELEASE_CACHE[project]);
var url = "https://gitlab.com/api/v4/projects/vyla-entertainment%2F" + encodeURIComponent(project) + "/releases";
return fetch(url)
.then(function (res) {
if (!res.ok) throw new Error();
return res.json();
})
.then(function (data) {
RELEASE_CACHE[project] = Array.isArray(data) ? data : [];
return RELEASE_CACHE[project];
})
.catch(function () {
return [];
});
}
function findAssetInReleases(releases, ext) {
for (var r = 0; r < releases.length; r++) {
var rel = releases[r];
var tag = rel.tag_name || "";
var links = (rel.assets && rel.assets.links) || [];
for (var i = 0; i < links.length; i++) {
var l = links[i];
var name = (l.name || "").toLowerCase();
var url = (l.url || "").toLowerCase();
if (name.indexOf(ext) !== -1 || url.indexOf(ext) !== -1) {
return { tag: tag, url: l.direct_asset_url || l.url };
}
}
if (rel.description) {
var reg = new RegExp("\\[([^\\]]+)\\]\\((https?:[^)]+\\." + ext + "[^)]*)\\)", "i");
var match = rel.description.match(reg);
if (match) return { tag: tag, url: match[2] };
var rawMatch = rel.description.match(new RegExp("(https?:\\S+\\." + ext + ")", "i"));
if (rawMatch) return { tag: tag, url: rawMatch[1] };
}
}
return null;
}
function resolveLatestDownloads() {
var path = window.location.pathname;
var project = null;
if (path.indexOf("/products/home") !== -1) project = "home";
else if (path.indexOf("/products/go") !== -1) project = "go";
else if (path.indexOf("/products/tv") !== -1) project = "TV";
if (!project) return;
fetchProjectReleases(project).then(function (releases) {
markRecommended(project);
if (!releases.length) return;
var links = document.querySelectorAll("#main-content a");
links.forEach(function (a) {
var text = a.textContent.toLowerCase();
var href = a.getAttribute("href") || "";
if (project === "home") {
if (text.indexOf(".exe") !== -1 || text.indexOf("windows") !== -1) {
var matchExe = findAssetInReleases(releases, "exe");
if (matchExe) {
a.href = matchExe.url;
a.textContent = "Download: " + matchExe.tag + " (.exe)";
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
}
} else if (text.indexOf(".dmg") !== -1 || text.indexOf("macos") !== -1) {
var matchDmg = findAssetInReleases(releases, "dmg");
if (matchDmg) {
a.href = matchDmg.url;
a.textContent = "Download: " + matchDmg.tag + " (.dmg)";
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
}
}
} else if (project === "go" || project === "TV") {
if (text.indexOf(".apk") !== -1 || href.indexOf("releases") !== -1) {
var matchApk = findAssetInReleases(releases, "apk");
if (matchApk) {
a.href = matchApk.url;
a.textContent = "Download: " + matchApk.tag + " (.apk)";
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
}
}
}
});
});
}
function getRecent() { function getRecent() {
try { return JSON.parse(localStorage.getItem("vyla-recent")) || []; } catch (e) { return []; } try { return JSON.parse(localStorage.getItem("vyla-recent")) || []; } catch (e) { return []; }
} }

View File

@@ -0,0 +1,230 @@
function syncSettingsUI() {
var m = settingsEl("settings-modal");
if (!m) return;
settingsEl("set-name").value = getPref("name", "");
settingsEl("set-api").value = getPref("api", "");
settingsEl("set-api").classList.remove("bad");
setApiHint("");
var accent = String(getPref("accent", "") || "").toLowerCase();
var validAccent = /^#[0-9a-f]{6}$/.test(accent);
m.querySelectorAll(".st-swatch[data-color]").forEach(function (b) {
b.classList.toggle("on", validAccent && b.getAttribute("data-color") === accent);
});
m.querySelector(".st-swatch.reset").classList.toggle("on", !validAccent);
m.querySelector(".st-swatch.custom").classList.toggle("on", validAccent && ACCENT_PRESETS.indexOf(accent) === -1);
settingsEl("set-accent").value = validAccent ? accent : "#ffffff";
var size = String(parseInt(getPref("text", "100"), 10) || 100);
m.querySelectorAll(".st-seg button").forEach(function (b) {
b.classList.toggle("on", b.getAttribute("data-v") === size);
});
settingsEl("set-video").checked = getPref("video", "on") !== "off";
settingsEl("set-motion").checked = getPref("motion", "on") !== "off";
settingsEl("set-deco").checked = getPref("deco", "on") !== "off";
}
var saveTimer = null;
function measureSettings() {
var m = settingsEl("settings-modal");
if (!m) return;
var panel = m.querySelector(".sheet-panel");
var top = m.querySelector(".st-top");
var bottom = m.querySelector(".st-bottom");
if (top) panel.style.setProperty("--st-top", Math.max(0, top.offsetHeight - 10) + "px");
if (bottom) panel.style.setProperty("--st-bottom", Math.max(0, bottom.offsetHeight - 10) + "px");
}
function openSettings() {
var m = settingsEl("settings-modal");
if (!m) return;
syncSettingsUI();
m.querySelector(".sheet-panel").style.transform = "";
m.classList.add("active");
measureSettings();
document.body.style.overflow = "hidden";
}
function closeSettings() {
var m = settingsEl("settings-modal");
if (!m || !m.classList.contains("active")) return;
m.querySelector(".sheet-panel").style.transform = "";
m.classList.remove("active");
document.body.style.overflow = "";
}
function buildSettings() {
var trigger = settingsEl("settings-trigger");
if (trigger) trigger.innerHTML = icon("sliders");
var modal = document.createElement("div");
modal.id = "settings-modal";
modal.className = "sheet-overlay settings-overlay";
var swatches = ACCENT_PRESETS.map(function (c) {
return '<button type="button" class="st-swatch" data-color="' + c + '" style="--sw:' + c + '" aria-label="Accent color ' + c + '"></button>';
}).join("");
var sizes = TEXT_STEPS.map(function (s) {
return '<button type="button" data-v="' + s.v + '">' + s.l + "</button>";
}).join("");
modal.innerHTML =
'<div class="sheet-backdrop"></div>' +
'<div class="sheet-panel settings-panel" role="dialog" aria-modal="true" aria-label="Personalize">' +
'<div class="st-top"><div class="sheet-handle-bar"><div class="sheet-handle"></div></div>' +
'<h3 class="sheet-title">Make it yours</h3>' +
'<p class="sheet-desc">Saved only in this browser. Nothing leaves your device.</p></div>' +
'<div class="st-scroll">' +
'<section class="st-group"><h4 class="st-heading">Theme</h4><div class="st-card st-themes">' +
'<div class="st-sub">General</div><div class="st-theme-grid">' + themeChips(THEMES_GENERAL) + "</div>" +
'<div class="st-sub">Seasonal</div><div class="st-theme-grid">' + themeChips(THEMES_SEASONAL) + "</div>" +
"</div></section>" +
'<section class="st-group"><h4 class="st-heading">You</h4><div class="st-card">' +
'<label class="st-field"><span class="st-label">Your name</span><input id="set-name" class="st-input" type="text" maxlength="24" placeholder="Shown in the home greeting" autocomplete="off"></label>' +
'<label class="st-field"><span class="st-label">Stream API URL</span><input id="set-api" class="st-input" type="url" placeholder="https://your-server.example.com" autocomplete="off" spellcheck="false"><span id="set-api-hint" class="st-hint"></span></label>' +
"</div></section>" +
'<section class="st-group"><h4 class="st-heading">Appearance</h4><div class="st-card">' +
'<div class="st-row stack"><span class="st-text"><span class="st-label">Accent color</span><span class="st-hint">Headings, buttons and highlights.</span></span>' +
'<span class="st-swatches"><button type="button" class="st-swatch reset" aria-label="Use theme color" title="Theme default"></button>' + swatches +
'<label class="st-swatch custom" title="Custom color"><input id="set-accent" type="color" value="#ffffff" aria-label="Custom accent color"></label></span></div>' +
'<div class="st-row stack"><span class="st-text"><span class="st-label">Text size</span><span class="st-hint">Applies to documentation pages.</span></span>' +
'<div class="st-seg">' + sizes + "</div></div>" +
"</div></section>" +
'<section class="st-group"><h4 class="st-heading">Experience</h4><div class="st-card">' +
switchRow("set-video", "Background video", "Looping video behind each theme.") +
switchRow("set-motion", "Animations", "Transitions, falling decorations and effects.") +
switchRow("set-deco", "Decorations", "Spiders, snow, leaves and lights.") +
"</div></section>" +
"</div>" +
'<div class="st-bottom"><button type="button" class="sheet-btn-never st-reset">Reset</button><button type="button" class="sheet-btn-primary st-save">Save preferences</button></div>' +
"</div>";
document.body.appendChild(modal);
refreshThemeUI();
measureSettings();
if (window.ResizeObserver) {
var settingsRO = new ResizeObserver(measureSettings);
settingsRO.observe(modal.querySelector(".st-top"));
settingsRO.observe(modal.querySelector(".st-bottom"));
}
var panel = modal.querySelector(".sheet-panel");
var handle = modal.querySelector(".st-top");
modal.addEventListener("click", function (e) {
if (e.target.closest(".sheet-backdrop")) {
closeSettings();
return;
}
var saveBtn = e.target.closest(".st-save");
if (saveBtn) {
var apiBox = settingsEl("set-api");
var apiRaw = apiBox.value.trim();
commitApi();
if (apiRaw && !cleanApiUrl(apiRaw)) {
apiBox.focus();
return;
}
saveBtn.textContent = "Saved";
saveBtn.classList.add("done");
clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
closeSettings();
saveBtn.textContent = "Save preferences";
saveBtn.classList.remove("done");
}, 450);
return;
}
var tb = e.target.closest(".st-theme");
if (tb) {
setTheme(tb.getAttribute("data-mode"));
return;
}
var sw = e.target.closest(".st-swatch[data-color]");
if (sw) {
var color = sw.getAttribute("data-color");
setPref("accent", color);
applyAccent(color);
syncSettingsUI();
return;
}
if (e.target.closest(".st-swatch.reset")) {
setPref("accent", null);
applyAccent("");
syncSettingsUI();
return;
}
var seg = e.target.closest(".st-seg button");
if (seg) {
var v = seg.getAttribute("data-v");
setPref("text", v === "100" ? null : v);
applyPreferences();
syncSettingsUI();
return;
}
if (e.target.closest(".st-reset")) {
Object.keys(PREF_KEYS).forEach(function (k) { setPref(k, null); });
applyTheme();
syncSettingsUI();
refreshPersonalPages();
}
});
settingsEl("set-name").addEventListener("input", function () {
setPref("name", this.value.trim());
});
function commitApi() {
var input = settingsEl("set-api");
var raw = input.value.trim();
var clean = cleanApiUrl(raw);
if (raw && !clean) {
input.classList.add("bad");
setApiHint("Enter a full URL starting with http:// or https://", true);
return;
}
input.classList.remove("bad");
setPref("api", clean);
input.value = clean;
setApiHint(clean ? "Saved. Docs examples now use your server." : "");
refreshPersonalPages();
}
settingsEl("set-api").addEventListener("change", commitApi);
settingsEl("set-api").addEventListener("keydown", function (e) {
if (e.key === "Enter") commitApi();
});
settingsEl("set-accent").addEventListener("input", function () {
var color = this.value.toLowerCase();
setPref("accent", color);
applyAccent(color);
syncSettingsUI();
});
[["set-video", "video"], ["set-motion", "motion"], ["set-deco", "deco"]].forEach(function (pair) {
settingsEl(pair[0]).addEventListener("change", function () {
setPref(pair[1], this.checked ? null : "off");
applyTheme();
});
});
var drag = null;
handle.addEventListener("touchstart", function (e) {
if (window.innerWidth > 900) return;
drag = { y: e.touches[0].clientY, d: 0 };
panel.classList.add("dragging");
}, { passive: true });
window.addEventListener("touchmove", function (e) {
if (!drag) return;
drag.d = Math.max(0, e.touches[0].clientY - drag.y);
panel.style.transform = "translateY(" + drag.d + "px)";
}, { passive: true });
window.addEventListener("touchend", function () {
if (!drag) return;
var d = drag.d;
drag = null;
panel.classList.remove("dragging");
if (d > 110) closeSettings();
else panel.style.transform = "";
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && modal.classList.contains("active")) closeSettings();
});
}

View File

@@ -2,7 +2,7 @@
<p class="page-description">A community-built media project focused on freedom, privacy, and giving people control over their own experience.</p> <p class="page-description">A community-built media project focused on freedom, privacy, and giving people control over their own experience.</p>
<img class="vyla-banner" src="/assets/images/devices.png" alt="A collage of films and shows available through a Vyla experience" loading="lazy" fetchpriority="low" /> <img class="vyla-banner" src="/src/assets/images/devices.png" alt="A collage of films and shows available through a Vyla experience" loading="lazy" fetchpriority="low" />
## About Vyla ## About Vyla