From c1ee5df94c82382ed5914b9c347adf38c5688401 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 18 Apr 2026 18:24:43 +0200 Subject: [PATCH] feat: save watch progress --- internal/database/models.go | 17 +-- internal/database/querier.go | 1 + internal/database/queries.sql | 12 +- internal/database/queries.sql.go | 108 ++++++++++++------ internal/features/playback/handler.go | 63 ++++++++++ internal/features/playback/service.go | 23 ++-- internal/features/playback/types.go | 1 + internal/features/watchlist/service.go | 2 + internal/server/routes.go | 1 + internal/templates/watch.templ | 3 + migrations/010_add_watch_progress_seconds.sql | 2 + static/player.ts | 75 ++++++++++++ 12 files changed, 252 insertions(+), 56 deletions(-) create mode 100644 migrations/010_add_watch_progress_seconds.sql diff --git a/internal/database/models.go b/internal/database/models.go index b1da0cc..2c4edf4 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -74,12 +74,13 @@ type User struct { } type WatchListEntry struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` - LastEpisodeAt sql.NullTime `json:"last_episode_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + LastEpisodeAt sql.NullTime `json:"last_episode_at"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` } diff --git a/internal/database/querier.go b/internal/database/querier.go index 547e2ea..dcf66db 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -33,6 +33,7 @@ type Querier interface { GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error MarkRelationsSynced(ctx context.Context, id int64) error + SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error SetJikanCache(ctx context.Context, arg SetJikanCacheParams) error UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 5d5568e..355077e 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -46,14 +46,22 @@ RETURNING *; SELECT * FROM anime WHERE id = ? LIMIT 1; -- name: UpsertWatchListEntry :one -INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at) -VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at) +VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, anime_id) DO UPDATE SET status = excluded.status, current_episode = excluded.current_episode, + current_time_seconds = excluded.current_time_seconds, updated_at = CURRENT_TIMESTAMP RETURNING *; +-- name: SaveWatchProgress :exec +UPDATE watch_list_entry +SET current_episode = ?, + current_time_seconds = ?, + updated_at = CURRENT_TIMESTAMP +WHERE user_id = ? AND anime_id = ?; + -- name: GetWatchListEntry :one SELECT * FROM watch_list_entry WHERE user_id = ? AND anime_id = ? LIMIT 1; diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 2c40cdc..ec5b07c 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -448,7 +448,7 @@ func (q *Queries) GetUserByUsernameAndRecoveryKeyHash(ctx context.Context, arg G const getUserWatchList = `-- name: GetUserWatchList :many SELECT - e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, + e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, a.title_original, a.title_english, a.title_japanese, @@ -461,19 +461,20 @@ ORDER BY e.updated_at DESC ` type GetUserWatchListRow struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` - LastEpisodeAt sql.NullTime `json:"last_episode_at"` - TitleOriginal string `json:"title_original"` - TitleEnglish sql.NullString `json:"title_english"` - TitleJapanese sql.NullString `json:"title_japanese"` - ImageUrl string `json:"image_url"` - Airing sql.NullBool `json:"airing"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + LastEpisodeAt sql.NullTime `json:"last_episode_at"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` + Airing sql.NullBool `json:"airing"` } func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) { @@ -494,6 +495,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse &i.UpdatedAt, &i.CurrentEpisode, &i.LastEpisodeAt, + &i.CurrentTimeSeconds, &i.TitleOriginal, &i.TitleEnglish, &i.TitleJapanese, @@ -514,7 +516,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse } const getWatchListEntry = `-- name: GetWatchListEntry :one -SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at FROM watch_list_entry +SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds FROM watch_list_entry WHERE user_id = ? AND anime_id = ? LIMIT 1 ` @@ -535,13 +537,14 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa &i.UpdatedAt, &i.CurrentEpisode, &i.LastEpisodeAt, + &i.CurrentTimeSeconds, ) return i, err } const getWatchingAnime = `-- name: GetWatchingAnime :many SELECT - e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, + e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, a.title_original, a.title_english, a.title_japanese, @@ -554,19 +557,20 @@ ORDER BY e.updated_at DESC ` type GetWatchingAnimeRow struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` - LastEpisodeAt sql.NullTime `json:"last_episode_at"` - TitleOriginal string `json:"title_original"` - TitleEnglish sql.NullString `json:"title_english"` - TitleJapanese sql.NullString `json:"title_japanese"` - ImageUrl string `json:"image_url"` - Airing sql.NullBool `json:"airing"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + LastEpisodeAt sql.NullTime `json:"last_episode_at"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` + Airing sql.NullBool `json:"airing"` } func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) { @@ -587,6 +591,7 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat &i.UpdatedAt, &i.CurrentEpisode, &i.LastEpisodeAt, + &i.CurrentTimeSeconds, &i.TitleOriginal, &i.TitleEnglish, &i.TitleJapanese, @@ -635,6 +640,31 @@ func (q *Queries) MarkRelationsSynced(ctx context.Context, id int64) error { return err } +const saveWatchProgress = `-- name: SaveWatchProgress :exec +UPDATE watch_list_entry +SET current_episode = ?, + current_time_seconds = ?, + updated_at = CURRENT_TIMESTAMP +WHERE user_id = ? AND anime_id = ? +` + +type SaveWatchProgressParams struct { + CurrentEpisode sql.NullInt64 `json:"current_episode"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` +} + +func (q *Queries) SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error { + _, err := q.db.ExecContext(ctx, saveWatchProgress, + arg.CurrentEpisode, + arg.CurrentTimeSeconds, + arg.UserID, + arg.AnimeID, + ) + return err +} + const setJikanCache = `-- name: SetJikanCache :exec INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?) @@ -750,21 +780,23 @@ func (q *Queries) UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelati } const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one -INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at) -VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at) +VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, anime_id) DO UPDATE SET status = excluded.status, current_episode = excluded.current_episode, + current_time_seconds = excluded.current_time_seconds, updated_at = CURRENT_TIMESTAMP -RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at +RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds ` type UpsertWatchListEntryParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CurrentEpisode sql.NullInt64 `json:"current_episode"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` } func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) { @@ -774,6 +806,7 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE arg.AnimeID, arg.Status, arg.CurrentEpisode, + arg.CurrentTimeSeconds, ) var i WatchListEntry err := row.Scan( @@ -785,6 +818,7 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE &i.UpdatedAt, &i.CurrentEpisode, &i.LastEpisodeAt, + &i.CurrentTimeSeconds, ) return i, err } diff --git a/internal/features/playback/handler.go b/internal/features/playback/handler.go index fe90072..1fbae17 100644 --- a/internal/features/playback/handler.go +++ b/internal/features/playback/handler.go @@ -2,6 +2,7 @@ package playback import ( "context" + "database/sql" "encoding/json" "io" "log" @@ -91,6 +92,7 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) { MalID: data.MalID, Title: data.Title, CurrentEpisode: data.CurrentEpisode, + StartTimeSeconds: data.StartTimeSeconds, CurrentStatus: data.CurrentStatus, InitialMode: data.InitialMode, AvailableModes: data.AvailableModes, @@ -297,6 +299,67 @@ func (h *Handler) HandleProxyPreviewSprite(w http.ResponseWriter, r *http.Reques http.ServeFile(w, r, spritePath) } +func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + user := middleware.GetUser(r.Context()) + if user == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + type saveProgressRequest struct { + MalID int `json:"mal_id"` + Episode int `json:"episode"` + TimeSecond float64 `json:"time_seconds"` + } + + var payload saveProgressRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + if payload.MalID <= 0 || payload.Episode <= 0 { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + timeSeconds := payload.TimeSecond + if timeSeconds < 0 || timeSeconds != timeSeconds { + timeSeconds = 0 + } + + if h.svc.db == nil { + http.Error(w, "database unavailable", http.StatusServiceUnavailable) + return + } + + if _, err := h.svc.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{ + UserID: user.ID, + AnimeID: int64(payload.MalID), + }); err != nil { + http.Error(w, "watchlist entry not found", http.StatusNotFound) + return + } + + if err := h.svc.db.SaveWatchProgress(r.Context(), database.SaveWatchProgressParams{ + CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true}, + CurrentTimeSeconds: timeSeconds, + UserID: user.ID, + AnimeID: int64(payload.MalID), + }); err != nil { + log.Printf("save progress failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err) + http.Error(w, "failed to save progress", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) proxyUpstream(w http.ResponseWriter, r *http.Request, targetURL string, referer string) { parsed, err := url.Parse(targetURL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { diff --git a/internal/features/playback/service.go b/internal/features/playback/service.go index a846dbc..b9d8f07 100644 --- a/internal/features/playback/service.go +++ b/internal/features/playback/service.go @@ -104,6 +104,7 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin segments := s.fetchSkipSegments(ctx, malID, normalizedEpisode) currentStatus := "" + startTimeSeconds := 0.0 if userID != "" && s.db != nil { entry, err := s.db.GetWatchListEntry(ctx, database.GetWatchListEntryParams{ UserID: userID, @@ -111,6 +112,9 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin }) if err == nil { currentStatus = entry.Status + if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == normalizedEpisode && entry.CurrentTimeSeconds > 0 { + startTimeSeconds = entry.CurrentTimeSeconds + } } } @@ -123,15 +127,16 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin } return WatchPageData{ - MalID: malID, - Title: watchTitle, - CurrentEpisode: normalizedEpisode, - CurrentStatus: currentStatus, - InitialMode: initialMode, - AvailableModes: availableModes, - ModeSources: modeSources, - Episodes: episodes, - Segments: segments, + MalID: malID, + Title: watchTitle, + CurrentEpisode: normalizedEpisode, + StartTimeSeconds: startTimeSeconds, + CurrentStatus: currentStatus, + InitialMode: initialMode, + AvailableModes: availableModes, + ModeSources: modeSources, + Episodes: episodes, + Segments: segments, }, nil } diff --git a/internal/features/playback/types.go b/internal/features/playback/types.go index febbd55..a2783a5 100644 --- a/internal/features/playback/types.go +++ b/internal/features/playback/types.go @@ -44,6 +44,7 @@ type WatchPageData struct { MalID int Title string CurrentEpisode string + StartTimeSeconds float64 CurrentStatus string InitialMode string AvailableModes []string diff --git a/internal/features/watchlist/service.go b/internal/features/watchlist/service.go index f8e172c..baafc79 100644 --- a/internal/features/watchlist/service.go +++ b/internal/features/watchlist/service.go @@ -71,6 +71,7 @@ func (s *Service) AddEntry(ctx context.Context, userID string, req AddRequest) e AnimeID: req.AnimeID, Status: req.Status, CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false}, + CurrentTimeSeconds: 0, }) if err != nil { return fmt.Errorf("failed to update watchlist: %w", err) @@ -165,6 +166,7 @@ func (s *Service) Import(ctx context.Context, userID string, export ExportData) AnimeID: entry.AnimeID, Status: entry.Status, CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false}, + CurrentTimeSeconds: 0, }) if err != nil { continue diff --git a/internal/server/routes.go b/internal/server/routes.go index 7b24670..a5a5ac2 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -58,6 +58,7 @@ func NewRouter(cfg Config) http.Handler { mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxyStream) mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxySegment) mux.HandleFunc("/watch/proxy/subtitle", playbackHandler.HandleProxySubtitle) + mux.HandleFunc("/api/watch-progress", playbackHandler.HandleSaveProgress) mux.HandleFunc("/watch/proxy/preview-map", playbackHandler.HandleProxyPreviewMap) mux.HandleFunc("/watch/proxy/preview-sprite", playbackHandler.HandleProxyPreviewSprite) diff --git a/internal/templates/watch.templ b/internal/templates/watch.templ index 9d82925..fd226ac 100644 --- a/internal/templates/watch.templ +++ b/internal/templates/watch.templ @@ -14,6 +14,7 @@ type WatchPageData struct { MalID int Title string CurrentEpisode string + StartTimeSeconds float64 CurrentStatus string InitialMode string AvailableModes []string @@ -192,10 +193,12 @@ templ VideoPlayer(data WatchPageData) { {{ streamURL := buildStreamURL(data.InitialMode, data.ModeSources) }}
{ const streamURL = container.getAttribute('data-stream-url') || '/watch/proxy/stream' const previewMapURL = container.getAttribute('data-preview-map-url') || '/watch/proxy/preview-map' const currentEpisode = container.getAttribute('data-current-episode') || '1' + const malID = Number.parseInt(container.getAttribute('data-mal-id') || '', 10) + const startTimeSeconds = Number.parseFloat(container.getAttribute('data-start-time-seconds') || '0') const modeSources = JSON.parse(container.getAttribute('data-mode-sources') || '{}') const availableModes = JSON.parse(container.getAttribute('data-available-modes') || '[]') const initialMode = container.getAttribute('data-initial-mode') || 'dub' @@ -157,6 +159,8 @@ const initPlayer = (): void => { let activeSegments: Array<{ type: string, start: number, end: number }> = [] let previewState: { [key: string]: PreviewPayload } = {} let previewRequestToken = 0 + let lastSavedProgress = { episode: currentEpisode, seconds: -1 } + let progressSaveTimer: number | undefined const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement const previewFrame = container.querySelector('[data-preview-frame]') as HTMLElement @@ -411,6 +415,47 @@ const initPlayer = (): void => { } } + const saveProgress = async (): Promise => { + if (!Number.isInteger(malID) || malID <= 0) return + if (!video.duration || !Number.isFinite(video.duration)) return + const episodeNumber = Number.parseInt(currentEpisode, 10) + if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return + + const safeTime = Math.max(0, Math.min(video.currentTime, video.duration)) + if (lastSavedProgress.episode === currentEpisode && Math.abs(lastSavedProgress.seconds - safeTime) < 2) { + return + } + + try { + const response = await fetch('/api/watch-progress', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + mal_id: malID, + episode: episodeNumber, + time_seconds: safeTime, + }), + }) + if (!response.ok) return + lastSavedProgress = { + episode: currentEpisode, + seconds: safeTime, + } + } catch { + return + } + } + + const scheduleProgressSave = (): void => { + if (progressSaveTimer !== undefined) return + progressSaveTimer = window.setTimeout(() => { + progressSaveTimer = undefined + saveProgress() + }, 1500) + } + const parseVttTime = (raw: string): number => { const parts = raw.trim().split(':') if (parts.length < 2) return 0 @@ -611,6 +656,14 @@ const initPlayer = (): void => { if (loading) loading.style.display = 'none' resolveActiveSegments() renderSegments() + if (Number.isFinite(startTimeSeconds) && startTimeSeconds > 0 && video.currentTime === 0) { + const nextStart = Math.min(startTimeSeconds, Math.max(0, video.duration - 0.5)) + if (nextStart > 0) { + try { + video.currentTime = nextStart + } catch {} + } + } if (pendingSeekTime !== null && Number.isFinite(pendingSeekTime)) { try { video.currentTime = pendingSeekTime @@ -634,6 +687,7 @@ const initPlayer = (): void => { updateTimeline(video.currentTime) updateSubtitleRender(video.currentTime) updateSkipButton(video.currentTime) + scheduleProgressSave() }) video.addEventListener('play', () => { @@ -644,6 +698,9 @@ const initPlayer = (): void => { video.addEventListener('pause', () => { updatePlayPauseIcons(false) showControls() + window.clearTimeout(progressSaveTimer) + progressSaveTimer = undefined + saveProgress() }) video.addEventListener('volumechange', () => { @@ -818,6 +875,7 @@ const initPlayer = (): void => { window.addEventListener('mouseup', () => { isScrubbing = false + saveProgress() }) window.addEventListener('mousemove', (event) => { @@ -855,6 +913,23 @@ const initPlayer = (): void => { showControls() }) + window.addEventListener('beforeunload', () => { + if (!Number.isInteger(malID) || malID <= 0) return + if (!video.duration || !Number.isFinite(video.duration)) return + const episodeNumber = Number.parseInt(currentEpisode, 10) + if (!Number.isInteger(episodeNumber) || episodeNumber <= 0) return + const safeTime = Math.max(0, Math.min(video.currentTime, video.duration)) + const payload = JSON.stringify({ + mal_id: malID, + episode: episodeNumber, + time_seconds: safeTime, + }) + if (navigator.sendBeacon) { + const blob = new Blob([payload], { type: 'application/json' }) + navigator.sendBeacon('/api/watch-progress', blob) + } + }) + updatePlayPauseIcons(false) syncVolumeUI() updateSkipButton(0)