- Browser/PWA nutzt jetzt direkte MP4-Wiedergabe mit Range-Requests - Codec-Pruefung (H.264/HEVC/AV1) mit automatischem HLS-Fallback - direct_play_url zur Library Video-Info-Route hinzugefuegt - Doppeltes endblock in series_detail.html entfernt (500er Fehler) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
17 KiB
HTML
387 lines
17 KiB
HTML
{% extends "tv/base.html" %}
|
|
{% block title %}{{ series.title or series.folder_name }} - VideoKonverter TV{% endblock %}
|
|
|
|
{% block content %}
|
|
<section class="tv-section">
|
|
<!-- Serien-Header -->
|
|
<div class="tv-detail-header">
|
|
{% if series.poster_url %}
|
|
<img src="{{ series.poster_url }}" alt="" class="tv-detail-poster">
|
|
{% endif %}
|
|
<div class="tv-detail-info">
|
|
<h1 class="tv-page-title">{{ series.title or series.folder_name }}</h1>
|
|
{% if series.genres %}
|
|
<p class="tv-detail-genres">{{ series.genres }}</p>
|
|
{% endif %}
|
|
{% if series.overview %}
|
|
<p class="tv-detail-overview">{{ series.overview }}</p>
|
|
{% endif %}
|
|
|
|
<!-- Bewertungen -->
|
|
<div class="tv-rating-section">
|
|
<!-- Eigene Bewertung (klickbare Sterne) -->
|
|
<div class="tv-rating-user">
|
|
<span class="tv-rating-label">{{ t('rating.your_rating') }}:</span>
|
|
<div class="tv-stars-input" id="user-stars"
|
|
data-series-id="{{ series.id }}" data-rating="{{ user_rating }}">
|
|
{% for i in range(1, 6) %}
|
|
<span class="tv-star {% if i <= user_rating %}active{% endif %}"
|
|
data-value="{{ i }}" data-focusable
|
|
onclick="setRating({{ i }})">★</span>
|
|
{% endfor %}
|
|
{% if user_rating > 0 %}
|
|
<span class="tv-rating-remove" onclick="setRating(0)"
|
|
data-focusable title="{{ t('rating.remove') }}">✕</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<!-- Durchschnitt -->
|
|
{% if avg_rating.count > 0 %}
|
|
<div class="tv-rating-avg">
|
|
<span class="tv-stars-display">
|
|
{% for i in range(1, 6) %}
|
|
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">★</span>
|
|
{% endfor %}
|
|
</span>
|
|
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
|
|
</div>
|
|
{% endif %}
|
|
<!-- TVDB-Score -->
|
|
{% if tvdb_score %}
|
|
<div class="tv-rating-external">
|
|
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="tv-detail-actions">
|
|
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
|
|
id="btn-watchlist"
|
|
data-focusable
|
|
data-series-id="{{ series.id }}"
|
|
onclick="toggleWatchlist(this)">
|
|
<span class="watchlist-icon">{% if in_watchlist %}♥{% else %}♡{% endif %}</span>
|
|
<span class="watchlist-text">{{ t('series.watchlist') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Staffel-Tabs -->
|
|
{% if seasons %}
|
|
<div class="tv-tabs" id="season-tabs">
|
|
{% for sn in seasons.keys() %}
|
|
<button class="tv-tab {% if loop.first %}active{% endif %} {% if season_watched.get(sn, {}).get('all_seen') %}tv-tab-complete{% endif %}"
|
|
data-focusable data-season="{{ sn }}"
|
|
onclick="showSeason({{ sn }})">
|
|
{% if sn == 0 %}{{ t('series.specials') }}{% else %}{{ t('series.season') }} {{ sn }}{% endif %}
|
|
{% if season_watched.get(sn, {}).get('all_seen') %}<span class="tv-tab-check">✓</span>{% endif %}
|
|
</button>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Episoden-Detail-Panel (wird per JS bei Focus befuellt) -->
|
|
<div class="tv-ep-detail-panel" id="ep-detail-panel">
|
|
<div class="tv-ep-detail-inner">
|
|
<h3 class="tv-ep-detail-title" id="ep-detail-title"></h3>
|
|
<p class="tv-ep-detail-desc" id="ep-detail-desc"></p>
|
|
<p class="tv-ep-detail-meta" id="ep-detail-meta"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Episoden pro Staffel (Card-Grid) -->
|
|
{% for sn, episodes in seasons.items() %}
|
|
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
|
|
<div class="tv-season-actions">
|
|
<button class="tv-season-mark-btn" data-focusable
|
|
onclick="markSeasonWatched({{ series.id }}, {{ sn }})">
|
|
✓ {{ t('status.mark_season') }}
|
|
</button>
|
|
</div>
|
|
<div class="tv-episode-grid">
|
|
{% for ep in episodes %}
|
|
<div class="tv-episode-tile {% if ep.is_duplicate %}tv-ep-duplicate{% endif %} {% if ep.progress_pct >= watched_threshold_pct|default(90) %}tv-ep-seen{% endif %}"
|
|
data-video-id="{{ ep.id }}"
|
|
data-ep-title="{{ ep.episode_title or ep.file_name }}"
|
|
data-ep-desc="{{ ep.ep_overview|default('', true)|e }}"
|
|
data-ep-meta="{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %} · {{ ep.container|upper|default('') }} {% if ep.video_codec %}· {{ ep.video_codec }}{% endif %} {% if ep.file_size %}· {{ (ep.file_size / 1048576)|round|int }} MB{% endif %} {% if ep.duration_sec %}· {{ (ep.duration_sec / 60)|round|int }} Min{% endif %}">
|
|
<a href="/tv/player?v={{ ep.id }}" class="tv-ep-tile-link" data-focusable>
|
|
<div class="tv-ep-thumb">
|
|
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
|
|
{% if ep.progress_pct > 0 and ep.progress_pct < watched_threshold_pct|default(90) %}
|
|
<div class="tv-ep-progress">
|
|
<div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div>
|
|
</div>
|
|
{% endif %}
|
|
{% if ep.progress_pct >= watched_threshold_pct|default(90) %}
|
|
<div class="tv-ep-watched">✓</div>
|
|
{% endif %}
|
|
<div class="tv-ep-duration">
|
|
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="tv-ep-tile-label">
|
|
<span class="tv-ep-num">{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}</span>
|
|
<span class="tv-ep-tile-title">{{ ep.episode_title or '' }}</span>
|
|
{% if ep.is_duplicate %}<span class="tv-ep-dup-badge">{{ t('series.duplicate') }}</span>{% endif %}
|
|
</div>
|
|
</a>
|
|
<button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
|
|
data-focusable
|
|
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
|
|
✓
|
|
</button>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% else %}
|
|
<div class="tv-empty">{{ t('series.no_episodes') }}</div>
|
|
{% endif %}
|
|
</section>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
function showSeason(sn) {
|
|
// Alle Staffeln verstecken
|
|
document.querySelectorAll('.tv-season').forEach(el => el.style.display = 'none');
|
|
// Alle Tabs deaktivieren
|
|
document.querySelectorAll('.tv-tab').forEach(el => el.classList.remove('active'));
|
|
// Gewaehlte Staffel anzeigen
|
|
const season = document.getElementById('season-' + sn);
|
|
if (season) season.style.display = '';
|
|
// Tab aktivieren
|
|
event.target.classList.add('active');
|
|
}
|
|
|
|
function toggleWatchlist(btn) {
|
|
const seriesId = btn.dataset.seriesId;
|
|
fetch('/tv/api/watchlist', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ series_id: parseInt(seriesId) }),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.in_watchlist) {
|
|
btn.classList.add('active');
|
|
btn.querySelector('.watchlist-icon').innerHTML = '♥';
|
|
} else {
|
|
btn.classList.remove('active');
|
|
btn.querySelector('.watchlist-icon').innerHTML = '♡';
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function setRating(value) {
|
|
const container = document.getElementById('user-stars');
|
|
const seriesId = container.dataset.seriesId;
|
|
fetch('/tv/api/rating', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ series_id: parseInt(seriesId), rating: value }),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
// Sterne aktualisieren
|
|
container.dataset.rating = data.user_rating;
|
|
container.querySelectorAll('.tv-star').forEach(star => {
|
|
const v = parseInt(star.dataset.value);
|
|
star.classList.toggle('active', v <= data.user_rating);
|
|
});
|
|
// Entfernen-Button anzeigen/verstecken
|
|
let removeBtn = container.querySelector('.tv-rating-remove');
|
|
if (data.user_rating > 0 && !removeBtn) {
|
|
removeBtn = document.createElement('span');
|
|
removeBtn.className = 'tv-rating-remove';
|
|
removeBtn.setAttribute('data-focusable', '');
|
|
removeBtn.innerHTML = '✕';
|
|
removeBtn.onclick = () => setRating(0);
|
|
container.appendChild(removeBtn);
|
|
} else if (data.user_rating === 0 && removeBtn) {
|
|
removeBtn.remove();
|
|
}
|
|
// Durchschnitt aktualisieren (Seite neu laden fuer Einfachheit)
|
|
if (data.avg_rating !== undefined) {
|
|
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
|
|
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function toggleWatched(videoId, btn) {
|
|
// Aktuellen Status pruefen und togglen
|
|
const card = btn.closest('.tv-episode-tile');
|
|
const isSeen = card.classList.contains('tv-ep-seen');
|
|
const newPct = isSeen ? 0 : 100;
|
|
|
|
fetch('/tv/api/watch-progress', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ video_id: videoId, position_sec: newPct, duration_sec: 100 }),
|
|
})
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
if (isSeen) {
|
|
// Als ungesehen markieren
|
|
card.classList.remove('tv-ep-seen');
|
|
btn.classList.remove('active');
|
|
const watchedEl = card.querySelector('.tv-ep-watched');
|
|
if (watchedEl) watchedEl.remove();
|
|
const progressEl = card.querySelector('.tv-ep-progress');
|
|
if (progressEl) progressEl.remove();
|
|
} else {
|
|
// Als gesehen markieren
|
|
card.classList.add('tv-ep-seen');
|
|
btn.classList.add('active');
|
|
// Haekchen-Symbol hinzufuegen
|
|
const thumb = card.querySelector('.tv-ep-thumb');
|
|
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
|
|
const check = document.createElement('div');
|
|
check.className = 'tv-ep-watched';
|
|
check.innerHTML = '✓';
|
|
thumb.appendChild(check);
|
|
}
|
|
// Fortschrittsbalken entfernen
|
|
const progressEl = card.querySelector('.tv-ep-progress');
|
|
if (progressEl) progressEl.remove();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function markSeasonWatched(seriesId, seasonNum) {
|
|
const season = document.getElementById('season-' + seasonNum);
|
|
if (!season) return;
|
|
const cards = season.querySelectorAll('.tv-episode-tile:not(.tv-ep-seen)');
|
|
const ids = [];
|
|
cards.forEach(card => {
|
|
const vid = card.dataset.videoId;
|
|
if (vid) ids.push(parseInt(vid));
|
|
});
|
|
if (ids.length === 0) return;
|
|
|
|
Promise.all(ids.map(id =>
|
|
fetch('/tv/api/watch-progress', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ video_id: id, position_sec: 100, duration_sec: 100 }),
|
|
})
|
|
)).then(() => {
|
|
season.querySelectorAll('.tv-episode-tile').forEach(card => {
|
|
card.classList.add('tv-ep-seen');
|
|
const btn = card.querySelector('.tv-ep-tile-mark');
|
|
if (btn) btn.classList.add('active');
|
|
const thumb = card.querySelector('.tv-ep-thumb');
|
|
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
|
|
const check = document.createElement('div');
|
|
check.className = 'tv-ep-watched';
|
|
check.innerHTML = '✓';
|
|
thumb.appendChild(check);
|
|
}
|
|
});
|
|
// Staffel-Tab als komplett markieren
|
|
const tab = document.querySelector('.tv-tab[data-season="' + seasonNum + '"]');
|
|
if (tab && !tab.classList.contains('tv-tab-complete')) {
|
|
tab.classList.add('tv-tab-complete');
|
|
if (!tab.querySelector('.tv-tab-check')) {
|
|
const check = document.createElement('span');
|
|
check.className = 'tv-tab-check';
|
|
check.innerHTML = ' ✓';
|
|
tab.appendChild(check);
|
|
}
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
// === Episode Detail-Panel bei Focus ===
|
|
document.addEventListener('focusin', function(e) {
|
|
const tile = e.target.closest('.tv-episode-tile');
|
|
const panel = document.getElementById('ep-detail-panel');
|
|
if (!panel) return;
|
|
if (tile) {
|
|
document.getElementById('ep-detail-title').textContent = tile.dataset.epTitle || '';
|
|
document.getElementById('ep-detail-desc').textContent = tile.dataset.epDesc || '';
|
|
document.getElementById('ep-detail-meta').innerHTML = tile.dataset.epMeta || '';
|
|
panel.classList.add('visible');
|
|
}
|
|
});
|
|
|
|
// === Post-Play Navigation (von Player nach Episoden-Ende) ===
|
|
{% if post_play and next_video_id %}
|
|
(function() {
|
|
var nextCard = document.querySelector('[data-video-id="{{ next_video_id }}"]');
|
|
if (!nextCard) return;
|
|
|
|
// Zur richtigen Staffel wechseln
|
|
var season = nextCard.closest('.tv-season');
|
|
if (season && season.style.display === 'none') {
|
|
var sn = season.id.replace('season-', '');
|
|
document.querySelectorAll('.tv-season').forEach(function(el) { el.style.display = 'none'; });
|
|
document.querySelectorAll('.tv-tab').forEach(function(el) { el.classList.remove('active'); });
|
|
season.style.display = '';
|
|
var tab = document.querySelector('.tv-tab[data-season="' + sn + '"]');
|
|
if (tab) tab.classList.add('active');
|
|
}
|
|
|
|
// Karte hervorheben und hineinsccrollen
|
|
nextCard.classList.add('tv-ep-next-loading');
|
|
nextCard.scrollIntoView({block: 'center', behavior: 'smooth'});
|
|
|
|
// Countdown-Overlay auf der Karte
|
|
var countdownEl = document.createElement('div');
|
|
countdownEl.className = 'tv-ep-countdown-overlay';
|
|
var remaining = {{ countdown }};
|
|
countdownEl.innerHTML = '<span class="tv-ep-countdown-num">' + remaining + '</span><span class="tv-ep-countdown-label">{{ t("player.next_episode") }}</span>';
|
|
nextCard.querySelector('.tv-ep-thumb').appendChild(countdownEl);
|
|
|
|
var timer = setInterval(function() {
|
|
remaining--;
|
|
var numEl = countdownEl.querySelector('.tv-ep-countdown-num');
|
|
if (numEl) numEl.textContent = remaining;
|
|
if (remaining <= 0) {
|
|
clearInterval(timer);
|
|
window.location.href = '/tv/player?v={{ next_video_id }}';
|
|
}
|
|
}, 1000);
|
|
|
|
// Abbrechen per Escape/Return
|
|
function cancelAutoplay(e) {
|
|
if (e.keyCode === 10009 || e.keyCode === 27 || e.key === 'Escape') {
|
|
clearInterval(timer);
|
|
nextCard.classList.remove('tv-ep-next-loading');
|
|
countdownEl.remove();
|
|
document.removeEventListener('keydown', cancelAutoplay);
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
document.addEventListener('keydown', cancelAutoplay);
|
|
})();
|
|
{% elif last_watched_id %}
|
|
// Zur letzten geschauten Episode scrollen
|
|
(function() {
|
|
var lastCard = document.querySelector('[data-video-id="{{ last_watched_id }}"]');
|
|
if (!lastCard) return;
|
|
|
|
// Zur richtigen Staffel wechseln
|
|
var season = lastCard.closest('.tv-season');
|
|
if (season && season.style.display === 'none') {
|
|
var sn = season.id.replace('season-', '');
|
|
document.querySelectorAll('.tv-season').forEach(function(el) { el.style.display = 'none'; });
|
|
document.querySelectorAll('.tv-tab').forEach(function(el) { el.classList.remove('active'); });
|
|
season.style.display = '';
|
|
var tab = document.querySelector('.tv-tab[data-season="' + sn + '"]');
|
|
if (tab) tab.classList.add('active');
|
|
}
|
|
|
|
lastCard.scrollIntoView({block: 'center', behavior: 'smooth'});
|
|
var focusEl = lastCard.querySelector('[data-focusable]');
|
|
if (focusEl) setTimeout(function() { focusEl.focus(); }, 300);
|
|
})();
|
|
{% endif %}
|
|
</script>
|
|
{% endblock %}
|