feat: save watch progress
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ type WatchPageData struct {
|
||||
MalID int
|
||||
Title string
|
||||
CurrentEpisode string
|
||||
StartTimeSeconds float64
|
||||
CurrentStatus string
|
||||
InitialMode string
|
||||
AvailableModes []string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) }}
|
||||
<div
|
||||
class="flex flex-col gap-4 w-full"
|
||||
data-mal-id={ fmt.Sprintf("%d", data.MalID) }
|
||||
data-video-player
|
||||
data-stream-url="/watch/proxy/stream"
|
||||
data-preview-map-url="/watch/proxy/preview-map"
|
||||
data-current-episode={ data.CurrentEpisode }
|
||||
data-start-time-seconds={ fmt.Sprintf("%.3f", data.StartTimeSeconds) }
|
||||
data-initial-mode={ data.InitialMode }
|
||||
data-available-modes={ toJSON(data.AvailableModes) }
|
||||
data-mode-sources={ toJSON(data.ModeSources) }
|
||||
|
||||
2
migrations/010_add_watch_progress_seconds.sql
Normal file
2
migrations/010_add_watch_progress_seconds.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE watch_list_entry
|
||||
ADD COLUMN current_time_seconds REAL NOT NULL DEFAULT 0;
|
||||
@@ -115,6 +115,8 @@ const initPlayer = (): void => {
|
||||
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<void> => {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user