feat: all-video media row, clickable media selector, download button
Build & Deploy / build-and-deploy (push) Failing after 44s
Build & Deploy / build-and-deploy (push) Failing after 44s
- Backend: Add GET /api/games/{id}/download endpoint (ZIP streaming)
- Frontend: All videos now shown in media row alongside screenshots
- Click any media thumbnail to load it into the media container
- Videos show YouTube thumbnail, screenshots show image preview
- Active thumbnail has green border highlight
- Download button between Edit and Delete triggers ZIP download
- Early return: if no media, the entire section is hidden
This commit is contained in:
@@ -84,6 +84,11 @@ export async function scrapeIGDB(gameId, igdbId) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Download a game's ZIP bundle */
|
||||||
|
export function downloadGame(id) {
|
||||||
|
window.open(`${BASE}/api/games/${id}/download`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
/** Check IGDB status */
|
/** Check IGDB status */
|
||||||
export async function igdbStatus() {
|
export async function igdbStatus() {
|
||||||
const res = await fetch(`${BASE}/api/igdb/status`);
|
const res = await fetch(`${BASE}/api/igdb/status`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getGame, deleteGame, updateGame, searchIGDB, scrapeIGDB, bundleUrl, artworkUrl } from "../lib/api.js";
|
import { getGame, deleteGame, updateGame, searchIGDB, scrapeIGDB, downloadGame, bundleUrl, artworkUrl } from "../lib/api.js";
|
||||||
import { push } from "../lib/router.js";
|
import { push } from "../lib/router.js";
|
||||||
|
|
||||||
let { id } = $props();
|
let { id } = $props();
|
||||||
@@ -24,11 +24,32 @@
|
|||||||
let igdbQuery = $state("");
|
let igdbQuery = $state("");
|
||||||
let applying = $state(null); // igdb_id being applied
|
let applying = $state(null); // igdb_id being applied
|
||||||
|
|
||||||
|
// Media selection — tracks which media item is shown in the media container
|
||||||
|
let selectedMedia = $state(null);
|
||||||
|
|
||||||
|
function selectMedia(type, idOrUrl) {
|
||||||
|
if (type === 'video') {
|
||||||
|
selectedMedia = { type: 'video', id: idOrUrl };
|
||||||
|
} else if (type === 'screenshot') {
|
||||||
|
selectedMedia = { type: 'screenshot', url: idOrUrl };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownload() {
|
||||||
|
downloadGame(id);
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
game = await getGame(id);
|
game = await getGame(id);
|
||||||
igdbQuery = game.title || "";
|
igdbQuery = game.title || "";
|
||||||
|
// Set initial selected media to first available item
|
||||||
|
if (game.videos?.length) {
|
||||||
|
selectedMedia = { type: 'video', id: game.videos[0] };
|
||||||
|
} else if (game.screenshots?.length) {
|
||||||
|
selectedMedia = { type: 'screenshot', url: game.screenshots[0] };
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = "Game not found";
|
error = "Game not found";
|
||||||
}
|
}
|
||||||
@@ -183,37 +204,49 @@
|
|||||||
<button class="btn btn-primary" onclick={handlePlay}>▶ Play</button>
|
<button class="btn btn-primary" onclick={handlePlay}>▶ Play</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn" onclick={startEdit}>✏ Edit</button>
|
<button class="btn" onclick={startEdit}>✏ Edit</button>
|
||||||
|
<button class="btn" onclick={handleDownload}>⬇ Download</button>
|
||||||
<button class="btn btn-danger" onclick={handleDelete}>✕ Delete</button>
|
<button class="btn btn-danger" onclick={handleDelete}>✕ Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Media Section — video + screenshots, handles missing media gracefully -->
|
<!-- Media Section — container + clickable row for all videos & screenshots -->
|
||||||
{#if game.videos?.length || game.screenshots?.length}
|
{#if selectedMedia}
|
||||||
<div class="media-section">
|
<div class="media-section">
|
||||||
<span class="media-label">📺 Media</span>
|
<span class="media-label">📺 Media</span>
|
||||||
<div class="media-grid">
|
<div class="media-container">
|
||||||
{#if game.videos?.length}
|
{#if selectedMedia.type === 'video'}
|
||||||
<div class="video-container">
|
|
||||||
<iframe
|
<iframe
|
||||||
src="https://www.youtube.com/embed/{game.videos[0]}"
|
src="https://www.youtube.com/embed/{selectedMedia.id}"
|
||||||
title="Gameplay video"
|
title="Gameplay video"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
{:else if selectedMedia.type === 'screenshot'}
|
||||||
|
<img src={selectedMedia.url} alt="Screenshot" class="media-main-img" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if game.screenshots?.length}
|
</div>
|
||||||
<div class="screenshots-row">
|
<div class="media-row">
|
||||||
{#each game.screenshots as shot}
|
{#each game.videos ?? [] as video, i}
|
||||||
<img
|
<button
|
||||||
src={shot}
|
class="media-thumb"
|
||||||
alt="Screenshot"
|
class:active={selectedMedia.type === 'video' && selectedMedia.id === video}
|
||||||
class="screenshot-thumb"
|
onclick={() => selectMedia('video', video)}
|
||||||
loading="lazy"
|
title="Video {i + 1}"
|
||||||
/>
|
>
|
||||||
|
<img src="https://img.youtube.com/vi/{video}/mqdefault.jpg" alt="Video {i + 1}" loading="lazy" />
|
||||||
|
<span class="media-thumb-icon">▶</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#each game.screenshots ?? [] as shot}
|
||||||
|
<button
|
||||||
|
class="media-thumb"
|
||||||
|
class:active={selectedMedia.type === 'screenshot' && selectedMedia.url === shot}
|
||||||
|
onclick={() => selectMedia('screenshot', shot)}
|
||||||
|
title="Screenshot"
|
||||||
|
>
|
||||||
|
<img src={shot} alt="Screenshot" loading="lazy" />
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -312,7 +345,7 @@
|
|||||||
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
|
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
textarea { width: 100%; min-height: 100px; }
|
textarea { width: 100%; min-height: 100px; }
|
||||||
|
|
||||||
/* Media Section — video + screenshots */
|
/* Media Section — container + clickable thumbnail row */
|
||||||
.media-section {
|
.media-section {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
@@ -327,44 +360,68 @@
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.media-grid {
|
.media-container {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.video-container {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #000;
|
background: #000;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.video-container iframe {
|
.media-container iframe {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.screenshots-row {
|
.media-main-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.media-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
.screenshot-thumb {
|
.media-thumb {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 240px;
|
position: relative;
|
||||||
height: 135px;
|
width: 200px;
|
||||||
object-fit: cover;
|
height: 113px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
transition: border-color 0.15s;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
transition: border-color 0.15s, opacity 0.15s;
|
||||||
}
|
}
|
||||||
.screenshot-thumb:hover {
|
.media-thumb:hover {
|
||||||
border-color: var(--phosphor-dim);
|
border-color: var(--phosphor-dim);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.media-thumb.active {
|
||||||
|
border-color: var(--phosphor);
|
||||||
|
}
|
||||||
|
.media-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.media-thumb-icon {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 4px;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* IGDB Scrape */
|
/* IGDB Scrape */
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import jakarta.inject.Inject;
|
|||||||
import jakarta.ws.rs.*;
|
import jakarta.ws.rs.*;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import jakarta.ws.rs.core.StreamingOutput;
|
||||||
import java.nio.file.NoSuchFileException;
|
import java.nio.file.NoSuchFileException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
@Path("/api/games")
|
@Path("/api/games")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@@ -90,6 +95,56 @@ public class GameResource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}/download")
|
||||||
|
@Produces("application/zip")
|
||||||
|
public Response download(@PathParam("id") String id) {
|
||||||
|
try {
|
||||||
|
var game = svc.load(id);
|
||||||
|
String filename = (game.getTitle() != null ? game.getTitle() : id) + ".zip";
|
||||||
|
|
||||||
|
if ("sockdrive".equals(game.getBundleType())) {
|
||||||
|
// ZIP all game files on the fly, excluding metadata
|
||||||
|
StreamingOutput stream = output -> {
|
||||||
|
Path gameDir = svc.gameDir(id);
|
||||||
|
try (var zos = new ZipOutputStream(output)) {
|
||||||
|
Files.walk(gameDir)
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.filter(p -> !p.getFileName().toString().equals("game.json"))
|
||||||
|
.filter(p -> !p.getFileName().toString().startsWith("cover."))
|
||||||
|
.forEach(p -> {
|
||||||
|
try {
|
||||||
|
String entryName = gameDir.relativize(p).toString().replace('\\', '/');
|
||||||
|
zos.putNextEntry(new ZipEntry(entryName));
|
||||||
|
Files.copy(p, zos);
|
||||||
|
zos.closeEntry();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Response.ok(stream)
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||||
|
.build();
|
||||||
|
} else {
|
||||||
|
// Standard .jsdos bundle
|
||||||
|
Path bundlePath = svc.getGamesDir().resolve(game.getBundleFile());
|
||||||
|
if (!Files.exists(bundlePath)) {
|
||||||
|
return Response.status(404).entity(Map.of("error", "Bundle file not found")).build();
|
||||||
|
}
|
||||||
|
StreamingOutput stream = output -> Files.copy(bundlePath, output);
|
||||||
|
return Response.ok(stream)
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
} catch (NoSuchFileException e) {
|
||||||
|
return Response.status(404).entity(Map.of("error", "Game not found")).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
public Response delete(@PathParam("id") String id) {
|
public Response delete(@PathParam("id") String id) {
|
||||||
|
|||||||
Reference in New Issue
Block a user