diff --git a/frontend/css/style.css b/frontend/css/style.css index 964e302..1f39a5d 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -910,12 +910,28 @@ body.theme-light .setting-item select option { color: var(--text-primary); margin: 0; flex: 1; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; font-family: var(--font-display); letter-spacing: -0.2px; + white-space: nowrap; + overflow: hidden; + position: relative; +} + +.video-title-text { + display: inline-block; + padding-right: 24px; + transform: translateX(0); +} + +.video-card.is-title-active .video-title-text { + animation: video-title-marquee 10s linear infinite; + will-change: transform; +} + +@keyframes video-title-marquee { + to { + transform: translateX(calc(-1 * var(--marquee-distance, 0px))); + } } .video-card p { @@ -929,6 +945,36 @@ body.theme-light .setting-item select option { padding-bottom: 4px; } +.video-tags { + display: flex; + flex-wrap: nowrap; + gap: 6px; + padding: 0 12px 8px 12px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; +} + +.video-tag { + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 999px; + padding: 3px 8px; + line-height: 1.2; + letter-spacing: 0.2px; + white-space: nowrap; + font-family: inherit; + cursor: pointer; +} + +.video-tag:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: 1px; +} + .uploader-link { background: transparent; border: none; diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 65f8224..118eaf1 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -10,6 +10,40 @@ App.videos = App.videos || {}; threshold: 1.0 }); + const titleEnv = { + useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches + }; + + const titleVisibility = new Map(); + let titleObserver = null; + if (!titleEnv.useHoverFocus) { + titleObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + titleVisibility.set(entry.target, entry.intersectionRatio || 0); + } else { + titleVisibility.delete(entry.target); + entry.target.dataset.titlePrimary = '0'; + updateTitleActive(entry.target); + } + }); + let topCard = null; + let topRatio = 0; + titleVisibility.forEach((ratio, card) => { + if (ratio > topRatio) { + topRatio = ratio; + topCard = card; + } + }); + titleVisibility.forEach((ratio, card) => { + card.dataset.titlePrimary = card === topCard && ratio >= 0.55 ? '1' : '0'; + updateTitleActive(card); + }); + }, { + threshold: [0, 0.25, 0.55, 0.8, 1.0] + }); + } + App.videos.observeSentinel = function() { const sentinel = document.getElementById('sentinel'); if (sentinel) { @@ -17,6 +51,47 @@ App.videos = App.videos || {}; } }; + const updateTitleActive = function(card) { + if (!card || !card.classList.contains('has-marquee')) { + if (card) card.classList.remove('is-title-active'); + return; + } + const hovered = card.dataset.titleHovered === '1'; + const focused = card.dataset.titleFocused === '1'; + const primary = card.dataset.titlePrimary === '1'; + const active = titleEnv.useHoverFocus ? (hovered || focused) : (focused || primary); + card.classList.toggle('is-title-active', active); + }; + + const measureTitle = function(card) { + if (!card) return; + const titleWrap = card.querySelector('.video-title'); + const titleText = card.querySelector('.video-title-text'); + if (!titleWrap || !titleText) return; + const overflow = titleText.scrollWidth - titleWrap.clientWidth; + if (overflow > 4) { + card.classList.add('has-marquee'); + titleText.style.setProperty('--marquee-distance', `${overflow + 12}px`); + } else { + card.classList.remove('has-marquee', 'is-title-active'); + titleText.style.removeProperty('--marquee-distance'); + } + updateTitleActive(card); + }; + + let titleMeasureRaf = null; + const scheduleTitleMeasure = function() { + if (titleMeasureRaf) return; + titleMeasureRaf = requestAnimationFrame(() => { + titleMeasureRaf = null; + document.querySelectorAll('.video-card').forEach((card) => { + measureTitle(card); + }); + }); + }; + + window.addEventListener('resize', scheduleTitleMeasure); + App.videos.formatDuration = function(seconds) { if (!seconds || seconds <= 0) return ''; const totalSeconds = Math.floor(seconds); @@ -126,6 +201,10 @@ App.videos = App.videos || {}; const durationText = App.videos.formatDuration(v.duration); const favoriteKey = App.favorites.getKey(v); const uploaderText = v.uploader || v.channel || ''; + const tags = Array.isArray(v.tags) ? v.tags.filter(tag => tag) : []; + const tagsMarkup = tags.length + ? `
${tags.map(tag => ``).join('')}
` + : ''; card.innerHTML = ` @@ -137,7 +216,8 @@ App.videos = App.videos || {}; -

${v.title}

+

${v.title}

+ ${tagsMarkup} ${uploaderText ? `

` : ''} ${durationText ? `

${durationText}

` : ''} `; @@ -154,6 +234,33 @@ App.videos = App.videos || {}; App.favorites.toggle(v); }; } + const titleWrap = card.querySelector('.video-title'); + const titleText = card.querySelector('.video-title-text'); + if (titleWrap && titleText) { + requestAnimationFrame(() => { + measureTitle(card); + }); + card.addEventListener('focusin', () => { + card.dataset.titleFocused = '1'; + updateTitleActive(card); + }); + card.addEventListener('focusout', () => { + card.dataset.titleFocused = '0'; + updateTitleActive(card); + }); + if (titleEnv.useHoverFocus) { + card.addEventListener('mouseenter', () => { + card.dataset.titleHovered = '1'; + updateTitleActive(card); + }); + card.addEventListener('mouseleave', () => { + card.dataset.titleHovered = '0'; + updateTitleActive(card); + }); + } else if (titleObserver) { + titleObserver.observe(card); + } + } const uploaderBtn = card.querySelector('.uploader-link'); if (uploaderBtn) { uploaderBtn.onclick = (event) => { @@ -162,6 +269,16 @@ App.videos = App.videos || {}; App.videos.handleSearch(uploader); }; } + const tagButtons = card.querySelectorAll('.video-tag'); + if (tagButtons.length) { + tagButtons.forEach((tagBtn) => { + tagBtn.onclick = (event) => { + event.stopPropagation(); + const tag = tagBtn.dataset.tag || tagBtn.textContent || ''; + App.videos.handleSearch(tag); + }; + }); + } const menuBtn = card.querySelector('.video-menu-btn'); const menu = card.querySelector('.video-menu'); const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');