Created
December 19, 2025 04:43
-
-
Save ackkerman/3d4fcfe8dd71c8dcd1e5e6fcea9dba22 to your computer and use it in GitHub Desktop.
simple local media gallery
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="ja"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>ローカル動画ギャラリー</title> | |
| <style> | |
| :root { | |
| color-scheme: light; | |
| } | |
| body { | |
| font-family: sans-serif; | |
| margin: 16px; | |
| background: #f7f9fb; | |
| } | |
| .layout { | |
| display: flex; | |
| flex-wrap: wrap; | |
| align-items: flex-start; | |
| gap: 16px; | |
| } | |
| .player-container { | |
| position: sticky; | |
| top: 0; | |
| z-index: 10; | |
| padding: 8px 0 16px; | |
| background: linear-gradient(180deg, rgba(247, 249, 251, 1) 60%, rgba(247, 249, 251, 0)); | |
| backdrop-filter: blur(4px); | |
| flex: 0 0 auto; | |
| } | |
| .player-shell { | |
| resize: both; | |
| overflow: hidden; | |
| width: min(70vw, 960px); | |
| min-width: 320px; | |
| height: 80vh; | |
| min-height: 180px; | |
| max-height: 80vh; | |
| border: 1px solid #cfd8e3; | |
| border-radius: 10px; | |
| background: #000; | |
| box-shadow: 0 6px 18px rgba(17, 24, 39, 0.12); | |
| } | |
| #player { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| display: block; | |
| } | |
| .controls { | |
| margin-top: 10px; | |
| } | |
| .loading-indicator { | |
| display: none; | |
| align-items: center; | |
| gap: 8px; | |
| font-weight: 600; | |
| color: #1f2a44; | |
| } | |
| .loading-indicator[aria-busy="true"] { | |
| display: inline-flex; | |
| } | |
| .spinner { | |
| width: 14px; | |
| height: 14px; | |
| border: 2px solid #cfd8e3; | |
| border-top-color: #3f6ff1; | |
| border-radius: 50%; | |
| animation: spin 0.9s linear infinite; | |
| } | |
| @keyframes spin { | |
| from { transform: rotate(0deg); } | |
| to { transform: rotate(360deg); } | |
| } | |
| #pickFolderBtn { | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| border: 1px solid #2f5de0; | |
| background: #3f6ff1; | |
| color: #fff; | |
| font-weight: 600; | |
| cursor: pointer; | |
| } | |
| #pickFolderBtn:hover { | |
| background: #2f5de0; | |
| } | |
| #gallery { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, 150px); | |
| gap: 10px; | |
| flex: 1 1 320px; | |
| } | |
| #gallery.loading { | |
| pointer-events: none; | |
| opacity: 0.55; | |
| filter: grayscale(0.15); | |
| } | |
| .thumb { | |
| width: 150px; | |
| cursor: pointer; | |
| border-radius: 8px; | |
| box-shadow: 0 3px 10px rgba(17, 24, 39, 0.14); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="layout"> | |
| <div class="player-container"> | |
| <div class="player-shell"> | |
| <video id="player" controls></video> | |
| </div> | |
| <div class="controls"> | |
| <button id="pickFolderBtn">フォルダを選択</button> | |
| <div id="loadingIndicator" class="loading-indicator" aria-live="polite" aria-busy="false" hidden> | |
| <span class="spinner" aria-hidden="true"></span> | |
| <span>読み込み中...</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="gallery"></div> | |
| </div> | |
| <script type="module"> | |
| const pickBtn = document.getElementById("pickFolderBtn"); | |
| const gallery = document.getElementById("gallery"); | |
| const player = document.getElementById("player"); | |
| const loadingIndicator = document.getElementById("loadingIndicator"); | |
| // 再生可能な拡張子一覧 | |
| const VIDEO_EXTS = [".mp4", ".webm", ".ogg", ".mov", ".mkv"]; | |
| const setLoading = (isLoading) => { | |
| loadingIndicator.hidden = !isLoading; | |
| loadingIndicator.setAttribute("aria-busy", String(isLoading)); | |
| gallery.classList.toggle("loading", isLoading); | |
| pickBtn.disabled = isLoading; | |
| }; | |
| // フォルダ選択 | |
| pickBtn.addEventListener("click", async () => { | |
| try { | |
| const dirHandle = await window.showDirectoryPicker(); | |
| setLoading(true); | |
| gallery.innerHTML = ""; | |
| for await (const entry of dirHandle.values()) { | |
| if (entry.kind === "file") { | |
| const name = entry.name; | |
| const ext = name.slice(name.lastIndexOf(".")).toLowerCase(); | |
| if (VIDEO_EXTS.includes(ext)) { | |
| const file = await entry.getFile(); | |
| const url = URL.createObjectURL(file); | |
| // サムネイル (video tag) | |
| const thumb = document.createElement("video"); | |
| thumb.className = "thumb"; | |
| thumb.src = url; | |
| thumb.muted = true; | |
| thumb.loop = true; | |
| thumb.title = name; | |
| thumb.addEventListener("click", () => { | |
| player.src = url; | |
| player.play(); | |
| }); | |
| gallery.appendChild(thumb); | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| if (err?.name !== "AbortError") { | |
| console.error("Failed to load directory", err); | |
| } | |
| } finally { | |
| setLoading(false); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment