Compare commits
10 Commits
89e0120ca6
...
2619dc2c94
| Author | SHA1 | Date | |
|---|---|---|---|
| 2619dc2c94 | |||
| e675f125d4 | |||
| 4f6b534093 | |||
| b3c906a16e | |||
| 950e143faf | |||
| efbce87d5c | |||
| 28e8b322d0 | |||
| 6c45a80623 | |||
| 413ee70923 | |||
| 851c9d701f |
@@ -253,10 +253,32 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
|
||||
}
|
||||
|
||||
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{
|
||||
"Anime": anime,
|
||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||
"User": user,
|
||||
"Status": status,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"ContinueWatchingEp": ep,
|
||||
"ContinueWatchingTime": cwSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
type PlaybackService interface {
|
||||
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
|
||||
CompleteAnime(ctx context.Context, userID string, animeID int64) error
|
||||
ResolveProxyToken(token string) (string, string, error)
|
||||
}
|
||||
|
||||
@@ -34,5 +35,7 @@ type PlaybackRepository interface {
|
||||
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
|
||||
GetContinueWatchingEntry(ctx context.Context, params db.GetContinueWatchingEntryParams) (db.ContinueWatchingEntry, 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)
|
||||
DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type WatchlistService interface {
|
||||
UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error
|
||||
RemoveEntry(ctx context.Context, userID string, animeID int64) 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)
|
||||
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)
|
||||
DeleteWatchListEntry(ctx context.Context, arg db.DeleteWatchListEntryParams) 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)
|
||||
DeleteContinueWatchingEntry(ctx context.Context, arg db.DeleteContinueWatchingEntryParams) error
|
||||
SaveWatchProgress(ctx context.Context, arg db.SaveWatchProgressParams) error
|
||||
|
||||
@@ -36,6 +36,7 @@ func (h *PlaybackHandler) Register(r *gin.Engine) {
|
||||
log.Println("Registering playback routes")
|
||||
r.GET("/anime/:id/watch", h.HandleWatchPage)
|
||||
r.POST("/api/watch-progress", h.HandleSaveProgress)
|
||||
r.POST("/api/watch-complete", h.HandleWatchComplete)
|
||||
r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails)
|
||||
r.GET("/watch/proxy/stream", h.HandleProxyStream)
|
||||
r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
|
||||
@@ -111,6 +112,32 @@ func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
|
||||
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) {
|
||||
id, err := strconv.Atoi(c.Param("animeId"))
|
||||
if err != nil {
|
||||
|
||||
@@ -26,6 +26,14 @@ func (r *playbackRepository) SaveWatchProgress(ctx context.Context, params db.Sa
|
||||
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) {
|
||||
return r.queries.UpsertContinueWatchingEntry(ctx, params)
|
||||
}
|
||||
|
||||
func (r *playbackRepository) DeleteContinueWatchingEntry(ctx context.Context, params db.DeleteContinueWatchingEntryParams) error {
|
||||
return r.queries.DeleteContinueWatchingEntry(ctx, params)
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
// 3. Get start time from progress
|
||||
startTime := 0.0
|
||||
var watchlistStatus string
|
||||
var watchlistIDs []int64
|
||||
if userID != "" {
|
||||
entry, err := s.repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
|
||||
UserID: userID,
|
||||
@@ -195,6 +196,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
})
|
||||
if err == nil {
|
||||
watchlistStatus = entry.Status
|
||||
watchlistIDs = []int64{entry.AnimeID}
|
||||
if entry.CurrentEpisode.Valid && strconv.FormatInt(entry.CurrentEpisode.Int64, 10) == episode {
|
||||
startTime = entry.CurrentTimeSeconds
|
||||
}
|
||||
@@ -318,10 +320,42 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
|
||||
"Episodes": domainEpisodes,
|
||||
"CurrentEpID": episode,
|
||||
"WatchlistStatus": watchlistStatus,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
"Seasons": seasons,
|
||||
}, 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 {
|
||||
_, err := s.repo.UpsertContinueWatchingEntry(ctx, db.UpsertContinueWatchingEntryParams{
|
||||
ID: uuid.New().String(),
|
||||
|
||||
@@ -34,6 +34,10 @@ func (r *watchlistRepository) GetUserWatchList(ctx context.Context, userID strin
|
||||
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) {
|
||||
return r.queries.GetContinueWatchingEntry(ctx, arg)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]d
|
||||
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) {
|
||||
return s.repo.GetContinueWatchingEntry(ctx, db.GetContinueWatchingEntryParams{
|
||||
UserID: userID,
|
||||
|
||||
@@ -209,7 +209,7 @@ export const setupControls = (): void => {
|
||||
// mouse move in container shows controls
|
||||
state.container.addEventListener('mousemove', showControls);
|
||||
|
||||
// initial sync
|
||||
updatePlayPauseIcons(false);
|
||||
// initial sync — check actual video state since inline script may have started playback
|
||||
updatePlayPauseIcons(!state.video.paused);
|
||||
syncVolumeUI();
|
||||
};
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
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> => {
|
||||
if (state.completionSent || !state.malID || !episodeNumber) return;
|
||||
state.completionSent = true;
|
||||
@@ -20,7 +14,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||
|
||||
if (!res.ok) {
|
||||
state.completionSent = false;
|
||||
// retry
|
||||
if (state.completionAttempts < 2) {
|
||||
state.completionAttempts++;
|
||||
setTimeout(() => completeAnime(episodeNumber), 1000);
|
||||
@@ -28,7 +21,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// update dropdown trigger text
|
||||
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null;
|
||||
if (trigger) {
|
||||
trigger.textContent = 'Completed ';
|
||||
@@ -37,37 +29,6 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||
caret.textContent = '▾';
|
||||
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 {
|
||||
state.completionSent = false;
|
||||
if (state.completionAttempts < 2) {
|
||||
|
||||
@@ -66,9 +66,10 @@ const initPlayer = (): void => {
|
||||
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
||||
|
||||
// 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 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)}` : ''}`;
|
||||
}
|
||||
|
||||
@@ -87,7 +88,7 @@ const initPlayer = (): void => {
|
||||
updateAutoSkipButton();
|
||||
showControls();
|
||||
|
||||
state.video.addEventListener('loadedmetadata', () => {
|
||||
const onLoadedMetadata = (): void => {
|
||||
loading && (loading.style.display = 'none');
|
||||
invalidateBounds();
|
||||
|
||||
@@ -104,11 +105,19 @@ const initPlayer = (): void => {
|
||||
state.video.currentTime = state.pendingSeekTime;
|
||||
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);
|
||||
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', () => {
|
||||
loading && (loading.style.display = 'flex');
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{{if $anime.ShortRating}}<span class="flex items-center gap-1.5"><span>•</span>{{$anime.ShortRating}}</span>{{end}}
|
||||
</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="grow lg:max-w-4xl">
|
||||
|
||||
@@ -109,7 +109,20 @@
|
||||
const watchlistIds = new Set()
|
||||
|
||||
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) {
|
||||
@@ -169,10 +182,7 @@ if (window.showToast) showToast({ message: 'Something went wrong' })
|
||||
const statusDisplay = document.getElementById('watchlist-status-display-' + id)
|
||||
if (statusDisplay) {
|
||||
statusDisplay.textContent = inWatchlist ? 'Plan to Watch' : 'Add to Watchlist'
|
||||
const removeContainer = document.getElementById('remove-watchlist-container-' + id)
|
||||
if (removeContainer) {
|
||||
removeContainer.classList.toggle('hidden', !inWatchlist)
|
||||
}
|
||||
syncRemoveButtonVisibility(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</head>
|
||||
<body class="bg-background text-foreground">
|
||||
|
||||
@@ -20,19 +20,11 @@
|
||||
</div>
|
||||
{{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">
|
||||
{{range $i, $anime := .Animes}}
|
||||
{{$isThreshold := eq (add $i 1) (sub (len $.Animes) 8)}}
|
||||
{{if and $isThreshold $.HasNextPage}}
|
||||
<div hx-get="/browse?q={{$.Query}}&type={{$.Type}}&status={{$.Status}}&order_by={{$.OrderBy}}&sort={{$.Sort}}&sfw={{$.SFW}}&{{genresParams $.Genres}}&page={{$.NextPage}}"
|
||||
hx-trigger="revealed"
|
||||
hx-swap="afterend"
|
||||
hx-target="this"
|
||||
class="contents">
|
||||
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{range .Animes}}
|
||||
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
|
||||
{{end}}
|
||||
{{if .HasNextPage}}
|
||||
{{template "browse_sentinel" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -41,19 +33,18 @@
|
||||
{{end}}
|
||||
|
||||
{{define "anime_card_scroll"}}
|
||||
{{$count := len .Animes}}
|
||||
{{range $i, $anime := .Animes}}
|
||||
{{$isThreshold := eq (add $i 1) (sub $count 8)}}
|
||||
{{if and $isThreshold $.HasNextPage}}
|
||||
<div hx-get="/browse?q={{$.Query}}&type={{$.Type}}&status={{$.Status}}&order_by={{$.OrderBy}}&sort={{$.Sort}}&sfw={{$.SFW}}&{{genresParams $.Genres}}&page={{$.NextPage}}"
|
||||
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)}}
|
||||
{{range .Animes}}
|
||||
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
|
||||
{{end}}
|
||||
{{if .HasNextPage}}
|
||||
{{template "browse_sentinel" .}}
|
||||
{{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}}
|
||||
|
||||
@@ -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 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" />
|
||||
</a>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</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">
|
||||
{{$title}}
|
||||
</h3>
|
||||
|
||||
@@ -14,7 +14,27 @@
|
||||
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 class="border-accent size-10 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||
|
||||
@@ -36,80 +36,28 @@
|
||||
<span class="font-medium text-sm text-foreground">Dropped</span>
|
||||
</button>
|
||||
|
||||
<div id="remove-watchlist-container-{{$anime.MalID}}" class="{{if not $status}}hidden{{end}}">
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
<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}})">
|
||||
<span class="font-medium text-sm text-red-500 text-left whitespace-nowrap">Remove from Watchlist</span>
|
||||
</button>
|
||||
</div>
|
||||
{{template "watchlist_remove_button" dict
|
||||
"ID" $anime.MalID
|
||||
"ContainerClass" "hidden"
|
||||
"DividerClass" "my-1 h-px bg-border"
|
||||
"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"
|
||||
"SpanClass" "font-medium text-sm text-red-500 text-left whitespace-nowrap"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<i class="fa-solid fa-play mr-2"></i>
|
||||
Watch Now
|
||||
<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">
|
||||
{{if and .ContinueWatchingEp (ne .ContinueWatchingEp 1)}}Continue Episode {{.ContinueWatchingEp}}{{else}}Watch Now{{end}}
|
||||
</a>
|
||||
</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}}
|
||||
|
||||
{{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}}
|
||||
@@ -24,7 +24,6 @@
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
@@ -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)">
|
||||
<span class="text-sm text-foreground">Dropped</span>
|
||||
</button>
|
||||
<div class="border-t border-border my-1"></div>
|
||||
<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)">
|
||||
<span class="text-sm text-red-400 whitespace-nowrap">Remove from Watchlist</span>
|
||||
</button>
|
||||
{{template "watchlist_remove_button" dict
|
||||
"ID" $anime.MalID
|
||||
"ContainerClass" "hidden"
|
||||
"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>
|
||||
{{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>
|
||||
</ui-dropdown>
|
||||
</div>
|
||||
@@ -179,66 +165,6 @@
|
||||
{{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>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user