video Library page: music-library visuals + posters + search + A-Z
Rebuilt the Library page to reuse the music library's exact look — no reinvention, just new data: - Same classes: .library-container, .library-artist-card grid, .alphabet- selector, .library-search-input, loading/empty states. Movies/Shows tab pill is the only video-specific bit. - Real posters via a server-side proxy: GET /api/video/poster/<kind>/<id> streams the Plex/Jellyfin artwork (token stays server-side); cards fall back to an emoji on miss. list_movies/list_shows now expose has_poster (no raw server paths leaked). - Client-side search + A-Z letter filter (article-aware) over the loaded set; cards are divs (not clickable yet, per request). Scan button in the header reuses the shared scan controller and reloads on done. 110 tests green.
This commit is contained in:
parent
9d3cf14cdd
commit
fba47e9665
8 changed files with 248 additions and 160 deletions
|
|
@ -41,9 +41,11 @@ def create_video_blueprint() -> Blueprint:
|
|||
from .scan import register_routes as reg_scan
|
||||
from .library import register_routes as reg_library
|
||||
from .libraries import register_routes as reg_libraries
|
||||
from .poster import register_routes as reg_poster
|
||||
reg_dashboard(bp)
|
||||
reg_scan(bp)
|
||||
reg_library(bp)
|
||||
reg_libraries(bp)
|
||||
reg_poster(bp)
|
||||
|
||||
return bp
|
||||
|
|
|
|||
54
api/video/poster.py
Normal file
54
api/video/poster.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Video poster proxy.
|
||||
|
||||
GET /api/video/poster/<kind>/<id> streams a movie/show poster from the media
|
||||
server, server-side (so the Plex token / Jellyfin key never reaches the
|
||||
browser). Falls back to 404 so the frontend shows its placeholder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Response, abort
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("video_api.poster")
|
||||
|
||||
|
||||
def register_routes(bp):
|
||||
@bp.route("/poster/<kind>/<int:item_id>", methods=["GET"])
|
||||
def video_poster(kind, item_id):
|
||||
from . import get_video_db
|
||||
ref = get_video_db().get_poster_ref(kind, item_id)
|
||||
if not ref or not ref.get("poster_url"):
|
||||
abort(404)
|
||||
try:
|
||||
import requests
|
||||
from config.settings import config_manager
|
||||
source = ref.get("server_source")
|
||||
if source == "plex":
|
||||
cfg = config_manager.get_plex_config() or {}
|
||||
base, token = cfg.get("base_url"), cfg.get("token")
|
||||
if not base or not token:
|
||||
abort(404)
|
||||
url = base.rstrip("/") + ref["poster_url"]
|
||||
params = {"X-Plex-Token": token}
|
||||
elif source == "jellyfin":
|
||||
cfg = config_manager.get_jellyfin_config() or {}
|
||||
base, key = cfg.get("base_url"), cfg.get("api_key")
|
||||
if not base:
|
||||
abort(404)
|
||||
url = base.rstrip("/") + f"/Items/{ref['server_id']}/Images/Primary"
|
||||
params = {"api_key": key} if key else {}
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
upstream = requests.get(url, params=params, timeout=15, stream=True)
|
||||
if upstream.status_code != 200:
|
||||
abort(404)
|
||||
ctype = upstream.headers.get("Content-Type", "image/jpeg")
|
||||
resp = Response(upstream.iter_content(8192), content_type=ctype)
|
||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||
return resp
|
||||
except Exception:
|
||||
logger.exception("video poster proxy failed for %s/%s", kind, item_id)
|
||||
abort(404)
|
||||
|
|
@ -328,6 +328,14 @@ class VideoDatabase:
|
|||
conn.close()
|
||||
|
||||
# ── library listing ───────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _with_poster_flag(row: dict) -> dict:
|
||||
# Don't leak the raw server thumb path; just say whether a poster exists
|
||||
# (the frontend hits /api/video/poster/<kind>/<id> when true).
|
||||
d = dict(row)
|
||||
d["has_poster"] = bool(d.pop("poster_url", None))
|
||||
return d
|
||||
|
||||
def list_movies(self) -> list[dict]:
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
|
|
@ -335,7 +343,7 @@ class VideoDatabase:
|
|||
"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]
|
||||
return [self._with_poster_flag(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -348,7 +356,21 @@ class VideoDatabase:
|
|||
"(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]
|
||||
return [self._with_poster_flag(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_poster_ref(self, kind: str, item_id: int) -> dict | None:
|
||||
"""Server source/id/poster path for one movie or show, for the poster proxy."""
|
||||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||||
if not table:
|
||||
return None
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"SELECT server_source, server_id, poster_url FROM {table} WHERE id=?",
|
||||
(item_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ def test_blueprint_exposes_dashboard_route():
|
|||
assert "/api/video/scan/status" in rules
|
||||
assert "/api/video/library" in rules
|
||||
assert "/api/video/libraries" in rules
|
||||
assert any(r.startswith("/api/video/poster/") for r in rules)
|
||||
|
||||
|
||||
def test_dashboard_endpoint_returns_zeroed_json(tmp_path):
|
||||
|
|
@ -51,11 +52,14 @@ def test_dashboard_endpoint_returns_zeroed_json(tmp_path):
|
|||
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"})
|
||||
videoapi._video_db.upsert_movie("plex", {"server_id": "m1", "title": "A",
|
||||
"poster_url": "/library/metadata/1/thumb/9"})
|
||||
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["movies"][0]["has_poster"] is True # flag, not the raw path
|
||||
assert "poster_url" not in data["movies"][0] # don't leak server paths
|
||||
assert data["shows"] == []
|
||||
finally:
|
||||
videoapi._video_db = None
|
||||
|
|
|
|||
|
|
@ -138,10 +138,15 @@ def test_video_dashboard_data_module_referenced_and_isolated():
|
|||
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
|
||||
# Reuses the music library structure verbatim (no reinvented markup).
|
||||
assert "library-container" in block
|
||||
assert "library-search-input" in block # search bar
|
||||
assert "alphabet-selector" in block # A–Z selector
|
||||
assert "library-artists-grid" in block # the music card grid
|
||||
assert "data-video-lib-grid" in block
|
||||
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
|
||||
assert "data-video-scan-mode" in block # the Scan button
|
||||
assert "onclick" not in block # data-attr wired, no inline handlers
|
||||
|
||||
|
||||
def test_video_library_module_referenced_and_isolated():
|
||||
|
|
|
|||
|
|
@ -699,28 +699,53 @@
|
|||
</div>
|
||||
</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>
|
||||
<!-- Reuses the music library's exact classes (.library-container,
|
||||
.library-artist-card, .alphabet-selector, .library-search-input)
|
||||
so it looks identical — just fed from /api/video/library. -->
|
||||
<div class="library-page-embed" id="video-library-page">
|
||||
<div class="library-container">
|
||||
<div class="library-header">
|
||||
<div class="library-header-content">
|
||||
<h2 class="library-title"><img src="/static/library.png" class="page-header-icon" alt=""><span>Library</span></h2>
|
||||
<p class="library-subtitle">Movies and shows from your media server</p>
|
||||
</div>
|
||||
<div class="library-stats">
|
||||
<span class="library-stat">
|
||||
<span class="stat-number" data-video-lib-count>0</span>
|
||||
<span class="stat-label" data-video-lib-count-label>Movies</span>
|
||||
</span>
|
||||
</div>
|
||||
<button class="library-watchlist-all-btn library-export-btn" type="button" data-video-scan-mode="full" title="Scan the media server">
|
||||
<span class="watchlist-all-icon" data-video-scan-icon>🔄</span>
|
||||
<span class="watchlist-all-text" data-video-scan-label>Scan</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 class="library-controls">
|
||||
<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>
|
||||
<div class="library-search-container">
|
||||
<input type="text" class="library-search-input" data-video-lib-search placeholder="Search movies...">
|
||||
<div class="library-search-icon">🔍</div>
|
||||
</div>
|
||||
<div class="alphabet-selector">
|
||||
<div class="alphabet-selector-inner" data-video-lib-alphabet></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="library-content">
|
||||
<div class="library-loading hidden" data-video-lib-loading>
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Loading library...</div>
|
||||
</div>
|
||||
<div class="library-artists-grid" data-video-lib-grid></div>
|
||||
<div class="library-empty hidden" data-video-lib-empty>
|
||||
<div class="empty-icon">🎬</div>
|
||||
<div class="empty-title" data-video-lib-empty-title>Nothing here yet</div>
|
||||
<div class="empty-subtitle">Run a scan to pull your library from the media server.</div>
|
||||
</div>
|
||||
</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>
|
||||
<section class="video-subpage" data-video-subpage="video-tools" hidden>
|
||||
|
|
|
|||
|
|
@ -1,84 +1,120 @@
|
|||
/*
|
||||
* 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.
|
||||
* Reuses the music library's exact look (.library-artist-card grid,
|
||||
* .alphabet-selector, .library-search-input) — only the data is new
|
||||
* (/api/video/library) and posters come from the video poster proxy. Movies/
|
||||
* Shows tabs, a search box, and an A–Z selector filter client-side. Cards are
|
||||
* NOT clickable yet (divs, not links). Self-contained IIFE, no globals.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var PAGE_ID = 'video-library';
|
||||
var LIBRARY_URL = '/api/video/library';
|
||||
var LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('');
|
||||
|
||||
var state = { tab: 'movies', data: null, loading: false, scanning: false };
|
||||
var state = { tab: 'movies', data: null, loading: false, search: '', letter: 'all' };
|
||||
|
||||
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 esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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()));
|
||||
// First letter for the A–Z buckets, ignoring a leading article.
|
||||
function letterOf(title) {
|
||||
var t = String(title || '').toLowerCase().replace(/^(the|a|an)\s+/, '').trim();
|
||||
var c = t.charAt(0);
|
||||
return /[a-z]/.test(c) ? c : '#';
|
||||
}
|
||||
|
||||
function buildAlphabet() {
|
||||
var host = $('[data-video-lib-alphabet]');
|
||||
if (!host || host.childNodes.length) return;
|
||||
var defs = [['all', 'All']].concat(
|
||||
LETTERS.map(function (l) { return [l, l.toUpperCase()]; }), [['#', '#']]);
|
||||
for (var i = 0; i < defs.length; i++) {
|
||||
var b = document.createElement('button');
|
||||
b.className = 'alphabet-btn' + (defs[i][0] === 'all' ? ' active' : '');
|
||||
b.setAttribute('data-letter', defs[i][0]);
|
||||
b.textContent = defs[i][1];
|
||||
(function (letter, btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
state.letter = letter;
|
||||
var all = host.querySelectorAll('.alphabet-btn');
|
||||
for (var j = 0; j < all.length; j++) all[j].classList.toggle('active', all[j] === btn);
|
||||
render();
|
||||
});
|
||||
})(defs[i][0], b);
|
||||
host.appendChild(b);
|
||||
}
|
||||
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 items() {
|
||||
return (state.data && state.data[state.tab]) || [];
|
||||
}
|
||||
|
||||
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'));
|
||||
function filtered() {
|
||||
var search = state.search.toLowerCase();
|
||||
return items().filter(function (it) {
|
||||
if (search && String(it.title || '').toLowerCase().indexOf(search) === -1) return false;
|
||||
if (state.letter !== 'all' && letterOf(it.title) !== state.letter) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function cardHTML(it, kind) {
|
||||
var fallbackEmoji = kind === 'movie' ? '🎬' : '📺';
|
||||
var img = it.has_poster
|
||||
? '<div class="library-artist-image"><img src="/api/video/poster/' + kind + '/' + it.id +
|
||||
'" alt="" loading="lazy" onerror="this.parentNode.innerHTML=\'<div class="library-artist-image-fallback">' +
|
||||
fallbackEmoji + '</div>\'"></div>'
|
||||
: '<div class="library-artist-image"><div class="library-artist-image-fallback">' + fallbackEmoji + '</div></div>';
|
||||
|
||||
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;
|
||||
if (it.year) meta.push(String(it.year));
|
||||
if (kind === 'movie') meta.push(it.has_file ? 'Owned' : 'Wanted');
|
||||
else meta.push((it.owned_count || 0) + '/' + (it.episode_count || 0) + ' eps');
|
||||
|
||||
return '<div class="library-artist-card">' + img +
|
||||
'<div class="library-artist-info">' +
|
||||
'<h3 class="library-artist-name" title="' + esc(it.title) + '">' + esc(it.title) + '</h3>' +
|
||||
'<div class="library-artist-stats"><span class="library-artist-stat">' +
|
||||
esc(meta.join(' · ')) + '</span></div></div></div>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
var grid = $('[data-video-lib-grid]');
|
||||
var empty = $('[data-video-lib-empty]');
|
||||
var loading = $('[data-video-lib-loading]');
|
||||
var count = $('[data-video-lib-count]');
|
||||
var countLabel = $('[data-video-lib-count-label]');
|
||||
var search = $('[data-video-lib-search]');
|
||||
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;
|
||||
|
||||
if (loading) loading.classList.toggle('hidden', !state.loading);
|
||||
if (search) search.placeholder = 'Search ' + (state.tab === 'movies' ? 'movies' : 'shows') + '...';
|
||||
if (countLabel) countLabel.textContent = state.tab === 'movies' ? 'Movies' : 'Shows';
|
||||
if (count) count.textContent = items().length;
|
||||
|
||||
if (state.loading) { grid.innerHTML = ''; if (empty) empty.classList.add('hidden'); return; }
|
||||
|
||||
var kind = state.tab === 'movies' ? 'movie' : 'show';
|
||||
var list = filtered();
|
||||
grid.innerHTML = list.map(function (it) { return cardHTML(it, kind); }).join('');
|
||||
|
||||
if (empty) {
|
||||
empty.classList.toggle('hidden', list.length > 0);
|
||||
var t = $('[data-video-lib-empty-title]');
|
||||
if (t) t.textContent = items().length === 0 ? 'Nothing here yet'
|
||||
: 'No ' + (state.tab === 'movies' ? 'movies' : 'shows') + ' match';
|
||||
}
|
||||
}
|
||||
|
||||
function loadLibrary(force) {
|
||||
function load(force) {
|
||||
if (state.data && !force) { render(); return; }
|
||||
state.loading = true;
|
||||
render();
|
||||
|
|
@ -89,33 +125,7 @@
|
|||
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;
|
||||
}
|
||||
|
||||
// Scan is triggered by the shared controller (video-scan.js wires the
|
||||
// [data-video-scan] button); we just reflect its progress + reload on done.
|
||||
function onScanProgress(e) {
|
||||
var s = e.detail || {};
|
||||
if (s.state !== 'scanning') return;
|
||||
var btn = $('[data-video-scan]');
|
||||
if (btn) btn.disabled = true;
|
||||
setScanLabel((s.phase || 'Scanning') + '… ' + (s.movies || 0) + 'm ' + (s.shows || 0) + 's');
|
||||
}
|
||||
|
||||
function onScanDone() {
|
||||
var btn = $('[data-video-scan]');
|
||||
if (btn) btn.disabled = false;
|
||||
setScanLabel('Scan Library');
|
||||
loadLibrary(true);
|
||||
.catch(function () { state.loading = false; state.data = { movies: [], shows: [] }; render(); });
|
||||
}
|
||||
|
||||
function wire() {
|
||||
|
|
@ -125,21 +135,37 @@
|
|||
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);
|
||||
}
|
||||
for (var j = 0; j < all.length; j++) all[j].classList.toggle('active', all[j] === tab);
|
||||
render();
|
||||
});
|
||||
})(tabs[i]);
|
||||
}
|
||||
var search = $('[data-video-lib-search]');
|
||||
if (search) {
|
||||
search.addEventListener('input', function () { state.search = search.value || ''; render(); });
|
||||
}
|
||||
}
|
||||
|
||||
function onScanProgress(e) {
|
||||
var s = e.detail || {};
|
||||
if (s.state !== 'scanning') return;
|
||||
var label = $('[data-video-scan-label]');
|
||||
if (label) label.textContent = 'Scanning…';
|
||||
}
|
||||
|
||||
function onScanDone() {
|
||||
var label = $('[data-video-scan-label]');
|
||||
if (label) label.textContent = 'Scan';
|
||||
load(true);
|
||||
}
|
||||
|
||||
function onPageShown(e) {
|
||||
if (!e || e.detail !== PAGE_ID) return;
|
||||
loadLibrary(false);
|
||||
load(false);
|
||||
}
|
||||
|
||||
function init() {
|
||||
buildAlphabet();
|
||||
wire();
|
||||
document.addEventListener('soulsync:video-page-shown', onPageShown);
|
||||
document.addEventListener('soulsync:video-scan-progress', onScanProgress);
|
||||
|
|
|
|||
|
|
@ -175,23 +175,20 @@ body[data-side="video"] .dashboard-header-sweep {
|
|||
}
|
||||
|
||||
/* ── Library page ──────────────────────────────────────────────────────── */
|
||||
.video-library-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
/* The grid/cards/search/alphabet all reuse the music library CSS verbatim.
|
||||
The only video-specific bit is the Movies/Shows tab pill. */
|
||||
.video-library-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
margin: 0 auto 4px;
|
||||
width: max-content;
|
||||
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;
|
||||
padding: 8px 22px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #9aa0aa);
|
||||
|
|
@ -206,50 +203,3 @@ body[data-side="video"] .dashboard-header-sweep {
|
|||
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