diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-02 09:33:22 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-02 09:33:22 +0300 |
| commit | 95c6ce72bdf16ead39d0df1ee27b0474441fbe4f (patch) | |
| tree | f6823246f8e7f85db2233fec461159005ffadc41 | |
| parent | 4dd62a8b61c56ef28f5f0cab58b8c2da94806717 (diff) | |
fix:x
| -rw-r--r-- | internal/api/handlers_more_test.go | 36 | ||||
| -rw-r--r-- | internal/api/handlers_share.go | 60 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 7 | ||||
| -rw-r--r-- | internal/api/server.go | 1 | ||||
| -rw-r--r-- | internal/service/media.go | 4 | ||||
| -rw-r--r-- | internal/service/media_share.go | 23 | ||||
| -rw-r--r-- | internal/service/mock.go | 7 | ||||
| -rw-r--r-- | internal/service/service.go | 23 | ||||
| -rw-r--r-- | web/index.html | 8 | ||||
| -rw-r--r-- | web/js/app.js | 18 | ||||
| -rw-r--r-- | web/js/keyboard.js | 8 | ||||
| -rw-r--r-- | web/js/player.js | 38 | ||||
| -rw-r--r-- | web/js/state.js | 2 | ||||
| -rw-r--r-- | web/share.html | 143 |
14 files changed, 279 insertions, 99 deletions
diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go index 8a8303d..23e94c7 100644 --- a/internal/api/handlers_more_test.go +++ b/internal/api/handlers_more_test.go @@ -951,7 +951,7 @@ func TestServer_SharePage(t *testing.T) { t.Run("service error", func(t *testing.T) { ms := &service.MockMediaService{ - ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) { return nil, errors.New("boom") }, } @@ -966,7 +966,7 @@ func TestServer_SharePage(t *testing.T) { t.Run("not found", func(t *testing.T) { ms := &service.MockMediaService{ - ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) { return nil, nil }, } @@ -981,7 +981,7 @@ func TestServer_SharePage(t *testing.T) { t.Run("expired", func(t *testing.T) { ms := &service.MockMediaService{ - ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { + GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) { return nil, service.ErrShareExpired }, } @@ -996,8 +996,12 @@ func TestServer_SharePage(t *testing.T) { t.Run("html default accept", func(t *testing.T) { ms := &service.MockMediaService{ - ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { - return &model.Share{Token: "abc", MediaID: 1}, nil + GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) { + return &service.GetSharedMediaResult{ + Media: &model.Media{ID: 1, FileName: "share.mp4", Type: model.MediaTypeVideo, Duration: 120}, + StreamURL: "/s/abc/stream", + ThumbURL: "/s/abc/thumbnail", + }, nil }, } srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs) @@ -1019,8 +1023,12 @@ func TestServer_SharePage(t *testing.T) { t.Run("html explicit accept", func(t *testing.T) { ms := &service.MockMediaService{ - ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { - return &model.Share{Token: "abc", MediaID: 1}, nil + GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) { + return &service.GetSharedMediaResult{ + Media: &model.Media{ID: 1, FileName: "share.mp4", Type: model.MediaTypeVideo, Duration: 120}, + StreamURL: "/s/abc/stream", + ThumbURL: "/s/abc/thumbnail", + }, nil }, } srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs) @@ -1039,8 +1047,12 @@ func TestServer_SharePage(t *testing.T) { t.Run("json accept", func(t *testing.T) { ms := &service.MockMediaService{ - ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { - return &model.Share{Token: "abc", MediaID: 1}, nil + GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) { + return &service.GetSharedMediaResult{ + Media: &model.Media{ID: 1, FileName: "share.mp4", Type: model.MediaTypeVideo, Duration: 120}, + StreamURL: "/s/abc/stream", + ThumbURL: "/s/abc/thumbnail", + }, nil }, } srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, ms, nil, nil, nil, fs) @@ -1055,12 +1067,12 @@ func TestServer_SharePage(t *testing.T) { if !strings.Contains(ct, "application/json") { t.Fatalf("expected application/json content type, got %q", ct) } - var body model.Share + var body service.GetSharedMediaResult if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { t.Fatalf("expected JSON body: %v", err) } - if body.Token != "abc" { - t.Fatalf("unexpected token %q", body.Token) + if body.StreamURL != "/s/abc/stream" { + t.Fatalf("unexpected stream_url %q", body.StreamURL) } }) } diff --git a/internal/api/handlers_share.go b/internal/api/handlers_share.go index 9fc2974..3eac87b 100644 --- a/internal/api/handlers_share.go +++ b/internal/api/handlers_share.go @@ -1,8 +1,11 @@ package api import ( + "encoding/json" "errors" + "io" "net/http" + "path/filepath" "strings" "time" @@ -69,8 +72,8 @@ func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) { return } token := r.PathValue("token") - share, err := s.mediaSvc.ValidateShareToken(r.Context(), token) - if err != nil || share == nil { + res, err := s.mediaSvc.GetSharedMedia(r.Context(), token) + if err != nil || res == nil { if err != nil && errors.Is(err, service.ErrShareExpired) { http.Error(w, "gone", http.StatusGone) return @@ -83,11 +86,58 @@ func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Vary", "Accept") accept := r.Header.Get("Accept") - if strings.Contains(accept, "text/html") || accept == "" { - s.serveFile(w, r, "share.html") + if strings.Contains(accept, "application/json") { + writeJSON(w, http.StatusOK, res) return } - writeJSON(w, http.StatusOK, share) + + // Serve HTML page with media metadata injected. + f, err := s.staticFS.Open("share.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + var buf strings.Builder + if _, err := io.Copy(&buf, f); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + html := buf.String() + data, _ := json.Marshal(res) + html = strings.Replace(html, "<!--SHARE_MEDIA-->", string(data), 1) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeContent(w, r, "share.html", stat.ModTime(), strings.NewReader(html)) +} + +func (s *Server) handleShareThumbnail(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.mediaSvc) { + return + } + token := r.PathValue("token") + res, err := s.mediaSvc.GetSharedMedia(r.Context(), token) + if err != nil || res == nil { + if err != nil && errors.Is(err, service.ErrShareExpired) { + http.Error(w, "gone", http.StatusGone) + return + } + http.Error(w, "not found", http.StatusNotFound) + return + } + if !res.HasThumb || res.Media == nil || res.Media.ThumbnailPath == "" { + http.Error(w, "not found", http.StatusNotFound) + return + } + fr := &service.FileResult{ + Path: res.Media.ThumbnailPath, + FileName: filepath.Base(res.Media.ThumbnailPath), + } + s.serveFileResult(w, r, fr, false) } func (s *Server) handleShareStream(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 41215c9..b574e61 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -1081,6 +1081,13 @@ func TestServer_Shares(t *testing.T) { RevokeShareFunc: func(ctx context.Context, token string, userID int64) error { return nil }, + GetSharedMediaFunc: func(ctx context.Context, token string) (*service.GetSharedMediaResult, error) { + return &service.GetSharedMediaResult{ + Media: &model.Media{ID: 1, FileName: "x.mp4", Type: model.MediaTypeVideo, Duration: 120}, + StreamURL: "/s/abc/stream", + ThumbURL: "/s/abc/thumbnail", + }, nil + }, ValidateShareTokenFunc: func(ctx context.Context, token string) (*model.Share, error) { return &model.Share{Token: token, MediaID: 1}, nil }, diff --git a/internal/api/server.go b/internal/api/server.go index 0ea96af..6c15c78 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -94,6 +94,7 @@ func (s *Server) routes() { // Public share routes s.mux.HandleFunc("GET /s/{token}", s.handleSharePage) s.mux.HandleFunc("GET /s/{token}/stream", s.handleShareStream) + s.mux.HandleFunc("GET /s/{token}/thumbnail", s.handleShareThumbnail) // Static assets (public) staticHandler := http.FileServer(s.staticFS) diff --git a/internal/service/media.go b/internal/service/media.go index 1856592..a617a29 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -73,6 +73,8 @@ var supportedExtensions = map[string]struct{}{ ".ogg": {}, ".m4a": {}, ".wma": {}, + ".m4b": {}, + ".opus": {}, } func isSupportedExtension(name string) bool { @@ -86,7 +88,7 @@ func guessMediaType(name string) model.MediaType { switch ext { case ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm": return model.MediaTypeVideo - case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a", ".wma": + case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a", ".wma", ".m4b", ".opus": return model.MediaTypeAudio default: return model.MediaTypeVideo diff --git a/internal/service/media_share.go b/internal/service/media_share.go index 0a96f85..3decf96 100644 --- a/internal/service/media_share.go +++ b/internal/service/media_share.go @@ -112,3 +112,26 @@ func (s *mediaService) StreamSharedMedia(ctx context.Context, token string) (*Fi FileSize: media.FileSizeBytes, }, nil } + +func (s *mediaService) GetSharedMedia(ctx context.Context, token string) (*GetSharedMediaResult, error) { + share, err := s.ValidateShareToken(ctx, token) + if err != nil { + return nil, err + } + + media, err := s.store.GetMediaByID(ctx, share.MediaID) + if err != nil { + return nil, fmt.Errorf("get media: %w", err) + } + if media == nil { + return nil, ErrMediaNotFound + } + + res := &GetSharedMediaResult{ + Media: media, + StreamURL: fmt.Sprintf("/s/%s/stream", token), + HasThumb: media.ThumbnailPath != "", + ThumbURL: fmt.Sprintf("/s/%s/thumbnail", token), + } + return res, nil +} diff --git a/internal/service/mock.go b/internal/service/mock.go index c6c2301..82a25de 100644 --- a/internal/service/mock.go +++ b/internal/service/mock.go @@ -42,6 +42,7 @@ type MockMediaService struct { RevokeShareFunc func(ctx context.Context, token string, userID int64) error ValidateShareTokenFunc func(ctx context.Context, token string) (*model.Share, error) StreamSharedMediaFunc func(ctx context.Context, token string) (*FileResult, error) + GetSharedMediaFunc func(ctx context.Context, token string) (*GetSharedMediaResult, error) GetNoteFunc func(ctx context.Context, mediaID, userID int64) (*model.Note, error) UpsertNoteFunc func(ctx context.Context, note *model.Note) error DeleteNoteFunc func(ctx context.Context, mediaID, userID int64) error @@ -161,6 +162,12 @@ func (m *MockMediaService) StreamSharedMedia(ctx context.Context, token string) } return nil, errors.New("not implemented") } +func (m *MockMediaService) GetSharedMedia(ctx context.Context, token string) (*GetSharedMediaResult, error) { + if m.GetSharedMediaFunc != nil { + return m.GetSharedMediaFunc(ctx, token) + } + return nil, errors.New("not implemented") +} func (m *MockMediaService) GetNote(ctx context.Context, mediaID, userID int64) (*model.Note, error) { if m.GetNoteFunc != nil { return m.GetNoteFunc(ctx, mediaID, userID) diff --git a/internal/service/service.go b/internal/service/service.go index 1ed1443..fd96b25 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -29,14 +29,23 @@ type MediaWriteService interface { UploadMedia(ctx context.Context, setID, userID int64, filename string, data io.Reader, size int64) (*model.Media, error) } +// GetSharedMediaResult wraps media metadata needed to render a share page. + type GetSharedMediaResult struct { + Media *model.Media `json:"media"` + HasThumb bool `json:"has_thumb"` + StreamURL string `json:"stream_url"` + ThumbURL string `json:"thumb_url"` + } + // MediaShareService handles creation, validation and revocation of share links. -type MediaShareService interface { - CreateShare(ctx context.Context, userID, mediaID int64, expiresAt time.Time) (*model.Share, error) - ListShares(ctx context.Context, mediaID, userID int64) ([]model.Share, error) - RevokeShare(ctx context.Context, token string, userID int64) error - ValidateShareToken(ctx context.Context, token string) (*model.Share, error) - StreamSharedMedia(ctx context.Context, token string) (*FileResult, error) -} + type MediaShareService interface { + CreateShare(ctx context.Context, userID, mediaID int64, expiresAt time.Time) (*model.Share, error) + ListShares(ctx context.Context, mediaID, userID int64) ([]model.Share, error) + RevokeShare(ctx context.Context, token string, userID int64) error + ValidateShareToken(ctx context.Context, token string) (*model.Share, error) + StreamSharedMedia(ctx context.Context, token string) (*FileResult, error) + GetSharedMedia(ctx context.Context, token string) (*GetSharedMediaResult, error) + } // MediaTagService handles tagging of media items. type MediaTagService interface { diff --git a/web/index.html b/web/index.html index 626ee25..3ac9db5 100644 --- a/web/index.html +++ b/web/index.html @@ -46,10 +46,8 @@ </div> <div id="filter-advanced" class="toolbar hidden"> <input id="filter-tags" type="text" placeholder="Tags (comma)" aria-label="Filter by tags" class="input-sm"> - <input id="filter-min-duration" type="number" min="0" placeholder="Min s" aria-label="Min duration seconds" class="input-sm"> - <input id="filter-max-duration" type="number" min="0" placeholder="Max s" aria-label="Max duration seconds" class="input-sm"> - <input id="filter-min-filesize" type="number" min="0" placeholder="Min MB" aria-label="Min file size MB" class="input-sm"> - <input id="filter-max-filesize" type="number" min="0" placeholder="Max MB" aria-label="Max file size MB" class="input-sm"> + <input id="filter-min-duration" type="number" min="0" placeholder="Min min" aria-label="Min duration minutes" class="input-sm"> + <input id="filter-max-duration" type="number" min="0" placeholder="Max min" aria-label="Max duration minutes" class="input-sm"> </div> <!-- Search bar: hidden by default, shown on '/' key --> @@ -126,6 +124,8 @@ <tr><td><kbd>n</kbd></td><td>Open notes for selected media</td></tr> <tr><td><kbd>t</kbd></td><td>Toggle toolbar / filters</td></tr> <tr><td><kbd>m</kbd></td><td>Toggle sets sidebar</td></tr> + <tr><td><kbd>,</kbd></td><td>Focus min duration filter</td></tr> + <tr><td><kbd>.</kbd></td><td>Focus max duration filter</td></tr> <tr><td><kbd>?</kbd></td><td>Show / hide this help</td></tr> </table> <p class="text-muted text-xs mt-2">Click the <strong>?</strong> button or press <kbd>?</kbd> anytime to show or hide this help.</p> diff --git a/web/js/app.js b/web/js/app.js index cd5ded4..2d0e15d 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -112,6 +112,8 @@ async function initApp() { toolbar: toggleToolbar, sidebar: toggleSidebar, upload: () => showUpload(), + focusMinDuration: () => focusFilter('filter-min-duration'), + focusMaxDuration: () => focusFilter('filter-max-duration'), }); initNotes(() => toast('Note saved')); initAdmin(); @@ -129,8 +131,6 @@ async function initApp() { document.getElementById('filter-tags')?.addEventListener('change', (e) => { state.filters.tags = e.target.value; loadMedia(); }); document.getElementById('filter-min-duration')?.addEventListener('change', (e) => { state.filters.minDuration = e.target.value; loadMedia(); }); document.getElementById('filter-max-duration')?.addEventListener('change', (e) => { state.filters.maxDuration = e.target.value; loadMedia(); }); - document.getElementById('filter-min-filesize')?.addEventListener('change', (e) => { state.filters.minFilesizeMB = e.target.value; loadMedia(); }); - document.getElementById('filter-max-filesize')?.addEventListener('change', (e) => { state.filters.maxFilesizeMB = e.target.value; loadMedia(); }); document.getElementById('filter-toggle')?.addEventListener('click', () => { document.getElementById('filter-advanced')?.classList.toggle('hidden'); }); @@ -265,10 +265,8 @@ async function loadMedia() { search: state.filters.search, favorites: state.filters.favorites ? 'true' : '', tags: state.filters.tags || '', - min_duration: state.filters.minDuration || '', - max_duration: state.filters.maxDuration || '', - filesize_min: state.filters.minFilesizeMB ? String(parseInt(state.filters.minFilesizeMB, 10) << 20) : '', - filesize_max: state.filters.maxFilesizeMB ? String(parseInt(state.filters.maxFilesizeMB, 10) << 20) : '', + min_duration: state.filters.minDuration ? String(parseFloat(state.filters.minDuration) * 60) : '', + max_duration: state.filters.maxDuration ? String(parseFloat(state.filters.maxDuration) * 60) : '', sort: isShuffle() ? 'random' : 'name', limit: '200', }; @@ -539,6 +537,14 @@ function toggleSidebar() { page?.classList.toggle('has-sidebar', open); } +function focusFilter(id) { + const el = document.getElementById(id); + if (!el) return; + document.getElementById('filter-advanced')?.classList.remove('hidden'); + el.focus(); + el.select(); +} + function showSearch() { const bar = document.getElementById('search-bar'); bar?.classList.remove('hidden'); diff --git a/web/js/keyboard.js b/web/js/keyboard.js index cad695c..2a9c78e 100644 --- a/web/js/keyboard.js +++ b/web/js/keyboard.js @@ -91,6 +91,14 @@ export function initKeyboard(handlers) { e.preventDefault(); handlers.help?.(e); break; + case ',': + e.preventDefault(); + handlers.focusMinDuration?.(e); + break; + case '.': + e.preventDefault(); + handlers.focusMaxDuration?.(e); + break; } }); } diff --git a/web/js/player.js b/web/js/player.js index 88ef404..cb3ccab 100644 --- a/web/js/player.js +++ b/web/js/player.js @@ -139,6 +139,44 @@ export function selectAndPlay(media, index, resumeFrom = 0) { highlightPlayingCard(); } +export function loadMediaDirect(media, streamUrl, thumbnailUrl, resumeFrom = 0) { + const e = els(); + const isVideo = media.type === 'video'; + const src = streamUrl; + if (isVideo) { + e.video.pause(); + e.audio.pause(); e.audio.src = ''; + e.video.style.display = ''; + e.audio.style.display = 'none'; + e.coverArt?.classList.add('hidden'); + e.video.src = src; + e.video.load(); + e.video.currentTime = resumeFrom; + } else { + e.audio.pause(); + e.video.pause(); e.video.src = ''; + e.video.style.display = 'none'; + e.audio.style.display = 'none'; + e.audio.src = src; + e.audio.currentTime = resumeFrom; + if (e.coverArt) { + if (thumbnailUrl) { + e.coverArt.src = thumbnailUrl; + e.coverArt.classList.remove('hidden'); + } else { + e.coverArt.classList.add('hidden'); + e.coverArt.src = ''; + } + } + } + e.player?.classList.add('open'); + e.btnPlay.textContent = '⏸'; + e.bigPlay?.classList.add('hidden'); + e.timeTotal.textContent = fmt(media.duration ?? 0); + e.fill.style.width = '0%'; + e.thumb.style.left = '0%'; +} + function loadMedia(media, resumeFrom = 0) { const e = els(); const isVideo = media.type === 'video'; diff --git a/web/js/state.js b/web/js/state.js index a0e715b..6597c00 100644 --- a/web/js/state.js +++ b/web/js/state.js @@ -4,7 +4,7 @@ export const state = { selectedSetId: null, selectedSetIds: [], // multi-selection media: [], - filters: { type: '', search: '', favorites: false, tags: '', sort: 'name', minDuration: '', maxDuration: '', minFilesizeMB: '', maxFilesizeMB: '' }, + filters: { type: '', search: '', favorites: false, tags: '', sort: 'name', minDuration: '', maxDuration: '' }, isAdmin: false, }; diff --git a/web/share.html b/web/share.html index 065e839..8ca053a 100644 --- a/web/share.html +++ b/web/share.html @@ -5,17 +5,23 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Shared Media</title> <link rel="stylesheet" href="/css/theme.css"> +<link rel="stylesheet" href="/css/player.css"> <style> body { margin: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; min-height: 100vh; background: var(--bg-body); color: var(--text-primary); font-family: var(--font-sans); + display: flex; + flex-direction: column; + } + .page-wrap { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; padding: 1rem; box-sizing: border-box; } @@ -29,77 +35,88 @@ font-size: 1.25rem; color: var(--text-primary); } - .player-wrapper { - position: relative; - background: var(--player-bg); - border-radius: var(--radius-md); - overflow: hidden; - box-shadow: var(--shadow-md); - } - video, audio { - width: 100%; - display: block; - } - .play-overlay { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-overlay); - cursor: pointer; - transition: opacity var(--transition-base); - } - .play-overlay.hidden { - opacity: 0; - pointer-events: none; - } - .play-icon { - font-size: 3rem; - color: var(--text-primary); - background: var(--accent-soft); - border-radius: 50%; - width: 4rem; - height: 4rem; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; - } .back-link { margin-top: 1.5rem; color: var(--accent); text-decoration: none; font-size: 0.9rem; } - .back-link:hover { - color: var(--accent-hover); + .back-link:hover { color: var(--accent-hover); } + + /* Player overrides for standalone share page */ + .player { + display: block; + position: static; + border-top: none; + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-md); + } + .player .stage { + height: clamp(14rem, 35vh, 28rem); + background: var(--player-stage-bg); + } + .player.is-fullscreen { + position: fixed; + inset: 0; + z-index: 100; + border-radius: 0; } </style> </head> <body> -<div class="container"> - <h1>Shared Media</h1> - <div class="player-wrapper"> - <video id="player" playsinline preload="metadata" src="./stream"></video> - <div id="overlay" class="play-overlay" role="button" aria-label="Play"> - <div class="play-icon">▶</div> - </div> +<div class="page-wrap"> + <div class="container"> + <h1>Shared Media</h1> + <aside id="player" class="player open" aria-label="Player"> + <div class="stage"> + <video id="media-video" playsinline controlslist="nodownload"></video> + <audio id="media-audio" class="hidden"></audio> + <img id="cover-art" class="cover-art hidden" alt="Cover" loading="lazy"> + <div id="big-play" class="big-play" role="button" aria-label="Play" tabindex="0"> + <div class="play-icon">▶</div> + </div> + </div> + <div class="controls"> + <button id="btn-toggle-stage" class="icon-btn" aria-label="Toggle stage">⛶</button> + <button id="btn-play" class="icon-btn" aria-label="Play/Pause">▶</button> + <div class="time" id="time-elapsed">0:00</div> + <div id="progress-track" class="progress-track" tabindex="0" aria-label="Seek"> + <div id="progress-fill" class="progress-fill"></div> + <div id="progress-thumb" class="progress-thumb" aria-hidden="true"></div> + </div> + <div class="time" id="time-total">0:00</div> + <div class="volume-control"> + <button id="btn-mute" class="icon-btn" aria-label="Mute">🔊</button> + <input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1" class="volume-slider" aria-label="Volume"> + </div> + <button id="btn-fullscreen" class="icon-btn" aria-label="Fullscreen">⛶</button> + </div> + </aside> + <a class="back-link" href="/">Back to Home</a> </div> - <a class="back-link" href="/">Back to Home</a> </div> -<script> - const player = document.getElementById('player'); - const overlay = document.getElementById('overlay'); - overlay.addEventListener('click', () => { - player.play(); - }); - player.addEventListener('play', () => { - overlay.classList.add('hidden'); - }); - player.addEventListener('pause', () => { - overlay.classList.remove('hidden'); - }); + +<script type="module"> + import { initPlayer, loadMediaDirect } from '/js/player.js'; + + initPlayer(); + + // Parse injected metadata + const metaEl = document.getElementById('share-meta'); + let data = null; + try { + data = JSON.parse(metaEl.textContent); + } catch {} + + if (data && data.media) { + loadMediaDirect(data.media, data.stream_url, data.thumb_url, 0); + // Auto-play on first click via the big play overlay handled by player.js, + // but start paused so the controls are visible. The overlay click already + // wires to togglePlay. + } </script> + +<script id="share-meta" type="application/json"><!--SHARE_MEDIA--></script> </body> </html> |
