DAP-79: WebUI Stream-Wiedergabe und Download
Erweitert Suchergebnisse um Auflösungsmetadaten, führt die Hoster-Auflösung über /api/resolve aus und stellt Wiedergabe/Download über kurzlebige Stream-Tokens bereit. Co-Authored-By: OpenAI Codex <noreply@openai.com>
This commit is contained in:
11
README.md
11
README.md
@@ -6,5 +6,16 @@ Standalone-Migration des Xstream-Kodi-Plugins aus dem Kino-Projekt (Nextcloud).
|
|||||||
|
|
||||||
- plugin.video.xstream — Kodi Video-Addon
|
- plugin.video.xstream — Kodi Video-Addon
|
||||||
- script.module.xstreamscraper — zugehoeriges Scraper-Modul
|
- script.module.xstreamscraper — zugehoeriges Scraper-Modul
|
||||||
|
- webui — Flask-Weboberflaeche fuer Suche, Stream-Wiedergabe und Download
|
||||||
|
|
||||||
Quelle: Nextcloud Botomir/Projekte/Angefangen/Kino-Projekt/. Migration am 2026-08-17 durchgefuehrt.
|
Quelle: Nextcloud Botomir/Projekte/Angefangen/Kino-Projekt/. Migration am 2026-08-17 durchgefuehrt.
|
||||||
|
|
||||||
|
## WebUI starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd webui
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach ist die Oberflaeche unter http://localhost:5000 erreichbar. Suchtreffer, die direkt auf Streams fuehren, zeigen Aktionen zum Wiedergeben im Browser und zum Download.
|
||||||
|
|||||||
226
webui/app.py
226
webui/app.py
@@ -4,7 +4,7 @@ xStream Web UI – standalone Flask application.
|
|||||||
Runs the existing xStream site-plugin scrapers outside of Kodi.
|
Runs the existing xStream site-plugin scrapers outside of Kodi.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
pip install flask certifi pyaes
|
pip install -r requirements.txt
|
||||||
python app.py
|
python app.py
|
||||||
open http://localhost:5000
|
open http://localhost:5000
|
||||||
"""
|
"""
|
||||||
@@ -14,7 +14,14 @@ import sys
|
|||||||
import logging
|
import logging
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import copy
|
import copy
|
||||||
|
import mimetypes
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
# ── 1. Install Kodi stubs BEFORE any plugin import ───────────────────────────
|
# ── 1. Install Kodi stubs BEFORE any plugin import ───────────────────────────
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
@@ -33,7 +40,7 @@ from resources.lib.gui.gui import cGui # noqa: E402
|
|||||||
from resources.lib.config import cConfig # noqa: E402
|
from resources.lib.config import cConfig # noqa: E402
|
||||||
|
|
||||||
# ── 4. Flask ──────────────────────────────────────────────────────────────────
|
# ── 4. Flask ──────────────────────────────────────────────────────────────────
|
||||||
from flask import Flask, request, jsonify, render_template # noqa: E402
|
from flask import Flask, request, jsonify, render_template, Response, abort, stream_with_context # noqa: E402
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger('xstream-webui')
|
logger = logging.getLogger('xstream-webui')
|
||||||
@@ -42,6 +49,8 @@ app = Flask(__name__, template_folder='templates')
|
|||||||
|
|
||||||
# ── Site plugin registry ──────────────────────────────────────────────────────
|
# ── Site plugin registry ──────────────────────────────────────────────────────
|
||||||
_SITE_MODULES = {} # identifier → module
|
_SITE_MODULES = {} # identifier → module
|
||||||
|
_STREAMS = {} # transient token → resolved stream data
|
||||||
|
_STREAM_TTL = 60 * 30
|
||||||
|
|
||||||
def _load_sites():
|
def _load_sites():
|
||||||
"""Import every site plugin that has a _search() function."""
|
"""Import every site plugin that has a _search() function."""
|
||||||
@@ -63,6 +72,150 @@ def _load_sites():
|
|||||||
logger.warning('Skipping site %s: %s', ident, exc)
|
logger.warning('Skipping site %s: %s', ident, exc)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _plugin_argv(params):
|
||||||
|
"""Provide Kodi-style sys.argv query params for site plugin functions."""
|
||||||
|
old_argv = sys.argv[:]
|
||||||
|
sys.argv = ['plugin://plugin.video.xstream/', '1', '?' + urlencode(params)]
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
sys.argv = old_argv
|
||||||
|
|
||||||
|
|
||||||
|
def _pipe_headers(url):
|
||||||
|
if '|' not in url:
|
||||||
|
return url, {}
|
||||||
|
clean_url, raw_headers = url.split('|', 1)
|
||||||
|
return clean_url, dict(parse_qsl(raw_headers, keep_blank_values=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _register_stream(url, title):
|
||||||
|
url, headers = _pipe_headers(url)
|
||||||
|
token = secrets.token_urlsafe(18)
|
||||||
|
_STREAMS[token] = {
|
||||||
|
'url': url,
|
||||||
|
'headers': headers,
|
||||||
|
'title': title or 'xstream-video',
|
||||||
|
'createdAt': time.time(),
|
||||||
|
}
|
||||||
|
_cleanup_streams()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_streams():
|
||||||
|
cutoff = time.time() - _STREAM_TTL
|
||||||
|
for token, data in list(_STREAMS.items()):
|
||||||
|
if data.get('createdAt', 0) < cutoff:
|
||||||
|
del _STREAMS[token]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(title, content_type=''):
|
||||||
|
name = ''.join(c if c.isalnum() or c in (' ', '.', '-', '_') else '_' for c in title).strip()
|
||||||
|
if not name:
|
||||||
|
name = 'xstream-video'
|
||||||
|
if '.' not in os.path.basename(name):
|
||||||
|
ext = mimetypes.guess_extension(content_type.split(';', 1)[0].strip()) or '.mp4'
|
||||||
|
name += ext
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _params_for_result(result):
|
||||||
|
params = copy.deepcopy(result.get('params') or {})
|
||||||
|
params['site'] = result.get('siteId', '')
|
||||||
|
params['function'] = result.get('function', '')
|
||||||
|
params['title'] = result.get('title', '')
|
||||||
|
if result.get('thumbnail') and 'thumb' not in params:
|
||||||
|
params['thumb'] = result['thumbnail']
|
||||||
|
if result.get('title') and 'MovieTitle' not in params:
|
||||||
|
params['MovieTitle'] = result['title']
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def _hoster_name(hoster):
|
||||||
|
return hoster.get('displayedName') or hoster.get('name') or hoster.get('host') or hoster.get('link', 'Stream')
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_stream_url(stream):
|
||||||
|
stream_url = stream.get('streamUrl') or stream.get('link')
|
||||||
|
if not stream_url:
|
||||||
|
return ''
|
||||||
|
if stream.get('resolved'):
|
||||||
|
return stream_url
|
||||||
|
try:
|
||||||
|
import resolveurl as resolver
|
||||||
|
return resolver.resolve(stream_url)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Resolver failed, returning original stream URL: %s', exc)
|
||||||
|
return stream_url
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_playback(result, hoster_index=0, part_index=0):
|
||||||
|
site_id = result.get('siteId')
|
||||||
|
function_name = result.get('function')
|
||||||
|
if not site_id or site_id not in _SITE_MODULES:
|
||||||
|
raise ValueError('Unknown site')
|
||||||
|
if not function_name:
|
||||||
|
raise ValueError('Result has no playback function')
|
||||||
|
|
||||||
|
mod = _SITE_MODULES[site_id]
|
||||||
|
if not hasattr(mod, function_name):
|
||||||
|
raise ValueError('Playback function not found')
|
||||||
|
|
||||||
|
params = _params_for_result(result)
|
||||||
|
with _plugin_argv(params):
|
||||||
|
site_result = getattr(mod, function_name)()
|
||||||
|
|
||||||
|
if not site_result:
|
||||||
|
raise ValueError('No hoster or stream found')
|
||||||
|
if not isinstance(site_result, list):
|
||||||
|
site_result = [site_result]
|
||||||
|
|
||||||
|
hosters = []
|
||||||
|
selected_hoster = None
|
||||||
|
if site_result and isinstance(site_result[-1], str):
|
||||||
|
resolver_function = site_result[-1]
|
||||||
|
hosters = site_result[:-1]
|
||||||
|
if not hosters:
|
||||||
|
raise ValueError('No hoster found')
|
||||||
|
selected_hoster = hosters[max(0, min(int(hoster_index), len(hosters) - 1))]
|
||||||
|
if not hasattr(mod, resolver_function):
|
||||||
|
raise ValueError('Hoster resolver function not found')
|
||||||
|
stream_result = getattr(mod, resolver_function)(selected_hoster.get('link'))
|
||||||
|
if not stream_result:
|
||||||
|
raise ValueError('Selected hoster returned no stream')
|
||||||
|
if not isinstance(stream_result, list):
|
||||||
|
stream_result = [stream_result]
|
||||||
|
else:
|
||||||
|
stream_result = site_result
|
||||||
|
|
||||||
|
streams = [item for item in stream_result if isinstance(item, dict)]
|
||||||
|
if not streams:
|
||||||
|
raise ValueError('No stream found')
|
||||||
|
stream = streams[max(0, min(int(part_index), len(streams) - 1))]
|
||||||
|
stream_url = _resolve_stream_url(stream)
|
||||||
|
if not stream_url:
|
||||||
|
raise ValueError('Stream could not be resolved')
|
||||||
|
|
||||||
|
token = _register_stream(stream_url, result.get('title'))
|
||||||
|
return {
|
||||||
|
'title': result.get('title') or stream.get('title') or 'Stream',
|
||||||
|
'streamToken': token,
|
||||||
|
'streamUrl': '/api/stream/%s' % token,
|
||||||
|
'downloadUrl': '/api/stream/%s?download=1' % token,
|
||||||
|
'hosters': [
|
||||||
|
{'index': idx, 'name': _hoster_name(hoster)}
|
||||||
|
for idx, hoster in enumerate(hosters)
|
||||||
|
],
|
||||||
|
'selectedHoster': _hoster_name(selected_hoster) if selected_hoster else '',
|
||||||
|
'parts': [
|
||||||
|
{'index': idx, 'title': item.get('title') or ('Teil %s' % (idx + 1))}
|
||||||
|
for idx, item in enumerate(streams)
|
||||||
|
],
|
||||||
|
'selectedPart': stream.get('title') or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Search helpers ────────────────────────────────────────────────────────────
|
# ── Search helpers ────────────────────────────────────────────────────────────
|
||||||
def _search_site(ident, mod, query):
|
def _search_site(ident, mod, query):
|
||||||
"""Run _search() on one site; return list of result dicts."""
|
"""Run _search() on one site; return list of result dicts."""
|
||||||
@@ -92,13 +245,19 @@ def _search_site(ident, mod, query):
|
|||||||
'rating': ge._rating,
|
'rating': ge._rating,
|
||||||
'site': getattr(mod, 'SITE_NAME', ident),
|
'site': getattr(mod, 'SITE_NAME', ident),
|
||||||
'siteId': ident,
|
'siteId': ident,
|
||||||
|
'function': ge.getFunction() or '',
|
||||||
'isFolder': item.get('isFolder', True),
|
'isFolder': item.get('isFolder', True),
|
||||||
|
'canPlay': not item.get('isFolder', True),
|
||||||
}
|
}
|
||||||
if params:
|
if params:
|
||||||
try:
|
try:
|
||||||
result['sUrl'] = params.getValue('sUrl') or ''
|
result['sUrl'] = params.getValue('sUrl') or ''
|
||||||
|
result['params'] = copy.deepcopy(params.getAllParameters())
|
||||||
except Exception:
|
except Exception:
|
||||||
result['sUrl'] = ''
|
result['sUrl'] = ''
|
||||||
|
result['params'] = {}
|
||||||
|
else:
|
||||||
|
result['params'] = {}
|
||||||
results.append(result)
|
results.append(result)
|
||||||
return results
|
return results
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -150,6 +309,69 @@ def api_search():
|
|||||||
return jsonify({'query': query, 'count': len(all_results), 'results': all_results})
|
return jsonify({'query': query, 'count': len(all_results), 'results': all_results})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/resolve', methods=['POST'])
|
||||||
|
def api_resolve():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
result = payload.get('result') or {}
|
||||||
|
try:
|
||||||
|
playback = _extract_playback(
|
||||||
|
result,
|
||||||
|
hoster_index=payload.get('hosterIndex', 0),
|
||||||
|
part_index=payload.get('partIndex', 0),
|
||||||
|
)
|
||||||
|
return jsonify(playback)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Resolve failed: %s', exc)
|
||||||
|
return jsonify({'error': str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/stream/<token>')
|
||||||
|
def api_stream(token):
|
||||||
|
_cleanup_streams()
|
||||||
|
data = _STREAMS.get(token)
|
||||||
|
if not data:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
headers = dict(data.get('headers') or {})
|
||||||
|
if request.headers.get('Range'):
|
||||||
|
headers['Range'] = request.headers['Range']
|
||||||
|
|
||||||
|
try:
|
||||||
|
upstream = requests.get(data['url'], headers=headers, stream=True, timeout=30)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.warning('Stream request failed: %s', exc)
|
||||||
|
return jsonify({'error': 'Stream could not be opened'}), 502
|
||||||
|
|
||||||
|
excluded = {
|
||||||
|
'connection', 'content-encoding', 'keep-alive', 'proxy-authenticate',
|
||||||
|
'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade',
|
||||||
|
}
|
||||||
|
response_headers = [
|
||||||
|
(key, value)
|
||||||
|
for key, value in upstream.headers.items()
|
||||||
|
if key.lower() not in excluded
|
||||||
|
]
|
||||||
|
content_type = upstream.headers.get('Content-Type', 'application/octet-stream')
|
||||||
|
if request.args.get('download') == '1':
|
||||||
|
filename = _safe_filename(data.get('title', ''), content_type)
|
||||||
|
response_headers.append(('Content-Disposition', 'attachment; filename="%s"' % filename))
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
try:
|
||||||
|
for chunk in upstream.iter_content(chunk_size=1024 * 256):
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
upstream.close()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
stream_with_context(generate()),
|
||||||
|
status=upstream.status_code,
|
||||||
|
headers=response_headers,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Startup ───────────────────────────────────────────────────────────────────
|
# ── Startup ───────────────────────────────────────────────────────────────────
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
_load_sites()
|
_load_sites()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
--surface2: #25252b;
|
--surface2: #25252b;
|
||||||
--accent: #e8a000;
|
--accent: #e8a000;
|
||||||
--accent2: #ffb700;
|
--accent2: #ffb700;
|
||||||
|
--danger: #e45858;
|
||||||
--text: #e2e2e8;
|
--text: #e2e2e8;
|
||||||
--muted: #6b6b7a;
|
--muted: #6b6b7a;
|
||||||
--border: #2e2e38;
|
--border: #2e2e38;
|
||||||
@@ -237,6 +238,51 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background .15s, border-color .15s, color .15s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent2);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: .45;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Detail overlay ── */
|
/* ── Detail overlay ── */
|
||||||
#overlay {
|
#overlay {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -300,6 +346,30 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#overlay-actions {
|
||||||
|
display: none;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#overlay-actions.visible { display: flex; }
|
||||||
|
|
||||||
|
#overlay-player {
|
||||||
|
background: #000;
|
||||||
|
display: none;
|
||||||
|
margin-top: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#overlay-player.visible { display: block; }
|
||||||
|
|
||||||
|
#overlay-action-status {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .78rem;
|
||||||
|
margin-top: 10px;
|
||||||
|
min-height: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
#overlay-close {
|
#overlay-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px; right: 14px;
|
top: 10px; right: 14px;
|
||||||
@@ -326,6 +396,21 @@
|
|||||||
|
|
||||||
#empty .icon { font-size: 3rem; margin-bottom: 12px; }
|
#empty .icon { font-size: 3rem; margin-bottom: 12px; }
|
||||||
#empty p { font-size: .95rem; }
|
#empty p { font-size: .95rem; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#overlay-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -358,6 +443,12 @@
|
|||||||
<div id="overlay-title"></div>
|
<div id="overlay-title"></div>
|
||||||
<div id="overlay-meta"></div>
|
<div id="overlay-meta"></div>
|
||||||
<div id="overlay-desc"></div>
|
<div id="overlay-desc"></div>
|
||||||
|
<div id="overlay-actions">
|
||||||
|
<button class="action-btn primary" id="overlay-play">Wiedergeben</button>
|
||||||
|
<a class="action-btn" id="overlay-download" href="#" download>Download</a>
|
||||||
|
</div>
|
||||||
|
<video id="overlay-player" controls playsinline></video>
|
||||||
|
<div id="overlay-action-status"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,6 +457,8 @@
|
|||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
let _allResults = [];
|
let _allResults = [];
|
||||||
let _activeFilters = { site: '', type: '' };
|
let _activeFilters = { site: '', type: '' };
|
||||||
|
let _activeResult = null;
|
||||||
|
let _activePlayback = null;
|
||||||
|
|
||||||
// Trigger search on Enter key
|
// Trigger search on Enter key
|
||||||
$('q').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
|
$('q').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
|
||||||
@@ -491,8 +584,16 @@
|
|||||||
(r.quality ? '<span>' + _esc(r.quality) + '</span>' : '') +
|
(r.quality ? '<span>' + _esc(r.quality) + '</span>' : '') +
|
||||||
'<span class="card-site">' + _esc(r.site) + '</span>' +
|
'<span class="card-site">' + _esc(r.site) + '</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
'<div class="card-actions">' +
|
||||||
|
'<button class="action-btn primary" type="button" ' + (r.canPlay ? '' : 'disabled') + '>Wiedergeben</button>' +
|
||||||
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
el.addEventListener('click', () => openOverlay(r));
|
el.addEventListener('click', () => openOverlay(r));
|
||||||
|
el.querySelector('button').addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openOverlay(r);
|
||||||
|
playActive();
|
||||||
|
});
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,8 +602,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openOverlay(r) {
|
function openOverlay(r) {
|
||||||
|
_activeResult = r;
|
||||||
|
_activePlayback = null;
|
||||||
$('overlay-title').textContent = r.title || '—';
|
$('overlay-title').textContent = r.title || '—';
|
||||||
$('overlay-desc').textContent = r.description || '';
|
$('overlay-desc').textContent = r.description || '';
|
||||||
|
$('overlay-action-status').textContent = '';
|
||||||
|
$('overlay-player').pause();
|
||||||
|
$('overlay-player').removeAttribute('src');
|
||||||
|
$('overlay-player').classList.remove('visible');
|
||||||
|
$('overlay-download').removeAttribute('href');
|
||||||
|
$('overlay-download').setAttribute('aria-disabled', 'true');
|
||||||
const fi = $('overlay-fanart-img');
|
const fi = $('overlay-fanart-img');
|
||||||
const src = r.fanart || r.thumbnail || '';
|
const src = r.fanart || r.thumbnail || '';
|
||||||
if (src) { fi.src = src; fi.style.display = ''; }
|
if (src) { fi.src = src; fi.style.display = ''; }
|
||||||
@@ -516,13 +625,75 @@
|
|||||||
parts.push(r.site);
|
parts.push(r.site);
|
||||||
$('overlay-meta').textContent = parts.join(' · ');
|
$('overlay-meta').textContent = parts.join(' · ');
|
||||||
|
|
||||||
|
if (r.canPlay) {
|
||||||
|
$('overlay-actions').classList.add('visible');
|
||||||
|
$('overlay-play').disabled = false;
|
||||||
|
$('overlay-download').classList.remove('disabled');
|
||||||
|
$('overlay-download').onclick = e => {
|
||||||
|
if (!_activePlayback) {
|
||||||
|
e.preventDefault();
|
||||||
|
resolveActive(false).then(playback => {
|
||||||
|
if (playback) window.location.href = playback.downloadUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
$('overlay-actions').classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
$('overlay').classList.add('visible');
|
$('overlay').classList.add('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeOverlay() {
|
function closeOverlay() {
|
||||||
|
$('overlay-player').pause();
|
||||||
$('overlay').classList.remove('visible');
|
$('overlay').classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$('overlay-play').addEventListener('click', () => playActive());
|
||||||
|
|
||||||
|
async function playActive() {
|
||||||
|
const playback = await resolveActive(true);
|
||||||
|
if (!playback) return;
|
||||||
|
const player = $('overlay-player');
|
||||||
|
player.src = playback.streamUrl;
|
||||||
|
player.classList.add('visible');
|
||||||
|
$('overlay-action-status').textContent = playback.selectedHoster
|
||||||
|
? 'Quelle: ' + playback.selectedHoster
|
||||||
|
: '';
|
||||||
|
try {
|
||||||
|
await player.play();
|
||||||
|
} catch (err) {
|
||||||
|
$('overlay-action-status').textContent = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveActive(forPlayback) {
|
||||||
|
if (!_activeResult) return null;
|
||||||
|
if (_activePlayback) return _activePlayback;
|
||||||
|
|
||||||
|
$('overlay-play').disabled = true;
|
||||||
|
$('overlay-action-status').textContent = 'Stream wird vorbereitet…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/resolve', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ result: _activeResult })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Stream konnte nicht vorbereitet werden.');
|
||||||
|
_activePlayback = data;
|
||||||
|
$('overlay-download').href = data.downloadUrl;
|
||||||
|
$('overlay-download').removeAttribute('aria-disabled');
|
||||||
|
$('overlay-action-status').textContent = forPlayback ? '' : 'Download bereit.';
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
$('overlay-action-status').textContent = err.message;
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
$('overlay-play').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _esc(s) {
|
function _esc(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g,'&')
|
.replace(/&/g,'&')
|
||||||
|
|||||||
Reference in New Issue
Block a user