video side: Library page (lists movies/shows, scan trigger)
- GET /api/video/library -> {movies, shows} from video.db (VideoDatabase.
list_movies/list_shows; shows carry episode_count + owned_count).
- Library page (video-library subpage, isolated video-library.js): tabbed
Movies/Shows grid of poster cards, count, empty-state. A 'Scan Library'
button POSTs /api/video/scan/request then polls /api/video/scan/status,
showing live phase/counts, and refreshes the grid when done.
- Reuses the music dashboard-header chrome (icon title, sweep hidden) + the
watchlist-button styling for the scan button; video-card grid styles added.
- All data-attr wired (no inline onclick); module is an isolated IIFE that
listens for soulsync:video-page-shown. 105 tests green.
Now: video.db -> scanner -> /api/video -> live dashboard + Library page, all
isolated from music. Scanner adapters await live Plex/Jellyfin validation.
This commit is contained in:
parent
6665ecaa12
commit
d7ab68c067
9 changed files with 384 additions and 0 deletions
|
|
@ -39,7 +39,9 @@ def create_video_blueprint() -> Blueprint:
|
||||||
|
|
||||||
from .dashboard import register_routes as reg_dashboard
|
from .dashboard import register_routes as reg_dashboard
|
||||||
from .scan import register_routes as reg_scan
|
from .scan import register_routes as reg_scan
|
||||||
|
from .library import register_routes as reg_library
|
||||||
reg_dashboard(bp)
|
reg_dashboard(bp)
|
||||||
reg_scan(bp)
|
reg_scan(bp)
|
||||||
|
reg_library(bp)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|
|
||||||
25
api/video/library.py
Normal file
25
api/video/library.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Video library listing endpoint.
|
||||||
|
|
||||||
|
GET /api/video/library -> {"movies": [...], "shows": [...]}
|
||||||
|
Reads what the last scan mirrored from the media server into video.db.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("video_api.library")
|
||||||
|
|
||||||
|
|
||||||
|
def register_routes(bp):
|
||||||
|
@bp.route("/library", methods=["GET"])
|
||||||
|
def video_library():
|
||||||
|
from . import get_video_db
|
||||||
|
try:
|
||||||
|
db = get_video_db()
|
||||||
|
return jsonify({"movies": db.list_movies(), "shows": db.list_shows()})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to list video library")
|
||||||
|
return jsonify({"error": "Failed to load video library"}), 500
|
||||||
|
|
@ -314,6 +314,31 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# ── library listing ───────────────────────────────────────────────────────
|
||||||
|
def list_movies(self) -> list[dict]:
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, title, year, poster_url, has_file, monitored "
|
||||||
|
"FROM movies ORDER BY COALESCE(sort_title, title) COLLATE NOCASE, title"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def list_shows(self) -> list[dict]:
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT s.id, s.title, s.year, s.poster_url, s.monitored, "
|
||||||
|
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id = s.id) AS episode_count, "
|
||||||
|
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id = s.id AND e.has_file = 1) AS owned_count "
|
||||||
|
"FROM shows s ORDER BY COALESCE(s.sort_title, s.title) COLLATE NOCASE, s.title"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
# ── health ───────────────────────────────────────────────────────────────
|
# ── health ───────────────────────────────────────────────────────────────
|
||||||
def health_check(self) -> bool:
|
def health_check(self) -> bool:
|
||||||
"""True when the DB opens and passes a quick integrity check."""
|
"""True when the DB opens and passes a quick integrity check."""
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ def test_blueprint_exposes_dashboard_route():
|
||||||
assert "/api/video/dashboard" in rules
|
assert "/api/video/dashboard" in rules
|
||||||
assert "/api/video/scan/request" in rules
|
assert "/api/video/scan/request" in rules
|
||||||
assert "/api/video/scan/status" in rules
|
assert "/api/video/scan/status" in rules
|
||||||
|
assert "/api/video/library" in rules
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_endpoint_returns_zeroed_json(tmp_path):
|
def test_dashboard_endpoint_returns_zeroed_json(tmp_path):
|
||||||
|
|
@ -46,6 +47,19 @@ def test_dashboard_endpoint_returns_zeroed_json(tmp_path):
|
||||||
videoapi._video_db = None # don't leak the tmp DB to other tests
|
videoapi._video_db = None # don't leak the tmp DB to other tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_endpoint_lists_content(tmp_path):
|
||||||
|
client, videoapi = _make_client(tmp_path)
|
||||||
|
try:
|
||||||
|
videoapi._video_db.upsert_movie("plex", {"server_id": "m1", "title": "A"})
|
||||||
|
resp = client.get("/api/video/library")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert [m["title"] for m in data["movies"]] == ["A"]
|
||||||
|
assert data["shows"] == []
|
||||||
|
finally:
|
||||||
|
videoapi._video_db = None
|
||||||
|
|
||||||
|
|
||||||
def test_video_api_imports_nothing_from_music():
|
def test_video_api_imports_nothing_from_music():
|
||||||
base = Path(__file__).resolve().parent.parent / "api" / "video"
|
base = Path(__file__).resolve().parent.parent / "api" / "video"
|
||||||
for py in base.glob("*.py"):
|
for py in base.glob("*.py"):
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,19 @@ def test_prune_missing_removes_unseen_top_level(db):
|
||||||
assert ids == {"a"}
|
assert ids == {"a"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_movies_and_shows(db):
|
||||||
|
db.upsert_movie("plex", {"server_id": "m1", "title": "Zardoz", "year": 1974})
|
||||||
|
db.upsert_movie("plex", {"server_id": "m2", "title": "Akira", "year": 1988})
|
||||||
|
db.upsert_show_tree("plex", {"server_id": "s1", "title": "Show", "seasons": [
|
||||||
|
{"season_number": 1, "episodes": [
|
||||||
|
{"episode_number": 1, "file": {"relative_path": "e1.mkv"}},
|
||||||
|
{"episode_number": 2}]}]})
|
||||||
|
assert [m["title"] for m in db.list_movies()] == ["Akira", "Zardoz"] # title NOCASE sort
|
||||||
|
shows = db.list_shows()
|
||||||
|
assert len(shows) == 1
|
||||||
|
assert (shows[0]["episode_count"], shows[0]["owned_count"]) == (2, 1)
|
||||||
|
|
||||||
|
|
||||||
# ── isolation: the video DB imports nothing from music ───────────────────────
|
# ── isolation: the video DB imports nothing from music ───────────────────────
|
||||||
|
|
||||||
def test_video_db_module_imports_nothing_from_music():
|
def test_video_db_module_imports_nothing_from_music():
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ _ROOT = Path(__file__).resolve().parent.parent
|
||||||
_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8", errors="replace")
|
_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8", errors="replace")
|
||||||
_JS = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8")
|
_JS = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8")
|
||||||
_DASH_JS = (_ROOT / "webui" / "static" / "video" / "video-dashboard.js").read_text(encoding="utf-8")
|
_DASH_JS = (_ROOT / "webui" / "static" / "video" / "video-dashboard.js").read_text(encoding="utf-8")
|
||||||
|
_LIB_JS = (_ROOT / "webui" / "static" / "video" / "video-library.js").read_text(encoding="utf-8")
|
||||||
_CSS_PATH = _ROOT / "webui" / "static" / "video" / "video-side.css"
|
_CSS_PATH = _ROOT / "webui" / "static" / "video" / "video-side.css"
|
||||||
|
|
||||||
EXPECTED_VIDEO_PAGES = {
|
EXPECTED_VIDEO_PAGES = {
|
||||||
|
|
@ -132,6 +133,25 @@ def test_video_dashboard_data_module_referenced_and_isolated():
|
||||||
assert "window." not in _DASH_JS
|
assert "window." not in _DASH_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_library_subpage_present():
|
||||||
|
block = _block(
|
||||||
|
_INDEX, r'<section class="video-subpage" data-video-subpage="video-library"', "</section>")
|
||||||
|
assert "data-video-lib-grid" in block # the card grid
|
||||||
|
assert "data-video-scan" in block # the Scan button
|
||||||
|
assert 'data-video-lib-tab="movies"' in block and 'data-video-lib-tab="shows"' in block
|
||||||
|
assert "onclick" not in block # data-attr wired, no inline handlers
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_library_module_referenced_and_isolated():
|
||||||
|
assert "video/video-library.js" in _INDEX
|
||||||
|
stripped = _LIB_JS.strip()
|
||||||
|
assert stripped.startswith("/*") or stripped.startswith("(function")
|
||||||
|
assert "(function" in _LIB_JS and "})();" in _LIB_JS
|
||||||
|
assert "soulsync:video-page-shown" in _LIB_JS # decoupled via the event
|
||||||
|
assert "addEventListener" in _LIB_JS
|
||||||
|
assert "window." not in _LIB_JS
|
||||||
|
|
||||||
|
|
||||||
def test_controller_is_isolated_iife_with_no_globals():
|
def test_controller_is_isolated_iife_with_no_globals():
|
||||||
stripped = _JS.strip()
|
stripped = _JS.strip()
|
||||||
# Wrapped in an IIFE → declares no module-level globals that could collide
|
# Wrapped in an IIFE → declares no module-level globals that could collide
|
||||||
|
|
|
||||||
|
|
@ -669,6 +669,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="video-subpage" data-video-subpage="video-library" hidden>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
|
||||||
|
<div class="header-text">
|
||||||
|
<h2 class="header-title"><img src="/static/dashboard.png" class="page-header-icon" alt=""><span>Library</span></h2>
|
||||||
|
<p class="header-subtitle">Movies and shows from your media server</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-quick-nav">
|
||||||
|
<button class="header-button watchlist-button" type="button" data-video-scan>
|
||||||
|
<span class="hero-btn-icon">🔄</span>
|
||||||
|
<span class="hero-btn-label" data-video-scan-label>Scan Library</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="video-library-toolbar">
|
||||||
|
<div class="video-library-tabs">
|
||||||
|
<button class="video-lib-tab active" type="button" data-video-lib-tab="movies">Movies</button>
|
||||||
|
<button class="video-lib-tab" type="button" data-video-lib-tab="shows">Shows</button>
|
||||||
|
</div>
|
||||||
|
<span class="video-library-count" data-video-lib-count></span>
|
||||||
|
</div>
|
||||||
|
<div class="video-library-grid" data-video-lib-grid></div>
|
||||||
|
<p class="video-empty-note" data-video-lib-empty hidden>Nothing here yet — run a scan to pull your library from the media server.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="video-placeholder-slot" id="video-placeholder-slot" hidden></div>
|
<div class="video-placeholder-slot" id="video-placeholder-slot" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -8799,6 +8824,8 @@
|
||||||
<script src="{{ url_for('static', filename='video/video-side.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='video/video-side.js', v=static_v) }}"></script>
|
||||||
<!-- Video dashboard data layer (isolated; populates the dashboard from the video DB) -->
|
<!-- Video dashboard data layer (isolated; populates the dashboard from the video DB) -->
|
||||||
<script src="{{ url_for('static', filename='video/video-dashboard.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='video/video-dashboard.js', v=static_v) }}"></script>
|
||||||
|
<!-- Video library page (isolated; lists movies/shows + scan trigger) -->
|
||||||
|
<script src="{{ url_for('static', filename='video/video-library.js', v=static_v) }}"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
178
webui/static/video/video-library.js
Normal file
178
webui/static/video/video-library.js
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
/*
|
||||||
|
* SoulSync — Video Library page (isolated).
|
||||||
|
*
|
||||||
|
* Same isolation contract as the other video modules: self-contained IIFE, no
|
||||||
|
* globals, no inline handlers, lives under static/video/. Listens for the
|
||||||
|
* 'soulsync:video-page-shown' event (dispatched by video-side.js) and, when the
|
||||||
|
* Library page is shown, fetches /api/video/library and renders it. The Scan
|
||||||
|
* button asks the server to re-read the media server (source of truth) via
|
||||||
|
* /api/video/scan/request and polls /api/video/scan/status.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var PAGE_ID = 'video-library';
|
||||||
|
var LIBRARY_URL = '/api/video/library';
|
||||||
|
var SCAN_REQUEST_URL = '/api/video/scan/request';
|
||||||
|
var SCAN_STATUS_URL = '/api/video/scan/status';
|
||||||
|
|
||||||
|
var state = { tab: 'movies', data: null, loading: false, scanning: false };
|
||||||
|
|
||||||
|
function $(sel) { return document.querySelector(sel); }
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
if (cls) n.className = cls;
|
||||||
|
if (text != null) n.textContent = text;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function poster(node, title, url) {
|
||||||
|
var p = el('div', 'video-card-poster');
|
||||||
|
if (url && /^https?:\/\//.test(url)) {
|
||||||
|
var img = el('img');
|
||||||
|
img.alt = '';
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.src = url;
|
||||||
|
p.appendChild(img);
|
||||||
|
} else {
|
||||||
|
p.appendChild(el('span', 'video-card-poster-letter',
|
||||||
|
(title || '?').charAt(0).toUpperCase()));
|
||||||
|
}
|
||||||
|
node.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function movieCard(m) {
|
||||||
|
var card = el('div', 'video-card');
|
||||||
|
poster(card, m.title, m.poster_url);
|
||||||
|
card.appendChild(el('div', 'video-card-title', m.title || 'Untitled'));
|
||||||
|
var meta = [];
|
||||||
|
if (m.year) meta.push(String(m.year));
|
||||||
|
meta.push(m.has_file ? 'Owned' : 'Wanted');
|
||||||
|
card.appendChild(el('div', 'video-card-meta', meta.join(' · ')));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCard(s) {
|
||||||
|
var card = el('div', 'video-card');
|
||||||
|
poster(card, s.title, s.poster_url);
|
||||||
|
card.appendChild(el('div', 'video-card-title', s.title || 'Untitled'));
|
||||||
|
var meta = [];
|
||||||
|
if (s.year) meta.push(String(s.year));
|
||||||
|
meta.push((s.owned_count || 0) + '/' + (s.episode_count || 0) + ' eps');
|
||||||
|
card.appendChild(el('div', 'video-card-meta', meta.join(' · ')));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var grid = $('[data-video-lib-grid]');
|
||||||
|
var empty = $('[data-video-lib-empty]');
|
||||||
|
var count = $('[data-video-lib-count]');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.textContent = '';
|
||||||
|
var items = state.data ? (state.data[state.tab] || []) : [];
|
||||||
|
var maker = state.tab === 'movies' ? movieCard : showCard;
|
||||||
|
for (var i = 0; i < items.length; i++) grid.appendChild(maker(items[i]));
|
||||||
|
if (empty) empty.hidden = items.length > 0;
|
||||||
|
if (count) {
|
||||||
|
count.textContent = state.loading ? 'Loading…'
|
||||||
|
: items.length + ' ' + state.tab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLibrary(force) {
|
||||||
|
if (state.data && !force) { render(); return; }
|
||||||
|
state.loading = true;
|
||||||
|
render();
|
||||||
|
fetch(LIBRARY_URL, { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (d) {
|
||||||
|
state.loading = false;
|
||||||
|
state.data = (d && !d.error) ? d : { movies: [], shows: [] };
|
||||||
|
render();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
state.loading = false;
|
||||||
|
state.data = { movies: [], shows: [] };
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScanLabel(text) {
|
||||||
|
var label = $('[data-video-scan-label]');
|
||||||
|
if (label) label.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollScan() {
|
||||||
|
fetch(SCAN_STATUS_URL, { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (s) {
|
||||||
|
if (s && s.state === 'scanning') {
|
||||||
|
setScanLabel((s.phase || 'Scanning') + '… '
|
||||||
|
+ (s.movies || 0) + 'm ' + (s.shows || 0) + 's');
|
||||||
|
setTimeout(pollScan, 1500);
|
||||||
|
} else {
|
||||||
|
state.scanning = false;
|
||||||
|
var btn = $('[data-video-scan]');
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
setScanLabel('Scan Library');
|
||||||
|
loadLibrary(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
state.scanning = false;
|
||||||
|
var btn = $('[data-video-scan]');
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
setScanLabel('Scan Library');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startScan() {
|
||||||
|
if (state.scanning) return;
|
||||||
|
state.scanning = true;
|
||||||
|
var btn = $('[data-video-scan]');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
setScanLabel('Starting…');
|
||||||
|
fetch(SCAN_REQUEST_URL, { method: 'POST', headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function () { setTimeout(pollScan, 600); })
|
||||||
|
.catch(function () {
|
||||||
|
state.scanning = false;
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
setScanLabel('Scan Library');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire() {
|
||||||
|
var tabs = document.querySelectorAll('[data-video-lib-tab]');
|
||||||
|
for (var i = 0; i < tabs.length; i++) {
|
||||||
|
(function (tab) {
|
||||||
|
tab.addEventListener('click', function () {
|
||||||
|
state.tab = tab.getAttribute('data-video-lib-tab');
|
||||||
|
var all = document.querySelectorAll('[data-video-lib-tab]');
|
||||||
|
for (var j = 0; j < all.length; j++) {
|
||||||
|
all[j].classList.toggle('active', all[j] === tab);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
})(tabs[i]);
|
||||||
|
}
|
||||||
|
var scan = document.querySelector('[data-video-scan]');
|
||||||
|
if (scan) scan.addEventListener('click', startScan);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageShown(e) {
|
||||||
|
if (!e || e.detail !== PAGE_ID) return;
|
||||||
|
loadLibrary(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
wire();
|
||||||
|
document.addEventListener('soulsync:video-page-shown', onPageShown);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -146,3 +146,83 @@ body[data-side="video"] .dashboard-header-sweep {
|
||||||
color: var(--text-secondary, #9aa0aa);
|
color: var(--text-secondary, #9aa0aa);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Library page ──────────────────────────────────────────────────────── */
|
||||||
|
.video-library-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.video-library-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.video-lib-tab {
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #9aa0aa);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.video-lib-tab:hover { color: var(--text-primary, #e8e8ea); }
|
||||||
|
.video-lib-tab.active {
|
||||||
|
color: #fff;
|
||||||
|
background: rgb(var(--accent-rgb, 88 101 242));
|
||||||
|
}
|
||||||
|
.video-library-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, #9aa0aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-library-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.video-card { cursor: default; }
|
||||||
|
.video-card-poster {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, rgba(var(--accent-rgb, 88 101 242), 0.25), rgba(0, 0, 0, 0.4));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.video-card-poster img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.video-card-poster-letter {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
.video-card-title {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e8e8ea);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.video-card-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #9aa0aa);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue