feat: add lesson detail view with Typst HTML rendering and Jellyfin media player
- Add Detail endpoint to LessonsController (GET /Plugins/Lessons/Detail?id=...) - Update lessonsPage.html with detail view, click handlers, and back navigation - Media cards fetch Jellyfin item details and render thumbnails & durations - Clicking media card launches Jellyfin native player via appRouter.showItem - Compiled Typst HTML rendered via innerHTML with dark theme styling - Works on web and Fire TV Silk browser Closes #6
This commit is contained in:
parent
68c6861e70
commit
6494567713
2 changed files with 371 additions and 32 deletions
|
|
@ -31,4 +31,35 @@ public class LessonsController : ControllerBase
|
||||||
|
|
||||||
return Ok(summaries);
|
return Ok(summaries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("Detail")]
|
||||||
|
public IActionResult Detail([FromQuery] string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(id))
|
||||||
|
return BadRequest("Lesson id is required.");
|
||||||
|
|
||||||
|
var lesson = _store.GetLessonById(id);
|
||||||
|
if (lesson == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
id = lesson.Id,
|
||||||
|
title = lesson.Title,
|
||||||
|
grade = lesson.Grade,
|
||||||
|
subject = lesson.Subject,
|
||||||
|
lessonDate = lesson.LessonDate,
|
||||||
|
description = lesson.Description,
|
||||||
|
sourcePath = lesson.SourcePath,
|
||||||
|
compiledHtml = lesson.CompiledHtml,
|
||||||
|
createdAt = lesson.CreatedAt,
|
||||||
|
updatedAt = lesson.UpdatedAt,
|
||||||
|
mediaItems = lesson.MediaItems.Select(m => new
|
||||||
|
{
|
||||||
|
lessonId = m.LessonId,
|
||||||
|
jellyfinItemId = m.JellyfinItemId,
|
||||||
|
sortOrder = m.SortOrder
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,32 @@
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back button */
|
||||||
|
.back-btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #00a4dc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.back-btn:hover {
|
||||||
|
background: #333;
|
||||||
|
border-color: #00a4dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List view */
|
||||||
.grade-group {
|
.grade-group {
|
||||||
margin-bottom: 2em;
|
margin-bottom: 2em;
|
||||||
}
|
}
|
||||||
|
|
@ -57,40 +83,158 @@
|
||||||
color: #999;
|
color: #999;
|
||||||
margin-top: 0.25em;
|
margin-top: 0.25em;
|
||||||
}
|
}
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
/* Detail view */
|
||||||
padding: 3em;
|
.detail-meta {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
}
|
||||||
|
.detail-meta span {
|
||||||
|
margin-right: 1.5em;
|
||||||
|
}
|
||||||
|
.detail-body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1.5em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.detail-body h1, .detail-body h2, .detail-body h3 {
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 1.2em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.detail-body h1 { font-size: 1.5em; }
|
||||||
|
.detail-body h2 { font-size: 1.3em; }
|
||||||
|
.detail-body h3 { font-size: 1.1em; }
|
||||||
|
.detail-body p { margin: 0.5em 0; }
|
||||||
|
.detail-body ul, .detail-body ol { padding-left: 1.5em; }
|
||||||
|
.detail-body img { max-width: 100%; border-radius: 4px; }
|
||||||
|
.detail-body table { border-collapse: collapse; width: 100%; }
|
||||||
|
.detail-body td, .detail-body th { border: 1px solid #444; padding: 0.4em 0.6em; }
|
||||||
|
.detail-body a { color: #00a4dc; }
|
||||||
|
|
||||||
|
/* Media section */
|
||||||
|
.media-section {
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.media-section h2 {
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 0.75em;
|
||||||
|
}
|
||||||
|
.media-card {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.media-card:hover {
|
||||||
|
background: #222;
|
||||||
|
border-color: #00a4dc;
|
||||||
|
}
|
||||||
|
.media-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 112px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #252525;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.media-thumb-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 112px;
|
||||||
|
background: #252525;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2em;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.media-info {
|
||||||
|
padding: 0.5em 0.6em;
|
||||||
|
}
|
||||||
|
.media-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.media-duration {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 0.15em;
|
||||||
|
}
|
||||||
|
.media-loading {
|
||||||
color: #666;
|
color: #666;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.5em 0;
|
||||||
}
|
}
|
||||||
.error-state {
|
|
||||||
|
/* States */
|
||||||
|
.empty-state, .error-state, .loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3em;
|
padding: 3em;
|
||||||
color: #c44;
|
|
||||||
}
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3em;
|
|
||||||
color: #666;
|
|
||||||
}
|
}
|
||||||
|
.empty-state { color: #666; }
|
||||||
|
.error-state { color: #c44; }
|
||||||
|
.loading { color: #666; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<h1>📚 Lessons</h1>
|
<h1 id="page-title">📚 Lessons</h1>
|
||||||
<div id="content" class="loading">Loading lessons…</div>
|
<div id="content" class="loading">Loading lessons…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var container = document.getElementById('content');
|
var container = document.getElementById('content');
|
||||||
|
var pageTitle = document.getElementById('page-title');
|
||||||
|
var currentView = 'list'; // 'list' | 'detail'
|
||||||
|
|
||||||
function apiUrl() {
|
function apiBase() {
|
||||||
// Use the Jellyfin base URL from the current page context
|
return window.location.origin + '/Plugins/Lessons';
|
||||||
var base = window.location.origin;
|
|
||||||
return base + '/Plugins/Lessons/List';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLessons(lessons) {
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(str));
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ticks) {
|
||||||
|
if (!ticks && ticks !== 0) return '';
|
||||||
|
var totalSeconds = Math.floor(ticks / 10000000);
|
||||||
|
var hours = Math.floor(totalSeconds / 3600);
|
||||||
|
var minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
var seconds = totalSeconds % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return hours + ':' + pad(minutes) + ':' + pad(seconds);
|
||||||
|
}
|
||||||
|
return minutes + ':' + pad(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n) {
|
||||||
|
return n < 10 ? '0' + n : '' + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- List view ---
|
||||||
|
|
||||||
|
function renderList(lessons) {
|
||||||
|
currentView = 'list';
|
||||||
|
pageTitle.textContent = '📚 Lessons';
|
||||||
|
|
||||||
if (!lessons || lessons.length === 0) {
|
if (!lessons || lessons.length === 0) {
|
||||||
container.className = 'empty-state';
|
container.className = 'empty-state';
|
||||||
container.innerHTML = '<p>No lessons found. Lessons will appear here after you build them.</p>';
|
container.innerHTML = '<p>No lessons found. Lessons will appear here after you build them.</p>';
|
||||||
|
|
@ -111,7 +255,7 @@
|
||||||
html += '<div class="grade-group">';
|
html += '<div class="grade-group">';
|
||||||
html += '<div class="grade-header">Grade ' + grade + '</div>';
|
html += '<div class="grade-header">Grade ' + grade + '</div>';
|
||||||
gradeMap[grade].forEach(function(l) {
|
gradeMap[grade].forEach(function(l) {
|
||||||
html += '<div class="lesson-card">';
|
html += '<div class="lesson-card" data-lesson-id="' + esc(l.id) + '" onclick="viewLesson(\'' + esc(l.id) + '\')">';
|
||||||
html += '<div class="lesson-title">' + esc(l.title) + '</div>';
|
html += '<div class="lesson-title">' + esc(l.title) + '</div>';
|
||||||
html += '<div class="lesson-meta">';
|
html += '<div class="lesson-meta">';
|
||||||
html += esc(l.subject || '');
|
html += esc(l.subject || '');
|
||||||
|
|
@ -128,24 +272,188 @@
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(str) {
|
// --- Detail view ---
|
||||||
var div = document.createElement('div');
|
|
||||||
div.appendChild(document.createTextNode(str));
|
function renderDetail(lesson) {
|
||||||
return div.innerHTML;
|
currentView = 'detail';
|
||||||
|
pageTitle.textContent = lesson.title;
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
html += '<button class="back-btn" onclick="showList()">← Back to Lessons</button>';
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
html += '<div class="detail-meta">';
|
||||||
|
html += '<span>Grade ' + esc(String(lesson.grade)) + '</span>';
|
||||||
|
html += '<span>' + esc(lesson.subject) + '</span>';
|
||||||
|
if (lesson.lessonDate) html += '<span>' + esc(lesson.lessonDate) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (lesson.description) {
|
||||||
|
html += '<div class="detail-meta">' + esc(lesson.description) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compiled HTML body
|
||||||
|
if (lesson.compiledHtml) {
|
||||||
|
html += '<div class="detail-body">' + lesson.compiledHtml + '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="detail-body" style="color:#666;">No lesson content available.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media section
|
||||||
|
if (lesson.mediaItems && lesson.mediaItems.length > 0) {
|
||||||
|
html += '<div class="media-section">';
|
||||||
|
html += '<h2>📺 Media</h2>';
|
||||||
|
html += '<div class="media-grid" id="media-grid">';
|
||||||
|
html += '<div class="media-loading">Loading media details…</div>';
|
||||||
|
html += '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Fetch media details asynchronously
|
||||||
|
loadMediaItems(lesson.mediaItems);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(apiUrl())
|
function loadMediaItems(mediaItems) {
|
||||||
.then(function(resp) {
|
var grid = document.getElementById('media-grid');
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
if (!grid) return;
|
||||||
return resp.json();
|
|
||||||
})
|
var userId = null;
|
||||||
.then(function(data) {
|
try {
|
||||||
renderLessons(data);
|
userId = Dashboard.getCurrentUserId();
|
||||||
})
|
} catch (e) {
|
||||||
.catch(function(err) {
|
// Dashboard may not be available in all contexts
|
||||||
container.className = 'error-state';
|
}
|
||||||
container.innerHTML = '<p>Failed to load lessons: ' + esc(err.message) + '</p>';
|
|
||||||
|
if (!userId) {
|
||||||
|
grid.innerHTML = '<div class="media-loading">Unable to identify user for media playback.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var promises = mediaItems.map(function(m) {
|
||||||
|
return fetch('/Users/' + userId + '/Items/' + m.jellyfinItemId)
|
||||||
|
.then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(item) {
|
||||||
|
return renderMediaCard(item);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
return '<div class="media-card" style="cursor:default;opacity:0.6;">' +
|
||||||
|
'<div class="media-thumb-placeholder">⚠️</div>' +
|
||||||
|
'<div class="media-info">' +
|
||||||
|
'<div class="media-name">' + esc(m.jellyfinItemId) + '</div>' +
|
||||||
|
'<div class="media-duration">Unavailable</div>' +
|
||||||
|
'</div></div>';
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Promise.all(promises).then(function(cards) {
|
||||||
|
grid.innerHTML = cards.join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMediaCard(item) {
|
||||||
|
var imageTag = '';
|
||||||
|
if (item.ImageTags && item.ImageTags.Primary) {
|
||||||
|
var imgUrl = window.ApiClient.getScaledImageUrl(item.Id, { type: 'Primary', tag: item.ImageTags.Primary });
|
||||||
|
imageTag = '<img class="media-thumb" src="' + imgUrl + '" alt="' + esc(item.Name) + '">';
|
||||||
|
} else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
|
||||||
|
var backdropUrl = window.ApiClient.getScaledImageUrl(item.Id, { type: 'Backdrop', tag: item.BackdropImageTags[0] });
|
||||||
|
imageTag = '<img class="media-thumb" src="' + backdropUrl + '" alt="' + esc(item.Name) + '">';
|
||||||
|
} else {
|
||||||
|
imageTag = '<div class="media-thumb-placeholder">▶️</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationStr = '';
|
||||||
|
if (item.RunTimeTicks) {
|
||||||
|
durationStr = formatDuration(item.RunTimeTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use JSON.stringify to safely embed the item for the click handler
|
||||||
|
var itemJson = JSON.stringify(item).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
|
|
||||||
|
return '<div class="media-card" onclick="playMedia(\'' + item.Id + '\')">' +
|
||||||
|
imageTag +
|
||||||
|
'<div class="media-info">' +
|
||||||
|
'<div class="media-name">' + esc(item.Name) + '</div>' +
|
||||||
|
(durationStr ? '<div class="media-duration">' + esc(durationStr) + '</div>' : '') +
|
||||||
|
'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation & playback (exposed globally for onclick handlers) ---
|
||||||
|
|
||||||
|
window.viewLesson = function(id) {
|
||||||
|
container.className = 'loading';
|
||||||
|
container.innerHTML = 'Loading lesson…';
|
||||||
|
|
||||||
|
fetch(apiBase() + '/Detail?id=' + encodeURIComponent(id))
|
||||||
|
.then(function(resp) {
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
return resp.json();
|
||||||
|
})
|
||||||
|
.then(function(lesson) {
|
||||||
|
renderDetail(lesson);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.className = 'error-state';
|
||||||
|
container.innerHTML = '<p>Failed to load lesson: ' + esc(err.message) + '</p>' +
|
||||||
|
'<button class="back-btn" onclick="showList()">← Back to Lessons</button>';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.showList = function() {
|
||||||
|
container.className = 'loading';
|
||||||
|
container.innerHTML = 'Loading lessons…';
|
||||||
|
|
||||||
|
fetch(apiBase() + '/List')
|
||||||
|
.then(function(resp) {
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
return resp.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
renderList(data);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
container.className = 'error-state';
|
||||||
|
container.innerHTML = '<p>Failed to load lessons: ' + esc(err.message) + '</p>';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.playMedia = function(itemId) {
|
||||||
|
// Use Jellyfin's appRouter to navigate to the item detail/player
|
||||||
|
try {
|
||||||
|
if (window.appRouter && typeof window.appRouter.showItem === 'function') {
|
||||||
|
window.appRouter.showItem(itemId);
|
||||||
|
} else if (window.ApiClient && typeof window.ApiClient.getItem === 'function') {
|
||||||
|
var userId = Dashboard.getCurrentUserId();
|
||||||
|
window.ApiClient.getItem(userId, itemId).then(function(item) {
|
||||||
|
if (window.appRouter) {
|
||||||
|
window.appRouter.showItem(item);
|
||||||
|
} else if (window.Dashboard && window.Dashboard.navigate) {
|
||||||
|
Dashboard.navigate('itemdetails.html?id=' + itemId);
|
||||||
|
} else {
|
||||||
|
window.location.href = '#!/details?id=' + itemId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: navigate to item details page directly
|
||||||
|
window.location.href = '#!/details?id=' + itemId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Last resort fallback
|
||||||
|
window.location.href = '#!/details?id=' + itemId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Initial load ---
|
||||||
|
showList();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue