summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-02 09:33:22 +0300
committerPaul Buetow <paul@buetow.org>2026-05-02 09:33:22 +0300
commit95c6ce72bdf16ead39d0df1ee27b0474441fbe4f (patch)
treef6823246f8e7f85db2233fec461159005ffadc41
parent4dd62a8b61c56ef28f5f0cab58b8c2da94806717 (diff)
fix:x
-rw-r--r--internal/api/handlers_more_test.go36
-rw-r--r--internal/api/handlers_share.go60
-rw-r--r--internal/api/handlers_test.go7
-rw-r--r--internal/api/server.go1
-rw-r--r--internal/service/media.go4
-rw-r--r--internal/service/media_share.go23
-rw-r--r--internal/service/mock.go7
-rw-r--r--internal/service/service.go23
-rw-r--r--web/index.html8
-rw-r--r--web/js/app.js18
-rw-r--r--web/js/keyboard.js8
-rw-r--r--web/js/player.js38
-rw-r--r--web/js/state.js2
-rw-r--r--web/share.html143
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>