Compare commits

...

10 Commits

Author SHA1 Message Date
2619dc2c94 fix: autoplay video instantly on watch page load 2026-05-13 23:48:09 +02:00
e675f125d4 fix: replace revealed sentinel with intersect once for infinite scroll 2026-05-13 20:55:45 +02:00
4f6b534093 refactor: extract watchlist remove button into shared component 2026-05-13 19:08:13 +02:00
b3c906a16e fix: centralize watchlist dropdown js and fix page load timing 2026-05-13 19:05:10 +02:00
950e143faf fix: clean up completion flow and watch page dropdown 2026-05-13 18:44:08 +02:00
efbce87d5c feat: set status to completed on anime completion
Check existing watchlist status — if already completed, just clear
progress and remove from continue watching. Otherwise upsert with
status 'completed'.
2026-05-13 18:28:33 +02:00
28e8b322d0 feat: add watch-complete endpoint
Removes continue_watching_entry and clears progress when the last
episode finishes so it no longer shows in Continue Watching.
2026-05-13 18:22:18 +02:00
6c45a80623 fix: pass watchlist status to anime detail page
Anime detail page never looked up or passed the user's watchlist
status, so the dropdown always showed 'Add to Watchlist'. Now
queries watch_list_entry and passes Status and WatchlistIDs.
2026-05-13 18:18:22 +02:00
413ee70923 feat: use saved progress for watch button on anime page
Check continue_watching_entry to find the episode to resume from.
Show 'Continue Episode N' instead of 'Watch Now' when progress exists.
2026-05-13 18:16:25 +02:00
851c9d701f feat: link continue watching cards to saved episode
Include ?ep=N in the watch links so clicking a continue watching
card loads the correct episode and resumes from saved progress.
2026-05-13 18:16:19 +02:00
18 changed files with 258 additions and 235 deletions

View File

@@ -253,10 +253,32 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
} }
user, _ := c.Get("User") user, _ := c.Get("User")
status := ""
var watchlistIDs []int64
ep := 1
var cwSeconds float64
if u, ok := user.(*domain.User); ok {
entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), u.ID, int64(id))
if err == nil {
status = entry.Status
watchlistIDs = []int64{entry.AnimeID}
}
cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), u.ID, int64(id))
if err == nil && cwEntry.CurrentEpisode.Valid {
ep = int(cwEntry.CurrentEpisode.Int64)
cwSeconds = cwEntry.CurrentTimeSeconds
}
}
c.HTML(http.StatusOK, "anime.gohtml", gin.H{ c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"Anime": anime, "Anime": anime,
"CurrentPath": fmt.Sprintf("/anime/%d", id), "CurrentPath": fmt.Sprintf("/anime/%d", id),
"User": user, "User": user,
"Status": status,
"WatchlistIDs": watchlistIDs,
"ContinueWatchingEp": ep,
"ContinueWatchingTime": cwSeconds,
}) })
} }

View File

@@ -8,6 +8,7 @@ import (
type PlaybackService interface { type PlaybackService interface {
BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error)
SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error
CompleteAnime(ctx context.Context, userID string, animeID int64) error
ResolveProxyToken(token string) (string, string, error) ResolveProxyToken(token string) (string, string, error)
} }
@@ -34,5 +35,7 @@ type PlaybackRepository interface {
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error SaveWatchProgress(ctx context.Context, params db.SaveWatchProgressParams) error
UpsertWatchListEntry(ctx context.Context, params db.UpsertWatchListEntryParams) (db.WatchListEntry, error)
UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error
} }

View File

@@ -12,6 +12,7 @@ type WatchlistService interface {
UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error
RemoveEntry(ctx context.Context, userID string, animeID int64) error RemoveEntry(ctx context.Context, userID string, animeID int64) error
GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error) GetWatchlist(ctx context.Context, userID string) ([]UserWatchListRow, error)
GetWatchListEntry(ctx context.Context, userID string, animeID int64) (WatchlistEntry, error)
GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error) GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error)
DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error DeleteContinueWatching(ctx context.Context, userID string, animeID int64) error
} }
@@ -22,6 +23,7 @@ type WatchlistRepository interface {
UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error) UpsertWatchListEntry(ctx context.Context, arg db.UpsertWatchListEntryParams) (db.WatchListEntry, error)
DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) error
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error)
DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error
SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error

View File

@@ -36,6 +36,7 @@ func (h *PlaybackHandler) Register(r *gin.Engine) {
log.Println("Registering playback routes") log.Println("Registering playback routes")
r.GET("/anime/:id/watch", h.HandleWatchPage) r.GET("/anime/:id/watch", h.HandleWatchPage)
r.POST("/api/watch-progress", h.HandleSaveProgress) r.POST("/api/watch-progress", h.HandleSaveProgress)
r.POST("/api/watch-complete", h.HandleWatchComplete)
r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails) r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails)
r.GET("/watch/proxy/stream", h.HandleProxyStream) r.GET("/watch/proxy/stream", h.HandleProxyStream)
r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle) r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
@@ -111,6 +112,32 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
c.Status(http.StatusOK) c.Status(http.StatusOK)
} }
func (h *PlaybackHandler) HandleWatchComplete(c *gin.Context) {
user, _ := c.Get("User")
userID := ""
if u, ok := user.(*domain.User); ok {
userID = u.ID
}
var req struct {
MalID int64 `json:"mal_id"`
Episode int `json:"episode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.Status(http.StatusBadRequest)
return
}
err := h.svc.CompleteAnime(c.Request.Context(), userID, req.MalID)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
}
func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) { func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
id, err := strconv.Atoi(c.Param("animeId")) id, err := strconv.Atoi(c.Param("animeId"))
if err != nil { if err != nil {

View File

@@ -26,6 +26,14 @@ func (r *playbackRepository) SaveWatchProgress(ctx context.Context, params db.Sa
return r.queries.SaveWatchProgress(ctx, params) return r.queries.SaveWatchProgress(ctx, params)
} }
func (r *playbackRepository) UpsertWatchListEntry(ctx context.Context, params db.UpsertWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.UpsertWatchListEntry(ctx, params)
}
func (r *playbackRepository) UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) { func (r *playbackRepository) UpsertContinueWatchingEntry(ctx context.Context, params db.UpsertContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) {
return r.queries.UpsertContinueWatchingEntry(ctx, params) return r.queries.UpsertContinueWatchingEntry(ctx, params)
} }
func (r *playbackRepository) DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error {
return r.queries.DeleteContinueWatchingEntry(ctx, params)
}

View File

@@ -188,6 +188,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
// 3. Get start time from progress // 3. Get start time from progress
startTime := 0.0 startTime := 0.0
var watchlistStatus string var watchlistStatus string
var watchlistIDs []int64
if userID != "" { if userID != "" {
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{ entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID, UserID: userID,
@@ -195,6 +196,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
}) })
if err == nil { if err == nil {
watchlistStatus = entry.Status watchlistStatus = entry.Status
watchlistIDs = []int64{entry.AnimeID}
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode { if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode {
startTime = entry.CurrentTimeSeconds startTime = entry.CurrentTimeSeconds
} }
@@ -318,10 +320,42 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
"Episodes": domainEpisodes, "Episodes": domainEpisodes,
"CurrentEpID": episode, "CurrentEpID": episode,
"WatchlistStatus": watchlistStatus, "WatchlistStatus": watchlistStatus,
"WatchlistIDs": watchlistIDs,
"Seasons": seasons, "Seasons": seasons,
}, nil }, nil
} }
func (s *playbackService) CompleteAnime(ctx context.Context, userID string, animeID int64) error {
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
if err != nil || entry.Status != "completed" {
_, err = s.repo.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: "completed",
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
})
if err != nil {
return err
}
}
_ = s.repo.DeleteContinueWatchingEntry(ctx, db.DeleteContinueWatchingEntryParams{
UserID: userID,
AnimeID: animeID,
})
return s.repo.SaveWatchProgress(ctx, db.SaveWatchProgressParams{
UserID: userID,
AnimeID: animeID,
CurrentEpisode: sql.NullInt64{Valid: false},
CurrentTimeSeconds: 0,
})
}
func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error { func (s *playbackService) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error {
_, err := s.repo.UpsertContinueWatchingEntry(ctx, db.UpsertContinueWatchingEntryParams{ _, err := s.repo.UpsertContinueWatchingEntry(ctx, db.UpsertContinueWatchingEntryParams{
ID: uuid.New().String(), ID: uuid.New().String(),

View File

@@ -34,6 +34,10 @@ func (r *watchlistRepository) GetUserWatchList(ctx context.Context, userID strin
return r.queries.GetUserWatchList(ctx, userID) return r.queries.GetUserWatchList(ctx, userID)
} }
func (r *watchlistRepository) GetWatchListEntry(ctx context.Context, arg db.GetWatchListEntryParams) (db.WatchListEntry, error) {
return r.queries.GetWatchListEntry(ctx, arg)
}
func (r *watchlistRepository) GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) { func (r *watchlistRepository) GetContinueWatchingEntry(ctx context.Context, arg db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, error) {
return r.queries.GetContinueWatchingEntry(ctx, arg) return r.queries.GetContinueWatchingEntry(ctx, arg)
} }

View File

@@ -55,6 +55,13 @@ func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]d
return s.repo.GetUserWatchList(ctx, userID) return s.repo.GetUserWatchList(ctx, userID)
} }
func (s *watchlistService) GetWatchListEntry(ctx context.Context, userID string, animeID int64) (db.WatchListEntry, error) {
return s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
}
func (s *watchlistService) GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error) { func (s *watchlistService) GetContinueWatchingEntry(ctx context.Context, userID string, animeID int64) (db.ContinueWatchingEntry, error) {
return s.repo.GetContinueWatchingEntry(ctx, db.GetContinueWatchingEntryParams{ return s.repo.GetContinueWatchingEntry(ctx, db.GetContinueWatchingEntryParams{
UserID: userID, UserID: userID,

View File

@@ -209,7 +209,7 @@ export const setupControls = (): void => {
// mouse move in container shows controls // mouse move in container shows controls
state.container.addEventListener('mousemove', showControls); state.container.addEventListener('mousemove', showControls);
// initial sync // initial sync — check actual video state since inline script may have started playback
updatePlayPauseIcons(false); updatePlayPauseIcons(!state.video.paused);
syncVolumeUI(); syncVolumeUI();
}; };

View File

@@ -1,11 +1,5 @@
import DOMPurify from 'dompurify';
import { state } from '../state'; import { state } from '../state';
/**
* Marks anime as completed when final episode finishes.
* Calls completion API, updates dropdown UI, adds to watchlist.
* Retries up to 2 times on failure.
*/
export const completeAnime = async (episodeNumber: number): Promise<void> => { export const completeAnime = async (episodeNumber: number): Promise<void> => {
if (state.completionSent || !state.malID || !episodeNumber) return; if (state.completionSent || !state.malID || !episodeNumber) return;
state.completionSent = true; state.completionSent = true;
@@ -20,7 +14,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
if (!res.ok) { if (!res.ok) {
state.completionSent = false; state.completionSent = false;
// retry
if (state.completionAttempts < 2) { if (state.completionAttempts < 2) {
state.completionAttempts++; state.completionAttempts++;
setTimeout(() => completeAnime(episodeNumber), 1000); setTimeout(() => completeAnime(episodeNumber), 1000);
@@ -28,7 +21,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
return; return;
} }
// update dropdown trigger text
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null; const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null;
if (trigger) { if (trigger) {
trigger.textContent = 'Completed '; trigger.textContent = 'Completed ';
@@ -37,37 +29,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
caret.textContent = '▾'; caret.textContent = '▾';
trigger.appendChild(caret); trigger.appendChild(caret);
} }
// add to watchlist with 'completed' status
const dropdown = document.getElementById('watch-status-dropdown');
if (dropdown) {
const payload = {
anime_id: String(state.malID),
anime_title: state.container.dataset.animeTitle ?? '',
anime_title_english: state.container.dataset.animeTitleEnglish ?? '',
anime_title_japanese: state.container.dataset.animeTitleJapanese ?? '',
anime_image: state.container.dataset.animeImage ?? '',
status: 'completed',
airing: state.container.dataset.animeAiring === 'true',
};
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'HX-Request': 'true' },
body: `anime_id=${encodeURIComponent(payload.anime_id)}&anime_title=${encodeURIComponent(payload.anime_title)}&anime_title_english=${encodeURIComponent(payload.anime_title_english)}&anime_title_japanese=${encodeURIComponent(payload.anime_title_japanese)}&anime_image=${encodeURIComponent(payload.anime_image)}&status=${encodeURIComponent(payload.status)}&airing=${encodeURIComponent(String(payload.airing))}`,
credentials: 'same-origin',
})
.then(async res => {
if (!res.ok) return;
// replace dropdown with HTMX response
const html = await res.text();
const wrapper = document.createElement('span');
wrapper.id = 'watch-status-dropdown';
wrapper.innerHTML = DOMPurify.sanitize(html);
dropdown.replaceWith(wrapper);
})
.catch(() => {});
}
} catch { } catch {
state.completionSent = false; state.completionSent = false;
if (state.completionAttempts < 2) { if (state.completionAttempts < 2) {

View File

@@ -66,9 +66,10 @@ const initPlayer = (): void => {
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null; const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
// build video src from mode, token, and saved quality preference // build video src from mode, token, and saved quality preference
// Only set if not already provided by the inline script during HTML parsing
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'; const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
const streamToken = state.modeSources[state.currentMode]?.token; const streamToken = state.modeSources[state.currentMode]?.token;
if (streamToken) { if (!state.video.src && streamToken) {
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`; state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
} }
@@ -87,7 +88,7 @@ const initPlayer = (): void => {
updateAutoSkipButton(); updateAutoSkipButton();
showControls(); showControls();
state.video.addEventListener('loadedmetadata', () => { const onLoadedMetadata = (): void => {
loading && (loading.style.display = 'none'); loading && (loading.style.display = 'none');
invalidateBounds(); invalidateBounds();
@@ -104,11 +105,19 @@ const initPlayer = (): void => {
state.video.currentTime = state.pendingSeekTime; state.video.currentTime = state.pendingSeekTime;
state.pendingSeekTime = null; state.pendingSeekTime = null;
} }
if (state.shouldAutoPlay) state.video.play().catch(() => {}); // autoplay if not already playing (inline script may have already called play())
if (state.shouldAutoPlay || state.video.paused) state.video.play().catch(() => {});
updateTimeline(state.video.currentTime); updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime); updateSkipButton(state.video.currentTime);
}); };
state.video.addEventListener('loadedmetadata', onLoadedMetadata);
// inline script runs during HTML parsing before initPlayer; if metadata
// already loaded, fire the handler immediately
if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
onLoadedMetadata();
}
state.video.addEventListener('waiting', () => { state.video.addEventListener('waiting', () => {
loading && (loading.style.display = 'flex'); loading && (loading.style.display = 'flex');

View File

@@ -85,7 +85,7 @@
{{if $anime.ShortRating}}<span class="flex items-center gap-1.5"><span>•</span>{{$anime.ShortRating}}</span>{{end}} {{if $anime.ShortRating}}<span class="flex items-center gap-1.5"><span>•</span>{{$anime.ShortRating}}</span>{{end}}
</div> </div>
{{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status}} {{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status "ContinueWatchingEp" .ContinueWatchingEp "ContinueWatchingTime" .ContinueWatchingTime}}
<div class="flex flex-col gap-12 lg:flex-row"> <div class="flex flex-col gap-12 lg:flex-row">
<div class="grow lg:max-w-4xl"> <div class="grow lg:max-w-4xl">

View File

@@ -109,7 +109,20 @@
const watchlistIds = new Set() const watchlistIds = new Set()
function initWatchlist(ids) { function initWatchlist(ids) {
ids.forEach(id => watchlistIds.add(id)) ids.forEach(id => watchlistIds.add(id));
const sync = () => ids.forEach(id => syncRemoveButtonVisibility(id));
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', sync);
} else {
sync();
}
}
function syncRemoveButtonVisibility(id) {
const container = document.getElementById('remove-watchlist-container-' + id);
if (container) {
container.classList.toggle('hidden', !watchlistIds.has(id));
}
} }
function toggleWatchlist(id, btn) { function toggleWatchlist(id, btn) {
@@ -169,10 +182,7 @@ if (window.showToast) showToast({ message: 'Something went wrong' })
const statusDisplay = document.getElementById('watchlist-status-display-' + id) const statusDisplay = document.getElementById('watchlist-status-display-' + id)
if (statusDisplay) { if (statusDisplay) {
statusDisplay.textContent = inWatchlist ? 'Plan to Watch' : 'Add to Watchlist' statusDisplay.textContent = inWatchlist ? 'Plan to Watch' : 'Add to Watchlist'
const removeContainer = document.getElementById('remove-watchlist-container-' + id) syncRemoveButtonVisibility(id)
if (removeContainer) {
removeContainer.classList.toggle('hidden', !inWatchlist)
}
} }
} }
@@ -186,6 +196,57 @@ if (window.showToast) showToast({ message: 'Something went wrong' })
} }
}) })
} }
function updateWatchlist(id, status, display, btn) {
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ animeId: id, status: status })
}).then(res => {
if (res.ok) {
watchlistIds.add(id);
document.getElementById('watchlist-status-display-' + id).textContent = display;
syncRemoveButtonVisibility(id);
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button');
if (button) {
const malId = button.dataset.malId;
if (malId && parseInt(malId) === id) {
button.classList.add('in-watchlist');
}
}
});
requestAnimationFrame(() => {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
});
}
});
}
function removeWatchlist(id, btn) {
fetch('/api/watchlist/' + id, { method: 'DELETE' }).then(res => {
if (res.ok) {
watchlistIds.delete(id);
document.getElementById('watchlist-status-display-' + id).textContent = 'Add to Watchlist';
syncRemoveButtonVisibility(id);
if (window.showToast) showToast({ message: 'Removed from watchlist' });
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button');
if (button) {
const malId = button.dataset.malId;
if (malId && parseInt(malId) === id) {
button.classList.remove('in-watchlist');
}
}
});
if (btn) {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
}
}
});
}
</script> </script>
</head> </head>
<body class="bg-background text-foreground"> <body class="bg-background text-foreground">

View File

@@ -20,19 +20,11 @@
</div> </div>
{{else}} {{else}}
<div id="anime-grid" class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6"> <div id="anime-grid" class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{{range $i, $anime := .Animes}} {{range .Animes}}
{{$isThreshold := eq (add $i 1) (sub (len $.Animes) 8)}} {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
{{if and $isThreshold $.HasNextPage}} {{end}}
<div hx-get="/browse?q={{$.Query}}&type={{$.Type}}&status={{$.Status}}&order_by={{$.OrderBy}}&sort={{$.Sort}}&sfw={{$.SFW}}&{{genresParams $.Genres}}&page={{$.NextPage}}" {{if .HasNextPage}}
hx-trigger="revealed" {{template "browse_sentinel" .}}
hx-swap="afterend"
hx-target="this"
class="contents">
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
</div>
{{else}}
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
{{end}}
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
@@ -41,19 +33,18 @@
{{end}} {{end}}
{{define "anime_card_scroll"}} {{define "anime_card_scroll"}}
{{$count := len .Animes}} {{range .Animes}}
{{range $i, $anime := .Animes}} {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
{{$isThreshold := eq (add $i 1) (sub $count 8)}} {{end}}
{{if and $isThreshold $.HasNextPage}} {{if .HasNextPage}}
<div hx-get="/browse?q={{$.Query}}&type={{$.Type}}&status={{$.Status}}&order_by={{$.OrderBy}}&sort={{$.Sort}}&sfw={{$.SFW}}&{{genresParams $.Genres}}&page={{$.NextPage}}" {{template "browse_sentinel" .}}
hx-trigger="revealed"
hx-swap="afterend"
hx-target="this"
class="contents">
{{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}}
</div>
{{else}}
{{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}}
{{end}}
{{end}} {{end}}
{{end}} {{end}}
{{define "browse_sentinel"}}
<div hx-get="/browse?q={{.Query}}&type={{.Type}}&status={{.Status}}&order_by={{.OrderBy}}&sort={{.Sort}}&sfw={{.SFW}}&{{genresParams .Genres}}&page={{.NextPage}}"
hx-trigger="intersect once"
hx-swap="outerHTML"
hx-target="this"
class="col-span-full h-px"></div>
{{end}}

View File

@@ -9,7 +9,7 @@
<div id="continue-watching-{{.AnimeID}}" class="continue-watching-item group relative w-70 shrink-0 snap-start space-y-2 2xl:w-lg"> <div id="continue-watching-{{.AnimeID}}" class="continue-watching-item group relative w-70 shrink-0 snap-start space-y-2 2xl:w-lg">
<div class="bg-background/80 relative aspect-video w-full overflow-hidden"> <div class="bg-background/80 relative aspect-video w-full overflow-hidden">
<a href="/anime/{{.AnimeID}}/watch" class="block h-full w-full"> <a href="/anime/{{.AnimeID}}/watch{{if .CurrentEpisode.Valid}}?ep={{.CurrentEpisode.Int64}}{{end}}" class="block h-full w-full">
<img src="{{if .ImageUrl}}{{.ImageUrl}}{{else}}https://placehold.co/500x500{{end}}" alt="{{$title}}" class="h-full w-full object-cover" /> <img src="{{if .ImageUrl}}{{.ImageUrl}}{{else}}https://placehold.co/500x500{{end}}" alt="{{$title}}" class="h-full w-full object-cover" />
</a> </a>
@@ -29,7 +29,7 @@
</div> </div>
<div> <div>
<a href="/anime/{{.AnimeID}}/watch" class="block"> <a href="/anime/{{.AnimeID}}/watch{{if .CurrentEpisode.Valid}}?ep={{.CurrentEpisode.Int64}}{{end}}" class="block">
<h3 class="text-foreground truncate text-lg font-normal"> <h3 class="text-foreground truncate text-lg font-normal">
{{$title}} {{$title}}
</h3> </h3>

View File

@@ -14,7 +14,27 @@
class="group relative aspect-video w-full overflow-hidden bg-black"> class="group relative aspect-video w-full overflow-hidden bg-black">
<video class="h-full w-full cursor-pointer" preload="metadata" playsinline autoplay></video> <video class="h-full w-full cursor-pointer" preload="metadata" playsinline></video>
<script>
(function() {
var p = document.currentScript.closest('[data-video-player]');
var v = p.querySelector('video');
var sources = JSON.parse(p.getAttribute('data-mode-sources') || '{}');
var mode = p.getAttribute('data-initial-mode') || 'dub';
var stored = localStorage.getItem('player-audio-mode');
if (stored && sources[stored] && sources[stored].token) mode = stored;
var src = sources[mode];
if (!src || !src.token) {
for (var k in sources) {
if (sources[k] && sources[k].token) { src = sources[k]; mode = k; break; }
}
}
if (src && src.token) {
v.src = '/watch/proxy/stream?mode=' + encodeURIComponent(mode) + '&token=' + encodeURIComponent(src.token);
v.play().catch(function() {});
}
})();
</script>
<div data-loading class="absolute inset-0 flex items-center justify-center bg-black/60 hidden z-50"> <div data-loading class="absolute inset-0 flex items-center justify-center bg-black/60 hidden z-50">
<div class="border-accent size-10 animate-spin rounded-full border-4 border-t-transparent"></div> <div class="border-accent size-10 animate-spin rounded-full border-4 border-t-transparent"></div>

View File

@@ -36,80 +36,28 @@
<span class="font-medium text-sm text-foreground">Dropped</span> <span class="font-medium text-sm text-foreground">Dropped</span>
</button> </button>
<div id="remove-watchlist-container-{{$anime.MalID}}" class="{{if not $status}}hidden{{end}}"> {{template "watchlist_remove_button" dict
<div class="my-1 h-px bg-border"></div> "ID" $anime.MalID
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10 focus:bg-red-500/10" onclick="removeWatchlist({{$anime.MalID}})"> "ContainerClass" "hidden"
<span class="font-medium text-sm text-red-500 text-left whitespace-nowrap">Remove from Watchlist</span> "DividerClass" "my-1 h-px bg-border"
</button> "ButtonClass" "flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10 focus:bg-red-500/10"
</div> "SpanClass" "font-medium text-sm text-red-500 text-left whitespace-nowrap"
}}
</div> </div>
</div> </div>
</ui-dropdown> </ui-dropdown>
<a href="/anime/{{$anime.MalID}}/watch" class="bg-background-button hover:bg-background-button-hover px-5 py-2.5 text-sm font-medium text-foreground transition-colors"> <a href="/anime/{{$anime.MalID}}/watch{{if and .ContinueWatchingEp (ne .ContinueWatchingEp 1)}}?ep={{.ContinueWatchingEp}}{{end}}" class="bg-background-button hover:bg-background-button-hover px-5 py-2.5 text-sm font-medium text-foreground transition-colors">
<i class="fa-solid fa-play mr-2"></i> {{if and .ContinueWatchingEp (ne .ContinueWatchingEp 1)}}Continue Episode {{.ContinueWatchingEp}}{{else}}Watch Now{{end}}
Watch Now
</a> </a>
</div> </div>
<script>
function updateWatchlist(id, status, display, btn) {
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ animeId: id, status: status })
}).then(res => {
if (res.ok) {
watchlistIds.add(id);
document.getElementById('watchlist-status-display-' + id).textContent = display;
document.getElementById('remove-watchlist-container-' + id).classList.remove('hidden');
// Update all watchlist icons on the page
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button')
if (button) {
const malId = button.dataset.malId
if (malId && parseInt(malId) === id) {
button.classList.add('in-watchlist')
}
}
});
// Close dropdown after a small delay to let click event finish
requestAnimationFrame(() => {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
});
}
});
}
function removeWatchlist(id) {
fetch('/api/watchlist/' + id, { method: 'DELETE' }).then(res => {
if (res.ok) {
watchlistIds.delete(id);
document.getElementById('watchlist-status-display-' + id).textContent = 'Add to Watchlist';
document.getElementById('remove-watchlist-container-' + id).classList.add('hidden');
// Update all watchlist icons on the page
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button')
if (button) {
const malId = button.dataset.malId
if (malId && parseInt(malId) === id) {
button.classList.remove('in-watchlist')
}
}
});
// Close dropdown
const btn = document.getElementById('watchlist-status-display-' + id);
if (btn) {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
}
}
});
}
</script>
{{end}} {{end}}
{{define "watchlist_remove_button"}}
<div id="remove-watchlist-container-{{.ID}}" class="{{.ContainerClass}}">
<div class="{{.DividerClass}}"></div>
<button class="{{.ButtonClass}}" onclick="removeWatchlist({{.ID}}, this)">
<span class="{{.SpanClass}}">Remove from Watchlist</span>
</button>
</div>
{{end}}

View File

@@ -24,7 +24,6 @@
</button> </button>
</div> </div>
<div data-content class="hidden absolute z-50 min-w-40 bg-background-button shadow-2xl right-0 top-full mt-2"> <div data-content class="hidden absolute z-50 min-w-40 bg-background-button shadow-2xl right-0 top-full mt-2">
{{if .WatchlistStatus}}
<div class="flex flex-col py-1"> <div class="flex flex-col py-1">
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'watching', 'Watching', this)"> <button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'watching', 'Watching', this)">
<span class="text-sm text-foreground">Watching</span> <span class="text-sm text-foreground">Watching</span>
@@ -38,27 +37,14 @@
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'dropped', 'Dropped', this)"> <button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'dropped', 'Dropped', this)">
<span class="text-sm text-foreground">Dropped</span> <span class="text-sm text-foreground">Dropped</span>
</button> </button>
<div class="border-t border-border my-1"></div> {{template "watchlist_remove_button" dict
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10" onclick="removeWatchlist({{$anime.MalID}}, this)"> "ID" $anime.MalID
<span class="text-sm text-red-400 whitespace-nowrap">Remove from Watchlist</span> "ContainerClass" "hidden"
</button> "DividerClass" "border-t border-border my-1"
"ButtonClass" "flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10"
"SpanClass" "text-sm text-red-400 whitespace-nowrap"
}}
</div> </div>
{{else}}
<div class="flex flex-col py-1">
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'watching', 'Watching', this)">
<span class="text-sm text-foreground">Watching</span>
</button>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'completed', 'Completed', this)">
<span class="text-sm text-foreground">Completed</span>
</button>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'plan_to_watch', 'Plan to Watch', this)">
<span class="text-sm text-foreground">Plan to Watch</span>
</button>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-surface-hover" onclick="updateWatchlist({{$anime.MalID}}, 'dropped', 'Dropped', this)">
<span class="text-sm text-foreground">Dropped</span>
</button>
</div>
{{end}}
</div> </div>
</ui-dropdown> </ui-dropdown>
</div> </div>
@@ -179,66 +165,6 @@
{{end}} {{end}}
{{end}} {{end}}
<script>
function updateWatchlist(id, status, display, btn) {
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ animeId: id, status: status })
}).then(res => {
if (res.ok) {
watchlistIds.add(id);
document.getElementById('watchlist-status-display-' + id).textContent = display;
// Update all watchlist icons on the page
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button')
if (button) {
const malId = button.dataset.malId
if (malId && parseInt(malId) === id) {
button.classList.add('in-watchlist')
}
}
});
// Close dropdown after a small delay to let click event finish
requestAnimationFrame(() => {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
});
}
});
}
function removeWatchlist(id, btn) {
fetch('/api/watchlist/' + id, { method: 'DELETE' }).then(res => {
if (res.ok) {
watchlistIds.delete(id);
document.getElementById('watchlist-status-display-' + id).textContent = 'Add to Watchlist';
if (window.showToast) showToast({ message: 'Removed from watchlist' });
// Update all watchlist icons on the page
document.querySelectorAll('.watchlist-icon').forEach(function(icon) {
const button = icon.closest('button')
if (button) {
const malId = button.dataset.malId
if (malId && parseInt(malId) === id) {
button.classList.remove('in-watchlist')
}
}
});
// Close dropdown
if (btn) {
const dropdown = btn.closest('ui-dropdown');
if (dropdown) dropdown.close();
}
}
});
}
</script>
</div> </div>
</div> </div>
{{end}} {{end}}