diff --git a/cmd/server/main.go b/cmd/server/main.go index 05aa700..0545666 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -128,6 +128,7 @@ func runWebServer(dbURL string, logger *slog.Logger) { // Media router.Handle("GET /media/{id}", handlers.HandleGetMedia) + router.Handle("GET /proxy/video/{id}", handlers.HandleProxyVideo) // Auth API router.Handle("POST /api/auth/login", handlers.HandleLogin) diff --git a/internal/handlers/item_page.go b/internal/handlers/item_page.go index 72d9027..c66e246 100644 --- a/internal/handlers/item_page.go +++ b/internal/handlers/item_page.go @@ -53,9 +53,12 @@ func (c itemPageContent) Render(sw *ssr.Writer) error { {{else if eq .Item.ItemType "embed"}} {{if .Item.EmbedVideoURL}}
-
{{else if .Item.ThumbnailID}}
diff --git a/internal/handlers/proxy.go b/internal/handlers/proxy.go new file mode 100644 index 0000000..df40fe7 --- /dev/null +++ b/internal/handlers/proxy.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "context" + "io" + "net/http" + "time" + + "lookbook/internal/data/item" +) + +// HandleProxyVideo proxies video requests for embed items to avoid +// cross-origin issues with external video CDNs (e.g., Twitter's video.twimg.com). +// GET /proxy/video/{id} +func HandleProxyVideo(rc *RequestContext, w http.ResponseWriter, r *http.Request) error { + pubID := r.PathValue("id") + if pubID == "" { + http.NotFound(w, r) + return nil + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + it, err := item.QFindByPubID(ctx, rc.DB, pubID) + if err != nil { + return err + } + if it == nil || !it.EmbedVideoURL.Valid || it.EmbedVideoURL.V == "" { + http.NotFound(w, r) + return nil + } + + videoURL := it.EmbedVideoURL.V + + req, err := http.NewRequestWithContext(ctx, "GET", videoURL, nil) + if err != nil { + return err + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Lookbook/1.0)") + + // Pass through Range header for video seeking + if rangeHeader := r.Header.Get("Range"); rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + + client := &http.Client{Timeout: 2 * time.Minute} + resp, err := client.Do(req) + if err != nil { + http.Error(w, "Failed to fetch video", http.StatusBadGateway) + return nil + } + defer resp.Body.Close() + + // Pass through relevant headers + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + if cl := resp.Header.Get("Content-Length"); cl != "" { + w.Header().Set("Content-Length", cl) + } + if cr := resp.Header.Get("Content-Range"); cr != "" { + w.Header().Set("Content-Range", cr) + } + if ar := resp.Header.Get("Accept-Ranges"); ar != "" { + w.Header().Set("Accept-Ranges", ar) + } + + // Cache for 1 day in the browser + w.Header().Set("Cache-Control", "public, max-age=86400") + + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) + + return nil +} diff --git a/internal/static/css/app.css b/internal/static/css/app.css index 07bc95a..a316fa5 100644 --- a/internal/static/css/app.css +++ b/internal/static/css/app.css @@ -296,6 +296,40 @@ button, input, textarea, select { width: 100%; } +.video-container { + position: relative; +} + +.video-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + cursor: pointer; + transition: background 0.15s ease; +} + +.video-overlay:hover { + background: rgba(0, 0, 0, 0.4); +} + +.video-overlay .play-button { + width: 4rem; + height: 4rem; + background: rgba(0, 0, 0, 0.7); + color: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + padding-left: 0.25rem; +} + + + .embed-container { position: relative; } diff --git a/internal/static/js/app.js b/internal/static/js/app.js index 1863ba9..deb1287 100644 --- a/internal/static/js/app.js +++ b/internal/static/js/app.js @@ -378,6 +378,19 @@ async function deleteItem(id) { } } +// Play video (initial click to start) +function playVideo(overlay) { + const container = overlay.parentElement; + const video = container.querySelector('video'); + if (video) { + video.play(); + overlay.remove(); + // Show controls on hover + video.addEventListener('mouseenter', () => video.controls = true); + video.addEventListener('mouseleave', () => video.controls = false); + } +} + // Utility function escapeHtml(str) { const div = document.createElement('div');