diff --git a/Dockerfile b/Dockerfile index 9bbc8f0..f2cf389 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ ENV CGO_ENABLED=1 RUN go install github.com/a-h/templ/cmd/templ@latest # Install build dependencies for bun + assets -RUN apt-get update && apt-get install -y ca-certificates sqlite3 ffmpeg curl unzip && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl unzip && rm -rf /var/lib/apt/lists/* RUN curl -fsSL https://bun.sh/install | bash ENV PATH="/root/.bun/bin:${PATH}" @@ -34,8 +34,8 @@ FROM debian:bullseye-slim WORKDIR /app -# Required at runtime (sqlite + preview generation) -RUN apt-get update && apt-get install -y ca-certificates sqlite3 ffmpeg && rm -rf /var/lib/apt/lists/* +# Required at runtime (sqlite) +RUN apt-get update && apt-get install -y ca-certificates sqlite3 && rm -rf /var/lib/apt/lists/* # Create data directory for sqlite RUN mkdir -p /app/data diff --git a/internal/features/playback/handler.go b/internal/features/playback/handler.go index 5714636..bbd498d 100644 --- a/internal/features/playback/handler.go +++ b/internal/features/playback/handler.go @@ -8,7 +8,6 @@ import ( "log" "net/http" "net/url" - "os" "strconv" "strings" "time" @@ -206,101 +205,6 @@ func (h *Handler) HandleProxySubtitle(w http.ResponseWriter, r *http.Request) { h.proxyUpstream(w, r, targetURL, r.URL.Query().Get("r")) } -func (h *Handler) HandleProxyPreviewMap(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - malIDText := strings.TrimSpace(r.URL.Query().Get("mal_id")) - malID, err := strconv.Atoi(malIDText) - if err != nil || malID <= 0 { - http.Error(w, "invalid mal id", http.StatusBadRequest) - return - } - - episode := strings.TrimSpace(r.URL.Query().Get("ep")) - if episode == "" { - episode = "1" - } - - mode := normalizeMode(r.URL.Query().Get("mode")) - if mode == "" { - mode = "dub" - } - - source := strings.TrimSpace(r.URL.Query().Get("u")) - if source == "" { - http.Error(w, "missing target url", http.StatusBadRequest) - return - } - - referer := strings.TrimSpace(r.URL.Query().Get("r")) - duration := 0.0 - durationText := strings.TrimSpace(r.URL.Query().Get("d")) - if durationText != "" { - parsedDuration, parseErr := strconv.ParseFloat(durationText, 64) - if parseErr != nil || parsedDuration <= 0 { - http.Error(w, "invalid duration", http.StatusBadRequest) - return - } - duration = parsedDuration - } - - mapData, previewKey, previewErr := h.svc.EnsurePreviewMap(r.Context(), PreviewRequest{ - MalID: malID, - Episode: episode, - Mode: mode, - Source: source, - Referer: referer, - Duration: duration, - }) - if previewErr != nil { - if previewErr.Error() != "preview temporarily disabled" { - log.Printf("preview map error mal_id=%d ep=%s mode=%s: %v", malID, episode, mode, previewErr) - } - http.Error(w, "failed to generate preview map", http.StatusBadGateway) - return - } - - spriteURL := "/watch/proxy/preview-sprite?k=" + url.QueryEscape(previewKey) - response := struct { - SpriteURL string `json:"sprite_url"` - Map PreviewMap `json:"map"` - }{ - SpriteURL: spriteURL, - Map: mapData, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("preview map encode error mal_id=%d ep=%s mode=%s: %v", malID, episode, mode, err) - } -} - -func (h *Handler) HandleProxyPreviewSprite(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - previewKey := strings.TrimSpace(r.URL.Query().Get("k")) - spritePath := h.svc.PreviewSpritePath(previewKey) - if spritePath == "" { - http.Error(w, "invalid preview key", http.StatusBadRequest) - return - } - - if _, err := os.Stat(spritePath); err != nil { - http.Error(w, "preview sprite not found", http.StatusNotFound) - return - } - - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") - w.Header().Set("Content-Type", "image/jpeg") - 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) diff --git a/internal/features/playback/preview_service.go b/internal/features/playback/preview_service.go deleted file mode 100644 index cb33b4a..0000000 --- a/internal/features/playback/preview_service.go +++ /dev/null @@ -1,468 +0,0 @@ -package playback - -import ( - "context" - "crypto/sha1" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "net/url" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "sync" - "time" -) - -const ( - previewFrameWidth = 128 - previewFrameHeight = 72 - previewGridColumns = 8 - previewGridRows = 8 - previewFrameInterval = 15 - previewFrameLimit = previewGridColumns * previewGridRows - previewGenerationLimit = 120 * time.Second - previewFailureTTL = 2 * time.Minute - previewManifestName = "map.json" - spriteFileName = "sprite.jpg" -) - -type ffprobeFormat struct { - Duration string `json:"duration"` -} - -type ffprobeResult struct { - Format ffprobeFormat `json:"format"` -} - -func newPreviewRootDir() string { - return filepath.Join(os.TempDir(), "mal-preview-cache") -} - -func (s *Service) EnsurePreviewMap(ctx context.Context, req PreviewRequest) (PreviewMap, string, error) { - if strings.TrimSpace(req.Source) == "" { - return PreviewMap{}, "", fmt.Errorf("missing preview source") - } - - parsedSource, err := url.Parse(req.Source) - if err != nil { - return PreviewMap{}, "", fmt.Errorf("invalid preview source") - } - if parsedSource.Scheme != "http" && parsedSource.Scheme != "https" { - return PreviewMap{}, "", fmt.Errorf("invalid preview source scheme") - } - - normalizedEpisode := strings.TrimSpace(req.Episode) - if normalizedEpisode == "" { - normalizedEpisode = "1" - } - - normalizedMode := normalizeMode(req.Mode) - if normalizedMode == "" { - normalizedMode = "dub" - } - - previewHash := hashPreviewIdentity(req.MalID, normalizedEpisode, normalizedMode, req.Source, req.Referer) - previewKey := fmt.Sprintf("%d-%s", req.MalID, previewHash) - if s.previewFailureActive(previewKey) { - return PreviewMap{}, "", fmt.Errorf("preview temporarily disabled") - } - previewDir := filepath.Join(s.previewRoot, previewKey) - manifestPath := filepath.Join(previewDir, previewManifestName) - spritePath := filepath.Join(previewDir, spriteFileName) - - if mapData, err := readPreviewManifest(manifestPath); err == nil { - if mapData.Duration > 0 && fileExists(spritePath) { - return mapData, previewKey, nil - } - } - - lock := s.previewLock(previewKey) - lock.Lock() - defer lock.Unlock() - - if mapData, err := readPreviewManifest(manifestPath); err == nil { - if mapData.Duration > 0 && fileExists(spritePath) { - return mapData, previewKey, nil - } - } - - if err := os.MkdirAll(previewDir, 0o755); err != nil { - return PreviewMap{}, "", fmt.Errorf("create preview cache dir: %w", err) - } - - ffmpegPath, err := exec.LookPath("ffmpeg") - if err != nil { - return PreviewMap{}, "", fmt.Errorf("ffmpeg not found in PATH") - } - - ffprobePath, err := exec.LookPath("ffprobe") - if err != nil { - return PreviewMap{}, "", fmt.Errorf("ffprobe not found in PATH") - } - - ctxWithTimeout, cancel := context.WithTimeout(ctx, previewGenerationLimit) - defer cancel() - - duration, err := probePreviewDuration(ctxWithTimeout, ffprobePath, req.Source, req.Referer) - if err != nil { - if req.Duration > 0 { - duration = req.Duration - } else { - return PreviewMap{}, "", fmt.Errorf("probe preview duration: %w", err) - } - } - - if duration <= 0 { - return PreviewMap{}, "", fmt.Errorf("invalid duration for preview generation") - } - - interval := selectPreviewInterval(duration) - - if err := generatePreviewSprite(ctxWithTimeout, ffmpegPath, req.Source, req.Referer, spritePath, interval); err != nil { - if shouldBackoffPreview(err) { - s.markPreviewFailure(previewKey) - } - return PreviewMap{}, "", err - } - s.clearPreviewFailure(previewKey) - - mapData := buildPreviewMap(duration, interval) - if err := writePreviewManifest(manifestPath, mapData); err != nil { - return PreviewMap{}, "", fmt.Errorf("write preview manifest: %w", err) - } - - return mapData, previewKey, nil -} - -func (s *Service) PreviewSpritePath(previewKey string) string { - trimmed := strings.TrimSpace(previewKey) - if trimmed == "" { - return "" - } - - safeKey := sanitizePreviewKey(trimmed) - if safeKey == "" { - return "" - } - - return filepath.Join(s.previewRoot, safeKey, spriteFileName) -} - -func (s *Service) previewLock(key string) *sync.Mutex { - s.previewMu.Lock() - defer s.previewMu.Unlock() - - lock, exists := s.previewLocks[key] - if exists { - return lock - } - - newLock := &sync.Mutex{} - s.previewLocks[key] = newLock - return newLock -} - -func (s *Service) previewFailureActive(key string) bool { - s.previewFailMu.Lock() - defer s.previewFailMu.Unlock() - - expiresAt, ok := s.previewFailTTL[key] - if !ok { - return false - } - - if time.Now().After(expiresAt) { - delete(s.previewFailTTL, key) - return false - } - - return true -} - -func (s *Service) markPreviewFailure(key string) { - s.previewFailMu.Lock() - defer s.previewFailMu.Unlock() - - s.previewFailTTL[key] = time.Now().Add(previewFailureTTL) -} - -func (s *Service) clearPreviewFailure(key string) { - s.previewFailMu.Lock() - defer s.previewFailMu.Unlock() - - delete(s.previewFailTTL, key) -} - -func hashPreviewIdentity(malID int, episode string, mode string, source string, referer string) string { - payload := fmt.Sprintf("%d|%s|%s|%s|%s", malID, episode, mode, source, referer) - sum := sha1.Sum([]byte(payload)) - return hex.EncodeToString(sum[:8]) -} - -func sanitizePreviewKey(raw string) string { - var builder strings.Builder - for _, char := range raw { - if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' { - builder.WriteRune(char) - } - } - - return builder.String() -} - -func selectPreviewInterval(duration float64) float64 { - if duration <= 0 { - return previewFrameInterval - } - - candidate := duration / float64(previewFrameLimit) - if candidate < previewFrameInterval { - return previewFrameInterval - } - - return math.Ceil(candidate) -} - -func computePreviewFrames(duration float64, interval float64) int { - if duration <= 0 || interval <= 0 { - return 1 - } - - frames := int(math.Ceil(duration / interval)) - if frames > previewFrameLimit { - return previewFrameLimit - } - - return frames -} - -func buildPreviewMap(duration float64, interval float64) PreviewMap { - frames := computePreviewFrames(duration, interval) - cues := make([]PreviewCue, 0, frames) - - for idx := 0; idx < frames; idx++ { - start := float64(idx) * interval - end := start + interval - if idx == frames-1 { - end = duration - } - if end > duration { - end = duration - } - - column := idx % previewGridColumns - row := idx / previewGridColumns - x := column * previewFrameWidth - y := row * previewFrameHeight - - cues = append(cues, PreviewCue{ - Start: start, - End: end, - Sprite: spriteFileName, - X: x, - Y: y, - Width: previewFrameWidth, - Height: previewFrameHeight, - }) - } - - return PreviewMap{ - Width: previewFrameWidth, - Height: previewFrameHeight, - Columns: previewGridColumns, - Rows: previewGridRows, - Interval: interval, - Duration: duration, - Cues: cues, - } -} - -func readPreviewManifest(path string) (PreviewMap, error) { - payload, err := os.ReadFile(path) - if err != nil { - return PreviewMap{}, err - } - - var mapData PreviewMap - if err := json.Unmarshal(payload, &mapData); err != nil { - return PreviewMap{}, err - } - - if mapData.Duration <= 0 || len(mapData.Cues) == 0 { - return PreviewMap{}, fmt.Errorf("invalid preview manifest") - } - - return mapData, nil -} - -func writePreviewManifest(path string, mapData PreviewMap) error { - payload, err := json.Marshal(mapData) - if err != nil { - return err - } - - return os.WriteFile(path, payload, 0o644) -} - -func probePreviewDuration(ctx context.Context, ffprobePath string, source string, referer string) (float64, error) { - args := []string{ - "-v", "error", - "-show_entries", "format=duration", - "-of", "json", - } - - headers := ffmpegHeaders(referer) - if headers != "" { - args = append(args, "-headers", headers) - } - - args = append(args, source) - - cmd := exec.CommandContext(ctx, ffprobePath, args...) - output, err := cmd.Output() - if err != nil { - return 0, err - } - - var parsed ffprobeResult - if err := json.Unmarshal(output, &parsed); err != nil { - return 0, err - } - - durationText := strings.TrimSpace(parsed.Format.Duration) - if durationText == "" { - return 0, fmt.Errorf("missing duration") - } - - duration, err := strconv.ParseFloat(durationText, 64) - if err != nil { - return 0, err - } - - if !isFinitePositive(duration) { - return 0, fmt.Errorf("non-finite duration") - } - - return duration, nil -} - -func generatePreviewSprite(ctx context.Context, ffmpegPath string, source string, referer string, outputPath string, interval float64) error { - filter := fmt.Sprintf("fps=1/%0.3f,scale=%d:%d,tile=%dx%d", interval, previewFrameWidth, previewFrameHeight, previewGridColumns, previewGridRows) - args := []string{ - "-hide_banner", - "-loglevel", "error", - "-y", - } - - headers := ffmpegHeaders(referer) - if headers != "" { - args = append(args, "-headers", headers) - } - - args = append(args, - "-threads", "1", - "-i", source, - "-map", "0:v:0", - "-an", - "-sn", - "-dn", - "-vf", filter, - "-frames:v", "1", - "-q:v", "8", - "-update", "1", - outputPath, - ) - - cmd := exec.CommandContext(ctx, ffmpegPath, args...) - stderr, err := cmd.StderrPipe() - if err != nil { - return fmt.Errorf("create ffmpeg stderr pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - return fmt.Errorf("start ffmpeg: %w", err) - } - - errPayload, _ := io.ReadAll(io.LimitReader(stderr, 64*1024)) - waitErr := cmd.Wait() - if waitErr != nil { - message := strings.TrimSpace(string(errPayload)) - if message == "" { - return fmt.Errorf("ffmpeg failed: %w", waitErr) - } - return fmt.Errorf("ffmpeg failed: %s", message) - } - - if _, statErr := os.Stat(outputPath); statErr != nil { - return fmt.Errorf("preview sprite not generated") - } - - return nil -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func ffmpegHeaders(referer string) string { - var builder strings.Builder - builder.WriteString("User-Agent: ") - builder.WriteString(defaultUserAgent) - builder.WriteString("\r\n") - - trimmedReferer := strings.TrimSpace(referer) - if trimmedReferer != "" { - builder.WriteString("Referer: ") - builder.WriteString(trimmedReferer) - builder.WriteString("\r\n") - } - - builder.WriteString("Connection: keep-alive\r\n") - return builder.String() -} - -func isFinitePositive(value float64) bool { - if value <= 0 { - return false - } - - if math.IsInf(value, 0) { - return false - } - - if math.IsNaN(value) { - return false - } - - return true -} - -func shouldBackoffPreview(err error) bool { - if err == nil { - return false - } - - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return true - } - - errText := strings.ToLower(err.Error()) - if strings.Contains(errText, "signal: killed") { - return true - } - if strings.Contains(errText, "context canceled") { - return true - } - if strings.Contains(errText, "cannot allocate memory") { - return true - } - - return false -} diff --git a/internal/features/playback/service.go b/internal/features/playback/service.go index 12d23e8..d4b7804 100644 --- a/internal/features/playback/service.go +++ b/internal/features/playback/service.go @@ -14,7 +14,6 @@ import ( "sort" "strconv" "strings" - "sync" "time" "mal/internal/jikan" @@ -25,11 +24,6 @@ type Service struct { jikanClient *jikan.Client httpClient *http.Client db database.Querier - previewRoot string - previewMu sync.Mutex - previewLocks map[string]*sync.Mutex - previewFailMu sync.Mutex - previewFailTTL map[string]time.Time } type sourceScore struct { @@ -47,9 +41,6 @@ func NewService(jikanClient *jikan.Client, db database.Querier) *Service { jikanClient: jikanClient, httpClient: &http.Client{Timeout: 12 * time.Second}, db: db, - previewRoot: newPreviewRootDir(), - previewLocks: make(map[string]*sync.Mutex), - previewFailTTL: make(map[string]time.Time), } } diff --git a/internal/features/playback/types.go b/internal/features/playback/types.go index a2783a5..b1c916c 100644 --- a/internal/features/playback/types.go +++ b/internal/features/playback/types.go @@ -52,32 +52,3 @@ type WatchPageData struct { Episodes []EpisodeListItem Segments []SkipSegment } - -type PreviewRequest struct { - MalID int - Episode string - Mode string - Source string - Referer string - Duration float64 -} - -type PreviewCue struct { - Start float64 `json:"start"` - End float64 `json:"end"` - Sprite string `json:"sprite"` - X int `json:"x"` - Y int `json:"y"` - Width int `json:"width"` - Height int `json:"height"` -} - -type PreviewMap struct { - Width int `json:"width"` - Height int `json:"height"` - Columns int `json:"columns"` - Rows int `json:"rows"` - Interval float64 `json:"interval"` - Duration float64 `json:"duration"` - Cues []PreviewCue `json:"cues"` -} diff --git a/internal/server/routes.go b/internal/server/routes.go index a5a5ac2..0c4f971 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -59,8 +59,6 @@ func NewRouter(cfg Config) http.Handler { 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) // Auth Endpoints mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/templates/watch.templ b/internal/templates/watch.templ index 70304d0..5c2b303 100644 --- a/internal/templates/watch.templ +++ b/internal/templates/watch.templ @@ -198,7 +198,6 @@ templ VideoPlayer(data WatchPageData) { 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 } @@ -225,7 +224,6 @@ templ VideoPlayer(data WatchPageData) {
diff --git a/static/player.ts b/static/player.ts index 0412db1..41fc5f3 100644 --- a/static/player.ts +++ b/static/player.ts @@ -18,73 +18,6 @@ interface SkipSegment { end: number } -interface PreviewCue { - start: number - end: number - sprite: string - x: number - y: number - width: number - height: number -} - -interface PreviewMap { - width: number - height: number - columns: number - rows: number - interval: number - duration: number - cues: PreviewCue[] -} - -interface PreviewPayload { - spriteURL: string - map: PreviewMap -} - -interface PreviewMapResponse { - sprite_url: string - map: PreviewMap -} - -const isObjectRecord = (value: unknown): value is Record => { - return typeof value === 'object' && value !== null -} - -const isPreviewCue = (value: unknown): value is PreviewCue => { - if (!isObjectRecord(value)) return false - return Number.isFinite(value.start) - && Number.isFinite(value.end) - && typeof value.sprite === 'string' - && Number.isFinite(value.x) - && Number.isFinite(value.y) - && Number.isFinite(value.width) - && Number.isFinite(value.height) -} - -const isPreviewMap = (value: unknown): value is PreviewMap => { - if (!isObjectRecord(value)) return false - if (!Array.isArray(value.cues)) return false - if (!value.cues.every((cue: unknown) => isPreviewCue(cue))) return false - return Number.isFinite(value.width) - && Number.isFinite(value.height) - && Number.isFinite(value.columns) - && Number.isFinite(value.rows) - && Number.isFinite(value.interval) - && Number.isFinite(value.duration) -} - -const parsePreviewMapResponse = (value: unknown): PreviewMapResponse | null => { - if (!isObjectRecord(value)) return null - if (typeof value.sprite_url !== 'string') return null - if (!isPreviewMap(value.map)) return null - return { - sprite_url: value.sprite_url, - map: value.map, - } -} - const initPlayer = (): void => { const container = document.querySelector('[data-video-player]') if (!container) return @@ -113,7 +46,6 @@ const initPlayer = (): void => { const subtitleText = container.querySelector('[data-subtitle-text]') as HTMLElement 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') @@ -121,13 +53,6 @@ const initPlayer = (): void => { const availableModes = JSON.parse(container.getAttribute('data-available-modes') || '[]') const initialMode = container.getAttribute('data-initial-mode') || 'dub' const segments = JSON.parse(container.getAttribute('data-segments') || '[]') - const malIDFromPath = (() => { - const pathParts = window.location.pathname.split('/').filter(Boolean) - if (pathParts.length < 2) return '' - if (pathParts[0] !== 'watch') return '' - return pathParts[1] || '' - })() - const maxIntroStartSeconds = 180 const minOutroStartRatio = 0.5 const minSegmentDurationSeconds = 20 @@ -157,14 +82,11 @@ const initPlayer = (): void => { let pendingSeekTime: number | null = null let activeSkipSegment: { type: string, start: number, end: number } | null = null 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 let transitionEpisode: number | null = null const previewPopover = container.querySelector('[data-preview-popover]') as HTMLElement - const previewFrame = container.querySelector('[data-preview-frame]') as HTMLElement const previewTime = container.querySelector('[data-preview-time]') as HTMLElement const streamUrlForMode = (mode: string): string => { @@ -291,22 +213,6 @@ const initPlayer = (): void => { showControls() } - const streamSourceForMode = (mode: string): { url: string, referer: string } | null => { - const modeSource = modeSources[mode] - if (!modeSource) return null - const sourceURL = String(modeSource.url || '').trim() - if (sourceURL === '') return null - return { - url: sourceURL, - referer: String(modeSource.referer || ''), - } - } - - const previewCacheKey = (mode: string, sourceURL: string, sourceReferer: string): string => { - const normalizedReferer = sourceReferer.trim() - return `${mode}|${sourceURL}|${normalizedReferer}` - } - const hidePreviewPopover = (): void => { if (!previewPopover) return previewPopover.style.left = '0px' @@ -320,19 +226,8 @@ const initPlayer = (): void => { previewPopover.classList.add('block') } - const cueForTime = (map: PreviewMap, time: number): PreviewCue | null => { - if (!map.cues.length) return null - const match = map.cues.find((cue: PreviewCue) => time >= cue.start && time < cue.end) - if (match) return match - const first = map.cues[0] - const last = map.cues[map.cues.length - 1] - if (time <= first.start) return first - if (time >= last.end) return last - return null - } - const updatePreviewUI = (ratio: number): void => { - if (!progressWrap || !previewPopover || !previewFrame || !previewTime) return + if (!progressWrap || !previewPopover || !previewTime) return if (!video.duration || !Number.isFinite(video.duration)) { hidePreviewPopover() return @@ -340,80 +235,22 @@ const initPlayer = (): void => { const targetTime = Math.max(0, Math.min(video.duration, ratio * video.duration)) previewTime.textContent = formatTime(targetTime) - - const source = streamSourceForMode(currentMode) - if (!source || malIDFromPath === '') { - hidePreviewPopover() - return - } - - const cacheKey = previewCacheKey(currentMode, source.url, source.referer) - const cached = previewState[cacheKey] - if (!cached || !cached.map || !cached.spriteURL) { - hidePreviewPopover() - return - } - - const cue = cueForTime(cached.map, targetTime) - if (!cue) { - hidePreviewPopover() - return - } - - previewFrame.style.width = `${cue.width}px` - previewFrame.style.height = `${cue.height}px` - previewFrame.style.backgroundImage = `url('${cached.spriteURL}')` - previewFrame.style.backgroundRepeat = 'no-repeat' - previewFrame.style.backgroundPosition = `-${cue.x}px -${cue.y}px` - previewFrame.style.backgroundSize = `${cached.map.columns * cue.width}px ${cached.map.rows * cue.height}px` - const barWidth = progressWrap.clientWidth - const popoverOffset = ratio * barWidth - const halfWidth = cue.width / 2 - const clampedOffset = Math.max(halfWidth, Math.min(barWidth - halfWidth, popoverOffset)) - previewPopover.style.left = `${clampedOffset}px` + if (barWidth <= 0) { + hidePreviewPopover() + return + } showPreviewPopover() - } - - const loadPreviewMap = async (): Promise => { - if (!video.duration || !Number.isFinite(video.duration)) return - const source = streamSourceForMode(currentMode) - if (!source || malIDFromPath === '') return - - const cacheKey = previewCacheKey(currentMode, source.url, source.referer) - if (previewState[cacheKey]) return - - const token = previewRequestToken + 1 - previewRequestToken = token - - const query = new URLSearchParams({ - mal_id: malIDFromPath, - ep: currentEpisode, - mode: currentMode, - u: source.url, - r: source.referer, - d: String(video.duration), - }) - - try { - const response = await fetch(`${previewMapURL}?${query.toString()}`) - if (!response.ok) return - const payloadRaw: unknown = await response.json() - if (token !== previewRequestToken) return - const payload = parsePreviewMapResponse(payloadRaw) - if (!payload) return - - previewState = { - ...previewState, - [cacheKey]: { - spriteURL: payload.sprite_url, - map: payload.map, - }, - } - } catch { - return + let popoverWidth = 72 + if (previewPopover.offsetWidth > 0) { + popoverWidth = previewPopover.offsetWidth } + + const popoverOffset = ratio * barWidth + const halfWidth = popoverWidth / 2 + const clampedOffset = Math.max(halfWidth, Math.min(barWidth - halfWidth, popoverOffset)) + previewPopover.style.left = `${clampedOffset}px` } const saveProgress = async (): Promise => { @@ -624,7 +461,6 @@ const initPlayer = (): void => { const wasPlaying = !video.paused const previousTime = video.currentTime currentMode = mode - previewRequestToken += 1 hidePreviewPopover() video.src = streamUrlForMode(currentMode) video.load() @@ -720,7 +556,6 @@ const initPlayer = (): void => { } updateTimeline(video.currentTime) updateSkipButton(video.currentTime) - loadPreviewMap() }) video.addEventListener('waiting', () => { @@ -908,10 +743,6 @@ const initPlayer = (): void => { showControls() }) - progressWrap?.addEventListener('mouseenter', () => { - loadPreviewMap() - }) - progressWrap?.addEventListener('mousemove', (event) => { const rect = progressWrap.getBoundingClientRect() const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))