Files
mal/internal/templates/watch.templ

338 lines
9.6 KiB
Plaintext

package templates
import "mal/internal/jikan"
import "mal/internal/nyaa"
import "fmt"
// WatchPage renders the video player page
templ WatchPage(anime jikan.Anime, episode int, torrents []nyaa.Torrent) {
@Layout(fmt.Sprintf("Watch %s - Episode %d", anime.DisplayTitle(), episode)) {
<div class="watch-page">
<div class="watch-header">
<a href={ templ.URL(fmt.Sprintf("/anime/%d", anime.MalID)) } class="back-link">
<svg viewBox="0 0 24 24" fill="currentColor" class="back-icon">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
</svg>
back to { anime.DisplayTitle() }
</a>
<h1>Episode { fmt.Sprintf("%d", episode) }</h1>
</div>
<div class="watch-content">
<div class="player-container" id="player-container">
<div class="player-placeholder" id="player-placeholder">
<div class="player-message">
<svg viewBox="0 0 24 24" fill="currentColor" class="player-icon">
<path d="M8 5v14l11-7z"/>
</svg>
<p>select a source to start streaming</p>
</div>
</div>
<div id="player-loading" class="player-loading hidden">
<div class="loading-stream">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
</div>
<p id="loading-status">connecting to peers...</p>
<p id="loading-progress" class="loading-progress"></p>
</div>
<video
id="video-player"
class="video-player hidden"
controls
playsinline
>
</video>
</div>
<div class="sources-panel">
<h3>available sources</h3>
if len(torrents) == 0 {
<div class="no-sources">
<p>no torrents found for this episode</p>
<p class="hint">try searching manually</p>
</div>
} else {
<div class="sources-list">
for _, t := range torrents {
if t.Magnet != "" {
@TorrentSource(t)
}
}
</div>
}
<div class="manual-search">
<h4>manual search</h4>
<form
hx-get="/api/stream/search-htmx"
hx-target="#search-results"
hx-indicator="#search-loading"
>
<input
type="text"
name="q"
placeholder={ fmt.Sprintf("%s %02d", anime.Title, episode) }
value={ fmt.Sprintf("%s %02d", anime.Title, episode) }
class="search-input"
/>
<button type="submit" class="search-btn">search</button>
</form>
<div id="search-loading" class="htmx-indicator">searching...</div>
<div id="search-results"></div>
</div>
</div>
</div>
<div class="episode-nav">
if episode > 1 {
<a
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", anime.MalID, episode-1)) }
class="nav-btn prev"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
previous
</a>
} else {
<span class="nav-btn disabled">previous</span>
}
if anime.Episodes == 0 || episode < anime.Episodes {
<a
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", anime.MalID, episode+1)) }
class="nav-btn next"
>
next
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</a>
} else {
<span class="nav-btn disabled">next</span>
}
</div>
</div>
<!-- HLS.js for browser playback -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
const currentEpisode = { fmt.Sprintf("%d", episode) };
let currentStreamHash = null;
let progressInterval = null;
let hls = null;
function startStream(magnet) {
if (!magnet) {
showError('no magnet link available for this torrent');
return;
}
showLoading('connecting to peers...');
fetch('/api/stream/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ magnet: magnet })
})
.then(res => {
if (!res.ok) {
return res.text().then(text => { throw new Error(text); });
}
return res.json();
})
.then(data => {
if (data.info_hash) {
currentStreamHash = data.info_hash;
showLoading('starting transcoding...');
startProgressUpdates();
startHLS();
} else {
showError('invalid response from server');
}
})
.catch(err => {
showError(err.message || 'failed to start stream');
});
}
function startHLS() {
showLoading('preparing video stream...');
fetch('/api/stream/hls/' + currentStreamHash + '?ep=' + currentEpisode, {
method: 'POST'
})
.then(res => {
if (!res.ok) {
return res.text().then(text => { throw new Error(text); });
}
return res.json();
})
.then(data => {
playHLS(data.playlist);
})
.catch(err => {
showError('transcoding failed: ' + (err.message || 'unknown error'));
});
}
function playHLS(playlistUrl) {
const video = document.getElementById('video-player');
// Cleanup previous instance
if (hls) {
hls.destroy();
hls = null;
}
document.getElementById('player-placeholder').classList.add('hidden');
document.getElementById('player-loading').classList.add('hidden');
video.classList.remove('hidden');
if (Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
lowLatencyMode: false,
backBufferLength: 90
});
hls.loadSource(playlistUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, function() {
video.play().catch(e => console.log('Autoplay blocked:', e));
});
hls.on(Hls.Events.ERROR, function(event, data) {
if (data.fatal) {
switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.error('Network error, trying to recover...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.error('Media error, trying to recover...');
hls.recoverMediaError();
break;
default:
showError('Playback error: ' + data.details);
hls.destroy();
break;
}
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS support (Safari)
video.src = playlistUrl;
video.addEventListener('loadedmetadata', function() {
video.play().catch(e => console.log('Autoplay blocked:', e));
});
} else {
showError('HLS playback not supported in this browser');
}
}
function showLoading(status) {
document.getElementById('player-placeholder').classList.add('hidden');
document.getElementById('video-player').classList.add('hidden');
document.getElementById('player-loading').classList.remove('hidden');
document.getElementById('loading-status').textContent = status;
document.getElementById('loading-progress').textContent = '';
}
function showError(message) {
stopProgressUpdates();
document.getElementById('player-loading').classList.add('hidden');
document.getElementById('video-player').classList.add('hidden');
const placeholder = document.getElementById('player-placeholder');
placeholder.classList.remove('hidden');
placeholder.innerHTML = '<div class="player-error">' + escapeHtml(message) + '</div>';
}
function startProgressUpdates() {
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(updateProgress, 2000);
updateProgress();
}
function stopProgressUpdates() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
}
function updateProgress() {
if (!currentStreamHash) return;
fetch('/api/stream/info/' + currentStreamHash)
.then(res => res.json())
.then(data => {
const progress = data.progress.toFixed(1);
const peers = data.peers;
const progressEl = document.getElementById('loading-progress');
if (progressEl) {
progressEl.textContent = progress + '% downloaded | ' + peers + ' peers';
}
})
.catch(() => {});
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle torrent selection from search results and source list
document.addEventListener('click', function(e) {
const item = e.target.closest('[data-magnet]');
if (item && item.dataset.magnet) {
e.preventDefault();
// Mark selected
document.querySelectorAll('[data-magnet]').forEach(el => el.classList.remove('selected'));
item.classList.add('selected');
startStream(item.dataset.magnet);
}
});
// Cleanup on page leave
window.addEventListener('beforeunload', function() {
stopProgressUpdates();
if (hls) {
hls.destroy();
}
});
</script>
}
}
// TorrentSource renders a single torrent source option
templ TorrentSource(t nyaa.Torrent) {
<div class="source-item" data-magnet={ t.Magnet }>
<div class="source-title">{ truncateTitle(t.Title, 60) }</div>
<div class="source-meta">
<span class="source-size">{ t.Size }</span>
<span class="source-seeds">{ fmt.Sprintf("%d", t.Seeders) } seeds</span>
</div>
</div>
}
func truncateTitle(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}