PageSourceSearch

https://immersionfeed.com/app.js?v=20260723-my-communities1

js immersionfeed.com collected 2026-10-02 01:32:10 UTC 293,713 bytes, 7,127 lines download raw bytes

1const tabs = [
2  { id: "new", label: "New" },
3  { id: "watch_later", label: "Watch Later" },
4  { id: "watched", label: "Watched", showCount: false },
5  { id: "skipped", label: "Skipped", showCount: false },
6];
7
8const PAGE_SIZE = 50;
9const IMAGE_PRELOAD_TIMEOUT_MS = 1500;
10const PULL_REFRESH_THRESHOLD = 76;
11const PULL_REFRESH_MAX_OFFSET = 52;
12const DOWNLOAD_POLL_INTERVAL_MS = 5000;
13const STUDY_SYNC_INTERVAL_MS = 250;
14const STUDY_EXTENSION_CAPTION_WAIT_MS = 12000;
15const STUDY_DICTIONARY_STORAGE_KEY = "immersion-feed:study-dictionary:v1";
16const STUDY_CAPTION_POSITION_STORAGE_KEY = "immersion-feed:study-caption-position:v1";
17const STUDY_QUEUE_STORAGE_KEY = "immersion-feed:study-queue:v1";
18const STUDY_QUEUE_PAGE_SIZE = 300;
19const STUDY_QUEUE_MAX_ITEMS = 5000;
20const STUDY_DICTIONARY_CLOSE_MS = 200;
21const STUDY_DICTIONARY_CACHE_MAX = 64;
22const STUDY_WAV_SAMPLE_RATE = 16000;
23const STUDY_WAV_CHUNK_SECONDS = 60;
24const STUDY_AUDIO_SOURCE_MAX_BYTES = 96 * 1024 * 1024;
25const STUDY_AUDIO_MAX_DURATION_SECONDS = 60 * 60;
26const STUDY_AUDIO_MAX_CHUNK_SAMPLE_VALUES = 12 * 1024 * 1024;
27const STUDY_AUDIO_PROBE_TIMEOUT_MS = 10 * 60 * 1000 + 15000;
28const JMDICT_SOURCE_URL = "https://www.edrdg.org/wiki/JMdict-EDICT_Dictionary_Project.html";
29const JMDICT_LICENSE_URL = "https://www.edrdg.org/edrdg/licence.html";
30const YOUTUBE_PLAYER_STATE_PLAYING = 1;
31const DISCOVER_CATEGORY_LABELS = Object.freeze({
32  "": "All",
33  comedy: "Comedy",
34  lifestyle: "Lifestyle",
35  talk: "Talk",
36  travel: "Travel",
37  food: "Food",
38  gaming: "Gaming",
39});
40const DISCOVER_FEATURED_COMMUNITY_COUNT = 3;
41const LEVEL_BADGES = Object.freeze([
42  { values: ["beginner"], label: "🌱 Beginner-friendly" },
43  { values: ["intermediate"], label: "📚 Intermediate" },
44  { values: ["advanced", "native", "mature"], label: "⚡ Native speed" },
45]);
46const LEVEL_TAG_VALUES = new Set(LEVEL_BADGES.flatMap((badge) => badge.values));
47const FEED_SNAPSHOT_PREFIX = "immersion-feed:feed:v1:";
48const FEED_SNAPSHOT_MAX_AGE_MS = 6 * 60 * 60 * 1000;
49const SOURCE_SNAPSHOT_PREFIX = "immersion-feed:sources:v1:";
50const SOURCE_SNAPSHOT_FRESH_AGE_MS = 60 * 1000;
51const SOURCE_SNAPSHOT_MAX_AGE_MS = 5 * 60 * 1000;
52const SOURCE_SNAPSHOT_MAX_ENTRIES = 12;
53const SECTION_PATHS = Object.freeze({
54  feed: "/",
55  discover: "/discover",
56  "my-sources": "/sources",
57  saved: "/saved",
58  downloads: "/downloads",
59  study: "/study",
60  profile: "/profile",
61});
62const LENGTH_PRESETS = [
63  { id: "any", label: "Any", min: "", max: "" },
64  { id: "under-10", label: "Under 10 min", min: "", max: "600" },
65  { id: "10-20", label: "10–20", min: "600", max: "1200" },
66  { id: "20-40", label: "20–40", min: "1200", max: "2400" },
67  { id: "40-plus", label: "40+", min: "2400", max: "" },
68];
69const OPTIONAL_TOPICS = ["vlogs", "podcasts", "anime", "food", "gaming", "grammar"];
70const SHOW_ADMIN_CHANNEL_UI = false;
71const REFRESH_USERNAME = "duelarm";
72const QUEUE_COACH_STORAGE_KEY = "if-coach-queue-v1";
73const FEED_CUTOFF_PROMPT_THRESHOLD = 200;
74const QUEUE_TAB_COUNT_CAP = 99;
75const RECOVERABLE_TELEMETRY_DEDUPE_MS = 5 * 60 * 1000;
76const RECOVERABLE_TELEMETRY_MAX_DEDUPE_ENTRIES = 50;
77const RECOVERABLE_TELEMETRY_MAX_MESSAGE_LENGTH = 500;
78const RECOVERABLE_TELEMETRY_MAX_DETAIL_LENGTH = 160;
79const recoverableTelemetrySeen = new Map();
80
81const state = {
82  activeSection: "feed",
83  activeTab: "new",
84  channel: "",
85  channelLabel: "",
86  tag: "",
87  minDuration: "",
88  maxDuration: "",
89  search: "",
90  channels: [],
91  total: null,
92  videos: [],
93  featured: null,
94  nextOffset: 0,
95  hasMore: false,
96  counts: { new: 0, watch_later: 0, watched: 0, skipped: 0 },
97  availableTopics: [],
98  stats: null,
99  health: null,
100  authUser: null,
101  sessionHydrated: false,
102  sourceOnboarding: null,
103  communities: [],
104  communitiesStatus: "loading",
105  discoverCommunitiesExpanded: false,
106  activeDiscoverTag: "",
107  discoverSearch: "",
108  discoverSources: [],
109  discoverHasMore: false,
110  discoverNextOffset: 0,
111  discoverTotalCount: null,
112  discoverCatalogTotal: null,
113  discoverTagCounts: {},
114  communityPreview: { id: null, community: null, sources: [], sampleVideos: [], playingSampleId: null, hasMore: false, nextOffset: 0, loading: false },
115  mySources: { direct: [], community: [], hidden: [] },
116  mySourcesFlash: null,
117  sourceDismissUndo: null,
118  refreshInFlight: false,
119  pullRefreshInFlight: false,
120  videosInFlight: false,
121  sourceInFlight: false,
122  adminChannelsLoaded: false,
123  downloadDevices: [],
124  downloadDevicesLoaded: false,
125  personalHelperToken: "",
126  downloadJobs: [],
127  downloadsLoading: false,
128  study: null,
129  studyLoading: false,
130  studyWhisperLoading: false,
131  studyQueue: null,
132  studyQueueLoading: false,
133  studyStateSaving: false,
134  studyAudioProbe: null,
135  studyUrlLoading: false,
136  studyActiveCueIndex: -1,
137  studyCaptionOverlayVisible: true,
138  studyDictionaryEnabled: true,
139  studyMigakuCoverage: null,
140  savedWords: [],
141  savedSentences: [],
142  savedStudyTab: "words",
143  savedStudyLoading: false,
144  savedStudyError: "",
145  hideShortVideos: false,
146  hiddenShortVideosCount: 0,
147  hideLongVideos: false,
148  hiddenLongVideosCount: 0,
149  studyModeEnabled: false,
150  feedCutoff: null,
151  feedCutoffPromptDismissed: false,
152  feedCutoffConfirmed: false,
153  feedCutoffInFlight: false,
154  preferenceSaving: false,
155  preferenceStatus: "Saved to your account.",
156  longVideoPreferenceStatus: "Saved to your account.",
157  studyModePreferenceStatus: "Saved to your account.",
158};
159
160const elements = {};
161const stagedFilters = { channel: "", tag: "", minDuration: "", maxDuration: "", search: "" };
162let filterCountVersion = 0;
163let videoRequestVersion = 0;
164let discoverRequestVersion = 0;
165let mySourcesRequestVersion = 0;
166let channelBusy = false;
167let downloadPollTimer = null;
168let studyPlayer = null;
169let studyPlayerVideoId = "";
170let studySyncTimer = null;
171let studyStatusPollTimer = null;
172let studyTranscriptSignature = "";
173let studyRequestVersion = 0;
174let studyUrlRequestVersion = 0;
175let studyQueueRequestVersion = 0;
176let studyWhisperRequestVideoId = "";
177let studyAudioProbeTimeout = null;
178const studyExtensionCaptionInFlight = new Set();
179const studyExtensionCaptionWaiters = new Map();
180const studyExtensionCaptionWaiting = new Set();
181const studyHelperCaptionInFlight = new Set();
182const studyHelperCaptionVideos = new Set();
183let studyDictionaryRequestVersion = 0;
184let studyDictionaryTrigger = null;
185let studyDictionaryPausedPlayer = null;
186let studyWordHoverPausedPlayer = null;
187const studyMigakuPopupBindings = new WeakSet();
188const studyMigakuObservedRoots = new WeakSet();
189let studyDictionaryCloseTimer = null;
190let studyDictionaryOpenFrame = null;
191const studyDictionarySessionCache = new Map();
192let savedStudyRequestVersion = 0;
193const savedWordRemoveInFlight = new Set();
194let studyCaptionOverlayPosition = 0.5;
195let studyCaptionOverlayDrag = null;
196let studyCaptionOverlaySuppressClick = false;
197let youtubeIframeApiPromise = null;
198let syncMobileTabbar = () => {};
199const recentlyAddedSources = new Set();
200
201window.addEventListener("message", handleStudyExtensionCaptionMessage);
202window.addEventListener("message", handleStudyAudioProbeMessage);
203window.addEventListener("message", handleStudyMigakuCoverageMessage);
204
205document.addEventListener("DOMContentLoaded", () => {
206  for (const id of [
207    "featuredHero", "featuredMedia", "featuredThumbnail", "featuredPlaceholder", "featuredWatch",
208    "featuredDuration", "featuredTitle", "featuredDescription", "featuredTags", "featuredLater",
209    "guestProofCard", "guestProofLink", "guestProofThumbnail", "guestProofPlaceholder", "guestProofDuration",
210    "guestProofEyebrow", "guestProofTitle", "guestProofMeta", "guestHowItWorks", "guestHowToggle", "guestHowSteps", "guestClosingCta",
211    "sectionFeed", "feed", "feedEyebrow", "feedTitle", "feedMeta", "feedPager", "feedLoading", "homeSourceUndo", "homeSourceUndoMessage", "homeSourceUndoButton", "notice", "noticeMessage", "noticeDismiss",
212    "queueCoach", "queueCoachDismiss", "feedCutoffNotice", "feedCutoffFooter",
213    "studyUrlTrigger", "studyUrlDialog", "studyUrlClose", "studyUrlForm", "studyUrlInput", "studyUrlError", "studyUrlSubmit", "studyUrlSubmitLabel",
214    "pullRefresh", "pullRefreshIcon", "pullRefreshText", "pullRefreshButton",
215    "refreshButton", "refreshStatus", "headerStats", "headerMinutes", "headerStatsLabel", "headerAvatar", "headerThemeToggle",
216    "accountName", "authSignedOut", "authSignedIn", "filterTrigger", "filterBadge", "filterOverlay",
217    "filterSheet", "filterReset", "filterSourceChips", "filterLengthChips", "filterTagInput",
218    "filterSearchInput", "filterApply",
219    "minutesToday", "videosToday", "minutesWeek", "guestBanner",
220    "communitySubtitle", "communityGuestNote", "communitySignupLink", "communityDirectory",
221    "communityDirectoryMeta", "communityDirectoryEyebrow", "communityDirectoryTitle", "communityDirectoryCopy",
222    "communityDirectoryToggle", "discoverList", "discoverSourcesEyebrow", "discoverSourcesTitle", "discoverSourcesCopy",
223    "discoverMeta", "discoverPager", "discoverSearch", "sourceOnboardingPanel", "sourceOnboardingTitle", "sourceOnboardingCopy",
224    "sourceOnboardingProgress", "sourceOnboardingStatus", "sourceOnboardingSkip", "sourceOnboardingContinue",
225    "sourceComposerTrigger", "sourceComposerModal", "sourceComposerClose", "sourceComposerSignedOutCta", "sourceForm", "sourceUrl", "sourceLinkStep",
226    "sourcePreviewButton", "sourcePreviewButtonLabel", "sourcePreview", "sourcePreviewMeta", "sourceStartOver", "sourceHandle",
227    "sourceChannelId", "sourceCanonicalUrl", "sourceDisplayName", "sourceTags", "sourceNote", "sourcePrivate",
228    "sourceSubmit", "sourceFormTarget",
229    "communityPreviewModal", "communityPreviewTitle", "communityPreviewCopy", "communityPreviewClose",
230    "communityPreviewMeta", "communityPreviewSamples", "communityPreviewList", "communityPreviewPager", "communityPreviewFooterMeta",
231    "communityPreviewJoinSlot",
232    "mySourcesList", "mySourcesMeta", "mySourcesGate", "mySourcesUndo",
233    "downloadsList", "downloadsMeta", "studyTitle", "studySubtitle", "studyStatus", "studyContent", "studyBackButton",
234    "studyQueueToolbar", "studyPreviousButton", "studyNextButton", "studyQueuePosition",
235    "studyWatchedButton", "studyLaterButton", "studySkipButton",
236    "studyPlayerShell", "studyPlayer", "studyPlayerMessage", "studyCaptionOverlay", "studyCaptionOverlayBubble",
237    "studyCaptionOverlayText", "studyCueList", "studyTranscriptHint", "studyShareButton",
238    "studyTranscriptSource", "studyTranscriptSourceDetail", "studyTranscriptStatus", "studyHelperButton", "studyWhisperButton",
239    "studyHelperReadiness", "studyHelperReadinessTitle", "studyHelperReadinessCopy",
240    "studyVocabularyCoverage", "studyVocabularyPercent", "studyVocabularyDetail", "studyVocabularyCounts",
241    "studySettingsButton", "studySettingsDialog", "studySettingsClose", "studyDictionaryToggle", "studyDictionaryPreferenceStatus", "studyReloadButton",
242    "studyDictionaryDialog", "studyDictionaryTitle", "studyDictionaryContext", "studyDictionaryBody", "studyDictionaryClose",
243    "savedTitle", "savedSubtitle", "savedStatus", "savedTabs", "savedWordsTab", "savedSentencesTab",
244    "savedWordsPanel", "savedSentencesPanel", "savedWordsList", "savedSentencesList",
245    "savedLoading", "savedEmpty", "savedError", "savedRetry",
246    "profileTitle", "profileSubtitle",
247    "profileSignedOut", "profileDashboard", "profileTodayMinutes", "profileTodayVideos",
248    "profileSevenDayMinutes", "profileSevenDayVideos", "profileThirtyDayMinutes", "profileThirtyDayVideos",
249    "profileUsername", "profileLogout", "profileAdmin", "profileThemeSlot", "profileHideShortVideos", "profileShortVideoStatus",
250    "profileHideLongVideos", "profileLongVideoStatus", "profileStudyMode", "profileStudyModeStatus",
251    "profileRecoveryEmail", "profileEmailStatus",
252    "profileEmailBadge", "profileEmailForm", "profileEmail", "profileEmailCurrentPassword", "profileEmailSubmit",
253    "profileEmailSubmitLabel", "profileEmailCancel", "profileEmailEdit", "profileEmailMessage",
254    "personalHelper", "personalHelperBadge", "personalHelperEmpty", "personalHelperName", "personalHelperCreate",
255    "personalHelperConnected", "personalHelperStatus", "personalHelperRevoke", "personalHelperToken",
256    "personalHelperTokenValue", "personalHelperCopy", "personalHelperMessage", "adminReviewSection",
257    "reviewStatus", "reviewList", "adminChannelsSection", "channelStatus", "channelAddForm", "channelList",
258  ]) {
259    elements[id] = document.getElementById(id);
260  }
261
262  setupSectionRouting();
263  setupMobileTabbar();
264  setupPullToRefresh();
265  document.querySelectorAll("[data-tab]").forEach((button) => {
266    button.addEventListener("click", () => {
267      state.activeTab = button.dataset.tab;
268      renderTabs();
269      loadVideos();
270    });
271  });
272  elements.filterTrigger?.addEventListener("click", () => {
273    if (elements.filterOverlay?.hidden) openFilterSheet();
274    else closeFilterSheet();
275  });
276  elements.filterOverlay?.addEventListener("click", (event) => {
277    if (event.target.closest("[data-filter-dismiss]")) closeFilterSheet();
278  });
279  document.addEventListener("keydown", (event) => {
280    if (event.key === "Escape" && elements.filterOverlay && !elements.filterOverlay.hidden) closeFilterSheet();
281    if (event.key === "Escape") closeVideoSourceMenus({ restoreFocus: true });
282  });
283  elements.filterReset?.addEventListener("click", resetStagedFilters);
284  elements.filterApply?.addEventListener("click", applyStagedFilters);
285  elements.filterSourceChips?.addEventListener("click", (event) => {
286    const chip = event.target.closest("[data-filter-source]");
287    if (!chip) return;
288    stagedFilters.channel = chip.dataset.filterSource;
289    renderFilterSheet();
290    refreshFilterCount();
291  });
292  elements.filterLengthChips?.addEventListener("click", (event) => {
293    const chip = event.target.closest("[data-filter-length]");
294    if (!chip) return;
295    const preset = LENGTH_PRESETS.find((item) => item.id === chip.dataset.filterLength);
296    stagedFilters.minDuration = preset?.min || "";
297    stagedFilters.maxDuration = preset?.max || "";
298    renderFilterSheet();
299    refreshFilterCount();
300  });
301  elements.filterTagInput?.addEventListener("input", debounce(() => {
302    stagedFilters.tag = elements.filterTagInput.value.trim();
303    refreshFilterCount();
304  }, 220));
305  elements.filterSearchInput?.addEventListener("input", debounce(() => {
306    stagedFilters.search = elements.filterSearchInput.value.trim();
307    refreshFilterCount();
308  }, 220));
309  elements.profileHideShortVideos?.addEventListener("change", () => {
310    saveShortVideoPreference(elements.profileHideShortVideos.checked);
311  });
312  elements.profileHideLongVideos?.addEventListener("change", () => {
313    saveLongVideoPreference(elements.profileHideLongVideos.checked);
314  });
315  elements.profileStudyMode?.addEventListener("change", () => {
316    saveStudyModePreference(elements.profileStudyMode.checked);
317  });
318  elements.queueCoachDismiss?.addEventListener("click", dismissQueueCoach);
319  window.addEventListener("resize", () => {
320    if (elements.filterOverlay && !elements.filterOverlay.hidden) positionFilterSheet();
321  });
322  elements.refreshButton?.addEventListener("click", () => refreshNow(true));
323  elements.noticeDismiss?.addEventListener("click", clearNotice);
324  elements.sourceComposerTrigger?.addEventListener("click", openSourceComposer);
325  elements.sourceComposerClose?.addEventListener("click", () => closeSourceComposer({ restoreFocus: true }));
326  elements.sourceComposerModal?.addEventListener("click", (event) => {
327    if (event.target === elements.sourceComposerModal) closeSourceComposer({ restoreFocus: true });
328  });
329  elements.sourceComposerModal?.addEventListener("cancel", (event) => {
330    event.preventDefault();
331    closeSourceComposer({ restoreFocus: true });
332  });
333  elements.sourceComposerModal?.addEventListener("close", syncSourceComposerState);
334  elements.communityPreviewClose?.addEventListener("click", () => closeCommunityPreview({ restoreFocus: true }));
335  elements.communityPreviewModal?.addEventListener("click", (event) => {
336    if (event.target === elements.communityPreviewModal) closeCommunityPreview({ restoreFocus: true });
337  });
338  elements.communityPreviewModal?.addEventListener("cancel", (event) => {
339    event.preventDefault();
340    closeCommunityPreview({ restoreFocus: true });
341  });
342  elements.communityPreviewModal?.addEventListener("close", syncCommunityPreviewState);
343  state.studyDictionaryEnabled = readStudyDictionaryEnabled();
344  studyCaptionOverlayPosition = readStudyCaptionOverlayPosition();
345  syncStudyDictionaryPreference();
346  elements.studyCaptionOverlayBubble?.addEventListener("pointerdown", startStudyCaptionOverlayDrag);
347  elements.studyCaptionOverlayBubble?.addEventListener("pointermove", moveStudyCaptionOverlayDrag);
348  elements.studyCaptionOverlayBubble?.addEventListener("pointerup", finishStudyCaptionOverlayDrag);
349  elements.studyCaptionOverlayBubble?.addEventListener("pointercancel", finishStudyCaptionOverlayDrag);
350  elements.studyCaptionOverlayBubble?.addEventListener("click", suppressStudyCaptionOverlayClick, true);
351  window.addEventListener("resize", applyStudyCaptionOverlayPosition, { passive: true });
352  elements.studySettingsButton?.addEventListener("click", openStudySettings);
353  elements.studySettingsClose?.addEventListener("click", closeStudySettings);
354  elements.studyReloadButton?.addEventListener("click", reloadStudyPage);
355  elements.studySettingsDialog?.addEventListener("cancel", (event) => {
356    event.preventDefault();
357    closeStudySettings();
358  });
359  elements.studySettingsDialog?.addEventListener("close", restoreStudySettingsFocus);
360  elements.studyDictionaryToggle?.addEventListener("change", () => {
361    setStudyDictionaryEnabled(elements.studyDictionaryToggle.checked);
362  });
363  elements.studyDictionaryClose?.addEventListener("click", closeStudyDictionary);
364  elements.studyDictionaryDialog?.addEventListener("cancel", (event) => {
365    event.preventDefault();
366    closeStudyDictionary();
367  });
368  elements.studyDictionaryDialog?.addEventListener("click", handleStudyDictionaryBackdropClick);
369  elements.studyDictionaryDialog?.addEventListener("close", restoreStudyDictionaryFocus);
370  elements.studyDictionaryDialog?.addEventListener("keydown", trapStudyDictionaryFocus);
371  elements.studyUrlTrigger?.addEventListener("click", openStudyUrlDialog);
372  elements.studyUrlClose?.addEventListener("click", () => closeStudyUrlDialog({ restoreFocus: true }));
373  elements.studyUrlDialog?.addEventListener("cancel", (event) => {
374    event.preventDefault();
375    closeStudyUrlDialog({ restoreFocus: true });
376  });
377  elements.studyUrlDialog?.addEventListener("click", (event) => {
378    if (event.target === elements.studyUrlDialog) closeStudyUrlDialog({ restoreFocus: true });
379  });
380  elements.studyUrlDialog?.addEventListener("close", syncStudyUrlDialogState);
381  elements.studyUrlInput?.addEventListener("input", clearStudyUrlError);
382  elements.studyUrlForm?.addEventListener("submit", submitStudyUrl);
383  elements.studyBackButton?.addEventListener("click", leaveStudy);
384  document.addEventListener("keydown", handleStudyKeyboardShortcut);
385  const debouncedDiscoverSearch = debounce(() => {
386    state.discoverSearch = elements.discoverSearch.value.trim();
387    loadDiscover();
388  }, 300);
389  elements.discoverSearch?.addEventListener("input", debouncedDiscoverSearch);
390  elements.discoverSearch?.addEventListener("search", () => {
391    state.discoverSearch = elements.discoverSearch.value.trim();
392    loadDiscover();
393  });
394  elements.sourceForm?.addEventListener("submit", submitSource);
395  elements.sourceSubmit?.addEventListener("click", approveSource);
396  elements.sourceStartOver?.addEventListener("click", resetSourceForm);
397  elements.profileLogout?.addEventListener("click", logoutFromProfile);
398  elements.personalHelperCreate?.addEventListener("click", createPersonalHelper);
399  elements.personalHelperRevoke?.addEventListener("click", revokePersonalHelper);
400  elements.personalHelperCopy?.addEventListener("click", copyPersonalHelperToken);
401  elements.profileEmailForm?.addEventListener("submit", saveRecoveryEmail);
402  elements.profileEmailEdit?.addEventListener("click", editRecoveryEmail);
403  elements.profileEmailCancel?.addEventListener("click", renderRecoveryEmail);
404  elements.channelAddForm?.addEventListener("submit", addAdminChannel);
405  elements.channelList?.addEventListener("click", handleAdminChannelClick);
406  document.addEventListener("click", handleDelegatedClick);
407  document.addEventListener("mouseover", handleStudyWordMouseOver);
408  document.addEventListener("mouseout", handleStudyWordMouseOut);
409  observeStudyMigakuPopups();
410
411  boot();
412  setupFeedPager();
413  window.setInterval(() => {
414    if (canRefreshFeed()) refreshNow(false);
415  }, 20 * 60 * 1000);
416});
417
418function canRefreshFeed() {
419  return state.authUser?.can_refresh === true
420    && String(state.authUser?.username || "").trim().toLowerCase() === REFRESH_USERNAME;
421}
422
423async function boot() {
424  const initialSection = sectionFromLocation();
425  const hadLegacySection = new URLSearchParams(window.location.search).has("section");
426  if (initialSection !== "feed" || hadLegacySection) {
427    switchSection(initialSection, {
428      historyMode: hadLegacySection ? "replace" : "none",
429      loadData: false,
430    });
431  }
432  await loadSession();
433  let allowedInitialSection = ["downloads", "study", "saved"].includes(initialSection) && !state.authUser
434    ? "feed"
435    : initialSection;
436  if (["study", "saved"].includes(allowedInitialSection) && state.authUser && !canAccessStudy()) {
437    allowedInitialSection = "feed";
438  }
439  if (allowedInitialSection !== initialSection) {
440    switchSection("feed", { historyMode: "replace", loadData: false });
441  }
442  const homeLoads = Promise.all([loadHealth(), loadChannels(), loadStats(), loadVideos()]);
443  const communityLoad = loadCommunities();
444  await loadSourceOnboarding();
445  const params = new URLSearchParams(window.location.search);
446  const onboardingRequested = ["communities", "sources"].includes(params.get("onboarding"));
447  const shouldStartOnboarding = state.authUser
448    && shouldShowSourceOnboarding(state.sourceOnboarding);
449  if (onboardingRequested || shouldStartOnboarding) {
450    switchSection("discover", { historyMode: "replace" });
451  } else if (allowedInitialSection !== "feed" || hadLegacySection) {
452    switchSection(allowedInitialSection, { historyMode: "none" });
453  }
454  await Promise.all([homeLoads, communityLoad]);
455  renderCommunityGate();
456}
457
458async function api(path, options = {}) {
459  const method = String(options.method || "GET").toUpperCase();
460  const response = await fetch(path, {
461    headers: { "Content-Type": "application/json", ...(options.headers || {}) },
462    ...options,
463  });
464  const data = await response.json().catch(() => ({}));
465  if (!response.ok) {
466    if (response.status === 401) {
467      clearFeedSnapshots();
468      clearSourceSnapshots();
469      state.authUser = null;
470      renderAuth();
471    }
472    throw new Error(data.detail || data.message || `Request failed: ${response.status}`);
473  }
474  if (method !== "GET") {
475    clearFeedSnapshots();
476    clearSourceSnapshots();
477  }
478  return data;
479}
480
481function normalizeClientTelemetryText(value, maxLength) {
482  return String(value ?? "")
483    .replace(/\s+/g, " ")
484    .trim()
485    .slice(0, maxLength);
486}
487
488function captureRecoverableError(operation, error, details = {}) {
489  const safeOperation = normalizeClientTelemetryText(operation, 64)
490    .toLowerCase()
491    .replace(/[^a-z0-9:_-]+/g, "_") || "unknown_operation";
492  const message = normalizeClientTelemetryText(
493    error?.message || error || "Recoverable background request failed.",
494    RECOVERABLE_TELEMETRY_MAX_MESSAGE_LENGTH,
495  );
496  const safeDetails = {};
497  for (const [key, value] of Object.entries(details).slice(0, 8)) {
498    const safeKey = normalizeClientTelemetryText(key, 40).replace(/[^a-zA-Z0-9_-]+/g, "_");
499    if (!safeKey || value === undefined || value === null) continue;
500    safeDetails[safeKey] = typeof value === "number" || typeof value === "boolean"
501      ? value
502      : normalizeClientTelemetryText(value, RECOVERABLE_TELEMETRY_MAX_DETAIL_LENGTH);
503  }
504  const page = normalizeClientTelemetryText(window.location?.pathname || "/", 120) || "/";
505  const dedupeKey = `${safeOperation}:${message}`;
506  const now = Date.now();
507  const previous = recoverableTelemetrySeen.get(dedupeKey) || 0;
508  if (now - previous < RECOVERABLE_TELEMETRY_DEDUPE_MS) return;
509  if (!recoverableTelemetrySeen.has(dedupeKey)
510    && recoverableTelemetrySeen.size >= RECOVERABLE_TELEMETRY_MAX_DEDUPE_ENTRIES) {
511    recoverableTelemetrySeen.delete(recoverableTelemetrySeen.keys().next().value);
512  }
513  recoverableTelemetrySeen.set(dedupeKey, now);
514
515  const payload = {
516    event: "recoverable_client_error",
517    operation: safeOperation,
518    page,
519    message,
520    details: safeDetails,
521    occurred_at: new Date(now).toISOString(),
522  };
523  console.warn("[Immersion Feed] recoverable background error", payload);
524  fetch("/api/telemetry", {
525    method: "POST",
526    credentials: "omit",
527    headers: { "Content-Type": "application/json" },
528    body: JSON.stringify(payload),
529    keepalive: true,
530  }).catch(() => {
531    // Telemetry must never create another user-facing failure.
532  });
533}
534
535async function loadSession() {
536  try {
537    state.authUser = (await api("/api/auth/me")).user || null;
538  } catch (_error) {
539    state.authUser = null;
540  }
541  state.sessionHydrated = true;
542  state.hideShortVideos = Boolean(state.authUser?.hide_short_videos);
543  state.hideLongVideos = Boolean(state.authUser?.hide_long_videos);
544  state.studyModeEnabled = Boolean(state.authUser?.study_mode_enabled);
545  state.preferenceStatus = state.authUser ? "Saved to your account." : "";
546  state.longVideoPreferenceStatus = state.authUser ? "Saved to your account." : "";
547  state.studyModePreferenceStatus = state.authUser ? "Saved to your account." : "";
548  renderAuth();
549}
550
551function canAccessStudy() {
552  return Boolean(state.authUser?.is_admin && state.authUser?.study_mode_enabled);
553}
554
555function renderShortVideoPreference() {
556  const signedIn = Boolean(state.authUser);
557  const preferences = [
558    {
559      value: state.hideShortVideos,
560      inputs: [elements.profileHideShortVideos],
561      statuses: [elements.profileShortVideoStatus],
562      statusText: state.preferenceStatus,
563    },
564    {
565      value: state.hideLongVideos,
566      inputs: [elements.profileHideLongVideos],
567      statuses: [elements.profileLongVideoStatus],
568      statusText: state.longVideoPreferenceStatus,
569    },
570    {
571      value: state.studyModeEnabled,
572      inputs: [elements.profileStudyMode],
573      statuses: [elements.profileStudyModeStatus],
574      statusText: state.studyModePreferenceStatus,
575    },
576  ];
577  for (const preference of preferences) {
578    for (const input of preference.inputs) {
579      if (!input) continue;
580      input.checked = preference.value;
581      input.disabled = !signedIn || state.preferenceSaving;
582      input.setAttribute("aria-busy", String(state.preferenceSaving));
583    }
584    for (const status of preference.statuses) {
585      if (status) status.textContent = preference.statusText;
586    }
587  }
588}
589
590async function saveVideoPreference({ nextValue, stateKey, apiKey, statusKey }) {
591  if (!state.authUser || state.preferenceSaving || nextValue === state[stateKey]) {
592    renderShortVideoPreference();
593    return;
594  }
595  const previousValue = state[stateKey];
596  state[stateKey] = Boolean(nextValue);
597  state.preferenceSaving = true;
598  state[statusKey] = "Saving…";
599  renderShortVideoPreference();
600  let saved = false;
601  try {
602    const data = await api("/api/me/preferences", {
603      method: "PATCH",
604      body: JSON.stringify({ [apiKey]: state[stateKey] }),
605    });
606    state[stateKey] = Boolean(data.preferences?.[apiKey]);
607    state.authUser[apiKey] = state[stateKey];
608    state[statusKey] = "Saved to your account.";
609    saved = true;
610  } catch (error) {
611    state[stateKey] = previousValue;
612    state[statusKey] = "Could not save. Your previous choice is still active.";
613    showNotice(error.message, "error");
614  } finally {
615    state.preferenceSaving = false;
616    renderShortVideoPreference();
617  }
618  if (saved) {
619    await loadVideos();
620    if (elements.filterOverlay && !elements.filterOverlay.hidden) updateFilterCount();
621  }
622}
623
624async function saveShortVideoPreference(nextValue) {
625  return saveVideoPreference({
626    nextValue,
627    stateKey: "hideShortVideos",
628    apiKey: "hide_short_videos",
629    statusKey: "preferenceStatus",
630  });
631}
632
633async function saveLongVideoPreference(nextValue) {
634  return saveVideoPreference({
635    nextValue,
636    stateKey: "hideLongVideos",
637    apiKey: "hide_long_videos",
638    statusKey: "longVideoPreferenceStatus",
639  });
640}
641
642async function saveStudyModePreference(nextValue) {
643  const result = await saveVideoPreference({
644    nextValue,
645    stateKey: "studyModeEnabled",
646    apiKey: "study_mode_enabled",
647    statusKey: "studyModePreferenceStatus",
648  });
649  renderAuth();
650  if (!canAccessStudy() && ["study", "saved"].includes(state.activeSection)) {
651    switchSection("feed", { historyMode: "replace" });
652  }
653  renderFeatured();
654  return result;
655}
656
657function normalizeSourceOnboarding(data = {}, sources = []) {
658  const directSourceCount = Number(
659    data.direct_source_count
660      ?? data.source_count
661      ?? (Array.isArray(sources) ? sources.length : 0),
662  );
663  const joinedCommunityCount = Number(data.joined_community_count ?? 0);
664  const directSourceTarget = Number(data.direct_source_target ?? data.target_count ?? 3);
665  const readyReason = data.ready_reason
666    || (joinedCommunityCount > 0 ? "community" : directSourceCount >= directSourceTarget ? "direct" : "none");
667  const isReady = Boolean(data.is_ready ?? (readyReason !== "none" || joinedCommunityCount > 0 || directSourceCount >= directSourceTarget));
668  return {
669    state: data.state || (isReady ? "completed" : directSourceCount > 0 ? "active" : "pending"),
670    direct_source_count: directSourceCount,
671    joined_community_count: joinedCommunityCount,
672    direct_source_target: directSourceTarget,
673    is_ready: isReady,
674    ready_reason: readyReason,
675  };
676}
677
678function shouldShowSourceOnboarding(onboarding) {
679  return Boolean(
680    onboarding
681      && ["pending", "active"].includes(onboarding.state),
682  );
683}
684
685function rawCommunityLabelsFor(source, preferDirect = false) {
686  if (preferDirect) {
687    return Array.isArray(source.also_from_communities) ? source.also_from_communities : [];
688  }
689  if (Array.isArray(source.joined_communities)) {
690    return source.joined_communities;
691  }
692  if (Array.isArray(source.communities)) {
693    return source.communities;
694  }
695  if (source.community_name) {
696    return [{ name: source.community_name, slug: source.community_slug }];
697  }
698  return [];
699}
700
701function communityLabelsFor(source, preferDirect = false) {
702  const values = rawCommunityLabelsFor(source, preferDirect);
703  return values
704    .map((community) => {
705      if (typeof community === "string") return { name: community, slug: community };
706      return {
707        name: community.name || community.community_name || community.slug || "Community",
708        slug: community.slug || community.community_slug || community.name || "community",
709      };
710    })
711    .filter((community, index, items) => community.name && items.findIndex((item) => item.name === community.name) === index);
712}
713
714function normalizeMySourcesPayload(data = {}) {
715  if (Array.isArray(data.direct_sources) || Array.isArray(data.community_sources) || Array.isArray(data.muted_sources)) {
716    return {
717      direct: Array.isArray(data.direct_sources) ? data.direct_sources : [],
718      community: Array.isArray(data.community_sources) ? data.community_sources : [],
719      hidden: Array.isArray(data.muted_sources) ? data.muted_sources : [],
720    };
721  }
722  return {
723    direct: Array.isArray(data.sources) ? data.sources : [],
724    community: [],
725    hidden: [],
726  };
727}
728
729function applyMySourcesPayload(data, { renderList = false } = {}) {
730  state.mySources = normalizeMySourcesPayload(data);
731  state.sourceOnboarding = normalizeSourceOnboarding(
732    data?.onboarding || state.sourceOnboarding || {},
733    state.mySources.direct,
734  );
735  renderSourceOnboarding();
736  renderCommunityDirectory();
737  if (!renderList) return;
738  renderMySources();
739}
740
741async function loadSourceOnboarding() {
742  if (!state.authUser) {
743    state.sourceOnboarding = null;
744    renderSourceOnboarding();
745    return;
746  }
747  const requestVersion = ++mySourcesRequestVersion;
748  const snapshotKey = sourceSnapshotKey("my-sources");
749  const snapshot = readSourceSnapshot(snapshotKey);
750  if (snapshot) {
751    applyMySourcesPayload(snapshot.data);
752    if (snapshot.isFresh) return;
753  }
754  try {
755    const data = await api("/api/me/sources");
756    writeSourceSnapshot(snapshotKey, data);
757    if (requestVersion !== mySourcesRequestVersion) return;
758    applyMySourcesPayload(data);
759  } catch (error) {
760    if (!snapshot) {
761      state.sourceOnboarding = null;
762      captureRecoverableError("load_source_onboarding", error);
763    }
764  }
765}
766
767async function loadHealth() {
768  try {
769    state.health = await api("/api/health");
770    const last = state.health.last_successful_refresh || state.health.last_refresh;
771    elements.refreshStatus.textContent = last?.finished_at
772      ? `Updated ${formatDateTime(last.finished_at)}`
773      : "Waiting for the first refresh";
774    renderRefreshControls();
775    if (!state.health.youtube_configured) {
776      captureRecoverableError(
777        "youtube_refresh_unavailable",
778        "YouTube is not configured; saved feed content remains available.",
779      );
780    } else if (state.health.last_refresh?.status === "failed") {
781      captureRecoverableError(
782        "youtube_refresh_failed",
783        state.health.last_refresh.message || "The last refresh did not finish.",
784        { last_successful_at: state.health.last_successful_refresh?.finished_at || "" },
785      );
786    }
787  } catch (error) {
788    captureRecoverableError("load_health", error);
789  }
790}
791
792async function loadCommunities() {
793  try {
794    const data = await api("/api/communities");
795    const communities = Array.isArray(data.communities) ? data.communities : [];
796    state.communities = communities
797      .filter((community) => community && community.is_joinable !== 0)
798      .map((community) => ({
799        ...community,
800        source_count: Number(community.source_count || 0),
801        member_count: Number(community.member_count || 0),
802        is_joined: Boolean(community.is_joined),
803        preview_sources: Array.isArray(community.preview_sources) ? community.preview_sources : [],
804        topic_tags: Array.isArray(community.topic_tags) ? community.topic_tags : [],
805    }));
806    state.communitiesStatus = "ready";
807    renderCommunityDirectory();
808    renderDiscoverContext();
809    if (state.activeSection === "my-sources" && state.authUser) renderMySources();
810  } catch (error) {
811    if (state.communitiesStatus !== "ready") state.communitiesStatus = "error";
812    if (state.activeSection === "my-sources" && state.authUser) renderMySources();
813    captureRecoverableError("load_communities", error);
814  }
815}
816
817async function loadChannels() {
818  const useEligibleSources = Boolean(state.authUser);
819  const path = useEligibleSources ? "/api/me/eligible-sources" : "/api/channels";
820  try {
821    const data = await api(path);
822    const items = useEligibleSources
823      ? (Array.isArray(data.sources) ? data.sources : [])
824      : (Array.isArray(data.channels) ? data.channels : []);
825    state.channels = items.map((channel) => {
826      const labels = [];
827      if (channel.is_added) labels.push("Direct");
828      if (communityLabelsFor(channel).length) labels.push("Community");
829      return {
830        ...channel,
831        filter_label: labels.length
832          ? `${channel.display_name || channel.source_key} · ${labels.join(" + ")}`
833          : "",
834      };
835    });
836    renderFeedTitle();
837    if (elements.filterOverlay && !elements.filterOverlay.hidden) renderFilterSheet();
838  } catch (error) {
839    if (useEligibleSources && !state.authUser) {
840      try {
841        const data = await api("/api/channels");
842        state.channels = (Array.isArray(data.channels) ? data.channels : []).map((channel) => ({
843          ...channel,
844          filter_label: "",
845        }));
846        renderFeedTitle();
847        if (elements.filterOverlay && !elements.filterOverlay.hidden) renderFilterSheet();
848        return;
849      } catch (_fallbackError) {
850        // Fall through and surface the original signed-in error below.
851      }
852    }
853    captureRecoverableError("load_channels", error, { path });
854  }
855}
856
857async function loadStats() {
858  try {
859    state.stats = await api("/api/stats");
860    elements.minutesToday.textContent = state.stats.today.minutes;
861    elements.videosToday.textContent = state.stats.today.videos;
862    elements.minutesWeek.textContent = state.stats.last_7_days.minutes;
863    elements.headerMinutes.textContent = `${state.stats.today.minutes} min`;
864    elements.headerStatsLabel.textContent = state.stats.today.videos ? "today" : "today";
865    renderProfileStats();
866  } catch (error) {
867    captureRecoverableError("load_stats", error);
868  }
869}
870
871function feedSnapshotIdentity() {
872  return state.authUser?.id ? `user-${state.authUser.id}` : "guest";
873}
874
875function sourceSnapshotKey(kind, params = null) {
876  const normalizedParams = new URLSearchParams(params || {});
877  normalizedParams.sort();
878  const suffix = normalizedParams.toString();
879  return `${SOURCE_SNAPSHOT_PREFIX}${feedSnapshotIdentity()}:${kind}${suffix ? `:${suffix}` : ""}`;
880}
881
882function discoverSnapshotKey() {
883  return sourceSnapshotKey("discover", {
884    q: String(state.discoverSearch || "").trim().toLowerCase(),
885    sort: "popular",
886    tag: String(state.activeDiscoverTag || "").trim().toLowerCase(),
887  });
888}
889
890function readSourceSnapshot(key) {
891  try {
892    const raw = window.sessionStorage.getItem(key);
893    if (!raw) return null;
894    const snapshot = JSON.parse(raw);
895    const ageMs = Date.now() - Number(snapshot?.savedAt || 0);
896    if (!snapshot?.data || !Number.isFinite(ageMs) || ageMs < 0 || ageMs > SOURCE_SNAPSHOT_MAX_AGE_MS) {
897      window.sessionStorage.removeItem(key);
898      return null;
899    }
900    return {
901      data: snapshot.data,
902      isFresh: ageMs <= SOURCE_SNAPSHOT_FRESH_AGE_MS,
903    };
904  } catch (_error) {
905    return null;
906  }
907}
908
909function pruneSourceSnapshots(identity = feedSnapshotIdentity()) {
910  try {
911    const prefix = `${SOURCE_SNAPSHOT_PREFIX}${identity}:`;
912    const entries = [];
913    for (let index = window.sessionStorage.length - 1; index >= 0; index -= 1) {
914      const key = window.sessionStorage.key(index);
915      if (!key?.startsWith(prefix)) continue;
916      let savedAt = 0;
917      try {
918        savedAt = Number(JSON.parse(window.sessionStorage.getItem(key))?.savedAt || 0);
919      } catch (_error) {
920        // Invalid entries sort last and are removed with old snapshots.
921      }
922      entries.push({ key, savedAt });
923    }
924    entries
925      .sort((left, right) => right.savedAt - left.savedAt)
926      .slice(SOURCE_SNAPSHOT_MAX_ENTRIES)
927      .forEach(({ key }) => window.sessionStorage.removeItem(key));
928  } catch (_error) {
929    // Source loading remains fully functional when browser storage is unavailable.
930  }
931}
932
933function writeSourceSnapshot(key, data) {
934  try {
935    window.sessionStorage.setItem(key, JSON.stringify({ savedAt: Date.now(), data }));
936    pruneSourceSnapshots();
937  } catch (_error) {
938    // Source loading remains fully functional when browser storage is unavailable.
939  }
940}
941
942function clearSourceSnapshots(identity = feedSnapshotIdentity()) {
943  try {
944    const prefix = `${SOURCE_SNAPSHOT_PREFIX}${identity}:`;
945    for (let index = window.sessionStorage.length - 1; index >= 0; index -= 1) {
946      const key = window.sessionStorage.key(index);
947      if (key?.startsWith(prefix)) window.sessionStorage.removeItem(key);
948    }
949  } catch (_error) {
950    // Nothing needs to be cleared when browser storage is unavailable.
951  }
952}
953
954function feedSnapshotKey() {
955  const params = new URLSearchParams({
956    tab: state.activeTab,
957    source: state.channel,
958    tag: state.tag,
959    min: state.minDuration,
960    max: state.maxDuration,
961    q: state.search,
962    short: state.hideShortVideos ? "hidden" : "shown",
963    long: state.hideLongVideos ? "hidden" : "shown",
964  });
965  params.sort();
966  return `${FEED_SNAPSHOT_PREFIX}${feedSnapshotIdentity()}:${params}`;
967}
968
969function readFeedSnapshot() {
970  try {
971    const raw = window.localStorage.getItem(feedSnapshotKey());
972    if (!raw) return null;
973    const snapshot = JSON.parse(raw);
974    if (!snapshot?.savedAt || Date.now() - snapshot.savedAt > FEED_SNAPSHOT_MAX_AGE_MS) {
975      window.localStorage.removeItem(feedSnapshotKey());
976      return null;
977    }
978    return snapshot.data || null;
979  } catch (_error) {
980    return null;
981  }
982}
983
984function writeFeedSnapshot(data) {
985  try {
986    const snapshot = {
987      savedAt: Date.now(),
988      data: {
989        videos: Array.isArray(data.videos) ? data.videos : [],
990        featured: data.featured || null,
991        available_topics: Array.isArray(data.available_topics) ? data.available_topics : [],
992        counts: data.counts || {},
993        total: typeof data.total === "number" ? data.total : null,
994        hidden_short_videos_count: Number(data.hidden_short_videos_count || 0),
995        hidden_long_videos_count: Number(data.hidden_long_videos_count || 0),
996        has_more: Boolean(data.has_more),
997        next_offset: Number(data.next_offset || 0),
998        feed_cutoff: data.feed_cutoff && typeof data.feed_cutoff === "object" ? data.feed_cutoff : null,
999      },
1000    };
1001    window.localStorage.setItem(feedSnapshotKey(), JSON.stringify(snapshot));
1002  } catch (_error) {
1003    // Feed loading remains fully functional when browser storage is unavailable.
1004  }
1005}
1006
1007function clearFeedSnapshots(identity = feedSnapshotIdentity()) {
1008  try {
1009    const prefix = `${FEED_SNAPSHOT_PREFIX}${identity}:`;
1010    for (let index = window.localStorage.length - 1; index >= 0; index -= 1) {
1011      const key = window.localStorage.key(index);
1012      if (key?.startsWith(prefix)) window.localStorage.removeItem(key);
1013    }
1014  } catch (_error) {
1015    // Nothing needs to be cleared when browser storage is unavailable.
1016  }
1017}
1018
1019function restoreFeedSnapshot() {
1020  const data = readFeedSnapshot();
1021  if (!data) return false;
1022  state.videos = Array.isArray(data.videos) ? data.videos : [];
1023  const shouldUpdateFeatured = state.activeTab === "new"
1024    && !state.channel
1025    && !state.tag
1026    && !state.minDuration
1027    && !state.maxDuration
1028    && !state.search;
1029  if (shouldUpdateFeatured) state.featured = data.featured || null;
1030  state.availableTopics = Array.isArray(data.available_topics) ? data.available_topics : [];
1031  state.counts = data.counts || state.counts;
1032  state.total = typeof data.total === "number" ? data.total : null;
1033  state.hiddenShortVideosCount = Number(data.hidden_short_videos_count || 0);
1034  state.hiddenLongVideosCount = Number(data.hidden_long_videos_count || 0);
1035  state.feedCutoff = data.feed_cutoff && typeof data.feed_cutoff === "object" ? data.feed_cutoff : null;
1036  state.hasMore = Boolean(data.has_more);
1037  state.nextOffset = Number(data.next_offset || state.videos.length);
1038  if (shouldUpdateFeatured) renderFeatured();
1039  renderTopicAvailability();
1040  renderFeed();
1041  renderTabs();
1042  renderFeedMeta();
1043  elements.feedLoading.hidden = true;
1044  return true;
1045}
1046
1047async function loadVideos({ append = false, forceNetwork = false } = {}) {
1048  if (append && state.videosInFlight) return;
1049  if (!append && !forceNetwork) restoreFeedSnapshot();
1050  const requestVersion = append ? videoRequestVersion : ++videoRequestVersion;
1051  const showInitialLoading = !append && !state.videos.length && !elements.feed.children.length;
1052  const hasVisibleVideos = !append && state.videos.length > 0;
1053  state.videosInFlight = true;
1054  if (!append) {
1055    elements.feed.setAttribute("aria-busy", "true");
1056    if (showInitialLoading) elements.feedLoading.hidden = false;
1057  }
1058  const params = new URLSearchParams({
1059    state: state.activeTab,
1060    limit: String(PAGE_SIZE),
1061    offset: String(append ? state.nextOffset : 0),
1062    selected: state.authUser ? "true" : "false",
1063  });
1064  if (state.channel) params.set("source_key", state.channel);
1065  if (state.tag) params.set("tag", state.tag);
1066  if (state.minDuration) params.set("min_duration", state.minDuration);
1067  if (state.maxDuration) params.set("max_duration", state.maxDuration);
1068  if (state.search) params.set("q", state.search);
1069  const shouldUpdateFeatured = !append
1070    && state.activeTab === "new"
1071    && !state.channel
1072    && !state.tag
1073    && !state.minDuration
1074    && !state.maxDuration
1075    && !state.search;
1076  try {
1077    const data = await api(
1078      `/api/videos?${params}`,
1079      forceNetwork ? { cache: "no-store" } : {},
1080    );
1081    if (requestVersion !== videoRequestVersion) return;
1082    const videos = data.videos || [];
1083    state.hiddenShortVideosCount = Number(data.hidden_short_videos_count || 0);
1084    state.hiddenLongVideosCount = Number(data.hidden_long_videos_count || 0);
1085    if (!append) {
1086      state.feedCutoff = data.feed_cutoff && typeof data.feed_cutoff === "object" ? data.feed_cutoff : null;
1087    }
1088    if (append) {
1089      state.videos.push(...videos);
1090      appendVideos(videos);
1091    } else {
1092      if (hasVisibleVideos) {
1093        await preloadImages(videos.slice(0, 8).map((video) => video.thumbnail_url));
1094        if (requestVersion !== videoRequestVersion) return;
1095      }
1096      if (shouldUpdateFeatured) {
1097        if (hasVisibleVideos) {
1098          await preloadImage(data.featured?.thumbnail_url);
1099          if (requestVersion !== videoRequestVersion) return;
1100        }
1101        state.featured = data.featured || null;
1102        renderFeatured();
1103      }
1104      state.videos = videos;
1105      state.availableTopics = data.available_topics || [];
1106      renderTopicAvailability();
1107      renderFeed();
1108      writeFeedSnapshot(data);
1109    }
1110    state.nextOffset = data.next_offset ?? state.videos.length;
1111    state.hasMore = Boolean(data.has_more);
1112    state.total = typeof data.total === "number" ? data.total : null;
1113    state.counts = data.counts || state.counts;
1114    renderTabs();
1115    renderFeedMeta();
1116    return true;
1117  } catch (error) {
1118    if (!append && showInitialLoading) {
1119      elements.feedMeta.textContent = "Could not load videos.";
1120      elements.feed.innerHTML = renderErrorState(error.message);
1121    }
1122    captureRecoverableError("load_videos", error, { append, has_cached_videos: Boolean(state.videos.length) });
1123    return false;
1124  } finally {
1125    if (requestVersion !== videoRequestVersion) return;
1126    elements.feedLoading.hidden = true;
1127    elements.feed.removeAttribute("aria-busy");
1128    state.videosInFlight = false;
1129  }
1130}
1131
1132async function preloadImages(urls) {
1133  await Promise.allSettled(urls.filter(Boolean).map((url) => preloadImage(url)));
1134}
1135
1136async function preloadImage(url) {
1137  if (!url || elements.featuredThumbnail.src === url) return;
1138  const image = new Image();
1139  image.src = url;
1140  if (!image.decode) return;
1141  let timeoutId;
1142  try {
1143    await Promise.race([
1144      image.decode(),
1145      new Promise((resolve) => {
1146        timeoutId = window.setTimeout(resolve, IMAGE_PRELOAD_TIMEOUT_MS);
1147      }),
1148    ]);
1149  } catch (_error) {
1150    // The regular image element still provides its normal broken-image behavior.
1151  } finally {
1152    if (timeoutId) window.clearTimeout(timeoutId);
1153  }
1154}
1155
1156async function refreshNow(manual) {
1157  if (state.refreshInFlight || !canRefreshFeed()) return;
1158  state.refreshInFlight = true;
1159  elements.refreshButton.disabled = true;
1160  elements.refreshStatus.textContent = "Refreshing...";
1161  try {
1162    const result = await api("/api/refresh", { method: "POST" });
1163    const refreshDelayed = result.status === "failed" || result.status === "warning";
1164    if (refreshDelayed) {
1165      captureRecoverableError(
1166        "manual_feed_refresh",
1167        result.message || "Refresh finished with warnings.",
1168        { status: result.status, manual: Boolean(manual) },
1169      );
1170    }
1171    await Promise.all([loadHealth(), loadChannels(), loadStats(), loadVideos(), loadDiscover()]);
1172    if (refreshDelayed) elements.refreshStatus.textContent = "Refresh delayed · showing saved videos";
1173  } catch (error) {
1174    captureRecoverableError("manual_feed_refresh", error, { manual: Boolean(manual) });
1175    await loadHealth();
1176    elements.refreshStatus.textContent = "Refresh delayed · showing saved videos";
1177  } finally {
1178    state.refreshInFlight = false;
1179    elements.refreshButton.disabled = false;
1180  }
1181}
1182
1183function applyDiscoverPayload(data) {
1184  const sources = Array.isArray(data.sources) ? data.sources : [];
1185  state.discoverSources = sources;
1186  state.discoverHasMore = Boolean(data.has_more);
1187  state.discoverNextOffset = Number(data.next_offset || sources.length || 0);
1188  updateDiscoverCounts(data);
1189  renderDiscoverContext();
1190  renderCommunityDirectory();
1191  renderDiscoverSources();
1192  renderSourceOnboarding();
1193}
1194
1195async function loadDiscover({ forceNetwork = false } = {}) {
1196  renderSourceOnboarding();
1197  const requestVersion = ++discoverRequestVersion;
1198  const snapshotKey = discoverSnapshotKey();
1199  const snapshot = forceNetwork ? null : readSourceSnapshot(snapshotKey);
1200  if (snapshot) {
1201    applyDiscoverPayload(snapshot.data);
1202    if (snapshot.isFresh) return;
1203  }
1204  try {
1205    const params = new URLSearchParams({ limit: "24", offset: "0", sort: "popular" });
1206    if (state.activeDiscoverTag) params.set("tag", state.activeDiscoverTag);
1207    if (state.discoverSearch) params.set("q", state.discoverSearch);
1208    const data = await api(`/api/sources?${params}`);
1209    writeSourceSnapshot(snapshotKey, data);
1210    if (requestVersion !== discoverRequestVersion) return;
1211    applyDiscoverPayload(data);
1212  } catch (error) {
1213    if (!snapshot) {
1214      elements.discoverMeta.textContent = "Could not load channels.";
1215      captureRecoverableError("load_discover", error);
1216    }
1217  }
1218}
1219
1220function validDiscoverCount(value) {
1221  if (value === null || value === undefined || value === "") return null;
1222  const count = Number(value);
1223  return Number.isFinite(count) && count >= 0 ? count : null;
1224}
1225
1226function normalizeDiscoverTagCounts(value) {
1227  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1228  return Object.fromEntries(Object.entries(value).flatMap(([tag, count]) => {
1229    const normalizedCount = validDiscoverCount(count);
1230    return normalizedCount === null ? [] : [[String(tag).trim().toLowerCase(), normalizedCount]];
1231  }));
1232}
1233
1234function updateDiscoverCounts(data) {
1235  const tagCounts = normalizeDiscoverTagCounts(data.tag_counts);
1236  if (Object.keys(tagCounts).length) state.discoverTagCounts = tagCounts;
1237
1238  const totalCount = validDiscoverCount(data.total_count);
1239  state.discoverTotalCount = totalCount ?? (data.has_more ? null : state.discoverSources.length);
1240
1241  const catalogTotal = [
1242    data.catalog_total_count,
1243    data.catalog_total,
1244    data.total_sources,
1245    tagCounts.all,
1246    !state.activeDiscoverTag && !state.discoverSearch ? totalCount : null,
1247  ].map(validDiscoverCount).find((count) => count !== null);
1248  if (catalogTotal !== undefined) state.discoverCatalogTotal = catalogTotal;
1249}
1250
1251async function reloadSourceViews({ includeDiscover = false } = {}) {
1252  clearSourceSnapshots();
1253  const work = [loadMySources({ forceNetwork: true }), loadChannels(), loadVideos()];
1254  if (includeDiscover) {
1255    work.unshift(loadDiscover({ forceNetwork: true }));
1256  }
1257  await Promise.all(work);
1258}
1259
1260async function loadMySources({ forceNetwork = false } = {}) {
1261  if (!state.authUser) {
1262    elements.mySourcesGate.hidden = false;
1263    elements.mySourcesList.innerHTML = "";
1264    elements.mySourcesMeta.textContent = "Sign in to manage your channels.";
1265    elements.mySourcesUndo.hidden = true;
1266    return;
1267  }
1268  elements.mySourcesGate.hidden = true;
1269  const requestVersion = ++mySourcesRequestVersion;
1270  const snapshotKey = sourceSnapshotKey("my-sources");
1271  const snapshot = forceNetwork ? null : readSourceSnapshot(snapshotKey);
1272  if (snapshot) {
1273    applyMySourcesPayload(snapshot.data, { renderList: true });
1274    if (snapshot.isFresh) return;
1275  }
1276  try {
1277    const data = await api("/api/me/sources");
1278    writeSourceSnapshot(snapshotKey, data);
1279    if (requestVersion !== mySourcesRequestVersion) return;
1280    applyMySourcesPayload(data, { renderList: true });
1281  } catch (error) {
1282    if (!snapshot) showNotice(error.message, "error");
1283  }
1284}
1285
1286function communityMatchesDiscoverFilters(community) {
1287  const tag = state.activeDiscoverTag;
1288  if (tag && !(community.topic_tags || []).some((topic) => topic && topic.value === tag)) return false;
1289  const query = String(state.discoverSearch || "").trim().toLowerCase();
1290  if (query) {
1291    const haystack = `${community.name || ""} ${community.description || ""}`.toLowerCase();
1292    if (!haystack.includes(query)) return false;
1293  }
1294  return true;
1295}
1296
1297function renderCommunityDirectory() {
1298  if (!elements.communityDirectory || !elements.communityDirectoryMeta) return;
1299  const communities = state.communities;
1300  const hasFilters = Boolean(state.activeDiscoverTag || state.discoverSearch);
1301  const matching = hasFilters ? communities.filter(communityMatchesDiscoverFilters) : communities;
1302  const tagLabel = DISCOVER_CATEGORY_LABELS[state.activeDiscoverTag] || state.activeDiscoverTag;
1303
1304  if (elements.communityDirectoryTitle) {
1305    elements.communityDirectoryTitle.textContent = !hasFilters
1306      ? "Great first picks"
1307      : state.activeDiscoverTag
1308        ? `Communities for ${tagLabel}`
1309        : `Communities matching “${state.discoverSearch}”`;
1310  }
1311  if (elements.communityDirectoryCopy) {
1312    elements.communityDirectoryCopy.textContent = hasFilters
1313      ? "One tap follows a whole bundle of channels on this topic."
1314      : "One tap follows a whole starter pack of channels. Preview shows what is inside — joining fills your Home feed.";
1315  }
1316
1317  if (!communities.length) {
1318    elements.communityDirectoryMeta.textContent = "No communities yet";
1319    elements.communityDirectory.innerHTML = '<div class="empty-state compact-empty"><h3>No communities yet</h3><p>Community groups will show up here once the catalog is ready.</p></div>';
1320    if (elements.communityDirectoryToggle) elements.communityDirectoryToggle.innerHTML = "";
1321    return;
1322  }
1323
1324  if (hasFilters) {
1325    elements.communityDirectoryMeta.textContent = `${matching.length} match${matching.length === 1 ? "" : "es"}`;
1326    elements.communityDirectory.innerHTML = matching.length
1327      ? matching.map((community) => renderCommunityCard(community)).join("")
1328      : `<div class="community-directory-empty">No ${escapeHtml(state.activeDiscoverTag ? tagLabel : "matching")} communities yet — but here are matching channels below.</div>`;
1329    if (elements.communityDirectoryToggle) elements.communityDirectoryToggle.innerHTML = "";
1330    return;
1331  }
1332
1333  const expanded = Boolean(state.discoverCommunitiesExpanded);
1334  const visible = expanded ? communities : communities.slice(0, DISCOVER_FEATURED_COMMUNITY_COUNT);
1335  elements.communityDirectoryMeta.textContent = `${communities.length} communit${communities.length === 1 ? "y" : "ies"}`;
1336  elements.communityDirectory.innerHTML = visible.map((community) => renderCommunityCard(community)).join("");
1337  if (elements.communityDirectoryToggle) {
1338    elements.communityDirectoryToggle.innerHTML = communities.length > DISCOVER_FEATURED_COMMUNITY_COUNT
1339      ? `<button class="ghost-button" data-community-directory-toggle type="button" aria-expanded="${expanded}">${expanded ? "Show fewer ▴" : `Browse all ${communities.length} communities ▾`}</button>`
1340      : "";
1341  }
1342}
1343
1344function renderCommunityCard(community) {
1345  const onboardingActive = Boolean(state.authUser && shouldShowSourceOnboarding(state.sourceOnboarding));
1346  const previewSources = Array.isArray(community.preview_sources) ? community.preview_sources.slice(0, 3) : [];
1347  const preview = previewSources.length
1348    ? previewSources.map((source) => renderCommunityPreview(source)).join("")
1349    : '<span class="community-preview-empty">Channel previews will appear here.</span>';
1350  const joinButton = community.is_joined
1351    ? `<button class="ghost-button" data-community-action="leave" data-community-id="${escapeAttribute(community.id)}" type="button">Leave</button>`
1352    : state.authUser
1353      ? `<button class="accent-button small-action" data-community-action="join" data-community-id="${escapeAttribute(community.id)}" type="button">${onboardingActive ? "Start with this community" : "Join"}</button>`
1354      : '<a class="ghost-button action-link" href="/login">Log in to join</a>';
1355  return `<article class="community-card" data-community-id="${escapeAttribute(community.id)}">
1356    <div class="community-card-header">
1357      <div>
1358        <h3>${escapeHtml(community.name || "Community")}</h3>
1359        <p>${escapeHtml(community.description || "Shared channels for one learning lane.")}</p>
1360      </div>
1361      ${community.is_joined ? '<span class="community-badge">Joined</span>' : ""}
1362    </div>
1363    <div class="community-card-meta">
1364      <span>${Number(community.source_count || 0)} channels</span>
1365      ${Number(community.member_count || 0) >= 5 ? `<span>${Number(community.member_count || 0)} members</span>` : ""}
1366    </div>
1367    <div class="community-preview-list">${preview}</div>
1368    <div class="community-card-actions">
1369      <button class="source-view-link" data-community-action="select" data-community-id="${escapeAttribute(community.id)}" type="button" aria-haspopup="dialog" aria-controls="communityPreviewModal">Preview channels</button>
1370      <div class="community-join-actions">${joinButton}</div>
1371    </div>
1372  </article>`;
1373}
1374
1375function renderCommunityPreview(source) {
1376  if (typeof source === "string") {
1377    return `<span class="community-preview-chip">${escapeHtml(source)}</span>`;
1378  }
1379  const name = source.display_name || source.name || source.source_key || "Source";
1380  const thumb = source.thumbnail_url
1381    ? `<img src="${escapeAttribute(source.thumbnail_url)}" alt="" loading="lazy" referrerpolicy="no-referrer">`
1382    : `<span>${initials(name)}</span>`;
1383  return `<span class="community-preview-chip">${thumb}<strong>${escapeHtml(name)}</strong></span>`;
1384}
1385
1386function renderDiscoverContext() {
1387  const onboardingActive = Boolean(state.authUser && shouldShowSourceOnboarding(state.sourceOnboarding));
1388  if (elements.discoverSourcesEyebrow) {
1389    elements.discoverSourcesEyebrow.textContent = onboardingActive
1390      ? "Optional custom path"
1391      : "Build your own mix";
1392  }
1393  if (elements.discoverSourcesTitle) {
1394    elements.discoverSourcesTitle.textContent = onboardingActive
1395      ? "Prefer your own channel mix?"
1396      : "Or hand-pick channels";
1397  }
1398  if (elements.discoverSourcesCopy) {
1399    elements.discoverSourcesCopy.textContent = onboardingActive
1400      ? "Follow 3 individual channels instead of joining a community. Previewing a community above does not add or join anything."
1401      : "Add channels one at a time — each one's videos join your Home feed.";
1402  }
1403  if (elements.discoverMeta) {
1404    const shownCount = state.discoverSources.length;
1405    const totalCount = state.discoverTotalCount;
1406    const catalogTotal = state.discoverCatalogTotal;
1407    const hasActiveFilters = Boolean(state.activeDiscoverTag || state.discoverSearch);
1408    const parts = [`${shownCount} shown`];
1409    if (hasActiveFilters && totalCount !== null) {
1410      const searchSuffix = state.discoverSearch ? ` for “${state.discoverSearch}”` : "";
1411      parts.push(`${totalCount} match${totalCount === 1 ? "" : "es"}${searchSuffix}`);
1412    }
1413    if (catalogTotal !== null) {
1414      parts.push(`${catalogTotal} total channel${catalogTotal === 1 ? "" : "s"}`);
1415    } else if (state.discoverHasMore) {
1416      parts.push("more available");
1417    }
1418    elements.discoverMeta.textContent = parts.join(" · ");
1419  }
1420  renderDiscoverFilters();
1421  if (elements.sourceFormTarget) {
1422    elements.sourceFormTarget.hidden = true;
1423    elements.sourceFormTarget.textContent = "";
1424  }
1425  if (elements.sourceSubmit) {
1426    elements.sourceSubmit.textContent = "Approve and share";
1427  }
1428}
1429
1430function renderDiscoverFilters() {
1431  document.querySelectorAll("[data-discover-tag]").forEach((button) => {
1432    const tag = button.dataset.discoverTag || "";
1433    button.classList.toggle("active", tag === state.activeDiscoverTag);
1434    button.setAttribute("aria-pressed", String(tag === state.activeDiscoverTag));
1435    const countElement = button.querySelector("[data-discover-count]");
1436    if (!countElement) return;
1437    const count = tag ? state.discoverTagCounts[tag] : state.discoverCatalogTotal;
1438    countElement.hidden = count === null || count === undefined;
1439    countElement.textContent = countElement.hidden ? "" : String(count);
1440  });
1441}
1442
1443function renderDiscoverEmptyState() {
1444  const hasSearch = Boolean(state.discoverSearch);
1445  const hasTag = Boolean(state.activeDiscoverTag);
1446  const tagLabel = DISCOVER_CATEGORY_LABELS[state.activeDiscoverTag] || state.activeDiscoverTag;
1447  let title = "No channels are available yet";
1448  let copy = "Check back soon for new learner-shared channels.";
1449  if (hasSearch && hasTag) {
1450    title = `No ${tagLabel} channels match “${state.discoverSearch}”`;
1451    copy = "Clear the search or show every channel to keep exploring.";
1452  } else if (hasSearch) {
1453    title = `No channels match “${state.discoverSearch}”`;
1454    copy = "Try another phrase or clear the search.";
1455  } else if (hasTag) {
1456    title = `No ${tagLabel} channels yet`;
1457    copy = "Show every channel to keep exploring.";
1458  }
1459  const clearSearch = hasSearch
1460    ? '<button class="ghost-button" data-discover-clear-search type="button">Clear search</button>'
1461    : "";
1462  const clearFilters = hasTag
1463    ? '<button class="ghost-button" data-discover-clear-filters type="button">Show all channels</button>'
1464    : "";
1465  const actions = clearSearch || clearFilters
1466    ? `<div class="empty-state-actions">${clearSearch}${clearFilters}</div>`
1467    : "";
1468  return `<div class="empty-state"><div class="empty-icon">⌕</div><h3>${escapeHtml(title)}</h3><p>${escapeHtml(copy)}</p>${actions}</div>`;
1469}
1470
1471function renderDiscoverSources() {
1472  const sources = state.discoverSources;
1473  elements.discoverList.innerHTML = sources.length
1474    ? sources.map((source) => renderSourceCard(source)).join("")
1475    : renderDiscoverEmptyState();
1476  renderDiscoverPager();
1477}
1478
1479function renderDiscoverPager() {
1480  if (!elements.discoverPager) return;
1481  if (!state.discoverHasMore) {
1482    elements.discoverPager.innerHTML = "";
1483    return;
1484  }
1485  elements.discoverPager.innerHTML = '<button class="ghost-button" data-discover-load-more type="button">Show more channels</button>';
1486}
1487
1488function renderMySources() {
1489  const { direct, community, hidden } = state.mySources;
1490  const joinedCommunities = state.communities.filter((item) => item.is_joined);
1491  const total = direct.length + community.length + hidden.length;
1492  const communitySummary = state.communitiesStatus === "ready"
1493    ? `${joinedCommunities.length} joined communit${joinedCommunities.length === 1 ? "y" : "ies"}`
1494    : state.communitiesStatus === "error"
1495      ? "Communities unavailable"
1496      : "Loading communities";
1497  elements.mySourcesMeta.textContent = `${communitySummary} · ${direct.length + community.length} active channel${direct.length + community.length === 1 ? "" : "s"} · ${hidden.length} hidden`;
1498  renderMySourcesFlash();
1499  if (!total) {
1500    elements.mySourcesList.innerHTML = [
1501      renderMyCommunitiesSection(joinedCommunities, state.communitiesStatus),
1502      '<div class="empty-state"><div class="empty-icon">✓</div><h3>Your channel list is empty</h3><p>Add a channel here, or pick an existing one from Discover. Its videos will join your Home feed.</p></div>',
1503    ].join("");
1504    return;
1505  }
1506  elements.mySourcesList.innerHTML = [
1507    renderMyCommunitiesSection(joinedCommunities, state.communitiesStatus),
1508    renderSourceSection({
1509      title: "Directly Followed",
1510      copy: "You chose these yourself.",
1511      sources: direct,
1512      variant: "direct",
1513      empty: "Follow a few channels directly if you want your own custom mix.",
1514    }),
1515    renderSourceSection({
1516      title: "From Communities",
1517      copy: "These come from the communities you joined.",
1518      sources: community,
1519      variant: "community",
1520      empty: "Join a community and its approved channels will show up here.",
1521    }),
1522    renderSourceSection({
1523      title: "Hidden Channels",
1524      copy: "Hidden community channels stay out of Home until you restore them.",
1525      sources: hidden,
1526      variant: "hidden",
1527      empty: "Nothing is hidden right now.",
1528    }),
1529  ].join("");
1530}
1531
1532function renderMyCommunitiesSection(communities, status = "ready") {
1533  const count = status === "ready"
1534    ? ` <span class="my-sources-section-count">· ${communities.length} communit${communities.length === 1 ? "y" : "ies"}</span>`
1535    : "";
1536  const items = status === "loading"
1537    ? '<div class="my-sources-empty"><p>Loading your communities...</p></div>'
1538    : status === "error"
1539      ? '<div class="my-sources-empty"><p>We could not load your communities right now. Your channel list is still available below.</p></div>'
1540      : communities.length
1541        ? `<div class="community-grid my-communities-grid">${communities.map((community) => renderCommunityCard(community)).join("")}</div>`
1542        : '<div class="my-sources-empty"><p>You have not joined any communities yet.</p><div class="empty-state-actions"><button class="ghost-button" data-go-section="discover" type="button">Browse communities</button></div></div>';
1543  return `<section class="my-sources-section" data-source-section="communities">
1544    <div class="my-sources-section-heading">
1545      <div><h3>My Communities${count}</h3><p>These community bundles add their approved channels to your Home feed.</p></div>
1546    </div>
1547    ${items}
1548  </section>`;
1549}
1550
1551function renderMySourcesFlash() {
1552  if (!elements.mySourcesUndo) return;
1553  const flash = state.mySourcesFlash;
1554  if (!flash) {
1555    elements.mySourcesUndo.hidden = true;
1556    elements.mySourcesUndo.innerHTML = "";
1557    return;
1558  }
1559  const action = flash.kind === "undo-hide"
1560    ? `<button class="ghost-button" data-my-sources-action="undo-hide" data-source-key="${escapeAttribute(flash.sourceKey)}" type="button">Undo</button>`
1561    : "";
1562  elements.mySourcesUndo.hidden = false;
1563  elements.mySourcesUndo.innerHTML = `<p>${escapeHtml(flash.message)}</p>${action}`;
1564}
1565
1566function renderSourceSection({ title, copy, sources, variant, empty }) {
1567  const items = sources.length
1568    ? sources.map((source) => renderSourceCard(source, { variant })).join("")
1569    : `<div class="my-sources-empty"><p>${escapeHtml(empty)}</p></div>`;
1570  return `<section class="my-sources-section" data-source-section="${escapeAttribute(variant)}">
1571    <div class="my-sources-section-heading">
1572      <div><h3>${escapeHtml(title)} <span class="my-sources-section-count">· ${sources.length} channel${sources.length === 1 ? "" : "s"}</span></h3><p>${escapeHtml(copy)}</p></div>
1573    </div>
1574    <div class="my-sources-section-list">${items}</div>
1575  </section>`;
1576}
1577
1578function renderReviewCard(submission) {
1579  const sourceName = submission.display_name || submission.source?.display_name || submission.channel_name || "Untitled source";
1580  const handle = submission.handle || submission.source?.handle || "";
1581  const note = submission.note || submission.review_note || "";
1582  const communityName = submission.community?.name || "Community";
1583  const submitter = submission.submitted_by_username || submission.submitter_username || submission.submitter || "A member";
1584  return `<article class="review-card">
1585    <div class="review-card-header">
1586      <div>
1587        <h4>${escapeHtml(sourceName)}</h4>
1588        <p>${escapeHtml(handle ? `@${handle}` : "YouTube channel")} · ${escapeHtml(communityName)}</p>
1589      </div>
1590      <span class="community-badge">Pending</span>
1591    </div>
1592    <p class="review-card-meta">Submitted by @${escapeHtml(submitter)}</p>
1593    ${note ? `<p class="review-card-note">${escapeHtml(note)}</p>` : ""}
1594    <div class="review-card-actions">
1595      <button class="accent-button small-action" data-review-action="approved" data-community-id="${escapeAttribute(submission.community?.id || submission.community_id)}" data-submission-id="${escapeAttribute(submission.id || submission.submission_id)}" type="button">Approve</button>
1596      <button class="danger-ghost" data-review-action="rejected" data-community-id="${escapeAttribute(submission.community?.id || submission.community_id)}" data-submission-id="${escapeAttribute(submission.id || submission.submission_id)}" type="button">Reject</button>
1597    </div>
1598  </article>`;
1599}
1600
1601function renderSourceOnboarding() {
1602  const panel = elements.sourceOnboardingPanel;
1603  if (!panel) return;
1604  const onboarding = state.sourceOnboarding;
1605  const visible = Boolean(state.authUser && shouldShowSourceOnboarding(onboarding));
1606  document.body.classList.toggle("is-source-onboarding", visible);
1607  panel.hidden = !visible;
1608  if (!visible) return;
1609
1610  const count = Number(onboarding.direct_source_count || 0);
1611  const joinedCount = Number(onboarding.joined_community_count || 0);
1612  const target = Number(onboarding.direct_source_target || 3);
1613  const ready = Boolean(onboarding.is_ready);
1614  const progressPercent = ready ? 100 : Math.min(100, (count / target) * 100);
1615  panel.classList.toggle("is-ready", ready);
1616  panel.style.setProperty("--onboarding-progress", `${progressPercent}%`);
1617  elements.sourceOnboardingTitle.textContent = ready ? "Your feed is ready" : "Choose your first community";
1618  elements.sourceOnboardingCopy.textContent = ready
1619    ? "You have a solid starting point. Go to Home and open your first video."
1620    : "Preview what a community follows, then join the one that feels right. Prefer your own mix? You can follow 3 individual channels instead.";
1621  elements.sourceOnboardingProgress.textContent = joinedCount > 0
1622    ? `Community joined · setup complete`
1623    : count > 0
1624      ? `${count} of ${target} individual channels added`
1625      : "No community joined yet";
1626  elements.sourceOnboardingStatus.textContent = ready
1627    ? onboarding.ready_reason === "community"
1628      ? "That community makes Home ready right away."
1629      : "Your Home feed is ready for its first listen."
1630    : joinedCount
1631      ? "You're ready now, but you can still follow more channels too."
1632      : count
1633        ? "Nice start. A few more direct picks will round things out."
1634        : "Previewing shows what is inside. Use “Start with this community” to actually join.";
1635  elements.sourceOnboardingSkip.hidden = ready;
1636  elements.sourceOnboardingSkip.disabled = false;
1637  elements.sourceOnboardingContinue.disabled = !ready;
1638  elements.sourceOnboardingContinue.textContent = ready ? "Go to Home" : "Choose a community first";
1639}
1640
1641async function finishSourceOnboarding(nextState) {
1642  if (nextState === "completed" && !state.sourceOnboarding?.is_ready) {
1643    renderSourceOnboarding();
1644    return;
1645  }
1646  const buttons = [elements.sourceOnboardingSkip, elements.sourceOnboardingContinue].filter(
1646Boolean);
1647  buttons.forEach((button) => {
1648    button.disabled = true;
1649    button.setAttribute("aria-busy", "true");
1650  });
1651  try {
1652    const data = await api("/api/me/onboarding", {
1653      method: "PATCH",
1654      body: JSON.stringify({ state: nextState }),
1655    });
1656    state.sourceOnboarding = normalizeSourceOnboarding(data.onboarding || { state: nextState }, state.mySources.direct);
1657    renderSourceOnboarding();
1658    switchSection("feed");
1659  } catch (error) {
1660    buttons.forEach((button) => {
1661      button.disabled = false;
1662      button.removeAttribute("aria-busy");
1663    });
1664    showNotice(error.message, "error");
1665  }
1666}
1667
1668function renderProfileStats() {
1669  const signedIn = Boolean(state.authUser);
1670  elements.profileSignedOut.hidden = signedIn;
1671  elements.profileDashboard.hidden = !signedIn;
1672  elements.profileTitle.textContent = signedIn ? `@${state.authUser.username}` : "Profile";
1673  if (!signedIn || !state.stats) return;
1674  elements.profileTodayMinutes.textContent = state.stats.today.minutes;
1675  elements.profileTodayVideos.textContent = state.stats.today.videos;
1676  elements.profileSevenDayMinutes.textContent = state.stats.last_7_days.minutes;
1677  elements.profileSevenDayVideos.textContent = state.stats.last_7_days.videos;
1678  elements.profileThirtyDayMinutes.textContent = state.stats.last_30_days.minutes;
1679  elements.profileThirtyDayVideos.textContent = state.stats.last_30_days.videos;
1680}
1681
1682function syncSourceComposerState() {
1683  const isOpen = Boolean(elements.sourceComposerModal?.open || elements.sourceComposerModal?.hasAttribute("open"));
1684  elements.sourceComposerTrigger?.setAttribute("aria-expanded", String(isOpen));
1685  document.body.classList.toggle("source-compose-open", isOpen);
1686}
1687
1688function openSourceComposer() {
1689  const modal = elements.sourceComposerModal;
1690  if (!modal || modal.open || modal.hasAttribute("open")) return;
1691  if (typeof modal.showModal === "function") modal.showModal();
1692  else modal.setAttribute("open", "");
1693  syncSourceComposerState();
1694  window.requestAnimationFrame(() => {
1695    const focusTarget = state.authUser ? elements.sourceUrl : modal.querySelector("[data-signup-link]");
1696    focusTarget?.focus();
1697  });
1698}
1699
1700function closeSourceComposer({ restoreFocus = false } = {}) {
1701  const modal = elements.sourceComposerModal;
1702  if (!modal || (!modal.open && !modal.hasAttribute("open"))) return;
1703  if (typeof modal.close === "function" && modal.open) modal.close();
1704  else modal.removeAttribute("open");
1705  syncSourceComposerState();
1706  if (restoreFocus) elements.sourceComposerTrigger?.focus();
1707}
1708
1709function syncStudyUrlDialogState() {
1710  const isOpen = Boolean(elements.studyUrlDialog?.open || elements.studyUrlDialog?.hasAttribute("open"));
1711  elements.studyUrlTrigger?.setAttribute("aria-expanded", String(isOpen));
1712}
1713
1714function clearStudyUrlError() {
1715  if (!elements.studyUrlError || !elements.studyUrlInput) return;
1716  elements.studyUrlError.textContent = "";
1717  elements.studyUrlError.hidden = true;
1718  elements.studyUrlInput.removeAttribute("aria-invalid");
1719}
1720
1721function showStudyUrlError(message) {
1722  if (!elements.studyUrlError || !elements.studyUrlInput) return;
1723  elements.studyUrlError.textContent = message;
1724  elements.studyUrlError.hidden = false;
1725  elements.studyUrlInput.setAttribute("aria-invalid", "true");
1726}
1727
1728function setStudyUrlLoading(loading) {
1729  state.studyUrlLoading = loading;
1730  if (elements.studyUrlSubmit) {
1731    elements.studyUrlSubmit.disabled = loading;
1732    elements.studyUrlSubmit.setAttribute("aria-busy", String(loading));
1733  }
1734  if (elements.studyUrlSubmitLabel) {
1735    elements.studyUrlSubmitLabel.textContent = loading ? "Opening Study Mode…" : "Start studying";
1736  }
1737}
1738
1739function openStudyUrlDialog() {
1740  const dialog = elements.studyUrlDialog;
1741  if (!state.authUser || !dialog || dialog.open || dialog.hasAttribute("open")) return;
1742  clearStudyUrlError();
1743  if (typeof dialog.showModal === "function") dialog.showModal();
1744  else dialog.setAttribute("open", "");
1745  syncStudyUrlDialogState();
1746  window.requestAnimationFrame(() => elements.studyUrlInput?.focus());
1747}
1748
1749function closeStudyUrlDialog({ restoreFocus = false, cancelPending = true } = {}) {
1750  const dialog = elements.studyUrlDialog;
1751  if (cancelPending) {
1752    studyUrlRequestVersion += 1;
1753    setStudyUrlLoading(false);
1754  }
1755  if (!dialog || (!dialog.open && !dialog.hasAttribute("open"))) return;
1756  if (typeof dialog.close === "function" && dialog.open) dialog.close();
1757  else dialog.removeAttribute("open");
1758  syncStudyUrlDialogState();
1759  if (restoreFocus) elements.studyUrlTrigger?.focus();
1760}
1761
1762function normalizeStudyQueueItem(item) {
1763  const videoId = normalizeYouTubeVideoId(item?.video_id);
1764  if (!videoId) return null;
1765  const itemState = ["new", "watch_later", "watched", "skipped"].includes(item?.state)
1766    ? item.state
1767    : "";
1768  return { video_id: videoId, state: itemState };
1769}
1770
1771function normalizeStudyQueueFilters(filters) {
1772  const value = filters && typeof filters === "object" ? filters : {};
1773  return {
1774    sourceKey: String(value.sourceKey || ""),
1775    tag: String(value.tag || ""),
1776    minDuration: String(value.minDuration || ""),
1777    maxDuration: String(value.maxDuration || ""),
1778    search: String(value.search || ""),
1779  };
1780}
1781
1782function studyQueueOwnerKey() {
1783  return String(state.authUser?.id || state.authUser?.username || "");
1784}
1785
1786function normalizeStudyQueueSnapshot(snapshot) {
1787  if (!snapshot || typeof snapshot !== "object") return null;
1788  const tab = tabs.some((item) => item.id === snapshot.tab) ? snapshot.tab : "new";
1789  const seen = new Set();
1790  const items = [];
1791  for (const value of Array.isArray(snapshot.items) ? snapshot.items : []) {
1792    const item = normalizeStudyQueueItem(value);
1793    if (!item || seen.has(item.video_id)) continue;
1794    seen.add(item.video_id);
1795    items.push(item);
1796    if (items.length >= STUDY_QUEUE_MAX_ITEMS) break;
1797  }
1798  return {
1799    version: 1,
1800    owner: String(snapshot.owner || studyQueueOwnerKey()),
1801    tab,
1802    filters: normalizeStudyQueueFilters(snapshot.filters),
1803    items,
1804    total: Math.max(items.length, Number(snapshot.total) || 0),
1805    complete: Boolean(snapshot.complete),
1806  };
1807}
1808
1809function studyQueueSnapshotFromFeed(videoId) {
1810  const items = state.videos.map(normalizeStudyQueueItem).filter(Boolean);
1811  if (!items.some((item) => item.video_id === videoId)) return null;
1812  return normalizeStudyQueueSnapshot({
1813    owner: studyQueueOwnerKey(),
1814    tab: state.activeTab,
1815    filters: {
1816      sourceKey: state.channel,
1817      tag: state.tag,
1818      minDuration: state.minDuration,
1819      maxDuration: state.maxDuration,
1820      search: state.search,
1821    },
1822    items,
1823    total: state.total ?? items.length,
1824    complete: !state.hasMore,
1825  });
1826}
1827
1828function saveStudyQueueSnapshot(snapshot = state.studyQueue) {
1829  const normalized = normalizeStudyQueueSnapshot(snapshot);
1830  if (!normalized?.owner) return;
1831  try {
1832    window.sessionStorage.setItem(STUDY_QUEUE_STORAGE_KEY, JSON.stringify(normalized));
1833  } catch (_error) {
1834    // Queue navigation still works for this page when storage is unavailable.
1835  }
1836}
1837
1838function restoreStudyQueueSnapshot(videoId, tab) {
1839  try {
1840    const snapshot = normalizeStudyQueueSnapshot(JSON.parse(
1841      window.sessionStorage.getItem(STUDY_QUEUE_STORAGE_KEY) || "null",
1842    ));
1843    if (snapshot?.owner !== studyQueueOwnerKey() || snapshot.tab !== tab) return null;
1844    return snapshot.items.some((item) => item.video_id === videoId) ? snapshot : null;
1845  } catch (_error) {
1846    return null;
1847  }
1848}
1849
1850function studyQueueTabFromLocation() {
1851  const requested = new URLSearchParams(window.location.search).get("queue");
1852  return tabs.some((item) => item.id === requested) ? requested : "new";
1853}
1854
1855function studyQueueRequestParams(snapshot, offset = 0) {
1856  const params = new URLSearchParams({
1857    state: snapshot.tab,
1858    limit: String(STUDY_QUEUE_PAGE_SIZE),
1859    offset: String(offset),
1860    selected: "true",
1861  });
1862  const filters = normalizeStudyQueueFilters(snapshot.filters);
1863  if (filters.sourceKey) params.set("source_key", filters.sourceKey);
1864  if (filters.tag) params.set("tag", filters.tag);
1865  if (filters.minDuration) params.set("min_duration", filters.minDuration);
1866  if (filters.maxDuration) params.set("max_duration", filters.maxDuration);
1867  if (filters.search) params.set("q", filters.search);
1868  return params;
1869}
1870
1871function renderStudyQueueControls() {
1872  if (!elements.studyQueueToolbar) return;
1873  const videoId = selectedStudyVideoId();
1874  elements.studyQueueToolbar.hidden = !videoId;
1875  const snapshot = normalizeStudyQueueSnapshot(state.studyQueue);
1876  const items = snapshot?.items || [];
1877  const index = items.findIndex((item) => item.video_id === videoId);
1878  const unavailable = !videoId || !snapshot || state.studyQueueLoading || state.studyStateSaving;
1879  elements.studyQueueToolbar.setAttribute("aria-busy", String(state.studyQueueLoading || state.studyStateSaving));
1880  elements.studyPreviousButton.disabled = unavailable || index <= 0;
1881  elements.studyNextButton.disabled = unavailable || index < 0 || index >= items.length - 1;
1882  elements.studyQueuePosition.textContent = state.studyQueueLoading
1883    ? "Loading…"
1884    : (index >= 0 ? `${index + 1} / ${items.length}` : "Queue");
1885  const currentState = index >= 0 ? items[index].state : "";
1886  for (const button of [elements.studyWatchedButton, elements.studyLaterButton, elements.studySkipButton]) {
1887    if (!button) continue;
1888    const selected = button.dataset.studyState === currentState;
1889    button.classList.toggle("is-selected", selected);
1890    button.setAttribute("aria-pressed", String(selected));
1891    button.disabled = unavailable || selected;
1892  }
1893}
1894
1895async function loadStudyQueue() {
1896  const videoId = selectedStudyVideoId();
1897  if (!state.authUser || !videoId) {
1898    state.studyQueue = null;
1899    renderStudyQueueControls();
1900    return;
1901  }
1902  const tab = studyQueueTabFromLocation();
1903  const current = normalizeStudyQueueSnapshot(state.studyQueue);
1904  const stored = restoreStudyQueueSnapshot(videoId, tab);
1905  const historyQueue = window.history.state?.studyQueue;
1906  const fallback = current?.tab === tab && current.items.some((item) => item.video_id === videoId)
1907    ? current
1908    : stored;
1909  state.studyQueue = fallback || normalizeStudyQueueSnapshot({
1910    owner: studyQueueOwnerKey(),
1911    tab,
1912    filters: historyQueue?.tab === tab ? historyQueue.filters : {},
1913    items: [],
1914    total: 0,
1915    complete: false,
1916  });
1917  renderStudyQueueControls();
1918  if (state.studyQueue.complete && state.studyQueue.items.some((item) => item.video_id === videoId)) return;
1919
1920  const requestVersion = ++studyQueueRequestVersion;
1921  const requestingUser = studyQueueOwnerKey();
1922  state.studyQueueLoading = true;
1923  renderStudyQueueControls();
1924  try {
1925    const items = [];
1926    const seen = new Set();
1927    let offset = 0;
1928    let total = 0;
1929    let hasMore = true;
1930    while (hasMore && items.length < STUDY_QUEUE_MAX_ITEMS) {
1931      const data = await api(`/api/videos?${studyQueueRequestParam
1931s(state.studyQueue, offset)}`, { cache: "no-store" });
1932      if (requestVersion !== studyQueueRequestVersion || studyQueueOwnerKey() !== requestingUser || state.activeSection !== "study") return;
1933      for (const value of data.videos || []) {
1934        const item = normalizeStudyQueueItem(value);
1935        if (!item || seen.has(item.video_id)) continue;
1936        seen.add(item.video_id);
1937        items.push(item);
1938      }
1939      total = Math.max(total, Number(data.total) || items.length);
1940      hasMore = Boolean(data.has_more);
1941      const nextOffset = Number(data.next_offset);
1942      if (!hasMore || !Number.isSafeInteger(nextOffset) || nextOffset <= offset) break;
1943      offset = nextOffset;
1944    }
1945    const existingCurrent = fallback?.items.find((item) => item.video_id === videoId);
1946    if (!seen.has(videoId)) items.unshift(existingCurrent || { video_id: videoId, state: "" });
1947    state.studyQueue = normalizeStudyQueueSnapshot({
1948      ...state.studyQueue,
1949      items,
1950      total: Math.max(total, items.length),
1951      complete: !hasMore,
1952    });
1953    saveStudyQueueSnapshot();
1954  } catch (error) {
1955    if (requestVersion !== studyQueueRequestVersion || state.activeSection !== "study") return;
1956    if (!state.studyQueue.items.some((item) => item.video_id === videoId)) {
1957      state.studyQueue.items.unshift({ video_id: videoId, state: "" });
1958    }
1959    captureRecoverableError("load_study_queue", error, { queue: tab });
1960  } finally {
1961    if (requestVersion === studyQueueRequestVersion) {
1962      state.studyQueueLoading = false;
1963      renderStudyQueueControls();
1964    }
1965  }
1966}
1967
1968function openStudyVideo(videoId, { queueContext = null } = {}) {
1969  if (!canAccessStudy()) return;
1970  videoId = normalizeYouTubeVideoId(videoId);
1971  if (!videoId) return false;
1972  const snapshot = normalizeStudyQueueSnapshot(
1973    queueContext
1974    || studyQueueSnapshotFromFeed(videoId)
1975    || {
1976      owner: studyQueueOwnerKey(),
1977      tab: state.activeTab,
1978      filters: {},
1979      items: [],
1980      total: 0,
1981      complete: false,
1982    },
1983  );
1984  state.studyQueue = snapshot;
1985  saveStudyQueueSnapshot();
1986  const url = new URL(window.location.href);
1987  url.pathname = "/study";
1988  url.search = "";
1989  url.searchParams.set("video", videoId);
1990  url.searchParams.set("queue", snapshot.tab);
1991  window.history.pushState(
1992    {
1993      section: "study",
1994      video: videoId,
1995      studyEntry: "internal",
1996      studyQueue: { tab: snapshot.tab, filters: snapshot.filters },
1997    },
1998    "",
1999    `${url.pathname}${url.search}`,
2000  );
2001  switchSection("study", { historyMode: "none" });
2002  return true;
2003}
2004
2005function navigateStudyQueue(direction) {
2006  if (state.studyQueueLoading || state.studyStateSaving) return false;
2007  const videoId = selectedStudyVideoId();
2008  const snapshot = normalizeStudyQueueSnapshot(state.studyQueue);
2009  const index = snapshot?.items.findIndex((item) => item.video_id === videoId) ?? -1;
2010  const nextIndex = index + Number(direction);
2011  const nextVideoId = snapshot?.items[nextIndex]?.video_id;
2012  if (!nextVideoId) return false;
2013  const url = new URL(window.location.href);
2014  url.pathname = "/study";
2015  url.searchParams.set("video", nextVideoId);
2016  url.searchParams.set("queue", snapshot.tab);
2017  window.history.replaceState(
2018    {
2019      ...(window.history.state || {}),
2020      section: "study",
2021      video: nextVideoId,
2022      studyEntry: "internal",
2023      studyQueue: { tab: snapshot.tab, filters: snapshot.filters },
2024    },
2025    "",
2026    `${url.pathname}${url.search}`,
2027  );
2028  switchSection("study", { historyMode: "none" });
2029  return true;
2030}
2031
2032async function updateStudyVideoState(nextState) {
2033  const videoId = selectedStudyVideoId();
2034  const snapshot = normalizeStudyQueueSnapshot(state.studyQueue);
2035  const currentIndex = snapshot?.items.findIndex((item) => item.video_id === videoId) ?? -1;
2036  if (!videoId || !snapshot || state.studyStateSaving || !["watch_later", "watched", "skipped"].includes(nextState)) return false;
2037  if (currentIndex >= 0 && snapshot.items[currentIndex].state === nextState) return true;
2038  state.studyStateSaving = true;
2039  renderStudyQueueControls();
2040  try {
2041    await persistVideoState(videoId, nextState);
2042    if (currentIndex >= 0) snapshot.items[currentIndex] = { ...snapshot.items[currentIndex], state: nextState };
2043    else snapshot.items.unshift({ video_id: videoId, state: nextState });
2044    state.studyQueue = snapshot;
2045    saveStudyQueueSnapshot();
2046    renderStudyQueueControls();
2047    await Promise.all([loadStats(), loadVideos()]);
2048    if (nextState === "watch_later" || nextState === "watched") window.ImmersionInstall?.showNudge();
2049    return true;
2050  } catch (error) {
2051    showNotice(error.message, "error");
2052    return false;
2053  } finally {
2054    state.studyStateSaving = false;
2055    renderStudyQueueControls();
2056  }
2057}
2058
2059function leaveStudy() {
2060  if (window.history.state?.studyEntry === "internal" && typeof window.history.back === "function") {
2061    window.history.back();
2062    return;
2063  }
2064  switchSection("feed", { historyMode: "replace" });
2065}
2066
2067async function submitStudyUrl(event) {
2068  event.preventDefault();
2069  if (!state.authUser || state.studyUrlLoading) return;
2070  const inputUrl = String(elements.studyUrlInput?.value || "").trim();
2071  clearStudyUrlError();
2072  if (!inputUrl) {
2073    showStudyUrlError("Paste a YouTube video link.");
2074    elements.studyUrlInput?.focus();
2075    return;
2076  }
2077  const requestVersion = ++studyUrlRequestVersion;
2078  setStudyUrlLoading(true);
2079  try {
2080    const data = await api("/api/study/resolve", {
2081      method: "POST",
2082      body: JSON.stringify({ url: inputUrl }),
2083    });
2084    if (requestVersion !== studyUrlRequestVersion) return;
2085    const videoId = normalizeYouTubeVideoId(data.video_id);
2086    if (!videoId) throw new Error("That YouTube link does not identify a video.");
2087    closeStudyUrlDialog({ restoreFocus: false, cancelPending: false });
2088    openStudyVideo(videoId);
2089  } catch (error) {
2090    if (requestVersion !== studyUrlRequestVersion) return;
2091    showStudyUrlError(error.message || "That YouTube video could not be opened.");
2092    elements.studyUrlInput?.focus();
2093  } finally {
2094    if (requestVersion === studyUrlRequestVersion) setStudyUrlLoading(false);
2095  }
2096}
2097
2098function syncCommunityPreviewState() {
2099  const isOpen = Boolean(elements.communityPreviewModal?.open || elements.communityPreviewModal?.hasAttribute("open"));
2100  document.body.classList.toggle("community-preview-open", isOpen);
2101}
2102
2103async function openCommunityPreview(communityId) {
2104  const modal = elements.communityPreviewModal;
2105  if (!modal) return;
2106  const community = state.communities.find((item) => String(item.id) === String(communityId)) || null;
2107  if (!community) return;
2108  state.communityPreview = {
2109    id: communityId,
2110    community,
2111    sources: [],
2112    sampleVideos: [],
2113    playingSampleId: null,
2114    hasMore: false,
2115    nextOffset: 0,
2116    loading: true,
2117  };
2118  if (elements.communityPreviewTitle) elements.communityPreviewTitle.textContent = community.name || "Community";
2119  if (elements.communityPreviewCopy) {
2120    elements.communityPreviewCopy.textContent = community.description || "Shared channels for one learning lane.";
2121  }
2122  if (elements.communityPreviewList) {
2123    elements.communityPreviewList.innerHTML = '<span class="community-preview-empty">Loading channels…</span>';
2124  }
2125  if (elements.communityPreviewMeta) elements.communityPreviewMeta.textContent = "Loading channels...";
2126  if (elements.communityPreviewSamples) {
2127    elements.communityPreviewSamples.hidden = true;
2128    elements.communityPreviewSamples.innerHTML = "";
2129  }
2130  if (elements.communityPreviewPager) elements.communityPreviewPager.innerHTML = "";
2131  renderCommunityPreviewFooter();
2132  if (!modal.open && !modal.hasAttribute("open")) {
2133    if (typeof modal.showModal === "function") modal.showModal();
2134    else modal.setAttribute("open", "");
2135  }
2136  syncCommunityPreviewState();
2137  window.requestAnimationFrame(() => {
2138    elements.communityPreviewClose?.focus();
2139  });
2140  await loadCommunityPreviewSources();
2141}
2142
2143function closeCommunityPreview({ restoreFocus = false } = {}) {
2144  const modal = elements.communityPreviewModal;
2145  if (!modal || (!modal.open && !modal.hasAttribute("open"))) return;
2146  const communityId = state.communityPreview.id;
2147  if (typeof modal.close === "function" && modal.open) modal.close();
2148  else modal.removeAttribute("open");
2149  if (state.communityPreview.playingSampleId) {
2150    state.communityPreview = { ...state.communityPreview, playingSampleId: null };
2151    renderCommunityPreviewSamples();
2152  }
2153  syncCommunityPreviewState();
2154  if (restoreFocus) {
2155    const trigger = document.querySelector(`[data-community-action="select"][data-community-id="${communityId}"]`);
2156    trigger?.focus();
2157  }
2158}
2159
2160async function loadCommunityPreviewSources({ append = false } = {}) {
2161  const preview = state.communityPreview;
2162  if (!preview.id) return;
2163  try {
2164    const params = new URLSearchParams({
2165      limit: "24",
2166      offset: String(append ? preview.nextOffset : 0),
2167      sort: "popular",
2168    });
2169    const data = await api(`/api/communities/${encodeURIComponent(preview.id)}/sources?${params}`);
2170    const sources = Array.isArray(data.sources) ? data.sources : [];
2171    const combined = append ? [...preview.sources, ...sources] : sources;
2172    const sampleVideos = append
2173      ? preview.sampleVideos
2174      : (Array.isArray(data.sample_videos) ? data.sample_videos.slice(0, 2) : []);
2175    state.communityPreview = {
2176      ...preview,
2177      sources: combined,
2178      sampleVideos,
2179      playingSampleId: append ? preview.playingSampleId : null,
2180      hasMore: Boolean(data.has_more),
2181      nextOffset: Number(data.next_offset || combined.length),
2182      loading: false,
2183      community: data.community || preview.community,
2184    };
2185    if (data.community) {
2186      state.communities = state.communities.map((item) => String(item.id) === String(preview.id)
2187        ? { ...item, ...data.community }
2188        : item);
2189    }
2190    renderCommunityPreviewList();
2191  } catch (error) {
2192    state.communityPreview = { ...preview, loading: false };
2193    if (elements.communityPreviewMeta) elements.communityPreviewMeta.textContent = "Could not load channels.";
2194    showNotice(error.message, "error");
2195  }
2196}
2197
2198function formatSampleDuration(seconds) {
2199  const total = Math.max(0, Math.floor(Number(seconds) || 0));
2200  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
2201}
2202
2203function renderCommunitySample(sample) {
2204  const videoId = String(sample.video_id || "");
2205  const title = sample.title || "Sample video";
2206  const playing = state.communityPreview.playingSampleId === videoId;
2207  const media = playing
2208    ? `<iframe class="community-sample-player" src="https://www.youtube-nocookie.com/embed/${encodeURIComponent(videoId)}?autoplay=1" title="${escapeAttribute(title)}" allow="autoplay; encrypted-media" allowfullscreen></iframe>`
2209    : `<button class="community-sample-play" data-sample-video-id="${escapeAttribute(videoId)}" type="button" aria-label="Play sample: ${escapeAttribute(title)}">
2210        ${sample.thumbnail_url ? `<img src="${escapeAttribute(sample.thumbnail_url)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : '<span class="thumb-placeholder">sample video</span>'}
2211        <span class="community-sample-play-icon" aria-hidden="true">▶</span>
2212        ${Number(sample.duration_seconds) > 0 ? `<span class="duration-badge">${escapeHtml(formatSampleDuration(sample.duration_seconds))}</span>` : ""}
2213      </button>`;
2214  return `<figure class="community-sample">
2215    <div class="community-sample-media">${media}</div>
2216    <figcaption>
2217      <strong class="community-sample-title">${escapeHtml(title)}</strong>
2218      ${sample.channel_name ? `<span class="community-sample-channel">${escapeHtml(sample.channel_name)}</span>` : ""}
2219    </figcaption>
2220  </figure>`;
2221}
2222
2223function renderCommunityPreviewSamples() {
2224  const container = elements.communityPreviewSamples;
2225  if (!container) return;
2226  const samples = state.communityPreview.sampleVideos || [];
2227  if (!samples.length) {
2228    container.hidden = true;
2229    container.innerHTML = "";
2230    return;
2231  }
2232  container.hidden = false;
2233  container.innerHTML = `<p class="community-preview-samples-title">Taste it first</p>
2234    <div class="community-sample-grid">${samples.map((sample) => renderCommunitySample(sample)).join("")}</div>`;
2235}
2236
2237function renderCommunityPreviewList() {
2238  const preview = state.communityPreview;
2239  const sources = preview.sources;
2240  renderCommunityPreviewSamples();
2241  if (elements.communityPreviewList) {
2242    elements.communityPreviewList.innerHTML = sources.length
2243      ? sources.map((source) => renderSourceCard(source)).join("")
2244      : '<div class="empty-state"><div class="empty-icon">✓</div><h3>No channels yet</h3><p>This community does not have any approved channels yet.</p></div>';
2245  }
2246  const community = preview.community || {};
2247  const sourceCount = Number(community.source_count ?? sources.length ?? 0);
2248  const memberCount = Number(community.member_count || 0);
2249  if (elements.communityPreviewMeta) {
2250    elements.communityPreviewMeta.textContent = `${sourceCount} channel${sourceCount === 1 ? "" : "s"} · ${memberCount} member${memberCount === 1 ? "" : "s"}`;
2251  }
2252  if (elements.communityPreviewPager) {
2253    elements.communityPreviewPager.innerHTML = preview.hasMore
2254      ? '<button class="ghost-button" data-community-preview-load-more type="button">Show more channels</button>'
2255      : "";
2256  }
2257  renderCommunityPreviewFooter();
2258}
2259
2260function renderCommunityPreviewFooter() {
2261  const preview = state.communityPreview;
2262  const community = state.communities.find((item) => String(item.id) === String(preview.id)) || preview.community;
2263  if (!elements.communityPreviewJoinSlot || !elements.communityPreviewFooterMeta || !community) return;
2264  if (!state.authUser) {
2265    elements.communityPreviewJoinSlot.innerHTML = '<a class="ghost-button action-link" href="/login">Log in to join</a>';
2266    elements.communityPreviewFooterMeta.textContent = "Log in to join this community.";
2267    return;
2268  }
2269  if (community.is_joined) {
2270    elements.communityPreviewJoinSlot.innerHTML = `<button class="ghost-button" data-community-action="leave" data-community-id="${escapeAttribute(community.id)}" type="button">Leave</button>`;
2271    elements.communityPreviewFooterMeta.textContent = "You're a member — its channels feed your Home.";
2272    return;
2273  }
2274  elements.communityPreviewJoinSlot.innerHTML = `<button class="accent-button" data-community-action="join" data-community-id="${escapeAttribute(community.id)}" type="button">Join this community</button>`;
2275  elements.communityPreviewFooterMeta.textContent = "Joining adds these channels to your Home feed.";
2276}
2277
2278async function submitSource(event) {
2279  event.preventDefault();
2280  if (!state.authUser || state.sourceInFlight) return;
2281  state.sourceInFlight = true;
2282  elements.sourcePreviewButton.disabled = true;
2283  elements.sourcePreviewButton.setAttribute("aria-busy", "true");
2284  elements.sourcePreviewButtonLabel.textContent = "Filling out the form...";
2285  try {
2286    const data = await api("/api/sources/preview", {
2287      method: "POST",
2288      body: JSON.stringify({ url: elements.sourceUrl.value.trim() }),
2289    });
2290    const proposal = data.proposal || {};
2291    elements.sourceDisplayName.value = proposal.display_name || "";
2292    elements.sourceHandle.value = proposal.handle ? `@${proposal.handle}` : "";
2293    elements.sourceChannelId.value = proposal.channel_id || "";
2294    elements.sourceCanonicalUrl.value = proposal.url || "";
2295    elements.sourceTags.value = (proposal.tags || []).map((tag) => tag.type === "topic" ? tag.value : `${tag.type}:${tag.value}`).join(", ");
2296    elements.sourceNote.value = proposal.note || "";
2297    elements.sourcePrivate.checked = Boolean(proposal.private);
2298    elements.sourcePreviewMeta.textContent = data.existing_source ? "This channel already exists. Approval will add it to your channels." : "Nothing has been submitted yet.";
2299    elements.sourceLinkStep.hidden = true;
2300    elements.sourcePreview.hidden = false;
2301  } catch (error) {
2302    showNotice(error.message, "error");
2303  } finally {
2304    state.sourceInFlight = false;
2305    elements.sourcePreviewButton.disabled = false;
2306    elements.sourcePreviewButton.setAttribute("aria-busy", "false");
2307    elements.sourcePreviewButtonLabel.textContent = "Preview channel";
2308  }
2309}
2310
2311async function approveSource() {
2312  if (!state.authUser || state.sourceInFlight || elements.sourcePreview.hidden) return;
2313  if (!elements.sourceDisplayName.value.trim()) {
2314    elements.sourceDisplayName.focus();
2315    showNotice("Add a display name before sharing.", "error");
2316    return;
2317  }
2318  state.sourceInFlight = true;
2319  elements.sourceSubmit.disabled = true;
2320  const tags = elements.sourceTags.value.split(",").map((value) => value.trim()).filter(Boolean).map((value) => {
2321    const [type, ...rest] = value.split(":");
2322    return { type: rest.length ? type : "topic", value: rest.length ? rest.join(":") : type };
2323  });
2324  try {
2325    const payload = {
2326      url: elements.sourceCanonicalUrl.value,
2327      handle: elements.sourceHandle.value.trim() || undefined,
2328      channel_id: elements.sourceChannelId.value || undefined,
2329      display_name: elements.sourceDisplayName.value.trim() || undefined,
2330      private: elements.sourcePrivate.checked,
2331      tags,
2332      note: elements.sourceNote.value.trim() || undefined,
2333    };
2334    await api("/api/sources", {
2335      method: "POST",
2336      body: JSON.stringify(payload),
2337    });
2338    resetSourceForm();
2339    closeSourceComposer({ restoreFocus: true });
2340    showNotice("Channel added to My Channels.", "success");
2341    await reloadSourceViews({ includeDiscover: true });
2342    window.ImmersionInstall?.showNudge();
2343  } catch (error) {
2344    showNotice(error.message, "error");
2345  } finally {
2346    state.sourceInFlight = false;
2347    elements.sourceSubmit.disabled = false;
2348  }
2349}
2350
2351function resetSourceForm() {
2352  elements.sourceForm.reset();
2353  elements.sourceLinkStep.hidden = false;
2354  elements.sourcePreview.hidden = true;
2355  elements.sourcePreviewMeta.textContent = "";
2356}
2357
2358async function toggleSource(sourceKey, makePrivate) {
2359  try {
2360    await api(`/api/me/sources/${encodeURIComponent(sourceKey)}`, { method: "PATCH", body: JSON.stringify({ private: makePrivate }) });
2361    await reloadSourceViews({ includeDiscover: true });
2362  } catch (error) {
2363    showNotice(error.message, "error");
2364  }
2365}
2366
2367async function removeSource(sourceKey) {
2368  const source = state.mySources.direct.find((item) => item.source_key === sourceKey);
2369  try {
2370    await api(`/api/me/sources/${encodeURIComponent(sourceKey)}`, { method: "DELETE" });
2371    const inheritedLabels = communityLabelsFor(source || {}, true);
2372    state.mySourcesFlash = inheritedLabels.length
2373      ? {
2374        kind: "info",
2375        sourceKey,
2376        message: `${source?.display_name || "That channel"} was removed from Directly Followed but still comes from ${inheritedLabels.map((item) => item.name).join(", ")}.`,
2377      }
2378      : null;
2379    await reloadSourceViews({ includeDiscover: true });
2380  } catch (error) {
2381    showNotice(error.message, "error");
2382  }
2383}
2384
2385async function hideInheritedSource(sourceKey, sourceName = "That channel") {
2386  try {
2387    await api(`/api/me/source-mutes/${encodeURIComponent(sourceKey)}`, { method: "PUT" });
2388    state.mySourcesFlash = {
2389      kind: "undo-hide",
2390      sourceKey,
2391      message: `${sourceName} is hidden from Home.`,
2392    };
2393    await reloadSourceViews();
2394  } catch (error) {
2395    showNotice(error.message, "error");
2396  }
2397}
2398
2399async function unhideInheritedSource(sourceKey) {
2400  try {
2401    await api(`/api/me/source-mutes/${encodeURIComponent(sourceKey)}`, { method: "DELETE" });
2402    if (state.mySourcesFlash?.sourceKey === sourceKey) state.mySourcesFlash = null;
2403    await reloadSourceViews();
2404  } catch (error) {
2405    showNotice(error.message, "error");
2406  }
2407}
2408
2409function normalizedSourceKey(value) {
2410  return String(value || "").trim().replace(/^@/, "").toLowerCase();
2411}
2412
2413function sourceMatchesKey(source, sourceKey) {
2414  const wanted = normalizedSourceKey(sourceKey);
2415  if (!wanted || !source) return false;
2416  return [source.source_key, source.channel_id, source.handle, source.source_identity]
2417    .some((value) => normalizedSourceKey(value) === wanted);
2418}
2419
2420function videoSourceContext(sourceKey) {
2421  const direct = state.mySources.direct.find((source) => sourceMatchesKey(source, sourceKey)) || null;
2422  const community = state.mySources.community.find((source) => sourceMatchesKey(source, sourceKey)) || null;
2423  const hidden = state.mySources.hidden.find((source) => sourceMatchesKey(source, sourceKey)) || null;
2424  const inherited = Boolean(community || communityLabelsFor(direct || {}, true).length);
2425  return { direct, community, hidden, inherited, source: direct || community || hidden };
2426}
2427
2428async function restoreDirectSources(sourceKey, sources) {
2429  if (!Array.isArray(sources) || !sources.length) return;
2430  await api(`/api/me/sources/${encodeURIComponent(sourceKey)}/restore`, {
2431    method: "POST",
2432    body: JSON.stringify({ sources }),
2433  });
2434}
2435
2436async function dismissVideoSource(card) {
2437  const sourceKey = card?.dataset.sourceKey;
2438  const sourceName = card?.dataset.sourceName || "That channel";
2439  const context = videoSourceContext(sourceKey);
2440  if (!sourceKey || (!context.direct && !context.inherited)) {
2441    showNotice("This channel could not be found in My Channels. Refresh and try again.", "error");
2442    return;
2443  }
2444
2445  card.classList.add("is-source-updating");
2446  let removedDirect = false;
2447  let removedSources = [];
2448  try {
2449    if (context.direct) {
2450      const result = await api(`/api/me/sources/${encodeURIComponent(sourceKey)}`, { method: "DELETE" });
2451      removedSources = Array.isArray(result.removed_sources) ? result.removed_sources : [];
2452      if (!removedSources.length) throw new Error("The channel was removed, but Undo data was not returned.");
2453      removedDirect = true;
2454    }
2455    if (context.inherited) {
2456      try {
2457        await api(`/api/me/source-mutes/${encodeURIComponent(sourceKey)}`, { method: "PUT" });
2458      } catch (error) {
2459        if (removedDirect) await restoreDirectSources(sourceKey, removedSources);
2460        throw error;
2461      }
2462    }
2463  } catch (error) {
2464    card.classList.remove("is-source-updating");
2465    showNotice(error.message, "error");
2466    return;
2467  }
2468
2469  state.sourceDismissUndo = {
2470    sourceKey,
2471    sourceName,
2472    removedSources,
2473    removedDirect,
2474    mutedCommunity: context.inherited,
2475  };
2476  renderHomeSourceUndo();
2477  try {
2478    await reloadSourceViews({ includeDiscover: removedDirect });
2479  } catch (error) {
2480    showNotice(error.message, "error");
2481  }
2482}
2483
2484async function undoVideoSourceDismissal() {
2485  const undo = state.sourceDismissUndo;
2486  if (!undo) return;
2487  elements.homeSourceUndoButton.disabled = true;
2488  try {
2489    if (undo.mutedCommunity) {
2490      await api(`/api/me/source-mutes/${encodeURIComponent(undo.sourceKey)}`, { method: "DELETE" });
2491    }
2492    if (undo.removedDirect) {
2493      await restoreDirectSources(undo.sourceKey, undo.removedSources);
2494    }
2495    state.sourceDismissUndo = null;
2496    renderHomeSourceUndo();
2497    await reloadSourceViews({ includeDiscover: undo.removedDirect });
2498  } catch (error) {
2499    elements.homeSourceUndoButton.disabled = false;
2500    showNotice(error.message, "error");
2501  }
2502}
2503
2504function renderHomeSourceUndo() {
2505  if (!elements.homeSourceUndo) return;
2506  const undo = state.sourceDismissUndo;
2507  elements.homeSourceUndo.hidden = !undo;
2508  if (!undo) return;
2509  elements.homeSourceUndoMessage.textContent = `${undo.sourceName} is hidden from Home.`;
2510  elements.homeSourceUndoButton.disabled = false;
2511}
2512
2513function closeVideoSourceMenus({ except = null, restoreFocus = false } = {}) {
2514  document.querySelectorAll("[data-video-source-menu][aria-expanded=\"true\"]").forEach((button) => {
2515    if (button === except) return;
2516    button.setAttribute("aria-expanded", "false");
2517    const menu = button.parentElement?.querySelector("[data-video-source-menu-panel]");
2518    if (menu) menu.hidden = true;
2519    if (restoreFocus) button.focus();
2520  });
2521}
2522
2523function toggleVideoSourceMenu(button) {
2524  const shouldOpen = button.getAttribute("aria-expanded") !== "true";
2525  closeVideoSourceMenus({ except: button });
2526  button.setAttribute("aria-expanded", String(shouldOpen));
2527  const menu = button.parentElement?.querySelector("[data-video-source-menu-panel]");
2528  if (menu) menu.hidden = !shouldOpen;
2529}
2530
2531async function setCommunityMembership(communityId, shouldJoin) {
2532  const community = state.communities.find((item) => String(item.id) === String(communityId));
2533  if (!community) return;
2534  try {
2535    await api(`/api/communities/${encodeURIComponent(communityId)}/membership`, {
2536      method: shouldJoin ? "PUT" : "DELETE",
2537    });
2538    state.communities = state.communities.map((item) => String(item.id) === String(communityId)
2539      ? {
2540        ...item,
2541        is_joined: shouldJoin,
2542        member_count: Math.max(0, Number(item.member_count || 0) + (shouldJoin ? 1 : -1)),
2543      }
2544      : item);
2545    if (shouldJoin) {
2546      state.sourceOnboarding = normalizeSourceOnboarding({
2547        ...(state.sourceOnboarding || {}),
2548        joined_community_count: Math.max(1, Number(state.sourceOnboarding?.joined_community_count || 0) + 1),
2549        is_ready: true,
2550        ready_reason: "community",
2551      }, state.mySources.direct);
2552    }
2553    clearNotice();
2554    renderCommunityDirectory();
2555    renderDiscoverContext();
2556    renderSourceOnboarding();
2557    if (state.activeSection === "my-sources") renderMySources();
2558    if (String(state.communityPreview.id) === String(communityId)) {
2559      renderCommunityPreviewFooter();
2560    }
2561    await reloadSourceViews();
2562  } catch (error) {
2563    showNotice(error.message, "error");
2564  }
2565}
2566
2567async function loadMoreDiscoverSources() {
2568  if (!state.discoverHasMore) return;
2569  try {
2570    const params = new URLSearchParams({
2571      limit: "24",
2572      offset: String(state.discoverNextOffset),
2573      sort: "popular",
2574    });
2575    if (state.activeDiscoverTag) params.set("tag", state.activeDiscoverTag);
2576    if (state.discoverSearch) params.set("q", state.discoverSearch);
2577    const data = await api(`/api/sources?${params}`);
2578    const next = Array.isArray(data.sources) ? data.sources : [];
2579    state.discoverSources = [...state.discoverSources, ...next];
2580    state.discoverHasMore = Boolean(data.has_more);
2581    state.discoverNextOffset = Number(data.next_offset || state.discoverSources.length || 0);
2582    updateDiscoverCounts(data);
2583    writeSourceSnapshot(discoverSnapshotKey(), {
2584      ...data,
2585      sources: state.discoverSources,
2586      total_count: state.discoverTotalCount,
2587      catalog_total: state.discoverCatalogTotal,
2588      tag_counts: state.discoverTagCounts,
2589      has_more: state.discoverHasMore,
2590      next_offset: state.discoverNextOffset,
2591    });
2592    renderDiscoverContext();
2593    renderDiscoverSources();
2594  } catch (error) {
2595    showNotice(error.message, "error");
2596  }
2597}
2598
2599async function addSourceFromCard(card) {
2600  if (!card || card.classList.contains("is-adding")) return;
2601  const button = card.querySelector('[data-source-action="add"]');
2602  card.classList.add("is-adding");
2603  button?.setAttribute("disabled", "");
2604  button?.setAttribute("aria-busy", "true");
2605  if (button) button.innerHTML = '<span class="button-spinner" aria-hidden="true"></span> Adding…';
2606  try {
2607    const data = await api("/api/sources", { method: "POST", body: JSON.stringify({ url: card.dataset.sourceUrl }) });
2608    const addedKeys = [card.dataset.sourceKey, data.source?.source_key].filter(Boolean);
2609    addedKeys.forEach((key) => recentlyAddedSources.add(key));
2610    card.classList.remove("is-adding");
2611    card.classList.add("source-card--added", "is-just-added");
2612    card.querySelector(".source-actions").innerHTML = renderAddedSourceAction();
2613    await reloadSourceViews();
2614    window.ImmersionInstall?.showNudge();
2615    window.setTimeout(() => addedKeys.forEach((key) => recentlyAddedSources.delete(key)), 1000);
2616  } catch (error) {
2617    card.classList.remove("is-adding");
2618    button?.removeAttribute("disabled");
2619    button?.removeAttribute("aria-busy");
2620    if (button) button.textContent = "+ Add to my feed";
2621    showNotice(error.message, "error");
2622  }
2623}
2624
2625function persistVideoState(videoId, nextState) {
2626  return api(`/api/videos/${encodeURIComponent(videoId)}/state`, {
2627    method: "POST",
2628    body: JSON.stringify({
2629      state: nextState,
2630      ...(nextState === "watched" ? { understanding_level: "medium" } : {}),
2631    }),
2632  });
2633}
2634
2635async function setVideoState(card, videoId, nextState) {
2636  if (!card || card.classList.contains("is-updating") || !videoId) return;
2637  card.classList.add("is-updating");
2638  try {
2639    await persistVideoState(videoId, nextState);
2640    await Promise.all([loadStats(), loadVideos()]);
2641    if (nextState === "watch_later" || nextState === "watched") window.ImmersionInstall?.showNudge();
2642  } catch (error) {
2643    showNotice(error.message, "error");
2644    card.classList.remove("is-updating");
2645  }
2646}
2647
2648function sortDownloadJobs(jobs) {
2649  return [...jobs].sort((left, right) => {
2650    const leftTime = Date.parse(left.requested_at || "") || 0;
2651    const rightTime = Date.parse(right.requested_at || "") || 0;
2652    return rightTime - leftTime;
2653  });
2654}
2655
2656function upsertDownloadJob(job) {
2657  if (!job?.id) return;
2658  state.downloadJobs = sortDownloadJobs([
2659    job,
2660    ...state.downloadJobs.filter((item) =>
2660 item.id !== job.id),
2661  ]);
2662}
2663
2664function isCurrentDownloadUser(user) {
2665  return Boolean(user && state.authUser === user);
2666}
2667
2668function safeHttpUrl(value) {
2669  try {
2670    const url = new URL(String(value || ""));
2671    return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
2672  } catch (_error) {
2673    return "";
2674  }
2675}
2676
2677function downloadStatusLabel(status) {
2678  return ({
2679    queued: "Waiting for helper",
2680    downloading: "Downloading",
2681    ready: "Ready",
2682    failed: "Failed",
2683  })[status] || "Waiting for helper";
2684}
2685
2686function studyStatusLabel(status) {
2687  return ({
2688    queued: "Study: waiting",
2689    processing: "Study: transcribing",
2690    ready: "Study ready",
2691    failed: "Study failed",
2692  })[status] || "";
2693}
2694
2695function safeStudyUrl(value) {
2696  try {
2697    const url = new URL(String(value || ""), window.location.origin);
2698    if (url.origin !== window.location.origin || url.pathname.replace(/\/+$/, "") !== "/study") return "";
2699    return `${url.pathname}${url.search}${url.hash}`;
2700  } catch (_error) {
2701    return "";
2702  }
2703}
2704
2705function normalizeYouTubeVideoId(value) {
2706  const videoId = String(value || "").trim();
2707  return /^[A-Za-z0-9_-]{6,20}$/.test(videoId) ? videoId : "";
2708}
2709
2710function studyVideoUrl(videoId) {
2711  const normalized = normalizeYouTubeVideoId(videoId);
2712  return normalized ? `/study?video=${encodeURIComponent(normalized)}` : "";
2713}
2714
2715function selectedDownloadJobId() {
2716  return new URLSearchParams(window.location.search).get("job") || "";
2717}
2718
2719function formatDownloadSize(value) {
2720  const bytes = Number(value || 0);
2721  if (!Number.isFinite(bytes) || bytes <= 0) return "";
2722  const megabytes = bytes / (1024 * 1024);
2723  return megabytes >= 10 ? `${Math.round(megabytes)} MB` : `${megabytes.toFixed(1)} MB`;
2724}
2725
2726function renderDownloadJob(job, selectedJobId = selectedDownloadJobId()) {
2727  const id = String(job.id || "");
2728  const rawStatus = String(job.status || "queued");
2729  const status = ["queued", "downloading", "ready", "failed"].includes(rawStatus) ? rawStatus : "queued";
2730  const title = escapeHtml(job.title || "YouTube video");
2731  const thumbnailUrl = safeHttpUrl(job.thumbnail_url);
2732  const downloadUrl = safeHttpUrl(job.download_url);
2733  const selectedClass = id && id === selectedJobId ? " is-selected" : "";
2734  const thumbnail = thumbnailUrl
2735    ? `<img src="${escapeAttribute(thumbnailUrl)}" alt="" loading="lazy" />`
2736    : '<span class="download-job-placeholder" aria-hidden="true">MP4</span>';
2737  const size = formatDownloadSize(job.file_size);
2738  const studyStatus = ["queued", "processing", "ready", "failed"].includes(String(job.study_status))
2739    ? String(job.study_status)
2740    : "";
2741  const studyUrl = safeStudyUrl(job.study_url);
2742  const videoId = normalizeYouTubeVideoId(job.video_id);
2743  const instantStudyUrl = studyVideoUrl(videoId);
2744  let action = "";
2745  let studyAction = "";
2746  let handoff = "";
2747  if (status === "ready") {
2748    if (downloadUrl) {
2749      const handoffId = `download-handoff-${id}`;
2750      action = `<a class="accent-button link-button download-mp4-link" href="${escapeAttribute(downloadUrl)}" target="_blank" rel="noopener" aria-describedby="${escapeAttribute(handoffId)}">Save MP4</a>`;
2751      handoff = `<p class="download-job-handoff" id="${escapeAttribute(handoffId)}">On iPhone, Save MP4 opens the download separately and may switch to Safari. Keep Tailscale connected.</p>`;
2752    } else {
2753      action = job.available_in_personal_helper
2754        ? '<span class="download-job-note">Saved in your Personal Media Helper.</span>'
2755        : '<span class="download-job-note">Download link unavailable.</span>';
2756    }
2757  } else if (status === "failed") {
2758    action = `<button class="ghost-button" data-download-retry="${escapeAttribute(id)}" type="button">Retry</button>`;
2759  }
2760  if (instantStudyUrl) {
2761    studyAction = `<a class="accent-button link-button study-open-link" href="${escapeAttribute(instantStudyUrl)}" data-study-video="${escapeAttribute(videoId)}">Study</a>`;
2762  } else if (status === "ready" && studyStatus === "ready" && studyUrl) {
2763    studyAction = `<a class="accent-button link-button study-open-link" href="${escapeAttribute(studyUrl)}" data-study-open="${escapeAttribute(id)}">Study</a>`;
2764  } else if (status === "ready" && studyStatus === "failed") {
2765    studyAction = `<button class="ghost-button" data-study-retry="${escapeAttribute(id)}" type="button">Retry Study</button>`;
2766  }
2767  const error = status === "failed" && job.error_message
2768    ? `<p class="download-job-error">${escapeHtml(job.error_message)}</p>`
2769    : "";
2770  const studyState = studyStatus
2771    ? `<p class="download-job-study download-job-study-${escapeAttribute(studyStatus)}">${escapeHtml(studyStatusLabel(studyStatus))}</p>`
2772    : "";
2773  return `<article class="download-job${selectedClass}" data-download-job-id="${escapeAttribute(id)}">
2774    <div class="download-job-thumb">${thumbnail}</div>
2775    <div class="download-job-body">
2776      <div class="download-job-heading"><h3>${title}</h3><span class="download-status download-status-${escapeAttribute(status)}">${downloadStatusLabel(status)}</span></div>
2777      ${error}
2778      ${studyState}
2779      ${handoff}
2780      <div class="download-job-footer">${size ? `<span>${escapeHtml(size)}</span>` : ""}<span class="download-job-actions">${action}${studyAction}</span></div>
2781    </div>
2782  </article>`;
2783}
2784
2785function renderDownloads() {
2786  if (!elements.downloadsList || !elements.downloadsMeta) return;
2787  const jobs = sortDownloadJobs(state.downloadJobs);
2788  state.downloadJobs = jobs;
2789  elements.downloadsMeta.textContent = `${jobs.length} recent download${jobs.length === 1 ? "" : "s"}`;
2790  elements.downloadsList.innerHTML = jobs.length
2791    ? jobs.map((job) => renderDownloadJob(job)).join("")
2792    : '<div class="empty-state"><div class="empty-icon">↓</div><h3>No downloads yet</h3><p>Set up your Personal Media Helper in Profile, then use the download button on a Home video.</p></div>';
2793}
2794
2795function hasActiveDownloadJobs() {
2796  return state.downloadJobs.some((job) => job.status === "queued"
2797    || job.status === "downloading"
2798    || job.study_status === "queued"
2799    || job.study_status === "processing");
2800}
2801
2802function stopDownloadPolling() {
2803  if (downloadPollTimer !== null) window.clearTimeout(downloadPollTimer);
2804  downloadPollTimer = null;
2805}
2806
2807function syncDownloadPolling() {
2808  const shouldPoll = state.activeSection === "downloads"
2809    && Boolean(state.authUser)
2810    && hasActiveDownloadJobs();
2811  if (!shouldPoll) {
2812    stopDownloadPolling();
2813    return;
2814  }
2815  if (downloadPollTimer !== null) return;
2816  downloadPollTimer = window.setTimeout(async () => {
2817    downloadPollTimer = null;
2818    if (state.activeSection !== "downloads" || !state.authUser) return;
2819    await loadDownloads({ silent: true });
2820  }, DOWNLOAD_POLL_INTERVAL_MS);
2821}
2822
2823async function loadDownloads({ silent = false } = {}) {
2824  if (!state.authUser) {
2825    state.downloadJobs = [];
2826    stopDownloadPolling();
2827    return;
2828  }
2829  const requestingUser = state.authUser;
2830  if (state.downloadsLoading) return;
2831  state.downloadsLoading = true;
2832  if (!silent && elements.downloadsMeta) elements.downloadsMeta.textContent = "Loading downloads...";
2833  try {
2834    const data = await api("/api/downloads", { cache: "no-store" });
2835    if (!isCurrentDownloadUser(requestingUser)) return;
2836    state.downloadJobs = sortDownloadJobs(Array.isArray(data.jobs) ? data.jobs : []);
2837    renderDownloads();
2838  } catch (error) {
2839    if (!isCurrentDownloadUser(requestingUser)) return;
2840    if (!silent) showNotice(error.message, "error");
2841    if (!state.downloadJobs.length && elements.downloadsList) {
2842      elements.downloadsList.innerHTML = `<div class="empty-state error-state"><div class="empty-icon">!</div><h3>Downloads could not load</h3><p>${escapeHtml(error.message)}</p></div>`;
2843    }
2844  } finally {
2845    state.downloadsLoading = false;
2846    syncDownloadPolling();
2847  }
2848}
2849
2850function selectDownloadJob(jobId) {
2851  if (!jobId) return;
2852  const url = new URL(window.location.href);
2853  url.searchParams.set("job", jobId);
2854  window.history.replaceState({ section: "downloads", job: jobId }, "", `${url.pathname}${url.search}${url.hash}`);
2855  renderDownloads();
2856}
2857
2858async function requestVideoDownload(button) {
2859  if (!state.authUser || button.disabled) return;
2860  const requestingUser = state.authUser;
2861  const videoId = String(button.dataset.downloadVideo || "").trim();
2862  if (!videoId) return;
2863  button.disabled = true;
2864  button.setAttribute("aria-busy", "true");
2865  try {
2866    const data = await api("/api/downloads", {
2867      method: "POST",
2868      body: JSON.stringify({ video_id: videoId }),
2869    });
2870    if (!isCurrentDownloadUser(requestingUser)) return;
2871    if (data.job) upsertDownloadJob(data.job);
2872    showNotice("Download requested.", "success");
2873    switchSection("downloads");
2874    if (data.job?.id) selectDownloadJob(data.job.id);
2875  } catch (error) {
2876    if (!isCurrentDownloadUser(requestingUser)) return;
2877    showNotice(error.message, "error");
2878  } finally {
2879    button.disabled = false;
2880    button.removeAttribute("aria-busy");
2881  }
2882}
2883
2884async function retryDownloadJob(button) {
2885  if (!state.authUser || button.disabled) return;
2886  const requestingUser = state.authUser;
2887  const jobId = String(button.dataset.downloadRetry || "").trim();
2888  if (!jobId) return;
2889  button.disabled = true;
2890  button.setAttribute("aria-busy", "true");
2891  try {
2892    const data = await api(`/api/downloads/${encodeURIComponent(jobId)}/retry`, { method: "POST" });
2893    if (!isCurrentDownloadUser(requestingUser)) return;
2894    if (data.job) upsertDownloadJob(data.job);
2895    renderDownloads();
2896    syncDownloadPolling();
2897    showNotice("Download queued again.", "success");
2898  } catch (error) {
2899    if (!isCurrentDownloadUser(requestingUser)) return;
2900    showNotice(error.message, "error");
2901  } finally {
2902    button.disabled = false;
2903    button.removeAttribute("aria-busy");
2904  }
2905}
2906
2907async function retryStudyJob(button) {
2908  if (!state.authUser || button.disabled) return;
2909  const requestingUser = state.authUser;
2910  const jobId = String(button.dataset.studyRetry || selectedStudyJobId()).trim();
2911  if (!jobId) return;
2912  button.disabled = true;
2913  button.setAttribute("aria-busy", "true");
2914  try {
2915    const data = await api(`/api/study/jobs/${encodeURIComponent(jobId)}/retry`, { method: "POST" });
2916    if (!isCurrentDownloadUser(requestingUser)) return;
2917    const study = data.study || {};
2918    const downloadJob = state.downloadJobs.find((job) => String(job.id) === jobId);
2919    if (downloadJob) {
2920      downloadJob.study_status = study.status || "queued";
2921      downloadJob.study_url = study.url || downloadJob.study_url;
2922      renderDownloads();
2923      syncDownloadPolling();
2924    }
2925    if (state.activeSection === "study") renderStudyState(study);
2926    showNotice("Study transcription queued again.", "success");
2927  } catch (error) {
2928    if (!isCurrentDownloadUser(requestingUser)) return;
2929    showNotice(error.message, "error");
2930  } finally {
2931    button.disabled = false;
2932    button.removeAttribute("aria-busy");
2933  }
2934}
2935
2936async function handleDelegatedClick(event) {
2937  const downloadButton = event.target.closest("[data-download-video]");
2938  if (downloadButton) {
2939    event.preventDefault();
2940    event.stopPropagation();
2941    await requestVideoDownload(downloadButton);
2942    return;
2943  }
2944  const downloadRetry = event.target.closest("[data-download-retry]");
2945  if (downloadRetry) {
2946    await retryDownloadJob(downloadRetry);
2947    return;
2948  }
2949  const studyWhisper = event.target.closest("[data-study-whisper]");
2950  if (studyWhisper) {
2951    await requestWhisperTranscription(studyWhisper);
2952    return;
2953  }
2954  const studyHelper = event.target.closest("[data-study-helper]");
2955  if (studyHelper) {
2956    await requestPersonalHelperCaptions(selectedStudyVideoId(), studyHelper);
2957    return;
2958  }
2959  const studyQueueNavigation = event.target.closest("[data-study-queue-direction]");
2960  if (studyQueueNavigation) {
2961    navigateStudyQueue(Number(studyQueueNavigation.dataset.studyQueueDirection));
2962    return;
2963  }
2964  const studyStateAction = event.target.closest("[data-study-state]");
2965  if (studyStateAction) {
2966    await updateStudyVideoState(studyStateAction.dataset.studyState);
2967    return;
2968  }
2969  const studyRetry = event.target.closest("[data-study-retry]");
2970  if (studyRetry) {
2971    await retryStudyJob(studyRetry);
2972    return;
2973  }
2974  const studyOpen = event.target.closest("[data-study-open]");
2975  if (studyOpen) {
2976    event.preventDefault();
2977    const jobId = String(studyOpen.dataset.studyOpen || "").trim();
2978    if (!jobId) return;
2979    const url = new URL(window.location.href);
2980    url.pathname = "/study";
2981    url.search = "";
2982    url.searchParams.set("job", jobId);
2983    window.history.pushState(
2984      { section: "study", job: jobId, studyEntry: "internal" },
2985      "",
2986      `${url.pathname}${url.search}`,
2987    );
2988    switchSection("study", { historyMode: "none" });
2989    return;
2990  }
2991  const studyVideo = event.target.closest("[data-study-video]");
2992  if (studyVideo) {
2993    event.preventDefault();
2994    const videoId = normalizeYouTubeVideoId(studyVideo.dataset.studyVideo);
2995    if (!videoId) return;
2996    openStudyVideo(videoId);
2997    return;
2998  }
2999  const studyWord = event.target.closest("[data-study-word]");
3000  if (studyWord) {
3001    await openStudyDictionary(studyWord);
3002    return;
3003  }
3004  const savedTab = event.target.closest("[data-saved-tab]");
3005  if (savedTab) {
3006    setSavedStudyTab(savedTab.dataset.savedTab);
3007    return;
3008  }
3009  if (event.target.closest("[data-saved-retry], #savedRetry")) {
3010    loadSavedStudyItems();
3011    return;
3012  }
3013  const savedWordRemove = event.target.closest("[data-saved-word-remove]");
3014  if (savedWordRemove) {
3015    await removeSavedStudyWord(savedWordRemove.dataset.savedWordRemove, savedWordRemove);
3016    return;
3017  }
3018  const studyCue = event.target.closest("[data-study-cue-index]");
3019  if (studyCue) {
3020    seekToStudyCue(Number(studyCue.dataset.studyCueIndex));
3021    return;
3022  }
3023  const sourceMenuToggle = event.target.closest("[data-video-source-menu]");
3024  if (!sourceMenuToggle) closeVideoSourceMenus();
3025  if (sourceMenuToggle) {
3026    toggleVideoSourceMenu(sourceMenuToggle);
3027    return;
3028  }
3029  const videoSourceAction = event.target.closest("[data-video-source-action]");
3030  if (videoSourceAction) {
3031    const card = videoSourceAction.closest(".video-card");
3032    const action = videoSourceAction.dataset.videoSourceAction;
3033    closeVideoSourceMenus();
3034    if (action === "view") viewSourceFeed(card?.dataset.sourceKey, card?.dataset.sourceName);
3035    if (action === "dismiss") dismissVideoSource(card);
3036    if (action === "manage") switchSection("my-sources");
3037    return;
3038  }
3039  if (event.target.closest("#homeSourceUndoButton")) {
3040    undoVideoSourceDismissal();
3041    return;
3042  }
3043  const scrollTarget = event.target.closest("[data-scroll-target]");
3044  if (scrollTarget) {
3045    event.preventDefault();
3046    const targetId = scrollTarget.dataset.scrollTarget;
3047    if (targetId === "guestHowItWorks") {
3048      if (state.activeSection !== "feed") switchSection("feed");
3049      if (elements.guestHowItWorks && !elements.guestHowItWorks.classList.contains("is-open")) toggleGuestHow();
3050    }
3051    scrollToGuestTarget(targetId);
3052    return;
3053  }
3054  if (event.target.closest("#guestHowToggle")) {
3055    toggleGuestHow();
3056    return;
3057  }
3058  const onboardingAction = event.target.closest("[data-source-onboarding-action]");
3059  if (onboardingAction) {
3060    const action = onboardingAction.dataset.sourceOnboardingAction;
3061    finishSourceOnboarding(action === "skip" ? "skipped" : "completed");
3062    return;
3063  }
3064  const dynamicTab = event.target.closest("[data-tab]");
3065  if (dynamicTab && !dynamicTab.closest(".queue-tabs")) {
3066    if (!state.authUser && dynamicTab.dataset.tab !== "new") {
3067      window.location.assign("/login");
3068      return;
3069    }
3070    state.activeTab = dynamicTab.dataset.tab;
3071    renderTabs();
3072    loadVideos();
3073    return;
3074  }
3075  const shareButton = event.target.closest("[data-share-url]");
3076  if (shareButton) {
3077    event.preventDefault();
3078    event.stopPropagation();
3079    await shareVideo(shareButton);
3080    return;
3081  }
3082  const topic = event.target.closest("[data-topic]");
3083  if (topic) {
3084    setFeedTopic(topic.dataset.topic);
3085    return;
3086  }
3087  const discoverTag = event.target.closest("[data-discover-tag]");
3088  if (discoverTag) {
3089    loadDiscoverTag(discoverTag.dataset.discoverTag);
3090    return;
3091  }
3092  const clearDiscoverSearch = event.target.closest("[data-discover-clear-search]");
3093  if (clearDiscoverSearch) {
3094    state.discoverSearch = "";
3095    if (elements.discoverSearch) elements.discoverSearch.value = "";
3096    loadDiscover();
3097    return;
3098  }
3099  const clearDiscoverFilters = event.target.closest("[data-discover-clear-filters]");
3100  if (clearDiscoverFilters) {
3101    state.activeDiscoverTag = "";
3102    state.discoverSearch = "";
3103    if (elements.discoverSearch) elements.discoverSearch.value = "";
3104    renderDiscoverFilters();
3105    loadDiscover();
3106    return;
3107  }
3108  const discoverPagerAction = event.target.closest("[data-discover-load-more]");
3109  if (discoverPagerAction) {
3110    loadMoreDiscoverSources();
3111    return;
3112  }
3113  const communityDirectoryToggle = event.target.closest("[data-community-directory-toggle]");
3114  if (communityDirectoryToggle) {
3115    state.discoverCommunitiesExpanded = !state.discoverCommunitiesExpanded;
3116    renderCommunityDirectory();
3117    return;
3118  }
3119  const samplePlayButton = event.target.closest("[data-sample-video-id]");
3120  if (samplePlayButton) {
3121    state.communityPreview = { ...state.communityPreview, playingSampleId: samplePlayButton.dataset.sampleVideoId };
3122    renderCommunityPreviewSamples();
3123    return;
3124  }
3125  const communityAction = event.target.closest("[data-community-action]");
3126  if (communityAction) {
3127    const communityId = communityAction.dataset.communityId;
3128    if (communityAction.dataset.communityAction === "select") {
3129      openCommunityPreview(communityId);
3130      return;
3131    }
3132    if (!state.authUser) {
3133      window.location.assign("/login");
3134      return;
3135    }
3136    if (communityAction.dataset.communityAction === "join") setCommunityMembership(communityId, true);
3137    if (communityAction.dataset.communityAction === "leave") setCommunityMembership(communityId, false);
3138    return;
3139  }
3140  const communityPreviewLoadMore = event.target.closest("[data-community-preview-load-more]");
3141  if (communityPreviewLoadMore) {
3142    loadCommunityPreviewSources({ append: true });
3143    return;
3144  }
3145  const mySourcesAction = event.target.closest("[data-my-sources-action]");
3146  if (mySourcesAction?.dataset.mySourcesAction === "undo-hide") {
3147    unhideInheritedSource(mySourcesAction.dataset.sourceKey);
3148    return;
3149  }
3150  const reviewAction = event.target.closest("[data-review-action]");
3151  if (reviewAction) {
3152    updateReviewSubmission(
3153      reviewAction.dataset.communityId,
3154      reviewAction.dataset.submissionId,
3155      reviewAction.dataset.reviewAction,
3156    );
3157    return;
3158  }
3159  const showShortVideos = event.target.closest("[data-show-short-videos]");
3160  if (showShortVideos) {
3161    saveShortVideoPreference(false);
3162    return;
3163  }
3164  const showLongVideos = event.target.closest("[data-show-long-videos]");
3165  if (showLongVideos) {
3166    saveLongVideoPreference(false);
3167    return;
3168  }
3169  const feedCutoffAction = event.target.closest("[data-feed-cutoff-action]");
3170  if (feedCutoffAction) {
3171    const action = feedCutoffAction.dataset.feedCutoffAction;
3172    if (action === "dismiss") {
3173      state.feedCutoffPromptDismissed = true;
3174      renderFeedCutoff();
3175      return;
3176    }
3177    applyFeedCutoff(action);
3178    return;
3179  }
3180  const goSection = event.target.closest("[data-go-section]");
3181  if (goSection) {
3182    if (goSection.closest("#filterOverlay")) closeFilterSheet();
3183    switchSection(goSection.dataset.goSection);
3184    return;
3185  }
3186  const featuredButton = event.target.closest("[data-featured-state]");
3187  if (featuredButton && state.featured?.video_id) {
3188    setVideoState(elements.featuredHero, state.featured.video_id, featuredButton.dataset.featuredState);
3189    return;
3190  }
3191  const videoButton = event.target.closest("[data-next-state]");
3192  if (videoButton) {
3193    const card = videoButton.closest(".video-card");
3194    setVideoState(card, card?.dataset.videoId, videoButton.dataset.nextState);
3195    return;
3196  }
3197  const sourceAction = event.target.closest("[data-source-action]");
3198  if (sourceAction) {
3199    const card = sourceAction.closest("[data-source-key]");
3200    const key = card?.dataset.sourceKey;
3201    if (sourceAction.dataset.sourceAction === "add") addSourceFromCard(card);
3202    if (sourceAction.dataset.sourceAction === "view-feed") viewSourceFeed(key, card?.dataset.sourceName);
3203    if (sourceAction.dataset.sourceAction === "private") toggleSource(key, true);
3204    if (sourceAction.dataset.sourceAction === "public") toggleSource(key, false);
3205    if (sourceAction.dataset.sourceAction === "remove") removeSource(key);
3206    if (sourceAction.dataset.sourceAction === "hide") hideInheritedSource(key, card?.dataset.sourceName || "That source");
3207    if (sourceAction.dataset.sourceAction === "unhide") unhideInheritedSource(key);
3208    return;
3209  }
3210}
3211
3212function selectedStudyJobId() {
3213  return new URLSearchParams(window.location.search).get("job") || "";
3214}
3215
3216function selectedStudyVideoId() {
3217  return normalizeYouTubeVideoId(new URLSearchParams(window.location.search).get("video"));
3218}
3219
3220function formatStudyTime(value) {
3221  const seconds = Math.max(0, Number(value) || 0);
3222  const minutes = Math.floor(seconds / 60);
3223  return `${minutes}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`;
3224}
3225
3226function normalizeStudyCues(study) {
3227  const cues = Array.isArray(study) ? study : (Array.isArray(study?.cues) ? study.cues : []);
3228  return cues.map((cue, index) => {
3229    const start = Number(cue?.start ?? cue?.start_seconds ?? 0);
3230    const endValue = Number(cue?.end ?? cue?.end_seconds);
3231    const text = String(cue?.text || "").trim();
3232    return {
3233      index: Number.isInteger(cue?.index) ? cue.index : index,
3234      start: Number.isFinite(start) && start >= 0 ? start : 0,
3235      end: Number.isFinite(endValue) && endValue > start ? endValue : start + 4,
3236      text,
3237      tokens: normalizeSuppliedStudyTokens(cue?.tokens, text),
3238    };
3239  }).filter((cue) => cue.text);
3240}
3241
3242function normalizeSuppliedStudyTokens(value, cueText) {
3243  if (!Array.isArray(value)) return null;
3244  const characters = Array.from(cueText);
3245  let previousEnd = 0;
3246  const tokens = [];
3247  for (const token of value) {
3248    const start = token?.start;
3249    const end = token?.end;
3250    const surface = typeof token?.surface === "string" ? token.surface : "";
3251    if (
3252      !Number.isInteger(start)
3253      || !Number.isInteger(end)
3254      || start < previousEnd
3255      || end <= start
3256      || end > characters.length
3257      || !surface
3258      || characters.slice(start, end).join("") !== surface
3259      || !isStudyTokenGap(characters.slice(previousEnd, start).join(""))
3260    ) return null;
3261    tokens.push({ ...token, start, end, surface });
3262    previousEnd = end;
3263  }
3264  if (!isStudyTokenGap(characters.slice(previousEnd).join(""))) return null;
3265  return tokens;
3266}
3267
3268function isStudyTokenGap(value) {
3269  return Array.from(value).every((character) => (
3270    /[\p{P}\p{S}\p{Z}\s]/u.test(character)
3271    || character === "\u200d"
3272    || /[\ufe00-\ufe0f\u{e0100}-\u{e01ef}]/u.test(character)
3273  ));
3274}
3275
3276function normalizeStudyTrack(track, fallbackStatus = "not_requested") {
3277  const value = track && typeof track === "object" ? track : {};
3278  const cues = normalizeStudyCues(value);
3279  const rawStatus = String(value.status || fallbackStatus || "not_requested");
3280  const status = ["not_requested", "loading", "queued", "processing", "ready", "unavailable", "failed"].includes(rawStatus)
3281    ? rawStatus
3282    : (cues.length ? "ready" : "not_requested");
3283  return {
3284    ...value,
3285    status: cues.length && status === "not_requested" ? "ready" : status,
3286    cues,
3287    error_message: String(value.error_message || ""),
3288  };
3289}
3290
3291function normalizeStudyState(study) {
3292  const value = study && typeof study === "object" ? study : {};
3293  const hasAggregateTracks = Boolean(value.youtube || value.youtube_captions || value.whisper);
3294  const youtube = normalizeStudyTrack(value.youtube || value.youtube_captions);
3295  const whisper = normalizeStudyTrack(
3296    value.whisper || (hasAggregateTracks ? null : value),
3297    hasAggregateTracks ? "not_requested" : String(value.status || "not_requested"),
3298  );
3299  const requestedSource = String(value.active_source || "");
3300  const activeSource = whisper.status === "ready" && whisper.cues.length
3301    ? "whisper"
3302    : (youtube.status === "ready" && youtube.cues.length ? "youtube" : (requestedSource === "whisper" || requestedSource === "youtube" ? requestedSource : ""));
3303  const activeTrack = activeSource === "whisper" ? whisper : (activeSource === "youtube" ? youtube : null);
3304  return {
3305    ...value,
3306    download_job_id: String(value.download_job_id || whisper.download_job_id || selectedStudyJobId()),
3307    video_id: normalizeYouTubeVideoId(value.video_id || selectedStudyVideoId()),
3308    status: whisper.status,
3309    youtube,
3310    youtube_captions: youtube,
3311    whisper,
3312    active_source: activeTrack?.cues?.length ? activeSource : "",
3313    cues: activeTrack?.cues || [],
3314  };
3315}
3316
3317function studyNeedsPolling(study = state.study) {
3318  const activeStatuses = new Set(["loading", "queued", "processing", "downloading"]);
3319  return Boolean(study && (
3320    activeStatuses.has(study.youtube?.status)
3321    || activeStatuses.has(study.whisper?.status)
3322    || activeStatuses.has(study.whisper?.download_status)
3323  ));
3324}
3325
3326function stopStudyStatusPolling() {
3327  if (studyStatusPollTimer !== null) window.clearTimeout(studyStatusPollTimer);
3328  studyStatusPollTimer = null;
3329}
3330
3331function scheduleStudyStatusPoll() {
3332  stopStudyStatusPolling();
3333  if (state.activeSection !== "study" || !state.authUser) return;
3334  if (!studyNeedsPolling()) return;
3335  studyStatusPollTimer = window.setTimeout(async () => {
3336    studyStatusPollTimer = null;
3337    if (state.activeSection === "study" && state.authUser) {
3338      await loadStudy({ silent: true, statusOnly: true });
3339    }
3340  }, DOWNLOAD_POLL_INTERVAL_MS);
3341}
3342
3343function destroyStudyPlayer() {
3344  if (studySyncTimer !== null) window.clearInterval(studySyncTimer);
3345  studySyncTimer = null;
3346  studyDictionaryPausedPlayer = null;
3347  studyWordHoverPausedPlayer = null;
3348  studyCaptionOverlayDrag = null;
3349  try {
3350    studyPlayer?.destroy?.();
3351  } catch (_error) {
3352    // The YouTube API may already have removed its iframe.
3353  }
3354  studyPlayer = null;
3355  studyPlayerVideoId = "";
3356}
3357
3358function destroyStudyExperience() {
3359  stopStudyStatusPolling();
3360  destroyStudyPlayer();
3361  studyRequestVersion += 1;
3362  studyDictionaryRequestVersion += 1;
3363  state.studyLoading = false;
3364  state.studyWhisperLoading = false;
3365  cancelStudyAudioProbe({ render: false });
3366  studyWhisperRequestVideoId = "";
3367  notifyStudyExtensionCaptionWaiters();
3368  studyExtensionCaptionInFlight.clear();
3369  studyExtensionCaptionWaiting.clear();
3370  studyHelperCaptionInFlight.clear();
3371  studyHelperCaptionVideos.clear();
3372  studyTranscriptSignature = "";
3373  if (elements.studyDictionaryDialog?.open) elements.studyDictionaryDialog.close();
3374}
3375
3376function showStudyStatus(message, kind = "loading") {
3377  if (!elements.studyStatus || !elements.studyContent) return;
3378  elements.studyStatus.hidden = false;
3379  elements.studyStatus.className = `study-status study-status-${kind}`;
3380  elements.studyStatus.replaceChildren();
3381  const copy = document.createElement("p");
3382  copy.textContent = message;
3383  elements.studyStatus.append(copy);
3384  elements.studyContent.hidden = true;
3385}
3386
3387function renderStudyFailure(study) {
3388  showStudyStatus(study.error_message || "The transcription did not finish.", "failed");
3389  const retry = document.createElement("button");
3390  retry.className = "ghost-button";
3391  retry.type = "button";
3392  retry.dataset.studyRetry = study.download_job_id || selectedStudyJobId();
3393  retry.textContent = "Retry transcription";
3394  elements.studyStatus.append(retry);
3395}
3396
3397function setStudyTranscriptStatus(message, kind = "") {
3398  if (!elements.studyTranscriptStatus) return;
3399  elements.studyTranscriptStatus.textContent = message;
3400  elements.studyTranscriptStatus.hidden = !message;
3401  if (kind) elements.studyTranscriptStatus.dataset.kind = kind;
3402  else delete elements.studyTranscriptStatus.dataset.kind;
3403}
3404
3405function renderStudyTranscriptState(study) {
3406  const youtube = study.youtube || normalizeStudyTrack(null);
3407  const whisper = study.whisper || normalizeStudyTrack(null);
3408  const activeSource = study.active_source;
3409  if (elements.studyTranscriptSource) {
3410    elements.studyTranscriptSource.textContent = activeSource === "whisper"
3411      ? "Whisper captions"
3412      : (activeSource === "youtube" ? "YouTube captions" : "Captions");
3413    elements.studyTranscriptSource.dataset.source = activeSource || "loading";
3414  }
3415  if (elements.studyTranscriptSourceDetail) {
3416    const trackKind = youtube.track_kind === "manual" ? "Manual" : (youtube.track_kind === "auto" ? "Auto" : "");
3417    const language = String((activeSource === "whisper" ? whisper.language : youtube.language) || "").toUpperCase();
3418    elements.studyTranscriptSourceDetail.textContent = activeSource === "youtube"
3419      ? [trackKind, language].filter(Boolean).join(" · ")
3420      : (activeSource === "whisper" ? [whisper.model, language].filter(Boolean).join(" · ") : "");
3421  }
3422
3423  const directAudioAvailable = hasStudyDirectAudioBridge();
3424  const audioProbe = directAudioAvailable ? state.studyAudioProbe : null;
3425  const audioProbeBusy = ["requested", "resolving", "fetching", "transcribing", "cancelling"].includes(audioProbe?.status);
3426  const whisperBusy = state.studyWhisperLoading || ["loading", "queued", "processing"].includes(whisper.status) || ["queued", "downloading"].includes(whisper.download_status);
3427  const whisperActionBusy = audioProbeBusy || whisperBusy;
3428  const whisperFailed = audioProbe?.status === "failed" || whisper.status === "failed";
3429  const helperDevice = state.downloadDevices[0] || null;
3430  const helperOffline = Boolean(helperDevice && !helperDevice.online);
3431  const helperUnavailable = !helperDevice?.online;
3432  const whisperUsesHelper = whisper.hosted === false;
3433  const waitingForDirectCaptions = studyExtensionCaptionWaiting.has(study.video_id);
3434  const youtubeUsesHelper = studyHelperCaptionVideos.has(study.video_id)
3435    || ["queued", "processing"].includes(youtube.status);
3436  const youtubeHelperBusy = youtubeUsesHelper && ["queued", "processing"].includes(youtube.status);
3437  const youtubeHelperFailed = youtubeUsesHelper && ["failed", "unavailable"].includes(youtube.status) && !activeSource;
3438  const whisperHelperFailed = whisperUsesHelper && whisper.status === "failed";
3439  const helperAvailabilityKnown = state.downloadDevicesLoaded;
3440  const helperNeedsAttention = helperAvailabilityKnown && helperUnavailable
3441    && (youtubeHelperBusy || youtubeHelperFailed || (whisperUsesHelper && whisperBusy) || whisperHelperFailed);
3442  if (elements.studyHelperReadiness) {
3443    elements.studyHelperReadiness.hidden = !helperNeedsAttention;
3444    if (helperNeedsAttention) {
3445      const helperWasSetUp = Boolean(helperDevice);
3446      elements.studyHelperReadinessTitle.textContent = helperWasSetUp
3447        ? "Your helper needs a quick check"
3448        : "Study captions need a quick setup";
3449      elements.studyHelperReadinessCopy.textContent = helperWasSetUp
3450        ? "We cannot see whether Docker or the helper stopped, so check these:"
3451        : "Set up the helper once on your computer, then come back here:";
3452    }
3453  }
3454  if (elements.studyWhisperButton) {
3455    elements.studyWhisperButton.hidden = !study.video_id || whisper.status === "ready";
3456    elements.studyWhisperButton.disabled = whisperActionBusy;
3457    elements.studyWhisperButton.textContent = whisperActionBusy
3458      ? "Generating…"
3459      : (whisperFailed ? "Retry Whisper" : "Use Whisper");
3460    if (whisperActionBusy) elements.studyWhisperButton.setAttribute("aria-busy", "true");
3461    else elements.studyWhisperButton.removeAttribute("aria-busy");
3462  }
3463  if (elements.studyHelperButton) {
3464    const helperActionBusy = studyHelperCaptionInFlight.has(study.video_id);
3465    const helperActionAvailable = Boolean(study.video_id)
3466      && !activeSource
3467      && !waitingForDirectCaptions
3468      && ["not_requested", "failed", "unavailable"].includes(youtube.status);
3469    elements.studyHelperButton.hidden = !helperActionAvailable;
3470    elements.studyHelperButton.disabled = helperActionBusy;
3471    elements.studyHelperButton.textContent = helperActionBusy
3472      ? "Starting…"
3473      : (youtubeHelperFailed ? "Retry Personal Helper" : "Use Personal Helper");
3474    if (helperActionBusy) elements.studyHelperButton.setAttribute("aria-busy", "true");
3475    else elements.studyHelperButton.removeAttribute("aria-busy");
3476  }
3477  if (audioProbe?.message) {
3478    const probeKind = audioProbe.status === "failed" ? "failed" : (audioProbe.status === "ready" ? "ready" : "processing");
3479    setStudyTranscriptStatus(audioProbe.message, probeKind);
3480  } else if (study.load_error) {
3481    setStudyTranscriptStatus(study.load_error, "failed");
3482  } else if (whisper.status === "failed") {
3483    setStudyTranscriptStatus(whisper.error_message || "Whisper failed. YouTube captions still work.", "failed");
3484  } else if (whisperBusy) {
3485    let whisperMessage = "Whisper is transcribing…";
3486    if (whisperUsesHelper) {
3487      if (helperOffline) whisperMessage = "Personal Helper is offline. Waiting…";
3488      else if (whisper.status === "processing") whisperMessage = "Personal Helper is transcribing…";
3489      else if (whisper.download_status === "downloading") whisperMessage = "Personal Helper is downloading the video…";
3490      else whisperMessage = "Waiting for Personal Helper…";
3491    }
3492    setStudyTranscriptStatus(whisperMessage, "processing");
3493  } else if (["failed", "unavailable"].includes(youtube.status)) {
3494    setStudyTranscriptStatus(youtube.error_message || "YouTube captions unavailable.", "failed");
3495  } else if (waitingForDirectCaptions || youtube.status === "loading") {
3496    setStudyTranscriptStatus("Checking YouTube captions…", "processing");
3497  } else if (youtube.status === "queued") {
3498    setStudyTranscriptStatus(helperOffline
3499      ? "Personal Helper is offline. Waiting…"
3500      : "Waiting for Personal Helper…", "processing");
3501  } else if (youtube.status === "processing") {
3502    setStudyTranscriptStatus("Personal Helper is getting captions…", "processing");
3503  } else if (["whisper", "youtube"].includes(activeSource)) {
3504    setStudyTranscriptStatus("");
3505  } else {
3506    const extensionIssue = studyExtensionReadinessIssue();
3507    setStudyTranscriptStatus(extensionIssue?.message || "Browser captions did not connect on this tab. You do not need to keep the extension popup open—reload this page, or choose the Personal Helper.", extensionIssue ? "failed" : "");
3508  }
3509}
3510
3511function studyTranscriptKey(study) {
3512  const track = study.active_source === "whisper" ? study.whisper : study.youtube;
3513  return `${study.active_source}:${track?.updated_at || track?.completed_at || ""}:${study.cues.length}`;
3514}
3515
3516function ensureStudyPlayerTarget() {
3517  let target = document.getElementById("studyPlayer");
3518  if (target) return target;
3519  target = document.createElement("div");
3520  target.id = "studyPlayer";
3521  target.className = "study-player";
3522  target.setAttribute("aria-label", "YouTube video player");
3523  elements.studyPlayerShell.prepend(target);
3524  elements.studyPlayer = target;
3525  return target;
3526}
3527
3528function wordSegmenter() {
3529  if (typeof Intl.Segmenter !== "function") return null;
3530  return new Intl.Segmenter("ja", { granularity: "word" });
3531}
3532
3533function appendStudyTokens(container, cue, cueIndex) {
3534  const tokens = studyCueWordTokens(cue);
3535  const characters = Array.from(cue.text);
3536  let cursor = 0;
3537  tokens.forEach((token, tokenIndex) => {
3538    if (token.start > cursor) {
3539      container.append(document.createTextNode(characters.slice(cursor, token.start).join("")));
3540    }
3541    appendStudyWord(
3542      container,
3543      token.surface,
3544      cueIndex,
3545      tokenIndex,
3546      token.start,
3547      token.end,
3548      studyMigakuTokenStatus(cueIndex, tokenIndex),
3549    );
3550    cursor = token.end;
3551  });
3552  if (cursor < characters.length) {
3553    container.append(document.createTextNode(characters.slice(cursor).join("")));
3554  }
3555}
3556
3557function studyCueWordTokens(cue) {
3558  if (Array.isArray(cue?.tokens)) return cue.tokens;
3559  const segmenter = wordSegmenter();
3560  const segments = segmenter
3561    ? [...segmenter.segment(cue.text)]
3562    : [{ segment: cue.text, isWordLike: /[\p{L}\p{N}]/u.test(cue.text) }];
3563  const tokens = [];
3564  let cursor = 0;
3565  for (const item of segments) {
3566    const start = cursor;
3567    cursor += Array.from(item.segment).length;
3568    if (item.isWordLike) {
3569      tokens.push({
3570        surface: item.segment,
3571        lemma: item.segment,
3572        reading: "",
3573        part_of_speech: "",
3574        inflection: null,
3575        start,
3576        end: cursor,
3577      });
3578    }
3579  }
3580  return tokens;
3581}
3582
3583function appendStudyWord(container, surface, cueIndex, tokenIndex, start, end, migakuStatus = "") {
3584  if (typeof state !== "undefined" && state.studyDictionaryEnabled === false) {
3585    const word = document.createElement("span");
3586    word.className = "study-hover-word";
3587    word.dataset.studyHoverWord = "";
3588    word.textContent = surface;
3589    applyStudyMigakuWordStatus(word, migakuStatus);
3590    container.append(word);
3591    return;
3592  }
3593  appendStudyWordButton(container, surface, cueIndex, tokenIndex, start, end, migakuStatus);
3594}
3595
3596function appendStudyWordButton(container, surface, cueIndex, tokenIndex, start, end, migakuStatus = "") {
3597  const button = document.createElement("button");
3598  button.className = "study-word study-hover-word";
3599  button.type = "button";
3600  button.dataset.studyHoverWord = "";
3601  button.dataset.studyWord = surface;
3602  button.dataset.studyCueIndex = String(cueIndex);
3603  button.dataset.studyTokenIndex = String(tokenIndex);
3604  button.dataset.studyTokenStart = String(start);
3605  button.dataset.studyTokenEnd = String(end);
3606  applyStudyMigakuWordStatus(button, migakuStatus);
3607  const statusLabel = studyMigakuStatusLabel(migakuStatus);
3608  button.setAttribute("aria-label", `Look up ${surface}${statusLabel ? ` · ${statusLabel}` : ""}`);
3609  button.textContent = surface;
3610  container.append(button);
3611}
3612
3613function studyMigakuTokenStatus(cueIndex, tokenIndex) {
3614  if (typeof state === "undefined") return "";
3615  return state.studyMigakuCoverage?.tokenStatuses?.get(`${cueIndex}:${tokenIndex}`) || "";
3616}
3617
3618function applyStudyMigakuWordStatus(element, status) {
3619  if (!["known", "learning", "unknown", "ignored", "unclassified"].includes(status)) return;
3620  element.dataset.migakuStatus = status;
3621  element.title = studyMigakuStatusLabel(status);
3622}
3623
3624function studyMigakuStatusLabel(status) {
3625  return ({
3626    known: "Known in Migaku",
3627    learning: "Learning in Migaku",
3628    unknown: "Not yet known in Migaku",
3629    ignored: "Ignored in Migaku",
3630    unclassified: "Could not classify with confidence",
3631  })[status] || "";
3632}
3633
3634function normalizeStudyCaptionOverlayPosition(value) {
3635  const position = Number(value);
3636  if (!Number.isFinite(position)) return 0.5;
3637  return Math.min(1, Math.max(0, position));
3638}
3639
3640function readStudyCaptionOverlayPosition(storage) {
3641  try {
3642    const value = (storage || window.localStorage).getItem(STUDY_CAPTION_POSITION_STORAGE_KEY);
3643    return value === null ? 0.5 : normalizeStudyCaptionOverlayPosition(value);
3644  } catch (_error) {
3645    return 0.5;
3646  }
3647}
3648
3649function saveStudyCaptionOverlayPosition(storage) {
3650  try {
3651    (storage || window.localStorage).setItem(
3652      STUDY_CAPTION_POSITION_STORAGE_KEY,
3653      String(normalizeStudyCaptionOverlayPosition(studyCaptionOverlayPosition)),
3654    );
3655  } catch (_error) {
3656    // The position still works for this page when browser storage is unavailable.
3657  }
3658}
3659
3660function studyCaptionOverlayCenter(position, shellHeight, overlayHeight) {
3661  const height = Math.max(0, Number(shellHeight) || 0);
3662  const captionHeight = Math.min(height, Math.max(0, Number(overlayHeight) || 0));
3663  const available = Math.max(0, height - captionHeight);
3664  return (captionHeight / 2) + (normalizeStudyCaptionOverlayPosition(position) * available);
3665}
3666
3667function studyCaptionOverlayMetrics() {
3668  const shellHeight = Number(elements.studyPlayerShell?.getBoundingClientRect?.().height) || 0;
3669  const overlayHeight = Number(elements.studyCaptionOverlayBubble?.getBoundingClientRect?.().height) || 0;
3670  return { shellHeight, overlayHeight };
3671}
3672
3673function applyStudyCaptionOverlayPosition() {
3674  if (elements.studyCaptionOverlay?.hidden || !elements.studyCaptionOverlayBubble) return;
3675  const { shellHeight, overlayHeight } = studyCaptionOverlayMetrics();
3676  const center = studyCaptionOverlayCenter(studyCaptionOverlayPosition, shellHeight, overlayHeight);
3677  elements.studyCaptionOverlayBubble.style.top = `${center}px`;
3678}
3679
3680function renderStudyCaptionOverlay(index) {
3681  const overlay = elements.studyCaptionOverlay;
3682  const text = elements.studyCaptionOverlayText;
3683  const cue = state.study?.cues?.[index];
3684  if (!overlay || !text) return;
3685  text.replaceChildren();
3686  if (!cue || state.studyCaptionOverlayVisible === false) {
3687    overlay.hidden = true;
3688    return;
3689  }
3690  appendStudyTokens(text, cue, Number.isInteger(cue.index) ? cue.index : index);
3691  overlay.hidden = false;
3692  if (typeof window.requestAnimationFrame === "function") {
3693    window.requestAnimationFrame(applyStudyCaptionOverlayPosition);
3694  } else applyStudyCaptionOverlayPosition();
3695}
3696
3697function startStudyCaptionOverlayDrag(event) {
3698  if (event.pointerType === "mouse" && event.button !== 0) return;
3699  const { shellHeight, overlayHeight } = studyCaptionOverlayMetrics();
3700  studyCaptionOverlayDrag = {
3701    pointerId: event.pointerId,
3702    startY: event.clientY,
3703    startCenter: studyCaptionOverlayCenter(studyCaptionOverlayPosition, shellHeight, overlayHeight),
3704    captureTarget: event.target,
3705    dragged: false,
3706  };
3707  event.target?.setPointerCapture?.(event.pointerId);
3708}
3709
3710function moveStudyCaptionOverlayDrag(event) {
3711  if (!studyCaptionOverlayDrag || event.pointerId !== studyCaptionOverlayDrag.pointerId) return;
3712  const movement = event.clientY - studyCaptionOverlayDrag.startY;
3713  if (!studyCaptionOverlayDrag.dragged && Math.abs(movement) < 4) return;
3714  if (!studyCaptionOverlayDrag.dragged) {
3715    studyCaptionOverlayDrag.dragged = true;
3716    elements.studyCaptionOverlayBubble?.classList.add("is-dragging");
3717  }
3718  const { shellHeight, overlayHeight } = studyCaptionOverlayMetrics();
3719  const half = Math.min(shellHeight, overlayHeight) / 2;
3720  const available = Math.max(0, shellHeight - Math.min(shellHeight, overlayHeight));
3721  const unclampedCenter = studyCaptionOverlayDrag.startCenter + movement;
3722  const center = Math.min(shellHeight - half, Math.max(half, unclampedCenter));
3723  studyCaptionOverlayPosition = available > 0 ? (center - half) / available : 0.5;
3724  applyStudyCaptionOverlayPosition();
3725  event.preventDefault();
3726}
3727
3728function finishStudyCaptionOverlayDrag(event) {
3729  if (!studyCaptionOverlayDrag || event.pointerId !== studyCaptionOverlayDrag.pointerId) return;
3730  const { captureTarget, dragged } = studyCaptionOverlayDrag;
3731  studyCaptionOverlayDrag = null;
3732  elements.studyCaptionOverlayBubble?.classList.remove("is-dragging");
3733  captureTarget?.releasePointerCapture?.(event.pointerId);
3734  if (dragged) {
3735    studyCaptionOverlaySuppressClick = true;
3736    saveStudyCaptionOverlayPosition();
3737    window.setTimeout(() => { studyCaptionOverlaySuppressClick = false; }, 0);
3738  }
3739}
3740
3741function suppressStudyCaptionOverlayClick(event) {
3742  if (!studyCaptionOverlaySuppressClick) return;
3743  studyCaptionOverlaySuppressClick = false;
3744  event.preventDefault();
3745  event.stopPropagation();
3746}
3747
3748function renderStudyTranscript(cues) {
3749  resumeStudyPlayerAfterWordHover();
3750  const fragment = document.createDocumentFragment();
3751  cues.forEach((cue, cueIndex) => {
3752    const item = document.createElement("li");
3753    item.className = "study-cue";
3754    item.dataset.studyCueRow = String(cueIndex);
3755    const seek = document.createElement("button");
3756    seek.className = "study-cue-time";
3757    seek.type = "button";
3758    seek.dataset.studyCueIndex = String(cueIndex);
3759    seek.setAttribute("aria-label", `Play caption at ${formatStudyTime(cue.start)}`);
3760    seek.textContent = `▶ ${formatStudyTime(cue.start)} – ${formatStudyTime(cue.end)}`;
3761    const text = document.createElement("p");
3762    text.className = "study-cue-text";
3763    appendStudyTokens(text, cue, cue.index);
3764    item.append(text, seek);
3765    fragment.append(item);
3766  });
3767  elements.studyCueList.replaceChildren(fragment);
3768}
3769
3770function requestStudyMigakuCoverage(study, transcriptKey = studyTranscriptKey(study)) {
3771  if (!study?.video_id || !Array.isArray(study.cues) || !study.cues.length) return;
3772  const current = state.studyMigakuCoverage;
3773  if (current?.videoId === study.video_id && current?.transcriptKey === transcriptKey
3774      && ["loading", "ready", "unavailable"].includes(current.status)) return;
3775  if (document.documentElement?.getAttribute("data-immersion-study-vocabulary-bridge") !== "1") {
3776    state.studyMigakuCoverage = {
3777      status: "unavailable",
3778      videoId: study.video_id,
3779      transcriptKey,
3780      message: "Reload the Immersion Feed extension and this Study page to compare Migaku words.",
3781      tokenStatuses: new Map(),
3782    };
3783    renderStudyMigakuCoverage();
3784    return;
3785  }
3786  const tokens = [];
3787  let surfaceOnly = false;
3788  for (const cue of study.cues) {
3789    const suppliedTokens = Array.isArray(cue.tokens);
3790    if (!suppliedTokens) surfaceOnly = true;
3791    studyCueWordTokens(cue).forEach((token, tokenIndex) => {
3792      tokens.push({
3793        cueIndex: cue.index,
3794        tokenIndex,
3795        surface: token.surface,
3796        lemma: suppliedTokens ? String(token.lemma || token.surface) : token.surface,
3797        reading: suppliedTokens ? String(token.reading || "") : "",
3798        partOfSpeech: suppliedTokens ? String(token.part_of_speech || "") : "",
3799        inflection: suppliedTokens ? String(token.inflection || "") : "",
3800      });
3801    });
3802  }
3803  if (!tokens.length || tokens.length > 50000) {
3804    state.studyMigakuCoverage = {
3805      status: "unavailable",
3806      videoId: study.video_id,
3807      transcriptKey,
3808      message: "This transcript does not have usable Japanese word tokens yet.",
3809      tokenStatuses: new Map(),
3810    };
3811    renderStudyMigakuCoverage();
3812    return;
3813  }
3814  const requestId = crypto.randomUUID();
3815  state.studyMigakuCoverage = {
3816    status: "loading",
3817    requestId,
3818    videoId: study.video_id,
3819    transcriptKey,
3820    surfaceOnly,
3821    tokenStatuses: new Map(),
3822  };
3823  renderStudyMigakuCoverage();
3824  window.postMessage({
3825    source: "immersion-feed-study-page",
3826    type: "study-vocabulary-coverage-request",
3827    requestId,
3828    videoId: study.video_id,
3829    transcriptKey,
3830    tokens,
3831  }, window.location.origin);
3832}
3833
3834function handleStudyMigakuCoverageMessage(event) {
3835  const message = event?.data;
3836  if (event?.source !== window || event?.origin !== window.location.origin
3837      || message?.source !== "immersion-feed-extension") return;
3838  const pending = state.studyMigakuCoverage;
3839  if (message?.type === "study-vocabulary-coverage-unavailable") {
3840    if (!pending || message.requestId !== pending.requestId || message.videoId !== pending.videoId
3841        || message.transcriptKey !== pending.transcriptKey) return;
3842    state.studyMigakuCoverage = {
3843      ...pending,
3844      status: "unavailable",
3845      message: String(message.message || "Migaku coverage is unavailable.").slice(0, 240),
3846      tokenStatuses: new Map(),
3847    };
3848    renderStudyMigakuCoverage();
3849    return;
3850  }
3851  if (message?.type !== "study-vocabulary-coverage-result") return;
3852  const coverage = normalizeStudyMigakuCoverage(message.coverage);
3853  if (!coverage || !pending || coverage.requestId !== pending.requestId
3854      || coverage.videoId !== pending.videoId || coverage.transcriptKey !== pending.transcriptKey
3855      || state.study?.video_id !== pending.videoId
3856      || studyTranscriptKey(state.study) !== pending.transcriptKey) return;
3857  const tokenStatuses = new Map(coverage.tokens.map((token) => [
3858    `${token.cueIndex}:${token.tokenIndex}`,
3859    token.status,
3860  ]));
3861  state.studyMigakuCoverage = {
3862    ...pending,
3863    ...coverage,
3864    status: "ready",
3865    tokenStatuses,
3866  };
3867  renderStudyMigakuCoverage();
3868  const activeCueIndex = state.studyActiveCueIndex;
3869  renderStudyTranscript(state.study.cues);
3870  setActiveStudyCue(activeCueIndex, { force: true });
3871}
3872
3873function normalizeStudyMigakuCoverage(value) {
3874  if (!value || value.version !== 1 || normalizeYouTubeVideoId(value.videoId) !== value.videoId
3875      || !/^[A-Za-z0-9_-]{16,80}$/.test(String(value.requestId || ""))
3876      || typeof value.transcriptKey !== "string" || !value.transcriptKey
3877      || !Array.isArray(value.tokens) || value.tokens.length > 50000) return null;
3878  const validStatuses = new Set(["known", "learning", "unknown", "ignored", "unclassified"]);
3879  const tokens = [];
3880  for (const token of value.tokens) {
3881    if (!Number.isSafeInteger(token?.cueIndex) || token.cueIndex < 0 || token.cueIndex > 12000
3882        || !Number.isSafeInteger(token?.tokenIndex) || token.tokenIndex < 0 || token.tokenIndex > 1000
3883        || !validStatuses.has(token?.status)) return null;
3884    tokens.push({ cueIndex: token.cueIndex, tokenIndex: token.tokenIndex, status: token.status });
3885  }
3886  const counts = normalizeStudyMigakuCounts(value.counts, tokens.length);
3887  const uniqueCounts = normalizeStudyMigakuCounts(value.uniqueCounts);
3888  const occurrencePercent = normalizeStudyCoveragePercent(value.occurrencePercent, counts?.eligible);
3889  const uniquePercent = normalizeStudyCoveragePercent(value.uniquePercent, uniqueCounts?.eligible);
3890  const snapshotDate = new Date(value.snapshotExtractedAt);
3891  if (!counts || !uniqueCounts || occurrencePercent === undefined || uniquePercent === undefined
3892      || !Number.isFinite(snapshotDate.getTime())) return null;
3893  return {
3894    version: 1,
3895    videoId: value.videoId,
3896    requestId: value.requestId,
3897    transcriptKey: value.transcriptKey.slice(0, 160),
3898    snapshotExtractedAt: snapshotDate.toISOString(),
3899    occurrencePercent,
3900    uniquePercent,
3901    counts,
3902    uniqueCounts,
3903    tokens,
3904  };
3905}
3906
3907function normalizeStudyMigakuCounts(value, expectedTotal = null) {
3908  const fields = ["total", "eligible", "known", "learning", "unknown", "ignored", "unclassified"];
3909  const counts = {};
3910  for (const field of fields) {
3911    const number = Number(value?.[field]);
3912    if (!Number.isSafeInteger(number) || number < 0 || number > 50000) return null;
3913    counts[field] = number;
3914  }
3915  if ((expectedTotal !== null && counts.total !== expectedTotal)
3916      || counts.eligible !== counts.total - counts.ignored
3917      || counts.total !== counts.known + counts.learning + counts.unknown + counts.ignored + counts.unclassified) return null;
3918  return counts;
3919}
3920
3921function normalizeStudyCoveragePercent(value, eligible) {
3922  if (eligible === 0) return value === null ? null : undefined;
3923  const number = Number(value);
3924  return Number.isFinite(number) && number >= 0 && number <= 100 ? number : undefined;
3925}
3926
3927function renderStudyMigakuCoverage() {
3928  const panel = elements.studyVocabularyCoverage;
3929  if (!panel) return;
3930  const coverage = state.studyMigakuCoverage;
3931  panel.hidden = !coverage;
3932  panel.dataset.state = coverage?.status || "";
3933  if (!coverage) return;
3934  if (coverage.status === "loading") {
3935    elements.studyVocabularyPercent.textContent = "…";
3936    elements.studyVocabularyDetail.textContent = "Comparing this transcript with your local Migaku words…";
3937    elements.studyVocabularyCounts.textContent = "Nothing is uploaded.";
3938    return;
3939  }
3940  if (coverage.status !== "ready") {
3941    elements.studyVocabularyPercent.textContent = "—";
3942    elements.studyVocabularyDetail.textContent = coverage.message || "Migaku coverage is unavailable.";
3943    elements.studyVocabularyCounts.textContent = "The transcript still works normally.";
3944    return;
3945  }
3946  const counts = coverage.counts;
3947  const uniqueCounts = coverage.uniqueCounts;
3948  elements.studyVocabularyPercent.textContent = formatStudyCoveragePercent(coverage.occurrencePercent);
3949  elements.studyVocabularyDetail.textContent = `${counts.known}/${counts.eligible} word occurrences known · ${uniqueCounts.known}/${uniqueCounts.eligible} unique words`;
3950  elements.studyVocabularyCounts.textContent = `Known ${counts.known} · Learning ${counts.learning} · Not yet known ${counts.unknown} · Unclassified ${counts.unclassified}${counts.ignored ? ` · Ignored ${counts.ignored}` : ""}`;
3951  if (coverage.surfaceOnly) {
3952    elements.studyVocabularyCounts.textContent += " · Surface-word estimate";
3953  }
3954}
3955
3956function formatStudyCoveragePercent(value) {
3957  if (value === null || !Number.isFinite(Number(value))) return "—";
3958  const number = Number(value);
3959  return `${Number.isInteger(number) ? number : number.toFixed(1)}%`;
3960}
3961
3962function readStudyDictionaryEnabled(storage) {
3963  try {
3964    return (storage || window.localStorage).getItem(STUDY_DICTIONARY_STORAGE_KEY) !== "off";
3965  } catch (_error) {
3966    return true;
3967  }
3968}
3969
3970function syncStudyDictionaryPreference() {
3971  const enabled = state.studyDictionaryEnabled !== false;
3972  if (elements.studyDictionaryToggle) elements.studyDictionaryToggle.checked = enabled;
3973  if (elements.studyDictionaryPreferenceStatus) {
3974    elements.studyDictionaryPreferenceStatus.textContent = enabled
3975      ? "On · Saved on this device."
3976      : "Off · Saved on this device.";
3977  }
3978  if (elements.studyTranscriptHint) {
3979    elements.studyTranscriptHint.textContent = enabled
3980      ? "Tap a word for its meaning. Tap the time to play that line."
3981      : "Pop-up dictionary is off. Tap the time to play that line.";
3982  }
3983}
3984
3985function setStudyDictionaryEnabled(enabled, storage) {
3986  const activeCueIndex = state.studyActiveCueIndex;
3987  state.studyDictionaryEnabled = Boolean(enabled);
3988  try {
3989    (storage || window.localStorage).setItem(STUDY_DICTIONARY_STORAGE_KEY, enabled ? "on" : "off");
3990  } catch (_error) {
3991    // The setting still works for this page when browser storage is unavailable.
3992  }
3993  if (!enabled) closeStudyDictionary();
3994  if (Array.isArray(state.study?.cues) && elements.studyCueList) {
3995    renderStudyTranscript(state.study.cues);
3996    setActiveStudyCue(activeCueIndex, { force: true });
3997  }
3998  syncStudyDictionaryPreference();
3999}
4000
4001function openStudySettings() {
4002  syncStudyDictionaryPreference();
4003  if (!elements.studySettingsDialog.open) elements.studySettingsDialog.showModal();
4004  elements.studyDictionaryToggle?.focus();
4005}
4006
4007function closeStudySettings() {
4008  if (elements.studySettingsDialog?.open) elements.studySettingsDialog.close();
4009}
4010
4011function reloadStudyPage() {
4012  window.location.reload();
4013}
4014
4015function restoreStudySettingsFocus() {
4016  elements.studySettingsButton?.focus?.();
4017}
4018
4019function setActiveStudyCue(index, { force = false, scroll = false } = {}) {
4020  if (!force && index === state.studyActiveCueIndex) return;
4021  state.studyActiveCueIndex = index;
4022  renderStudyCaptionOverlay(index);
4023  let activeRow = null;
4024  document.querySelectorAll("[data-study-cue-row]").forEach((row) => {
4025    const active = Number(row.dataset.studyCueRow) === index;
4026    row.classList.toggle("is-active", active);
4027    if (active) {
4028      activeRow = row;
4029      row.setAttribute("aria-current", "true");
4030    }
4031    else row.removeAttribute("aria-current");
4032  });
4033  if (scroll && activeRow) {
4034    const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
4035    activeRow.scrollIntoView?.({
4036      behavior: reduceMotion ? "auto" : "smooth",
4037      block: "center",
4038      inline: "nearest",
4039    });
4040  }
4041}
4042
4043function findStudyCueIndex(time) {
4044  const cues = state.study?.cues || [];
4045  let low = 0;
4046  let high = cues.length - 1;
4047  while (low <= high) {
4048    const middle = Math.floor((low + high) / 2);
4049    const cue = cues[middle];
4050    if (time < cue.start) high = middle - 1;
4051    else if (time >= cue.end) low = middle + 1;
4052    else return middle;
4053  }
4054  return -1;
4055}
4056
4057function syncStudyCueToPlayer() {
4058  try {
4059    const time = Number(studyPlayer?.getCurrentTime?.());
4060    const isPlaying = Number(studyPlayer?.getPlayerState?.()) === YOUTUBE_PLAYER_STATE_PLAYING;
4061    if (Number.isFinite(time)) setActiveStudyCue(findStudyCueIndex(time), { scroll: isPlaying });
4062  } catch (_error) {
4063    // The player can briefly be unavailable while YouTube changes state.
4064  }
4065}
4066
4067function seekToStudyCue(index) {
4068  const cue = state.study?.cues?.[index];
4069  if (!cue) return;
4070  setActiveStudyCue(index, { force: true });
4071  try {
4072    studyPlayer?.seekTo?.(cue.start, true);
4073    studyPlayer?.playVideo?.();
4074  } catch (_error) {
4075    showStudyPlayerMessage("The player is not ready yet. Try this line again in a moment.");
4076  }
4077}
4078
4079function studyShortcutTargetIsEditable(target) {
4080  const tagName = String(target?.tagName || "").toUpperCase();
4081  if (["INPUT", "TEXTAREA", "SELECT"].includes(tagName) || target?.isContentEditable) return true;
4082  return Boolean(target?.closest?.('input, textarea, select, [contenteditable]:not([contenteditable="false"])'));
4083}
4084
4085function studyShortcutDialogIsOpen() {
4086  return Boolean(
4087    elements.studySettingsDialog?.open
4088    || elements.studyDictionaryDialog?.open
4089    || elements.studyUrlDialog?.open
4090  );
4091}
4092
4093function currentStudyShortcutCueIndex() {
4094  const cues = state.study?.cues || [];
4095  if (!cues.length) return -1;
4096  const activeIndex = Number(state.studyActiveCueIndex);
4097  if (Number.isInteger(activeIndex) && activeIndex >= 0 && activeIndex < cues.length) return activeIndex;
4098
4099  let time = NaN;
4100  try {
4101    time = Number(studyPlayer?.getCurrentTime?.());
4102  } catch (_error) {
4103    // Fall back to the first caption while the player is becoming ready.
4104  }
4105  if (!Number.isFinite(time)) return -1;
4106
4107  let low = 0;
4108  let high = cues.length - 1;
4109  let nearestIndex = -1;
4110  while (low <= high) {
4111    const middle = Math.floor((low + high) / 2);
4112    if (time >= Number(cues[middle]?.start)) {
4113      nearestIndex = middle;
4114      low = middle + 1;
4115    } else high = middle - 1;
4116  }
4117  return nearestIndex;
4118}
4119
4120function handleStudyKeyboardShortcut(event) {
4121  const key = event?.key;
4122  if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(key)) return;
4123  if (
4124    state.activeSection !== "study"
4125    || event.defaultPrevented
4126    || event.isComposing
4127    || event.altKey
4128    || event.ctrlKey
4129    || event.metaKey
4130    || event.shiftKey
4131    || studyShortcutDialogIsOpen()
4132    || studyShortcutTargetIsEditable(event.target)
4133  ) return;
4134
4135  if (key === "ArrowUp") {
4136    event.preventDefault();
4137    if (event.repeat) return;
4138    state.studyCaptionOverlayVisible = state.studyCaptionOverlayVisible === false;
4139    renderStudyCaptionOverlay(state.studyActiveCueIndex);
4140    return;
4141  }
4142
4143  const cues = state.study?.cues || [];
4144  const currentIndex = currentStudyShortcutCueIndex();
4145  if (!cues.length) return;
4146  event.preventDefault();
4147  if (key === "ArrowDown" && event.repeat) return;
4148
4149  const targetIndex = key === "ArrowLeft"
4150    ? Math.max(-1, currentIndex - 1)
4151    : key === "ArrowRight"
4152      ? Math.min(cues.length - 1, currentIndex + 1)
4153      : Math.max(0, currentIndex);
4154  if (targetIndex >= 0 && (key === "ArrowDown" || targetIndex !== currentIndex)) seekToStudyCue(targetIndex);
4155}
4156
4157function showStudyPlayerMessage(message) {
4158  if (!elements.studyPlayerMessage) return;
4159  elements.studyPlayerMessage.textContent = message;
4160  elements.studyPlayerMessage.hidden = !message;
4161}
4162
4163function loadYouTubeIframeApi() {
4164  if (window.YT?.Player) return Promise.resolve(window.YT);
4165  if (youtubeIframeApiPromise) return youtubeIframeApiPromise;
4166  youtubeIframeApiPromise = new Promise((resolve, reject) => {
4167    const previousReady = window.onYouTubeIframeAPIReady;
4168    window.onYouTubeIframeAPIReady = () => {
4169      previousReady?.();
4170      if (window.YT?.Player) resolve(window.YT);
4171      else reject(new Error("YouTube player unavailable"));
4172    };
4173    let script = document.getElementById("youtubeIframeApi");
4174    if (!script) {
4175      script = document.createElement("script");
4176      script.id = "youtubeIframeApi";
4177      script.src = "https://www.youtube.com/iframe_api";
4178      script.async = true;
4179      document.head.append(script);
4180    }
4181    script.addEventListener("error", () => reject(new Error("YouTube player could not load")), { once: true });
4182  });
4183  return youtubeIframeApiPromise;
4184}
4185
4186async function initializeStudyPlayer(videoId) {
4187  if (!videoId || studyPlayerVideoId === videoId) return;
4188  destroyStudyPlayer();
4189  studyPlayerVideoId = videoId;
4190  showStudyPlayerMessage("");
4191  const target = ensureStudyPlayerTarget();
4192  try {
4193    const YT = await loadYouTubeIframeApi();
4194    if (state.activeSection !== "study" || state.study?.video_id !== videoId || studyPlayerVideoId !== videoId) return;
4195    studyPlayer = new YT.Player(target, {
4196      host: "https://www.youtube.com",
4197      videoId,
4198      playerVars: {
4199        playsinline: 1,
4200        cc_lang_pref: "ja",
4201        hl: "ja",
4202        origin: window.location.origin,
4203        rel: 0,
4204      },
4205      events: {
4206        onReady: () => {
4207          studySyncTimer = window.setInterval(syncStudyCueToPlayer, STUDY_SYNC_INTERVAL_MS);
4208          syncStudyCueToPlayer();
4209        },
4210        onError: () => showStudyPlayerMessage("This video cannot play inside Study Mode. You can still read the transcript here."),
4211      },
4212    });
4213  } catch (_error) {
4214    if (studyPlayerVideoId === videoId) studyPlayerVideoId = "";
4215    showStudyPlayerMessage("The YouTube player could not load. Refresh the page to try again.");
4216  }
4217}
4218
4219function renderStudyState(study) {
4220  stopStudyStatusPolling();
4221  const previousVideoId = state.study?.video_id || "";
4222  const normalized = normalizeStudyState(study);
4223  state.study = normalized;
4224  if (previousVideoId !== normalized.video_id) {
4225    state.studyActiveCueIndex = -1;
4226    state.studyMigakuCoverage = null;
4227    studyTranscriptSignature = "";
4228    renderStudyMigakuCoverage();
4229  }
4230  if (elements.studyShareButton) {
4231    const shareUrl = normalized.video_id
4232      ? `https://www.youtube.com/watch?v=${encodeURIComponent(normalized.video_id)}`
4233      : "";
4234    elements.studyShareButton.disabled = !shareUrl;
4235    if (shareUrl) {
4236      elements.studyShareButton.dataset.shareUrl = shareUrl;
4237      elements.studyShareButton.dataset.shareTitle = "Study this video";
4238    } else {
4239      delete elements.studyShareButton.dataset.shareUrl;
4240      delete elements.studyShareButton.dataset.shareTitle;
4241    }
4242  }
4243  elements.studySubtitle.textContent = normalized.video_id
4244    ? "Start with YouTube captions. Upgrade to Whisper whenever you want."
4245    : "Private captions for your downloaded video.";
4246  if (!normalized.video_id) {
4247    if (normalized.status === "failed") renderStudyFailure(normalized.whisper);
4248    else if (["queued", "processing"].includes(normalized.status)) {
4249      showStudyStatus("Whisper is preparing this transcript. This page will update when it is ready.", normalized.status);
4250      scheduleStudyStatusPoll();
4251    } else showStudyStatus("This Study link is missing its YouTube video.", "missing");
4252    return;
4253  }
4254  elements.studyStatus.hidden = true;
4255  elements.studyContent.hidden = false;
4256  renderStudyTranscriptState(normalized);
4257  const nextTranscriptSignature = studyTranscriptKey(normalized);
4258  if (nextTranscriptSignature !== studyTranscriptSignature) {
4259    let currentTime = NaN;
4260    try {
4261      currentTime = Number(studyPlayer?.getCurrentTime?.());
4262    } catch (_error) {
4263      // The caption source can still swap while the player is between states.
4264    }
4265    renderStudyTranscript(normalized.cues);
4266    studyTranscriptSignature = nextTranscriptSignature;
4267    setActiveStudyCue(Number.isFinite(currentTime) ? findStudyCueIndex(currentTime) : -1, { force: true });
4268  }
4269  requestStudyMigakuCoverage(normalized, nextTranscriptSignature);
4270  initializeStudyPlayer(normalized.video_id);
4271  scheduleStudyStatusPoll();
4272}
4273
4274function hasStudyExtensionCaptionBridge() {
4275  return document.documentElement?.getAttribute("data-immersion-study-caption-bridge") === "1";
4276}
4277
4278function hasStudyDirectAudioBridge() {
4279  return isStudyAudioProbeMode()
4280    && document.documentElement?.getAttribute("data-immersion-study-audio-probe") === "1";
4281}
4282
4283async function waitForStudyExtensionCaptions(videoId) {
4284  if (!hasStudyExtensionCaptionBridge() || state.study?.youtube?.status === "ready") return;
4285  await new Promise((resolve) => {
4286    const waiters = studyExtensionCaptionWaiters.get(videoId) || new Set();
4287    const timeout = window.setTimeout(() => {
4288      waiters.delete(finish);
4289      if (!waiters.size) studyExtensionCaptionWaiters.delete(videoId);
4290      resolve();
4291    }, STUDY_EXTENSION_CAPTION_WAIT_MS);
4292    const finish = () => {
4293      window.clearTimeout(timeout);
4294      waiters.delete(finish);
4295      if (!waiters.size) studyExtensionCaptionWaiters.delete(videoId);
4296      resolve();
4297    };
4298    waiters.add(finish);
4299    studyExtensionCaptionWaiters.set(videoId, waiters);
4300  });
4301}
4302
4303function notifyStudyExtensionCaptionWaiters(videoId = "") {
4304  const keys = videoId ? [videoId] : [...studyExtensionCaptionWaiters.keys()];
4305  keys.forEach((key) => {
4306    [...(studyExtensionCaptionWaiters.get(key) || [])].forEach((finish) => finish());
4307  });
4308}
4309
4310function handleStudyExtensionCaptionMessage(event) {
4311  const message = event?.data;
4312  const videoId = normalizeYouTubeVideoId(message?.videoId);
4313  if (event?.source !== window || event?.origin !== window.location.origin
4314      || message?.source !== "immersion-feed-extension"
4315      || message?.type !== "study-captions-ready"
4316      || !videoId
4317      || !["manual", "auto"].includes(message?.trackKind)
4318      || typeof message?.durationSeconds !== "number"
4319      || !Number.isFinite(message.durationSeconds)
4320      || message.durationSeconds <= 0
4321      || message.durationSeconds > 6 * 60 * 60
4322      || !Array.isArray(message?.cues)
4323      || !message.cues.length
4324      || message.cues.length > 12000
4325      || !state.authUser
4326      || state.activeSection !== "study"
4327      || selectedStudyVideoId() !== videoId
4328      || studyExtensionCaptionInFlight.has(videoId)) return;
4329  const requestingUser = state.authUser;
4330  studyExtensionCaptionInFlight.add(videoId);
4331  api(`/api/study/videos/${encodeURIComponent(videoId)}/captions/browser`, {
4332    method: "POST",
4333    body: JSON.stringify({
4334      source: "youtube",
4335      track_kind: message.trackKind,
4336      duration_seconds: message.durationSeconds,
4337      cues: message.cues,
4338    }),
4339  }).then((data) => {
4340    if (isCurrentDownloadUser(requestingUser)
4341        && state.activeSection === "study"
4342        && selectedStudyVideoId() === videoId) {
4343      renderStudyState({ ...(data.study || {}), video_id: videoId });
4344    }
4345  }).catch(() => {
4346    // The normal Personal Media Helper request remains the fallback.
4347  }).finally(() => {
4348    studyExtensionCaptionInFlight.delete(videoId);
4349    notifyStudyExtensionCaptionWaiters(videoId);
4350  });
4351}
4352
4353function studyExtensionReadinessIssue() {
4354  const stage = document.documentElement?.getAttribute("data-immersion-caption-frame-stage") || "";
4355  const tokenStage = document.documentElement?.getAttribute("data-immersion-caption-token-stage") || "";
4356  const issues = {
4357    "rejected-consent": {
4358      code: "consent_required",
4359      message: "Open the Immersion Feed extension, choose Review and continue, then reload this page.",
4360    },
4361    "rejected-connect": {
4362      code: "connection_required",
4363      message: "Open the Immersion Feed extension, connect your account, then reload this page.",
4364    },
4365    "rejected-disabled": {
4366      code: "enable_required",
4367      message: "Your extension is connected. In this older version, the gray YouTube Home switch also blocks Study captions. Turn it on, close the popup, then reload this page. The popup does not need to stay open.",
4368    },
4369    empty: {
4370      code: "captions_empty",
4371      message: "The extension is connected, but YouTube returned an empty caption track for this video. Reload once, or choose the Personal Helper.",
4372    },
4373    "fetch-failed": {
4374      code: "caption_request_failed",
4375      message: "The extension is connected, but YouTube's caption request failed for this video. Reload once, or choose the Personal Helper.",
4376    },
4377    "rejected-caption": {
4378      code: "caption_request_failed",
4379      message: "The extension is connected, but YouTube's caption request failed for this video. Reload once, or choose the Personal Helper.",
4380    },
4381  };
4382  if (issues[stage]) return issues[stage];
4383  if (tokenStage === "no-track") {
4384    return {
4385      code: "captions_missing",
4386      message: "The extension is connected, but YouTube did not offer a caption track for this video. You can choose the Personal Helper.",
4387    };
4388  }
4389  if (tokenStage === "failed-caption") {
4390    return {
4391      code: "caption_request_failed",
4392      message: "The extension is connected, but YouTube's caption request failed for this video. Reload once, or choose the Personal Helper.",
4393    };
4394  }
4395  return null;
4396}
4397
4398function reportStudyExtensionReadinessIssue(issue, videoId, pipeline) {
4399  if (!issue) return;
4400  captureRecoverableError("study_extension_readiness", new Error(issue.message), {
4401    code: issue.code,
4402    pipeline,
4403    video_id: videoId,
4404  });
4405}
4406
4407async function requestPersonalHelperCaptions(videoId, button = null) {
4408  const requestingUser = state.authUser;
4409  if (!requestingUser || !videoId || !["not_requested", "failed", "unavailable"].includes(state.study?.youtube?.status)
4410      || studyHelperCaptionInFlight.has(videoId)) return;
4411  studyHelperCaptionInFlight.add(videoId);
4412  if (button) {
4413    button.disabled = true;
4414    button.setAttribute("aria-busy", "true");
4415  }
4416  try {
4417    await loadDownloadDevices({ force: true });
4418    if (!isCurrentDownloadUser(requestingUser) || state.activeSection !== "study"
4419        || selectedStudyVideoId() !== videoId
4420        || !["not_requested", "failed", "unavailable"].includes(state.study?.youtube?.status)) return;
4421    studyHelperCaptionVideos.add(videoId);
4422    renderStudyState({
4423      ...state.study,
4424      video_id: videoId,
4425      youtube: { ...(state.study?.youtube || {}), status: "queued", error_message: "" },
4426    });
4427    const data = await api(`/api/study/videos/${encodeURIComponent(videoId)}/captions`, { method: "POST" });
4428    if (!isCurrentDownloadUser(requestingUser) || state.activeSection !== "study"
4429        || selectedStudyVideoId() !== videoId) return;
4430    renderStudyState({ ...(data.study || {}), video_id: videoId });
4431  } catch (error) {
4432    if (!isCurrentDownloadUser(requestingUser) || state.activeSection !== "study"
4433        || selectedStudyVideoId() !== videoId) return;
4434    renderStudyState({
4435      ...state.study,
4436      video_id: videoId,
4437      youtube: {
4438        ...(state.study?.youtube || {}),
4439        status: "failed",
4440        error_message: error.message || "Your Personal Media Helper could not start YouTube captions.",
4441      },
4442    });
4443  } finally {
4444    studyHelperCaptionInFlight.delete(videoId);
4445    if (button) {
4446      button.disabled = false;
4447      button.removeAttribute("aria-busy");
4448    }
4449    if (state.study?.video_id === videoId) renderStudyTranscriptState(state.study);
4450  }
4451}
4452
4453async function requestWhisperTranscription(button) {
4454  const videoId = normalizeYouTubeVideoId(state.study?.video_id || selectedStudyVideoId());
4455  const requestingUser = state.authUser;
4456  if (!requestingUser || !videoId || state.studyWhisperLoading) return;
4457  if (hasStudyDirectAudioBridge()) {
4458    const extensionIssue = studyExtensionReadinessIssue();
4459    if (extensionIssue) {
4460      state.studyAudioProbe = {
4461        videoId,
4462        status: "failed",
4463        message: extensionIssue.message,
4464      };
4465      renderStudyTranscriptState(state.study);
4466      reportStudyExtensionReadinessIssue(extensionIssue, videoId, "whisper");
4467      return;
4468    }
4469    requestStudyAudioProbe(videoId);
4470    return;
4471  }
4472  studyWhisperRequestVideoId = videoId;
4473  state.studyWhisperLoading = true;
4474  if (button) {
4475    button.disabled = true;
4476    button.setAttribute("aria-busy", "true");
4477  }
4478  try {
4479    await loadDownloadDevices({ force: true });
4480    if (!isCurrentDownloadUser(requestingUser) || state.activeSection !== "study" || state.study?.video_id !== videoId) return;
4481    renderStudyState({
4482      ...state.study,
4483      video_id: videoId,
4484      whisper: { ...(state.study?.whisper || {}), status: "queued", hosted: false, error_message: "" },
4485    });
4486    const data = await api(`/api/study/videos/${encodeURIComponent(videoId)}/whisper`, { method: "POST" });
4487    if (!isCurrentDownloadUser(requestingUser) || state.activeSection !== "study" || state.study?.video_id !== videoId) return;
4488    renderStudyState({ ...(data.study || {}), video_id: videoId });
4489  } catch (error) {
4490    if (!isCurrentDownloadUser(requestingUser) || state.activeSection !== "study" || state.study?.video_id !== videoId) return;
4491    renderStudyState({
4492      ...state.study,
4493      whisper: { ...(state.study?.whisper || {}), status: "failed", error_message: error.message || "Whisper could not start." },
4494    });
4495  } finally {
4496    if (studyWhisperRequestVideoId === videoId) {
4497      studyWhisperRequestVideoId = "";
4498      state.studyWhisperLoading = false;
4499      if (button) {
4500        button.disabled = false;
4501        button.removeAttribute("aria-busy");
4502      }
4503      if (state.study?.video_id === videoId) renderStudyTranscriptState(state.study);
4504    }
4505  }
4506}
4507
4508function isStudyAudioProbeMode() {
4509  if (window.location.protocol === "https:" && window.location.hostname === "immersionfeed.com") return true;
4510  return window.location.hostname === "127.0.0.1"
4511    && window.location.port === "8797"
4512    && new URLSearchParams(window.location.search).get("audio_probe") === "1";
4513}
4514
4515function requestStudyAudioProbe(videoId) {
4516  if (!isStudyAudioProbeMode() || !videoId || state.studyAudioProbe?.status === "requested"
4517      || ["resolving", "fetching", "transcribing", "cancelling"].includes(state.studyAudioProbe?.status)) return;
4518  if (document.documentElement?.getAttribute("data-immersion-study-audio-probe") !== "1") {
4519    state.studyAudioProbe = {
4520      status: "failed",
4521      message: "Reload the extension and this page, then retry.",
4522    };
4523    renderStudyTranscriptState(state.study || normalizeStudyState({ video_id: videoId }));
4524    return;
4525  }
4526  const requestId = crypto.randomUUID();
4527  state.studyAudioProbe = {
4528    requestId,
4529    videoId,
4530    status: "requested",
4531    message: "Starting Whisper…",
4532  };
4533  renderStudyTranscriptState(state.study);
4534  window.postMessage({
4535    source: "immersion-feed-study-page",
4536    type: "study-audio-probe-request",
4537    requestId,
4538    videoId,
4539  }, window.location.origin);
4540  if (studyAudioProbeTimeout !== null) window.clearTimeout(studyAudioProbeTimeout);
4541  studyAudioProbeTimeout = window.setTimeout(() => {
4542    studyAudioProbeTimeout = null;
4543    if (state.studyAudioProbe?.requestId !== requestId) return;
4544    window.postMessage({
4545      source: "immersion-feed-study-page",
4546      type: "study-audio-probe-cancel",
4547      requestId,
4548      videoId,
4549    }, window.location.origin);
4550    state.studyAudioProbe = {
4551      ...state.studyAudioProbe,
4552      requestId: "",
4553      status: "failed",
4554      message: "Audio timed out. Your captions are safe.",
4555    };
4556    renderStudyTranscriptState(state.study);
4557  }, STUDY_AUDIO_PROBE_TIMEOUT_MS);
4558}
4559
4560async function handleStudyAudioProbeMessage(event) {
4561  const message = event?.data;
4562  if (!isStudyAudioProbeMode() || event.source !== window || event.origin !== window.location.origin
4563      || message?.source !== "immersion-feed-extension"
4564      || !["study-audio-probe-stage", "study-audio-probe-result", "study-audio-upload-ready"].includes(message?.type)
4565      || message?.requestId !== state.studyAudioProbe?.requestId
4566      || normalizeYouTubeVideoId(message?.videoId) !== state.studyAudioProbe?.videoId) return;
4567  if (message.type === "study-audio-probe-stage") {
4568    const stages = {
4569      resolving: "Checking audio…",
4570      fetching: "Loading audio…",
4571    };
4572    if (!stages[message.stage]) return;
4573    state.studyAudioProbe = { ...state.studyAudioProbe, status: message.stage, message: stages[message.stage] };
4574    renderStudyTranscriptState(state.study);
4575    return;
4576  }
4577  if (studyAudioProbeTimeout !== null) window.clearTimeout(studyAudioProbeTimeout);
4578  studyAudioProbeTimeout = null;
4579  if (message.type === "study-audio-upload-ready" && message.status === "ready") {
4580    const audioBytes = message.audioBytes;
4581    if (!(audioBytes instanceof ArrayBuffer) || audioBytes.byteLength !== Number(message.bytes)) return;
4582    state.studyAudioProbe = {
4583      ...state.studyAudioProbe,
4584      status: "transcribing",
4585      message: "Whisper is transcribing…",
4586    };
4587    renderStudyTranscriptState(state.study);
4588    try {
4589      const data = await uploadStudyAudioToWhisper(message);
4590      if (state.studyAudioProbe?.requestId !== message.requestId
4591          || selectedStudyVideoId() !== message.videoId) return;
4592      state.studyAudioProbe = {
4593      ...state.studyAudioProbe,
4594      status: "ready",
4595      message: `Whisper ready · ${Number(data.study?.whisper?.cue_count || 0)} lines`,
4596      };
4597      renderStudyState({ ...(data.study || {}), video_id: message.videoId });
4598    } catch (error) {
4599      if (state.studyAudioProbe?.requestId !== message.requestId) return;
4600      state.studyAudioProbe = {
4601        ...state.studyAudioProbe,
4602        status: "failed",
4603        message: error.message || "Hosted Whisper could not transcribe this audio.",
4604      };
4605      renderStudyTranscriptState(state.study);
4606    }
4607    return;
4608  }
4609  if (message.status !== "ready") {
4610    state.studyAudioProbe = {
4611      ...state.studyAudioProbe,
4612      status: "failed",
4613      message: studyAudioProbeFailureMessage(message.code),
4614    };
4615  }
4616  renderStudyTranscriptState(state.study);
4617}
4618
4619async function uploadStudyAudioToWhisper(message) {
4620  const videoId = normalizeYouTubeVideoId(message?.videoId);
4621  const mimeType = ["audio/mp4", "audio/webm"].includes(message?.mimeType) ? message.mimeType : "";
4622  const bytes = message?.audioBytes;
4623  if (!videoId || !mimeType || !(bytes instanceof ArrayBuffer)
4624      || bytes.byteLength <= 0 || bytes.byteLength > STUDY_AUDIO_SOURCE_MAX_BYTES) {
4625    throw new Error("The direct YouTube audio upload is invalid.");
4626  }
4627  const decoded = await decodeStudyAudio(bytes, mimeType);
4628  const totalChunks = Math.ceil(decoded.duration / STUDY_WAV_CHUNK_SECONDS);
4629  const started = await api(
4630    `/api/study/videos/${encodeURIComponent(videoId)}/whisper/browser/start`,
4631    {
4632      method: "POST",
4633      body: JSON.stringify({
4634        total_chunks: totalChunks,
4635        duration_seconds: Number(decoded.duration.toFixed(3)),
4636        source_audio_sha256: String(message.sha256 || ""),
4637        source_audio_bytes: bytes.byteLength,
4638      }),
4639    },
4640  );
4641  if (started.already_ready) {
4642    return api(`/api/study/videos/${encodeURIComponent(videoId)}`, { cache: "no-store" });
4643  }
4644  let latest = null;
4645  const startIndex = Math.min(Number(started.uploaded_chunks || 0), totalChunks - 1);
4646  try {
4647    for await (const chunk of createStudyWavChunks(decoded, startIndex)) {
4648      state.studyAudioProbe = {
4649        ...state.studyAudioProbe,
4650        status: "transcribing",
4651        message: `Transcribing ${chunk.index + 1} of ${totalChunks}…`,
4652      };
4653      renderStudyTranscriptState(state.study);
4654      const chunkSha256 = await sha256ArrayBuffer(chunk.audioBytes);
4655      latest = await uploadStudyWhisperChunk({
4656        videoId,
4657        artifactId: started.artifact_id,
4658        totalChunks,
4659        chunk,
4660        sha256: chunkSha256,
4661      });
4662    }
4663  } finally {
4664    decoded.input.dispose();
4665  }
4666  if (!latest?.study || latest.study.whisper?.status !== "ready") {
4667    throw new Error("Hosted Whisper did not finish merging the caption chunks.");
4668  }
4669  return latest;
4670}
4671
4672async function uploadStudyWhisperChunk({ videoId, artifactId, totalChunks, chunk, sha256 }) {
4673  const response = await fetch(
4674    `/api/study/videos/${encodeURIComponent(videoId)}/whisper/browser/chunks/${encodeURIComponent(artifactId)}/${chunk.index}`,
4675    {
4676      method: "POST",
4677      headers: {
4678        "Content-Type": "audio/wav",
4679        "X-Immersion-Chunk-Total": String(totalChunks),
4680        "X-Immersion-Chunk-Offset-Seconds": String(chunk.offsetSeconds),
4681        "X-Immersion-Chunk-Duration-Seconds": String(chunk.durationSeconds),
4682        "X-Immersion-Audio-SHA256": sha256,
4683      },
4684      body: chunk.audioBytes,
4685    },
4686  );
4687  const data = await response.json().catch(() => ({}));
4688  if (!response.ok) {
4689    if (response.status === 401) {
4690      state.authUser = null;
4691      renderAuth();
4692    }
4693    throw new Error(data.detail || data.message || `Hosted Whisper failed (${response.status}).`);
4694  }
4695  return data;
4696}
4697
4698async function decodeStudyAudio(audioBytes, mimeType) {
4699  const media = globalThis.ImmersionStudyMedia;
4700  if (!media?.Input || !media?.BlobSource || !media?.AudioBufferSink || !Array.isArray(media.formats)) {
4701    throw new Error("Reload Study Mode before preparing this audio for Whisper.");
4702  }
4703  const input = new media.Input({
4704    source: new media.BlobSource(new Blob([audioBytes], { type: mimeType })),
4705    formats: media.formats,
4706  });
4707  try {
4708    const track = await input.getPrimaryAudioTrack();
4709    if (!track || !(await track.canDecode())) {
4710      throw new Error("This YouTube audio format cannot be decoded in Chrome.");
4711    }
4712    const [firstTimestampValue, endTimestamp, sampleRate, numberOfChannels] = await Promise.all([
4713      track.getFirstTimestamp(),
4714      track.computeDuration(),
4715      track.getSampleRate(),
4716      track.getNumberOfChannels(),
4717    ]);
4718    const firstTimestamp = Math.max(0, Number(firstTimestampValue));
4719    const duration = Number(endTimestamp) - firstTimestamp;
4720    if (!Number.isFinite(duration) || duration <= 0 || duration > STUDY_AUDIO_MAX_DURATION_SECONDS
4721        || !Number.isSafeInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000
4722        || !Number.isSafeInteger(numberOfChannels) || numberOfChannels < 1 || numberOfChannels > 2) {
4723      throw new Error("This audio is too long or complex to prepare safely in the browser.");
4724    }
4725    return {
4726      AudioBufferSink: media.AudioBufferSink,
4727      duration,
4728      firstTimestamp,
4729      input,
4730      numberOfChannels,
4731      sampleRate,
4732      track,
4733    };
4734  } catch (error) {
4735    input.dispose();
4736    if (String(error?.message || "").includes("too long or complex")) throw error;
4737    if (String(error?.message || "").includes("cannot be decoded")) throw error;
4738    if (String(error?.message || "").includes("Reload Study Mode")) throw error;
4739    throw new Error("The browser could not decode this YouTube audio file.");
4740  }
4741}
4742
4743async function* createStudyWavChunks(decoded, startIndex = 0) {
4744  const OfflineAudioContextCtor = window.OfflineAudioContext || window.webkitOfflineAudioContext;
4745  if (!OfflineAudioContextCtor) throw new Error("This browser cannot split audio for Whisper.");
4746  const totalChunks = Math.ceil(decoded.duration / STUDY_WAV_CHUNK_SECONDS);
4747  const sink = new decoded.AudioBufferSink(decoded.track);
4748  for (let index = Math.max(0, startIndex); index < totalChunks; index += 1) {
4749    const offsetSeconds = index * STUDY_WAV_CHUNK_SECONDS;
4750    const durationSeconds = Math.min(STUDY_WAV_CHUNK_SECONDS, decoded.duration - offsetSeconds);
4751    const sourceChunk = await readStudyMonoChunk(sink, decoded, offsetSeconds, durationSeconds);
4752    const frameCount = Math.ceil(durationSeconds * STUDY_WAV_SAMPLE_RATE);
4753    const offline = new OfflineAudioContextCtor(1, frameCount, STUDY_WAV_SAMPLE_RATE);
4754    const sourceBuffer = offline.createBuffer(1, sourceChunk.samples.length, sourceChunk.sampleRate);
4755    sourceBuffer.copyToChannel(sourceChunk.samples, 0);
4756    const source = offline.createBufferSource();
4757    source.buffer = sourceBuffer;
4758    source.connect(offline.destination);
4759    source.start();
4760    const rendered = await offline.startRendering();
4761    const audioBytes = encodeMonoPcm16Wav(rendered.getChannelData(0), STUDY_WAV_SAMPLE_RATE);
4762    if (audioBytes.byteLength > 2 * 1024 * 1024) {
4763      throw new Error("A hosted Whisper audio chunk exceeded its safety limit.");
4764    }
4765    yield {
4766      index,
4767      offsetSeconds: Number(offsetSeconds.toFixed(3)),
4768      durationSeconds: Number(durationSeconds.toFixed(3)),
4769      audioBytes,
4770    };
4771  }
4772}
4773
4774async function readStudyMonoChunk(sink, decoded, offsetSeconds, durationSeconds) {
4775  const rangeStart = decoded.firstTimestamp + offsetSeconds;
4776  const rangeEnd = rangeStart + durationSeconds;
4777  const parts = [];
4778  let totalFrames = 0;
4779  let decodedSampleRate = 0;
4780  for await (const wrapped of sink.buffers(rangeStart, rangeEnd)) {
4781    const buffer = wrapped?.buffer;
4782    const bufferSampleRate = Number(buffer?.sampleRate);
4783    if (!buffer || !Number.isSafeInteger(bufferSampleRate)
4784        || bufferSampleRate < 8000 || bufferSampleRate > 192000
4785        || (decodedSampleRate && bufferSampleRate !== decodedSampleRate)
4786        || buffer.numberOfChannels < 1 || buffer.numberOfChannels > 2) {
4787      throw new Error("The browser returned an invalid decoded audio chunk.");
4788    }
4789    decodedSampleRate ||= bufferSampleRate;
4790    const overlapStart = Math.max(rangeStart, Number(wrapped.timestamp));
4791    const overlapEnd = Math.min(rangeEnd, Number(wrapped.timestamp) + Number(wrapped.duration));
4792    if (!(overlapEnd > overlapStart)) continue;
4793    const firstFrame = Math.max(0, Math.floor((overlapStart - Number(wrapped.timestamp)) * buffer.sampleRate));
4794    const lastFrame = Math.min(buffer.length, Math.ceil((overlapEnd - Number(wrapped.timestamp)) * buffer.sampleRate));
4795    if (lastFrame <= firstFrame) continue;
4796    const part = new Float32Array(lastFrame - firstFrame);
4797    for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) {
4798      const values = buffer.getChannelData(channel);
4799      for (let frame = firstFrame; frame < lastFrame; frame += 1) {
4800        part[frame - firstFrame] += values[frame] / buffer.numberOfChannels;
4801      }
4802    }
4803    totalFrames += part.length;
4804    if (totalFrames > STUDY_AUDIO_MAX_CHUNK_SAMPLE_VALUES) {
4805      throw new Error("A decoded audio chunk exceeded its browser memory limit.");
4806    }
4807    parts.push(part);
4808  }
4809  if (!totalFrames) throw new Error("The browser returned an empty decoded audio chunk.");
4810  const combined = new Float32Array(totalFrames);
4811  let writeOffset = 0;
4812  for (const part of parts) {
4813    combined.set(part, writeOffset);
4814    writeOffset += part.length;
4815  }
4816  return { samples: combined, sampleRate: decodedSampleRate };
4817}
4818
4819function encodeMonoPcm16Wav(samples, sampleRate) {
4820  if (!(samples instanceof Float32Array) || sampleRate !== STUDY_WAV_SAMPLE_RATE) {
4821    throw new Error("The prepared Whisper audio is invalid.");
4822  }
4823  const buffer = new ArrayBuffer(44 + samples.length * 2);
4824  const view = new DataView(buffer);
4825  writeStudyWavAscii(view, 0, "RIFF");
4826  view.setUint32(4, 36 + samples.length * 2, true);
4827  writeStudyWavAscii(view, 8, "WAVE");
4828  writeStudyWavAscii(view, 12, "fmt ");
4829  view.setUint32(16, 16, true);
4830  view.setUint16(20, 1, true);
4831  view.setUint16(22, 1, true);
4832  view.setUint32(24, sampleRate, true);
4833  view.setUint32(28, sampleRate * 2, true);
4834  view.setUint16(32, 2, true);
4835  view.setUint16(34, 16, true);
4836  writeStudyWavAscii(view, 36, "data");
4837  view.setUint32(40, samples.length * 2, true);
4838  for (let index = 0; index < samples.length; index += 1) {
4839    const sample = Math.max(-1, Math.min(1, Number.isFinite(samples[index]) ? samples[index] : 0));
4840    view.setInt16(44 + index * 2, Math.round(sample < 0 ? sample * 0x8000 : sample * 0x7fff), true);
4841  }
4842  return buffer;
4843}
4844
4845function writeStudyWavAscii(view, offset, value) {
4846  for (let index = 0; index < value.length; index += 1) {
4847    view.setUint8(offset + index, value.charCodeAt(index));
4848  }
4849}
4850
4851async function sha256ArrayBuffer(value) {
4852  const digest = await crypto.subtle.digest("SHA-256", value);
4853  return [...new Uint8Array(digest)]
4854    .map((byte) => byte.toString(16).padStart(2, "0"))
4855    .join("");
4856}
4857
4858function studyAudioProbeFailureMessage(code) {
4859  if (code === "PROBE_TIMEOUT") {
4860    return "Audio timed out. Your captions are safe.";
4861  }
4862  if (code === "AUDIO_TOO_LARGE" || code === "AUDIO_TOO_LONG") {
4863    return "This video is over the Whisper limit.";
4864  }
4865  if (["CIPHERED_SOURCE", "SABR_ONLY", "NO_DIRECT_AUDIO"].includes(code)) {
4866    return "Direct audio is unavailable for this video.";
4867  }
4868  if (code === "AMBIGUOUS_AUDIO_TRACK") {
4869    return "This video has multiple audio tracks.";
4870  }
4871  return "Whisper stopped. Your captions are safe.";
4872}
4873
4874function cancelStudyAudioProbe({ render = true } = {}) {
4875  const probe = state.studyAudioProbe;
4876  if (studyAudioProbeTimeout !== null) window.clearTimeout(studyAudioProbeTimeout);
4877  studyAudioProbeTimeout = null;
4878  if (probe?.requestId && ["requested", "resolving", "fetching"].includes(probe.status)) {
4879    window.postMessage({
4880      source: "immersion-feed-study-page",
4881      type: "study-audio-probe-cancel",
4882      requestId: probe.requestId,
4883      videoId: probe.videoId,
4884    }, window.location.origin);
4885  }
4886  state.studyAudioProbe = null;
4887  if (render && state.study) renderStudyTranscriptState(state.study);
4888}
4889
4890async function loadStudy({ silent = false, statusOnly = false } = {}) {
4891  const videoId = selectedStudyVideoId();
4892  const jobId = selectedStudyJobId();
4893  if (videoId && state.study?.video_id && state.study.video_id !== videoId) destroyStudyExperience();
4894  if (!state.authUser || state.studyLoading) return;
4895  if (!videoId && !jobId) {
4896    showStudyStatus("This Study link is missing its YouTube video.", "missing");
4897    return;
4898  }
4899  const requestingUser = state.authUser;
4900  const requestVersion = ++studyRequestVersion;
4901  state.studyLoading = true;
4902  if (!silent && videoId) {
4903    renderStudyState({
4904      video_id: videoId,
4905      youtube: { status: "loading", cues: [] },
4906      whisper: { status: "not_requested", cues: [] },
4907    });
4908  } else if (!silent) showStudyStatus("Loading Study Mode…", "loading");
4909  try {
4910    const path = videoId
4911      ? `/api/study/videos/${encodeURIComponent(videoId)}${statusOnly ? "?status_only=1" : ""}`
4912      : `/api/study/jobs/${encodeURIComponent(jobId)}`;
4913    const data = await api(path, { cache: "no-store" });
4914    if (requestVersion !== studyRequestVersion || !isCurrentDownloadUser(requestingUser) || state.activeSection !== "study") return;
4915    let nextStudy = data.study || {};
4916    if (videoId && statusOnly) {
4917      const needsFullTranscript = ["youtube", "whisper"].some((source) => {
4918        const incoming = nextStudy[source];
4919        const current = state.study?.[source];
4920        return incoming?.cues_omitted && (
4921          !current?.cues?.length
4922          || current.updated_at !== incoming.updated_at
4923        );
4924      });
4925      if (needsFullTranscript) {
4926        state.studyLoading = false;
4927        await loadStudy({ silent: true });
4928        return;
4929      }
4930      nextStudy = {
4931        ...nextStudy,
4932        youtube: nextStudy.youtube?.cues_omitted
4933          ? { ...(state.study?.youtube || {}), ...nextStudy.youtube, cues: state.study?.youtube?.cues || [] }
4934          : nextStudy.youtube,
4935        whisper: nextStudy.whisper?.cues_omitted
4936          ? { ...(state.study?.whisper || {}), ...nextStudy.whisper, cues: state.study?.whisper?.cues || [] }
4937          : nextStudy.whisper,
4938      };
4939    }
4940    renderStudyState(videoId ? { ...nextStudy, video_id: videoId } : nextStudy);
4941    if (videoId && state.study?.youtube?.status === "not_requested") {
4942      studyExtensionCaptionWaiting.add(videoId);
4943      renderStudyTranscriptState(state.study);
4944      try {
4945        await waitForStudyExtensionCaptions(videoId);
4946      } finally {
4947        studyExtensionCaptionWaiting.delete(videoId);
4948      }
4949      if (requestVersion !== studyRequestVersion || !isCurrentDownloadUser(requestingUser)
4950          || state.activeSection !== "study") return;
4951      if (state.study?.youtube?.status === "not_requested") {
4952        const extensionIssue = studyExtensionReadinessIssue();
4953        if (extensionIssue) reportStudyExtensionReadinessIssue(extensionIssue, videoId, "youtube");
4954        renderStudyTranscriptState(state.study);
4955      }
4956    }
4957  } catch (error) {
4958    if (requestVersion !== studyRequestVersion || !isCurrentDownloadUser(requestingUser) || state.activeSection !== "study") return;
4959    if (videoId) {
4960      renderStudyState({
4961        ...(state.study || {}),
4962        video_id: videoId,
4963        load_error: error.message || "Study Mode could not load. Refresh to try again.",
4964      });
4965    } else showStudyStatus(error.message || "Study Mode could not load.", "failed");
4966  } finally {
4967    if (requestVersion === studyRequestVersion) state.studyLoading = false;
4968  }
4969}
4970
4971function dictionaryFocusables() {
4972  return [...elements.studyDictionaryDialog.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')]
4973    .filter((element) => !element.disabled && !element.hidden);
4974}
4975
4976function trapStudyDictionaryFocus(event) {
4977  if (event.key !== "Tab") return;
4978  const focusable = dictionaryFocusables();
4979  if (!focusable.length) return;
4980  const first = focusable[0];
4981  const last = focusable.at(-1);
4982  if (event.shiftKey && document.activeElement === first) {
4983    event.preventDefault();
4984    last.focus();
4985  } else if (!event.shiftKey && document.activeElement === last) {
4986    event.preventDefault();
4987    first.focus();
4988  }
4989}
4990
4991function restoreStudyDictionaryFocus() {
4992  cancelStudyDictionaryMotion();
4993  elements.studyDictionaryDialog?.classList.remove("is-visible", "is-closing");
4994  studyDictionaryTrigger?.focus?.();
4995  studyDictionaryTrigger = null;
4996  resumeStudyPlayerAfterDictionary();
4997}
4998
4999function studyWordFromMouseTarget(target) {
5000  return target?.closest?.("[data-study-hover-word]") || null;
5001}
5002
5003function isStudyMigakuPopupElement(target) {
5004  return Boolean(
5005    target?.classList?.contains?.("UiPopupDict")
5006    || target?.classList?.contains?.("PopupDictContainer")
5007  );
5008}
5009
5010function studyMigakuPopupFromMouseEvent(event) {
5011  return event?.composedPath?.().find(isStudyMigakuPopupElement) || null;
5012}
5013
5014function studyMigakuPopupFromMouseTarget(target) {
5015  const directPopup = target?.closest?.(".UiPopupDict, .PopupDictContainer");
5016  if (directPopup) return directPopup;
5017
5018  const host = target?.closest?.("#MigakuShadowDom");
5019  const popup = host?.shadowRoot?.querySelector?.(".UiPopupDict");
5020  if (!popup) return null;
5021  const bounds = popup.getBoundingClientRect?.();
5022  return bounds && bounds.width > 0 && bounds.height > 0 ? popup : null;
5023}
5024
5025function handleStudyWordMouseOver(event) {
5026  if (studyMigakuPopupFromMouseEvent(event)) {
5027    pauseStudyPlayerForWordHover();
5028    return;
5029  }
5030  const word = studyWordFromMouseTarget(event.target);
5031  if (!word || studyWordFromMouseTarget(event.relatedTarget)) return;
5032  pauseStudyPlayerForWordHover();
5033}
5034
5035function handleStudyWordMouseOut(event) {
5036  if (studyMigakuPopupFromMouseEvent(event)) {
5037    if (
5038      studyWordFromMouseTarget(event.relatedTarget)
5039      || studyMigakuPopupFromMouseTarget(event.relatedTarget)
5040    ) return;
5041    resumeStudyPlayerAfterWordHover();
5042    return;
5043  }
5044  const word = studyWordFromMouseTarget(event.target);
5045  if (
5046    !word
5047    || studyWordFromMouseTarget(event.relatedTarget)
5048    || studyMigakuPopupFromMouseTarget(event.relatedTarget)
5049  ) return;
5050  resumeStudyPlayerAfterWordHover();
5051}
5052
5053function handleStudyMigakuPopupMouseEnter() {
5054  pauseStudyPlayerForWordHover();
5055}
5056
5057function handleStudyMigakuPopupMouseLeave(event) {
5058  if (
5059    studyWordFromMouseTarget(event.relatedTarget)
5060    || studyMigakuPopupFromMouseTarget(event.relatedTarget)
5061  ) return;
5062  resumeStudyPlayerAfterWordHover();
5063}
5064
5065function bindStudyMigakuPopup(popup) {
5066  if (!popup || studyMigakuPopupBindings.has(popup)) return;
5067  studyMigakuPopupBindings.add(popup);
5068  popup.addEventListener("mouseenter", handleStudyMigakuPopupMouseEnter);
5069  popup.addEventListener("mouseleave", handleStudyMigakuPopupMouseLeave);
5070}
5071
5072function syncStudyMigakuPopups() {
5073  const root = document.querySelector("#MigakuShadowDom")?.shadowRoot;
5074  if (!root) return;
5075  root.querySelectorAll(".UiPopupDict").forEach(bindStudyMigakuPopup);
5076  if (studyMigakuObservedRoots.has(root)) return;
5077  studyMigakuObservedRoots.add(root);
5078  new MutationObserver(syncStudyMigakuPopups).observe(root, { childList: true, subtree: true });
5079}
5080
5081function observeStudyMigakuPopups() {
5082  syncStudyMigakuPopups();
5083  new MutationObserver(syncStudyMigakuPopups).observe(document.documentElement, {
5084    childList: true,
5085    subtree: true,
5086  });
5087}
5088
5089function pauseStudyPlayerForWordHover() {
5090  if (
5091    state.activeSection !== "study"
5092    || elements.studyDictionaryDialog?.open
5093    || studyWordHoverPausedPlayer
5094  ) return;
5095  try {
5096    if (Number(studyPlayer?.getPlayerState?.()) === YOUTUBE_PLAYER_STATE_PLAYING) {
5097      studyWordHoverPausedPlayer = studyPlayer;
5098      studyPlayer.pauseVideo();
5099    }
5100  } catch (_error) {
5101    // The player can briefly be unavailable while YouTube changes state.
5102  }
5103}
5104
5105function resumeStudyPlayerAfterWordHover() {
5106  const pausedPlayer = studyWordHoverPausedPlayer;
5107  studyWordHoverPausedPlayer = null;
5108  if (!pausedPlayer || state.activeSection !== "study" || studyPlayer !== pausedPlayer) return;
5109  try {
5110    pausedPlayer.playVideo();
5111  } catch (_error) {
5112    // Leave playback paused if YouTube is no longer ready.
5113  }
5114}
5115
5116function pauseStudyPlayerForDictionary() {
5117  if (elements.studyDictionaryDialog?.open) return;
5118  if (studyWordHoverPausedPlayer && studyWordHoverPausedPlayer === studyPlayer) {
5119    studyDictionaryPausedPlayer = studyWordHoverPausedPlayer;
5120    studyWordHoverPausedPlayer = null;
5121    return;
5122  }
5123  try {
5124    if (Number(studyPlayer?.getPlayerState?.()) === YOUTUBE_PLAYER_STATE_PLAYING) {
5125      studyDictionaryPausedPlayer = studyPlayer;
5126      studyPlayer.pauseVideo();
5127    }
5128  } catch (_error) {
5129    // The player can briefly be unavailable while YouTube changes state.
5130  }
5131}
5132
5133function resumeStudyPlayerAfterDictionary() {
5134  const pausedPlayer = studyDictionaryPausedPlayer;
5135  studyDictionaryPausedPlayer = null;
5136  if (!pausedPlayer || state.activeSection !== "study" || studyPlayer !== pausedPlayer) return;
5137  try {
5138    pausedPlayer.playVideo();
5139  } catch (_error) {
5140    // Leave playback paused if YouTube is no longer ready.
5141  }
5142}
5143
5144function handleStudyDictionaryBackdropClick(event) {
5145  const dialog = elements.studyDictionaryDialog;
5146  if (event.target !== dialog) return;
5147  const bounds = dialog.getBoundingClientRect();
5148  const isOutside = event.clientX < bounds.left
5149    || event.clientX > bounds.right
5150    || event.clientY < bounds.top
5151    || event.clientY > bounds.bottom;
5152  if (isOutside) closeStudyDictionary();
5153}
5154
5155function cancelStudyDictionaryMotion() {
5156  if (studyDictionaryCloseTimer !== null) window.clearTimeout(studyDictionaryCloseTimer);
5157  if (studyDictionaryOpenFrame !== null) window.cancelAnimationFrame(studyDictionaryOpenFrame);
5158  studyDictionaryCloseTimer = null;
5159  studyDictionaryOpenFrame = null;
5160}
5161
5162function prefersReducedStudyMotion() {
5163  return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
5164}
5165
5166function showStudyDictionaryDialog() {
5167  const dialog = elements.studyDictionaryDialog;
5168  if (!dialog) return;
5169  const wasOpen = dialog.open;
5170  cancelStudyDictionaryMotion();
5171  dialog.classList.remove("is-closing");
5172  if (!wasOpen) dialog.showModal();
5173  if (wasOpen || prefersReducedStudyMotion()) {
5174    dialog.classList.add("is-visible");
5175    return;
5176  }
5177  dialog.classList.remove("is-visible");
5178  dialog.getBoundingClientRect();
5179  studyDictionaryOpenFrame = window.requestAnimationFrame(() => {
5180    studyDictionaryOpenFrame = null;
5181    if (dialog.open) dialog.classList.add("is-visible");
5182  });
5183}
5184
5185function closeStudyDictionary() {
5186  studyDictionaryRequestVersion += 1;
5187  const dialog = elements.studyDictionaryDialog;
5188  if (!dialog?.open) return;
5189  cancelStudyDictionaryMotion();
5190  dialog.classList.add("is-closing");
5191  dialog.classList.remove("is-visible");
5192  if (prefersReducedStudyMotion()) {
5193    dialog.close();
5194    return;
5195  }
5196  studyDictionaryCloseTimer = window.setTimeout(() => {
5197    studyDictionaryCloseTimer = null;
5198    if (dialog.open) dialog.close();
5199  }, STUDY_DICTIONARY_CLOSE_MS);
5200}
5201
5202function studyDictionaryCacheKey(requestPayload, aiFallback = false) {
5203  const artifact = requestPayload.caption_id
5204    ? `caption:${requestPayload.caption_id}`
5205    : `job:${requestPayload.job_id}`;
5206  return `${artifact}|${requestPayload.cue_index}|${requestPayload.token_start}:${requestPayload.token_end}|${aiFallback ? "ai" : "jmdict"}`;
5207}
5208
5209function readStudyDictionaryCache(requestPayload, aiFallback = false) {
5210  const key = studyDictionaryCacheKey(requestPayload, aiFallback);
5211  const cached = studyDictionarySessionCache.get(key);
5212  if (!cached) return null;
5213  studyDictionarySessionCache.delete(key);
5214  studyDictionarySessionCache.set(key, cached);
5215  return cached;
5216}
5217
5218function writeStudyDictionaryCache(requestPayload, aiFallback, data) {
5219  const key = studyDictionaryCacheKey(requestPayload, aiFallback);
5220  studyDictionarySessionCache.delete(key);
5221  studyDictionarySessionCache.set(key, data);
5222  while (studyDictionarySessionCache.size > STUDY_DICTIONARY_CACHE_MAX) {
5223    studyDictionarySessionCache.delete(studyDictionarySessionCache.keys().next().value);
5224  }
5225}
5226
5227function appendJmdictAttribution(body) {
5228  const attribution = document.createElement("p");
5229  attribution.className = "study-dictionary-attribution";
5230  attribution.append(document.createTextNode("Definitions from "));
5231  const sourceLink = document.createElement("a");
5232  sourceLink.href = JMDICT_SOURCE_URL;
5233  sourceLink.target = "_blank";
5234  sourceLink.rel = "noreferrer";
5235  sourceLink.textContent = "JMdict/EDRDG";
5236  const separator = document.createTextNode(" · ");
5237  const licenseLink = document.createElement("a");
5238  licenseLink.href = JMDICT_LICENSE_URL;
5239  licenseLink.target = "_blank";
5240  licenseLink.rel = "noreferrer";
5241  licenseLink.textContent = "CC BY-SA 4.0";
5242  attribution.append(sourceLink, separator, licenseLink);
5243  body.append(attribution);
5244}
5245
5246function renderDictionaryEntry(data, requestPayload = null) {
5247  const body = elements.studyDictionaryBody;
5248  body.replaceChildren();
5249  const heading = document.createElement("div");
5250  heading.className = "study-dictionary-word";
5251  const lemma = document.createElement("strong");
5252  lemma.textContent = String(data.lemma || data.term || "Word");
5253  const reading = document.createElement("span");
5254  reading.textContent = String(data.reading || "Reading unavailable");
5255  heading.append(lemma, reading);
5256  body.append(heading);
5257  if (data.part_of_speech) {
5258    const partOfSpeech = document.createElement("p");
5259    partOfSpeech.className = "study-dictionary-pos";
5260    partOfSpeech.textContent = Array.isArray(data.part_of_speech)
5261      ? data.part_of_speech.map(String).join(" · ")
5262      : String(data.part_of_speech);
5263    body.append(partOfSpeech);
5264  }
5265  const definitions = Array.isArray(data.definitions) ? data.definitions : [];
5266  if (definitions.length) {
5267    const list = document.createElement("ol");
5268    definitions.forEach((definition) => {
5269      const item = document.createElement("li");
5270      item.textContent = String(definition);
5271      list.append(item);
5272    });
5273    body.append(list);
5274  } else if (data.not_found) {
5275    const empty = document.createElement("p");
5276    empty.textContent = "We couldn't find this word in JMdict.";
5277    body.append(empty);
5278    if (data.ai_available && requestPayload) {
5279      const askAi = document.createElement("button");
5280      askAi.type = "button";
5281      askAi.className = "ghost-button study-dictionary-ai-button";
5282      askAi.textContent = "Ask AI for this context";
5283      askAi.onclick = () => lookupStudyDictionaryWithAi(requestPayload);
5284      body.append(askAi);
5285    }
5286  } else {
5287    const empty = document.createElement("p");
5288    empty.textContent = "No definition was returned.";
5289    body.append(empty);
5290  }
5291  if (data.source === "jmdict" && definitions.length) {
5292    appendJmdictAttribution(body);
5293  } else if (data.ai_generated) {
5294    const label = document.createElement("p");
5295    label.className = "study-dictionary-ai-label";
5296    label.textContent = "AI-generated lookup · Check important meanings in a full dictionary.";
5297    body.append(label);
5298  }
5299  if (requestPayload && definitions.length && (data.lemma || data.term)) {
5300    appendStudyDictionarySaveActions(body, data, requestPayload);
5301  }
5302}
5303
5304function appendStudyDictionarySaveActions(body, data, requestPayload) {
5305  const actions = document.createElement("div");
5306  actions.className = "study-dictionary-save-actions";
5307  const saveButton = document.createElement("button");
5308  saveButton.type = "button";
5309  saveButton.className = "accent-button study-dictionary-save-button";
5310  saveButton.textContent = "Save word";
5311  const viewButton = document.createElement("button");
5312  viewButton.type = "button";
5313  viewButton.className = "ghost-button study-dictionary-view-saved";
5314  viewButton.textContent = "View saved";
5315  viewButton.hidden = true;
5316  const status = document.createElement("p");
5317  status.className = "study-dictionary-save-status";
5318  status.setAttribute("role", "status");
5319  status.setAttribute("aria-live", "polite");
5320  status.hidden = true;
5321  saveButton.addEventListener("click", async () => {
5322    saveButton.disabled = true;
5323    saveButton.textContent = "Saving…";
5324    status.hidden = true;
5325    try {
5326      const saved = await api("/api/me/saved-words", {
5327        method: "POST",
5328        body: JSON.stringify({
5329          ...requestPayload,
5330          ...(data.ai_generated ? { ai_fallback: true } : {}),
5331        }),
5332      });
5333      saveButton.textContent = "Saved ✓";
5334      status.textContent = saved.created === false
5335        ? "Already in your saved words."
5336        : "Saved with this sentence.";
5337      status.hidden = false;
5338      viewButton.hidden = false;
5339    } catch (error) {
5340      saveButton.disabled = false;
5341      saveButton.textContent = "Try saving again";
5342      status.textContent = error.message || "The word could not be saved.";
5343      status.hidden = false;
5344    }
5345  });
5346  viewButton.addEventListener("click", openSavedStudyFromDictionary);
5347  actions.append(saveButton, viewButton);
5348  body.append(actions, status);
5349}
5350
5351function openSavedStudyFromDictionary() {
5352  studyDictionaryPausedPlayer = null;
5353  cancelStudyDictionaryMotion();
5354  if (elements.studyDictionaryDialog?.open) elements.studyDictionaryDialog.close();
5355  switchSection("saved");
5356}
5357
5358async function requestStudyDictionaryEntry(requestPayload, { aiFallback = false, requestVersion } = {}) {
5359  const cached = readStudyDictionaryCache(requestPayload, aiFallback);
5360  if (cached) {
5361    if (requestVersion === studyDictionaryRequestVersion && elements.studyDictionaryDialog.open) {
5362      renderDictionaryEntry(cached, requestPayload);
5363    }
5364    return cached;
5365  }
5366  const data = await api("/api/study/dictionary", {
5367    method: "POST",
5368    body: JSON.stringify({
5369      ...requestPayload,
5370      ...(aiFallback ? { ai_fallback: true } : {}),
5371    }),
5372  });
5373  writeStudyDictionaryCache(requestPayload, aiFallback, data);
5374  if (requestVersion === studyDictionaryRequestVersion && elements.studyDictionaryDialog.open) {
5375    renderDictionaryEntry(data, requestPayload);
5376  }
5377  return data;
5378}
5379
5380async function lookupStudyDictionaryWithAi(requestPayload) {
5381  const requestVersion = ++studyDictionaryRequestVersion;
5382  elements.studyDictionaryBody.textContent = "Asking AI about this context…";
5383  try {
5384    await requestStudyDictionaryEntry(requestPayload, { aiFallback: true, requestVersion });
5385  } catch (error) {
5386    if (requestVersion !== studyDictionaryRequestVersion || !elements.studyDictionaryDialog.open) return;
5387    elements.studyDictionaryBody.textContent = error.message || "The AI lookup failed.";
5388  }
5389}
5390
5391async function openStudyDictionary(button) {
5392  if (state.studyDictionaryEnabled === false) return;
5393  const term = String(button.dataset.studyWord || "").trim();
5394  const cueIndex = Number(button.dataset.studyCueIndex);
5395  const tokenStart = Number(button.dataset.studyTokenStart);
5396  const tokenEnd = Number(button.dataset.studyTokenEnd);
5397  const sentence = state.study?.cues?.find((cue) => cue.index === cueIndex)?.text || "";
5398  const captionId = state.study?.active_source === "youtube"
5399    ? String(state.study?.youtube?.id || "").trim()
5400    : "";
5401  const hostedArtifactId = state.study?.active_source === "whisper"
5402    ? String(state.study?.whisper?.artifact_id || "").trim()
5403    : "";
5404  const jobId = state.study?.active_source === "youtube" || hostedArtifactId
5405    ? ""
5406    : String(state.study?.download_job_id || selectedStudyJobId()).trim();
5407  if (
5408    !term
5409    || (!captionId && !hostedArtifactId && !jobId)
5410    || !Number.isInteger(cueIndex)
5411    || !Number.isInteger(tokenStart)
5412    || !Number.isInteger(tokenEnd)
5413    || tokenStart < 0
5414    || tokenEnd <= tokenStart
5415  ) return;
5416  const requestVersion = ++studyDictionaryRequestVersion;
5417  studyDictionaryTrigger = button;
5418  elements.studyDictionaryTitle.textContent = term;
5419  elements.studyDictionaryContext.textContent = sentence;
5420  elements.studyDictionaryBody.textContent = "Looking up this word…";
5421  pauseStudyPlayerForDictionary();
5422  showStudyDictionaryDialog();
5423  elements.studyDictionaryClose.focus();
5424  const requestPayload = {
5425    ...(captionId
5426      ? { caption_id: captionId }
5427      : (hostedArtifactId ? { hosted_artifact_id: hostedArtifactId } : { job_id: jobId })),
5428    cue_index: cueIndex,
5429    token_start: tokenStart,
5430    token_end: tokenEnd,
5431  };
5432  try {
5433    await requestStudyDictionaryEntry(requestPayload, { requestVersion });
5434  } catch (error) {
5435    if (requestVersion !== studyDictionaryRequestVersion || !elements.studyDictionaryDialog.open) return;
5436    elements.studyDictionaryBody.textContent = error.message || "The dictionary lookup failed.";
5437  }
5438}
5439
5440function setSavedStudyTab(tab) {
5441  state.savedStudyTab = tab === "sentences" ? "sentences" : "words";
5442  renderSavedStudyItems();
5443}
5444
5445function savedStudyDate(value) {
5446  const date = new Date(value || "");
5447  if (Number.isNaN(date.getTime())) return "Saved recently";
5448  return `Saved ${new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date)}`;
5449}
5450
5451function createSavedStudySourceLink(item) {
5452  const videoId = normalizeYouTubeVideoId(item?.video_id);
5453  if (!videoId) return null;
5454  const link = document.createElement("a");
5455  const url = new URL("https://www.youtube.com/watch");
5456  url.searchParams.set("v", videoId);
5457  const cueStart = Number(item?.cue_start_seconds);
5458  if (Number.isFinite(cueStart) && cueStart > 0) url.searchParams.set("t", `${Math.floor(cueStart)}s`);
5459  link.href = url.toString();
5460  link.target = "_blank";
5461  link.rel = "noopener";
5462  link.textContent = "Open source video";
5463  return link;
5464}
5465
5466function appendSavedStudyEmpty(list, title, copy) {
5467  const empty = document.createElement("div");
5468  empty.className = "empty-state saved-study-empty";
5469  const heading = document.createElement("h3");
5470  heading.textContent = title;
5471  const description = document.createElement("p");
5472  description.textContent = copy;
5473  empty.append(heading, description);
5474  list.append(empty);
5475}
5476
5477function renderSavedStudyWords() {
5478  const list = elements.savedWordsList;
5479  if (!list) return;
5480  list.replaceChildren();
5481  if (!state.savedWords.length) {
5482    appendSavedStudyEmpty(list, "No saved words yet", "Open Study Mode, tap a word in the captions, and choose Save word.");
5483    return;
5484  }
5485  state.savedWords.forEach((word) => {
5486    const card = document.createElement("article");
5487    card.className = "saved-card saved-study-card saved-word-card";
5488    const heading = document.createElement("div");
5489    heading.className = "saved-study-card-heading";
5490    const identity = document.createElement("div");
5491    const title = document.createElement("h3");
5492    title.lang = "ja";
5493    title.textContent = String(word.lemma || word.term || "Saved word");
5494    const reading = document.createElement("p");
5495    reading.className = "saved-study-reading";
5496    reading.lang = "ja";
5497    reading.textContent = String(word.reading || word.term || "");
5498    identity.append(title, reading);
5499    const remove = document.createElement("button");
5500    remove.type = "button";
5501    remove.className = "text-button saved-study-remove";
5502    remove.dataset.savedWordRemove = String(word.id || "");
5503    remove.textContent = savedWordRemoveInFlight.has(String(word.id || "")) ? "Removing…" : "Remove";
5504    remove.disabled = savedWordRemoveInFlight.has(String(word.id || ""));
5505    heading.append(identity, remove);
5506    card.append(heading);
5507    if (word.part_of_speech) {
5508      const partOfSpeech = document.createElement("p");
5509      partOfSpeech.className = "saved-study-pos";
5510      partOfSpeech.textContent = String(word.part_of_speech);
5511      card.append(partOfSpeech);
5512    }
5513    const definitions = Array.isArray(word.definitions) ? word.definitions : [];
5514    if (definitions.length) {
5515      const definitionList = document.createElement("ol");
5516      definitionList.className = "saved-study-definitions";
5517      definitions.forEach((definition) => {
5518        const item = document.createElement("li");
5519        item.textContent = String(definition);
5520        definitionList.append(item);
5521      });
5522      card.append(definitionList);
5523    }
5524    const sentenceText = String(word.sentence_text || word.sentence || "").trim();
5525    if (sentenceText) {
5526      const sentence = document.createElement("p");
5527      sentence.className = "saved-study-sentence";
5528      sentence.lang = "ja";
5529      sentence.textContent = sentenceText;
5530      card.append(sentence);
5531    }
5532    const footer = document.createElement("footer");
5533    footer.className = "saved-study-card-footer";
5534    const sourceLink = createSavedStudySourceLink(word);
5535    if (sourceLink) footer.append(sourceLink);
5536    const savedAt = document.createElement("span");
5537    savedAt.textContent = savedStudyDate(word.created_at || word.saved_at);
5538    footer.append(savedAt);
5539    card.append(footer);
5540    list.append(card);
5541  });
5542}
5543
5544function renderSavedStudySentences() {
5545  const list = elements.savedSentencesList;
5546  if (!list) return;
5547  list.replaceChildren();
5548  if (!state.savedSentences.length) {
5549    appendSavedStudyEmpty(list, "No saved sentences yet", "Each saved word keeps the caption sentence where you found it.");
5550    return;
5551  }
5552  state.savedSentences.forEach((savedSentence) => {
5553    const card = document.createElement("article");
5554    card.className = "saved-card saved-study-card saved-sentence-card";
5555    const sentence = document.createElement("p");
5556    sentence.className = "saved-study-sentence-text";
5557    sentence.lang = "ja";
5558    sentence.textContent = String(savedSentence.sentence_text || savedSentence.sentence || "");
5559    card.append(sentence);
5560    const words = Array.isArray(savedSentence.words) ? savedSentence.words : [];
5561    if (words.length) {
5562      const wordList = document.createElement("div");
5563      wordList.className = "saved-study-word-chips";
5564      wordList.setAttribute("aria-label", "Words saved from this sentence");
5565      words.forEach((word) => {
5566        const chip = document.createElement("span");
5567        chip.lang = "ja";
5568        chip.textContent = String(word.lemma || word.term || "");
5569        wordList.append(chip);
5570      });
5571      card.append(wordList);
5572    }
5573    const footer = document.createElement("footer");
5574    footer.className = "saved-study-card-footer";
5575    const sourceLink = createSavedStudySourceLink(savedSentence);
5576    if (sourceLink) footer.append(sourceLink);
5577    const savedAt = document.createElement("span");
5578    savedAt.textContent = savedStudyDate(savedSentence.created_at || savedSentence.saved_at);
5579    footer.append(savedAt);
5580    card.append(footer);
5581    list.append(card);
5582  });
5583}
5584
5585function renderSavedStudyItems() {
5586  const tab = state.savedStudyTab === "sentences" ? "sentences" : "words";
5587  const isEmpty = !state.savedWords.length && !state.savedSentences.length;
5588  if (elements.savedWordsTab) {
5589    elements.savedWordsTab.classList.toggle("active", tab === "words");
5590    elements.savedWordsTab.setAttribute("aria-selected", String(tab === "words"));
5591    elements.savedWordsTab.tabIndex = tab === "words" ? 0 : -1;
5592  }
5593  if (elements.savedSentencesTab) {
5594    elements.savedSentencesTab.classList.toggle("active", tab === "sentences");
5595    elements.savedSentencesTab.setAttribute("aria-selected", String(tab === "sentences"));
5596    elements.savedSentencesTab.tabIndex = tab === "sentences" ? 0 : -1;
5597  }
5598  if (elements.savedWordsPanel) elements.savedWordsPanel.hidden = tab !== "words" || isEmpty || state.savedStudyLoading || Boolean(state.savedStudyError);
5599  if (elements.savedSentencesPanel) elements.savedSentencesPanel.hidden = tab !== "sentences" || isEmpty || state.savedStudyLoading || Boolean(state.savedStudyError);
5600  if (elements.savedLoading) elements.savedLoading.hidden = !state.savedStudyLoading;
5601  if (elements.savedEmpty) elements.savedEmpty.hidden = state.savedStudyLoading || Boolean(state.savedStudyError) || !isEmpty;
5602  if (elements.savedError) elements.savedError.hidden = !state.savedStudyError;
5603  if (elements.savedStatus) {
5604    if (state.savedStudyLoading) elements.savedStatus.textContent = "Loading your saved words…";
5605    else if (state.savedStudyError) elements.savedStatus.textContent = state.savedStudyError;
5606    else {
5607      const wordCount = state.savedWords.length;
5608      const sentenceCount = state.savedSentences.length;
5609      elements.savedStatus.textContent = `${wordCount} saved ${wordCount === 1 ? "word" : "words"} · ${sentenceCount} ${sentenceCount === 1 ? "sentence" : "sentences"}`;
5610    }
5611  }
5612  if (!state.savedStudyLoading && !state.savedStudyError && !isEmpty) {
5613    renderSavedStudyWords();
5614    renderSavedStudySentences();
5615  }
5616}
5617
5618async function loadSavedStudyItems() {
5619  if (!state.authUser) return;
5620  const requestVersion = ++savedStudyRequestVersion;
5621  state.savedStudyLoading = true;
5622  state.savedStudyError = "";
5623  renderSavedStudyItems();
5624  try {
5625    const [wordPayload, sentencePayload] = await Promise.all([
5626      api("/api/me/saved-words"),
5627      api("/api/me/saved-sentences"),
5628    ]);
5629    if (requestVersion !== savedStudyRequestVersion) return;
5630    state.savedWords = Array.isArray(wordPayload.words) ? wordPayload.words : [];
5631    state.savedSentences = Array.isArray(sentencePayload.sentences) ? sentencePayload.sentences : [];
5632  } catch (error) {
5633    if (requestVersion !== savedStudyRequestVersion) return;
5634    state.savedStudyError = error.message || "Your saved words could not be loaded.";
5635  } finally {
5636    if (requestVersion !== savedStudyRequestVersion) return;
5637    state.savedStudyLoading = false;
5638    renderSavedStudyItems();
5639  }
5640}
5641
5642async function removeSavedStudyWord(wordId, button) {
5643  const id = String(wordId || "").trim();
5644  if (!id || savedWordRemoveInFlight.has(id)) return;
5645  savedWordRemoveInFlight.add(id);
5646  if (button) {
5647    button.disabled = true;
5648    button.textContent = "Removing…";
5649  }
5650  try {
5651    await api(`/api/me/saved-words/${encodeURIComponent(id)}`, { method: "DELETE" });
5652    state.savedWords = state.savedWords.filter((word) => String(word.id) !== id);
5653    await loadSavedStudyItems();
5654  } catch (error) {
5655    state.savedStudyError = error.message || "That word could not be removed.";
5656    if (button) {
5657      button.disabled = false;
5658      button.textContent = "Try again";
5659    }
5660    renderSavedStudyItems();
5661  } finally {
5662    savedWordRemoveInFlight.delete(id);
5663  }
5664}
5665
5666function countActiveFilters(filters) {
5667  let count = 0;
5668  if (filters.channel) count += 1;
5669  if (filters.tag) count += 1;
5670  if (filters.minDuration || filters.maxDuration) count += 1;
5671  if (filters.search) count += 1;
5672  return count;
5673}
5674
5675function renderFilterBadge() {
5676  if (!elements.filterBadge) return;
5677  const count = countActiveFilters(state);
5678  elements.filterBadge.textContent = String(count);
5679  elements.filterBadge.hidden = count === 0;
5680}
5681
5682function stagedLengthId() {
5683  const match = LENGTH_PRESETS.find(
5684    (preset) => preset.min === stagedFilters.minDuration && preset.max === stagedFilters.maxDuration,
5685  );
5686  return match?.id ?? null;
5687}
5688
5689function renderFilterSheet() {
5690  if (!elements.filterSourceChips) return;
5691  const sourceChips = [
5692    `<button class="filter-chip${stagedFilters.channel ? "" : " active"}" data-filter-source="" type="button">All channels</button>`,
5693  ];
5694  for (const channel of state.channels) {
5695    const active = stagedFilters.channel === channel.source_key ? " active" : "";
5696    sourceChips.push(
5697      `<button class="filter-chip${active}" data-filter-source="${escapeAttribute(channel.source_key)}" type="button">${escapeHtml(channel.filter_label || channel.display_name || channel.source_key)}</button>`,
5698    );
5699  }
5700  elements.filterSourceChips.innerHTML = sourceChips.join("");
5701  const lengthId = stagedLengthId();
5702  elements.filterLengthChips.innerHTML = LENGTH_PRESETS.map(
5703    (preset) => `<button class="filter-chip${preset.id === lengthId ? " active" : ""}" data-filter-length="${preset.id}" type="button">${preset.label}</button>`,
5704  ).join("");
5705  elements.filterTagInput.value = stagedFilters.tag;
5706  elements.filterSearchInput.value = stagedFilters.search;
5707}
5708
5709function setFilterApplyLabel(total) {
5710  if (!elements.filterApply) return;
5711  elements.filterApply.textContent = typeof total === "number"
5712    ? `Show ${total} video${total === 1 ? "" : "s"}`
5713    : "Show videos";
5714}
5715
5716const refreshFilterCount = debounce(updateFilterCount, 250);
5717
5718async function updateFilterCount() {
5719  const version = ++filterCountVersion;
5720  const params = new URLSearchParams({
5721    state: state.activeTab,
5722    limit: "1",
5723    offset: "0",
5724    selected: state.authUser ? "true" : "false",
5725  });
5726  if (stagedFilters.channel) params.set("source_key", stagedFilters.channel);
5727  if (stagedFilters.tag) params.set("tag", stagedFilters.tag);
5728  if (stagedFilters.minDuration) params.set("min_duration", stagedFilters.minDuration);
5729  if (stagedFilters.maxDuration) params.set("max_duration", stagedFilters.maxDuration);
5730  if (stagedFilters.search) params.set("q", stagedFilters.search);
5731  try {
5732    const data = await api(`/api/videos?${params}`);
5733    if (version !== filterCountVersion) return;
5734    setFilterApplyLabel(typeof data.total === "number" ? data.total : null);
5735  } catch (_error) {
5736    if (version !== filterCountVersion) return;
5737    setFilterApplyLabel(null);
5738  }
5739}
5740
5741function positionFilterSheet() {
5742  if (!elements.filterSheet || !elements.filterTrigger) return;
5743  if (window.innerWidth <= 820) {
5744    elements.filterSheet.style.top = "";
5745    elements.filterSheet.style.right = "";
5746    elements.filterSheet.style.maxHeight = "";
5747    return;
5748  }
5749  const rect = elements.filterTrigger.getBoundingClientRect();
5750  elements.filterSheet.style.right = `${Math.max(window.innerWidth - rect.right, 12)}px`;
5751  elements.filterSheet.style.maxHeight = `${window.innerHeight - 24}px`;
5752  const height = elements.filterSheet.offsetHeight;
5753  const top = Math.min(rect.bottom + 10, window.innerHeight - height - 12);
5754  elements.filterSheet.style.top = `${Math.max(top, 12)}px`;
5755}
5756
5757function openFilterSheet() {
5758  stagedFilters.channel = state.channel;
5759  stagedFilters.tag = state.tag;
5760  stagedFilters.minDuration = state.minDuration;
5761  stagedFilters.maxDuration = state.maxDuration;
5762  stagedFilters.search = state.search;
5763  renderFilterSheet();
5764  setFilterApplyLabel(state.total);
5765  elements.filterOverlay.hidden = false;
5766  elements.filterTrigger.setAttribute("aria-expanded", "true");
5767  elements.filterTrigger.classList.add("open");
5768  document.body.classList.add("filter-sheet-open");
5769  positionFilterSheet();
5770  elements.filterSheet.focus();
5771  updateFilterCount();
5772}
5773
5774function closeFilterSheet() {
5775  if (!elements.filterOverlay || elements.filterOverlay.hidden) return;
5776  filterCountVersion += 1;
5777  elements.filterOverlay.hidden = true;
5778  elements.filterTrigger.setAttribute("aria-expanded", "false");
5779  elements.filterTrigger.classList.remove("open");
5780  document.body.classList.remove("filter-sheet-open");
5781  elements.filterTrigger.focus();
5782}
5783
5784function resetStagedFilters() {
5785  stagedFilters.channel = "";
5786  stagedFilters.tag = "";
5787  stagedFilters.minDuration = "";
5788  stagedFilters.maxDuration = "";
5789  stagedFilters.search = "";
5790  renderFilterSheet();
5791  refreshFilterCount();
5792}
5793
5794function applyStagedFilters() {
5795  state.channel = stagedFilters.channel;
5796  state.channelLabel = "";
5797  state.tag = stagedFilters.tag;
5798  state.minDuration = stagedFilters.minDuration;
5799  state.maxDuration = stagedFilters.maxDuration;
5800  state.search = stagedFilters.search;
5801  closeFilterSheet();
5802  renderFeedTitle();
5803  renderFilterBadge();
5804  loadVideos();
5805}
5806
5807function setFeedTopic(topic) {
5808  state.channel = "";
5809  state.channelLabel = "";
5810  state.tag = "";
5811  state.minDuration = "";
5812  state.maxDuration = "";
5813  state.search = "";
5814  if (topic === "under-10") state.maxDuration = "600";
5815  if (["vlogs", "podcasts", "anime", "food", "gaming", "grammar"].includes(topic)) state.tag = topic === "anime" ? "anime" : topic;
5816  state.activeTab = "new";
5817  document.querySelectorAll("[data-topic]").forEach((button) => button.classList.toggle("active", button.dataset.topic === topic));
5818  renderTabs();
5819  renderFilterBadge();
5820  loadVideos();
5821}
5822
5823function viewSourceFeed(sourceKey, sourceName = "") {
5824  if (!sourceKey) return;
5825  state.channel = sourceKey;
5826  state.channelLabel = sourceName;
5827  state.tag = "";
5828  state.minDuration = "";
5829  state.maxDuration = "";
5830  state.search = "";
5831  state.activeTab = "new";
5832  document.querySelectorAll("[data-topic]").forEach((button) => button.classList.remove("active"));
5833  renderTabs();
5834  renderFeedTitle();
5835  renderFilterBadge();
5836  switchSection("feed");
5837  loadVideos();
5838}
5839
5840async function loadDiscoverTag(tag) {
5841  state.activeDiscoverTag = tag;
5842  renderDiscoverFilters();
5843  renderCommunityDirectory();
5844  try {
5845    await loadDiscover();
5846  } catch (error) {
5847    showNotice(error.message, "error");
5848  }
5849}
5850
5851function sectionFromLocation(location = window.location) {
5852  const pathname = location.pathname === "/"
5853    ? "/"
5854    : location.pathname.replace(/\/+$/, "");
5855  const legacySection = new URLSearchParams(location.search).get("section");
5856  if (pathname === "/" && Object.hasOwn(SECTION_PATHS, legacySection)) return legacySection;
5857  return Object.entries(SECTION_PATHS)
5858    .find(([, path]) => path === pathname)?.[0] || "feed";
5859}
5860
5861function sectionUrl(section) {
5862  const url = new URL(window.location.href);
5863  url.pathname = SECTION_PATHS[section] || SECTION_PATHS.feed;
5864  url.searchParams.delete("section");
5865  url.searchParams.delete("onboarding");
5866  if (!["downloads", "study"].includes(section)) url.searchParams.delete("job");
5867  if (section !== "study") url.searchParams.delete("video");
5868  if (section !== "study") url.searchParams.delete("queue");
5869  if (section !== "profile" && url.hash === "#personalHelper") url.hash = "";
5870  return `${url.pathname}${url.search}${url.hash}`;
5871}
5872
5873function updateSectionHistory(section, historyMode) {
5874  if (historyMode === "none") return;
5875  const nextUrl = sectionUrl(section);
5876  const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
5877  if (nextUrl === currentUrl) return;
5878  const method = historyMode === "replace" ? "replaceState" : "pushState";
5879  window.history[method]({ section }, "", nextUrl);
5880}
5881
5882function setupSectionRouting() {
5883  document.querySelectorAll("[data-section]").forEach((control) => {
5884    control.addEventListener("click", (event) => {
5885      if (event.defaultPrevented || event.button > 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
5886      event.preventDefault();
5887      switchSection(control.dataset.section, { historyMode: "push" });
5888    });
5889  });
5890  window.addEventListener("popstate", () => {
5891    switchSection(sectionFromLocation(), { historyMode: "none" });
5892  });
5893}
5894
5895function focusPersonalHelperSetup(section) {
5896  if (section !== "profile" || window.location.hash !== "#personalHelper" || !elements.personalHelper) return;
5897  window.requestAnimationFrame(() => {
5898    elements.personalHelper.scrollIntoView?.({ behavior: "smooth", block: "start" });
5899    elements.personalHelper.focus?.({ preventScroll: true });
5900  });
5901}
5902
5903function switchSection(section, { historyMode = "push", loadData = true } = {}) {
5904  if (!Object.hasOwn(SECTION_PATHS, section)) section = "feed";
5905  if (["downloads", "study", "saved"].includes(section) && state.sessionHydrated && !state.authUser) {
5906    section = "feed";
5907    historyMode = "replace";
5908    loadData = false;
5909  }
5910  if (["study", "saved"].includes(section) && state.sessionHydrated && !canAccessStudy()) {
5911    section = "feed";
5912    historyMode = "replace";
5913    loadData = false;
5914  }
5915  updateSectionHistory(section, historyMode);
5916  if (section !== "my-sources") closeSourceComposer();
5917  if (section !== "feed") closeStudyUrlDialog();
5918  if (section !== "discover") closeCommunityPreview();
5919  state.activeSection = section;
5920  document.body.classList.toggle("study-mode-active", section === "study");
5921  if (section !== "downloads") stopDownloadPolling();
5922  if (section !== "study") destroyStudyExperience();
5923  renderCommunityGate();
5924  positionThemeToggle();
5925  document.querySelectorAll("[data-section-panel]").forEach((panel) => {
5926    panel.hidden = panel.dataset.sectionPanel !== section;
5927  });
5928  document.querySelectorAll("[data-section]").forEach((button) => {
5929    const visibleSection = section === "study" ? "downloads" : section;
5930    const isActive = button.dataset.section === visibleSection;
5931    button.classList.toggle("active", isActive);
5932    if (button.closest(".section-nav")) {
5933      if (isActive) button.setAttribute("aria-current", "page");
5934      else button.removeAttribute("aria-current");
5935    }
5936  });
5937  syncMobileTabbar();
5938  if (loadData && section === "discover") loadDiscover();
5939  if (loadData && section === "my-sources") loadMySources();
5940  if (loadData && section === "saved" && state.authUser) loadSavedStudyItems();
5941  if (section === "downloads") {
5942    if (loadData && state.authUser) loadDownloads();
5943    else syncDownloadPolling();
5944  }
5945  if (section === "study" && loadData && state.authUser) {
5946    loadStudyQueue();
5947    loadStudy();
5948  }
5949  if (loadData && section === "profile") {
5950    loadStats();
5951    if (state.authUser) loadDownloadDevices();
5952    if (SHOW_ADMIN_CHANNEL_UI && state.authUser?.is_admin) loadAdminChannels();
5953    if (state.authUser?.is_admin) loadAdminReviews();
5954  }
5955  focusPersonalHelperSetup(section);
5956}
5957
5958function scrollToGuestTarget(targetId) {
5959  const target = document.getElementById(targetId);
5960  if (!target) return;
5961  const top = target.getBoundingClientRect().top + window.scrollY - 20;
5962  window.scrollTo({ top, behavior: "smooth" });
5963}
5964
5965function toggleGuestHow() {
5966  if (!elements.guestHowToggle) return;
5967  const open = elements.guestHowItWorks?.classList.toggle("is-open") || false;
5968  elements.guestHowToggle.setAttribute("aria-expanded", String(open));
5969  const indicator = elements.guestHowToggle.querySelector("span");
5970  if (indicator) indicator.textContent = open ? "▴" : "▾";
5971}
5972
5973// Touch-following highlight adapted from Konsta UI v5.2's iOS ToolbarPane.
5974// Full MIT license notice: /THIRD_PARTY_NOTICES.txt
5975function setupMobileTabbar() {
5976  const nav = document.querySelector(".section-nav");
5977  const highlight = nav?.querySelector(".nav-highlight");
5978  if (!nav || !highlight) return;
5979
5980  const touch = {
5981    active: false,
5982    activeIndex: 0,
5983    targetIndex: 0,
5984    pointerId: null,
5985    rect: null,
5986    frame: 0,
5987    nextTransform: "",
5988    suppressClick: false,
5989  };
5990  const mobileLayout = window.matchMedia("(max-width: 600px)");
5991
5992  const links = () => [...nav.querySelectorAll(".nav-link")].filter((link) => !link.hidden);
5993  const paintTransform = (value) => {
5994    touch.nextTransform = value;
5995    if (touch.frame) return;
5996    touch.frame = requestAnimationFrame(() => {
5997      highlight.style.transform = touch.nextTransform;
5998      touch.frame = 0;
5999    });
6000  };
6001  const clearQueuedTransform = () => {
6002    if (touch.frame) cancelAnimationFrame(touch.frame);
6003    touch.frame = 0;
6004    touch.nextTransform = "";
6005  };
6006  const stopTracking = () => {
6007    clearQueuedTransform();
6008    touch.active = false;
6009    touch.pointerId = null;
6010    touch.rect = null;
6011    nav.classList.remove("is-tracking");
6012  };
6013
6014  syncMobileTabbar = () => {
6015    const tabLinks = links();
6016    const activeIndex = Math.max(0, tabLinks.findIndex((link) => link.classList.contains("active")));
6017    touch.activeIndex = activeIndex;
6018    touch.targetIndex = activeIndex;
6019    highlight.style.width = `${100 / tabLinks.length}%`;
6020    if (!touch.active) paintTransform(`translateX(${activeIndex * 100}%)`);
6021  };
6022
6023  const followPointer = (event) => {
6024    if (!touch.active || event.pointerId !== touch.pointerId || !touch.rect) return;
6025    const tabLinks = links();
6026    const highlightWidth = touch.rect.width / tabLinks.length;
6027    const rawLeft = event.clientX - touch.rect.left - highlightWidth / 2;
6028    const left = Math.max(0, Math.min(rawLeft, touch.rect.width - highlightWidth));
6029    touch.targetIndex = Math.max(0, Math.min(tabLinks.length - 1, Math.round(left / highlightWidth)));
6030    paintTransform(`translateX(${left}px)`);
6031  };
6032
6033  nav.addEventListener("pointerdown", (event) => {
6034    if (!mobileLayout.matches || event.pointerType !== "touch" || !event.target.closest(".nav-link")) return;
6035    touch.active = true;
6036    touch.pointerId = event.pointerId;
6037    touch.rect = nav.getBoundingClientRect();
6038    nav.classList.add("is-tracking");
6039    nav.setPointerCapture?.(event.pointerId);
6040    followPointer(event);
6041  });
6042
6043  document.addEventListener("pointermove", followPointer);
6044  document.addEventListener("pointerup", (event) => {
6045    if (!touch.active || event.pointerId !== touch.pointerId) return;
6046    const target = links()[touch.targetIndex];
6047    stopTracking();
6048    highlight.style.transform = `translateX(${touch.targetIndex * 100}%)`;
6049    if (target) {
6050      target.click();
6051      touch.suppressClick = true;
6052      setTimeout(() => { touch.suppressClick = false; }, 0);
6053    }
6054  });
6055  document.addEventListener("pointercancel", (event) => {
6056    if (!touch.active || event.pointerId !== touch.pointerId) return;
6057    stopTracking();
6058    highlight.style.transform = `translateX(${touch.activeIndex * 100}%)`;
6059  });
6060  mobileLayout.addEventListener("change", () => {
6061    if (touch.active) {
6062      stopTracking();
6063      highlight.style.transform = `translateX(${touch.activeIndex * 100}%)`;
6064    }
6065    syncMobileTabbar();
6066  });
6067  nav.addEventListener("click", (event) => {
6068    if (!touch.suppressClick || !event.isTrusted) return;
6069    event.preventDefault();
6070    event.stopImmediatePropagation();
6071    touch.suppressClick = false;
6072  }, true);
6073
6074  syncMobileTabbar();
6075}
6076
6077function setPullRefreshState(mode, distance = 0, message = "") {
6078  if (!elements.pullRefresh) return;
6079  const visible = mode !== "idle";
6080  const ready = mode === "ready";
6081  const refreshing = mode === "refreshing";
6082  const offset = Math.min(PULL_REFRESH_MAX_OFFSET, Math.max(0, distance * 0.52));
6083  elements.pullRefresh.classList.toggle("is-visible", visible);
6084  elements.pullRefresh.classList.toggle("is-ready", ready);
6085  elements.pullRefresh.classList.toggle("is-refreshing", refreshing);
6086  elements.pullRefresh.classList.toggle("is-complete", mode === "complete");
6087  elements.pullRefresh.classList.toggle("is-error", mode === "error");
6088  elements.pullRefresh.style.setProperty("--pull-offset", `${offset}px`);
6089  elements.pullRefresh.setAttribute("aria-hidden", String(!visible));
6090  if (elements.pullRefreshIcon) {
6091    elements.pullRefreshIcon.textContent = refreshing ? "" : mode === "complete" ? "✓" : mode === "error" ? "!" : "↓";
6092  }
6093  if (message && elements.pullRefreshText) elements.pullRefreshText.textContent = message;
6094}
6095
6096function resetPullRefreshIndicator() {
6097  setPullRefreshState("idle", 0, "Pull to check for new videos");
6098}
6099
6100async function refreshVideosFromDatabase() {
6101  if (state.pullRefreshInFlight || state.videosInFlight) return false;
6102  state.pullRefreshInFlight = true;
6103  const previousVideoIds = new Set(state.videos.map((video) => video.id));
6104  setPullRefreshState("refreshing", PULL_REFRESH_THRESHOLD, "Checking saved feed…");
6105  let loaded = false;
6106  try {
6107    loaded = await loadVideos({ forceNetwork: true });
6108    if (!loaded) {
6109      setPullRefreshState("error", PULL_REFRESH_THRESHOLD, "Could not check the feed");
6110      return false;
6111    }
6112    const newVideoCount = state.videos.filter((video) => !previousVideoIds.has(video.id)).length;
6113    const message = newVideoCount
6114      ? `${newVideoCount} new video${newVideoCount === 1 ? "" : "s"} loaded`
6115      : "Feed is up to date";
6116    setPullRefreshState("complete", PULL_REFRESH_THRESHOLD, message);
6117    return true;
6118  } catch (_error) {
6119    setPullRefreshState("error", PULL_REFRESH_THRESHOLD, "Could not check the feed");
6120    return false;
6121  } finally {
6122    state.pullRefreshInFlight = false;
6123    window.setTimeout(resetPullRefreshIndicator, loaded ? 900 : 1400);
6124  }
6125}
6126
6127function setupPullToRefresh() {
6128  const target = elements.sectionFeed;
6129  if (!target || !window.matchMedia) return;
6130  const mobileLayout = window.matchMedia("(max-width: 600px)");
6131  const gesture = { tracking: false, startX: 0, startY: 0, distance: 0 };
6132
6133  const cancelGesture = () => {
6134    gesture.tracking = false;
6135    gesture.distance = 0;
6136    if (!state.pullRefreshInFlight) resetPullRefreshIndicator();
6137  };
6138  const blockedTarget = (eventTarget) => eventTarget?.closest?.(
6139    ".section-nav, dialog, .filter-overlay, input, textarea, select, [contenteditable='true']",
6140  );
6141
6142  target.addEventListener("touchstart", (event) => {
6143    if (!mobileLayout.matches
6144      || state.activeSection !== "feed"
6145      || window.scrollY > 1
6146      || state.pullRefreshInFlight
6147      || state.videosInFlight
6148      || state.refreshInFlight
6149      || event.touches.length !== 1
6150      || blockedTarget(event.target)) return;
6151    gesture.tracking = true;
6152    gesture.startX = event.touches[0].clientX;
6153    gesture.startY = event.touches[0].clientY;
6154    gesture.distance = 0;
6155  }, { passive: true });
6156
6157  target.addEventListener("touchmove", (event) => {
6158    if (!gesture.tracking || event.touches.length !== 1) return;
6159    const deltaX = event.touches[0].clientX - gesture.startX;
6160    const deltaY = event.touches[0].clientY - gesture.startY;
6161    if (window.scrollY > 1 || deltaY <= 0 || (Math.abs(deltaX) > deltaY && Math.abs(deltaX) > 10)) {
6162      cancelGesture();
6163      return;
6164    }
6165    if (deltaY < 8) return;
6166    event.preventDefault();
6167    gesture.distance = deltaY;
6168    const ready = deltaY >= PULL_REFRESH_THRESHOLD;
6169    setPullRefreshState(
6170      ready ? "ready" : "pulling",
6171      deltaY,
6172      ready ? "Release to check saved feed" : "Pull to check for new videos",
6173    );
6174  }, { passive: false });
6175
6176  target.addEventListener("touchend", () => {
6177    if (!gesture.tracking) return;
6178    const shouldRefresh = gesture.distance >= PULL_REFRESH_THRESHOLD;
6179    gesture.tracking = false;
6180    gesture.distance = 0;
6181    if (shouldRefresh) refreshVideosFromDatabase();
6182    else resetPullRefreshIndicator();
6183  });
6184  target.addEventListener("touchcancel", cancelGesture);
6185  mobileLayout.addEventListener("change", cancelGesture);
6186  elements.pullRefreshButton?.addEventListener("click", refreshVideosFromDatabase);
6187}
6188
6189function positionThemeToggle() {
6190  if (state.activeSection === "profile") {
6191    elements.profileThemeSlot.append(elements.headerThemeToggle);
6192  } else {
6193    elements.headerStats.before(elements.headerThemeToggle);
6194  }
6195}
6196
6197function renderAuth() {
6198  const signedIn = Boolean(state.authUser);
6199  const isAdmin = Boolean(state.authUser?.is_admin);
6200  document.body.classList.toggle("is-signed-in", signedIn);
6201  document.body.classList.toggle("is-signed-out", !signedIn);
6202  elements.authSignedOut.hidden = signedIn;
6203  elements.authSignedIn.hidden = !signedIn;
6204  elements.headerStats.hidden = !signedIn;
6205  elements.headerAvatar.hidden = !signedIn;
6206  document.querySelectorAll(".guest-only-nav").forEach((button) => { button.hidden = signedIn; });
6207  document.querySelectorAll(".member-only-nav").forEach((button) => { button.hidden = !signedIn; });
6208  document.querySelectorAll(".admin-only-nav").forEach((button) => { button.hidden = !isAdmin; });
6209  document.querySelectorAll(".study-only-nav").forEach((button) => { button.hidden = !canAccessStudy(); });
6210  syncMobileTabbar();
6211  elements.headerAvatar.textContent = signedIn ? initials(state.authUser.username) : "";
6212  elements.headerAvatar.setAttribute("aria-label", signedIn ? `Open @${state.authUser.username} profile` : "Open profile");
6213  elements.accountName.textContent = signedIn ? `@${state.authUser.username}` : "";
6214  elements.profileUsername.textContent = signedIn ? `@${state.authUser.username}` : "";
6215  elements.profileAdmin.hidden = !isAdmin;
6216  elements.adminChannelsSection.hidden = !SHOW_ADMIN_CHANNEL_UI;
6217  if (!state.authUser) {
6218    state.adminChannelsLoaded = false;
6219    state.downloadDevices = [];
6220    state.downloadDevicesLoaded = false;
6221    state.personalHelperToken = "";
6222    elements.personalHelperTokenValue.textContent = "";
6223    elements.personalHelperToken.hidden = true;
6224    state.downloadJobs = [];
6225    stopDownloadPolling();
6226    destroyStudyExperience();
6227    state.study = null;
6228    state.savedWords = [];
6229    state.savedSentences = [];
6230    state.savedStudyError = "";
6231    savedStudyRequestVersion += 1;
6232    if (["downloads", "study", "saved"].includes(state.activeSection)) {
6233      switchSection("feed", { historyMode: "replace", loadData: false });
6234    }
6235  }
6236  window.ImmersionInstall?.setUser(state.authUser);
6237  window.ImmersionPush?.setUser(state.authUser);
6238  renderRecoveryEmail();
6239  renderPersonalHelper();
6240  renderShortVideoPreference();
6241  renderProfileStats();
6242  renderCommunityGate();
6243  renderTabs();
6244  renderFeatured();
6245}
6246
6247function renderRecoveryEmail() {
6248  if (!elements.profileRecoveryEmail || !state.authUser) return;
6249
6250  const verifiedEmail = state.authUser.email_verified ? state.authUser.email || "" : "";
6251  const pendingEmail = state.authUser.pending_email
6252    || (!state.authUser.email_verified ? state.authUser.email || "" : "");
6253  const hasVerifiedEmail = Boolean(verifiedEmail);
6254  const hasPendingEmail = Boolean(pendingEmail);
6255
6256  if (hasPendingEmail && hasVerifiedEmail) {
6257    elements.profileEmailStatus.textContent = `${verifiedEmail} is verified. Verification is pending for ${pendingEmail}.`;
6258  } else if (hasPendingEmail) {
6259    elements.profileEmailStatus.textContent = `Verification is pending for ${pendingEmail}.`;
6260  } else if (hasVerifiedEmail) {
6261    elements.profileEmailStatus.textContent = verifiedEmail;
6262  } else {
6263    elements.profileEmailStatus.textContent = "No recovery email is set.";
6264  }
6265
6266  elements.profileEmailBadge.hidden = !hasVerifiedEmail && !hasPendingEmail;
6267  elements.profileEmailBadge.textContent = hasPendingEmail ? "Pending" : "Verified";
6268  elements.profileEmailBadge.classList.toggle("is-verified", hasVerifiedEmail && !hasPendingEmail);
6269  elements.profileEmailForm.hidden = hasVerifiedEmail && !hasPendingEmail;
6270  elements.profileEmailEdit.hidden = !hasVerifiedEmail || hasPendingEmail;
6271  elements.profileEmailCancel.hidden = true;
6272  elements.profileEmail.value = pendingEmail || verifiedEmail;
6273  elements.profileEmailCurrentPassword.value = "";
6274  elements.profileEmailSubmitLabel.textContent = hasPendingEmail ? "Resend verification" : "Save and send verification";
6275}
6276
6277async function loadDownloadDevices({ force = false } = {}) {
6278  if (!state.authUser || (state.downloadDevicesLoaded && !force)) {
6279    renderPersonalHelper();
6280    return;
6281  }
6282  try {
6283    const data = await api("/api/download-devices", { cache: "no-store" });
6284    state.downloadDevices = Array.isArray(data.devices) ? data.devices : [];
6285    state.downloadDevicesLoaded = true;
6286    renderPersonalHelper();
6287  } catch (error) {
6288    setPersonalHelperMessage(error.message, "error");
6289  }
6290}
6291
6292function renderPersonalHelper() {
6293  if (!elements.personalHelper || !state.authUser) return;
6294  const device = state.downloadDevices[0] || null;
6295  elements.personalHelperEmpty.hidden = Boolean(device);
6296  elements.personalHelperConnected.hidden = !device;
6297  elements.personalHelperBadge.textContent = device ? (device.online ? "Connected" : "Offline") : "Not connected";
6298  elements.personalHelperBadge.classList.toggle("is-verified", Boolean(device?.online));
6299  if (device) {
6300    const lastSeen = device.last_seen_at
6301      ? new Date(device.last_seen_at).toLocaleString()
6302      : "Never connected";
6303    elements.personalHelperStatus.textContent = `${device.label} · ${device.online ? "Connected now" : `Last seen ${lastSeen}`}`;
6304    elements.personalHelperRevoke.dataset.deviceId = device.id;
6305  } else {
6306    elements.personalHelperRevoke.removeAttribute("data-device-id");
6307  }
6308  elements.personalHelperToken.hidden = !state.personalHelperToken;
6309  elements.personalHelperTokenValue.textContent = state.personalHelperToken;
6310}
6311
6312async function createPersonalHelper() {
6313  if (!state.authUser || elements.personalHelperCreate.disabled) return;
6314  const label = elements.personalHelperName.value.trim();
6315  elements.personalHelperCreate.disabled = true;
6316  setPersonalHelperMessage("");
6317  try {
6318    const data = await api("/api/download-devices", {
6319      method: "POST",
6320      body: JSON.stringify({ label }),
6321    });
6322    state.downloadDevices = data.device ? [data.device] : [];
6323    state.downloadDevicesLoaded = true;
6324    state.personalHelperToken = String(data.token || "");
6325    renderPersonalHelper();
6326    setPersonalHelperMessage("Helper created. Copy the setup token into the package now.");
6327  } catch (error) {
6328    setPersonalHelperMessage(error.message, "error");
6329  } finally {
6330    elements.personalHelperCreate.disabled = false;
6331  }
6332}
6333
6334async function revokePersonalHelper() {
6335  const deviceId = elements.personalHelperRevoke.dataset.deviceId || "";
6336  if (!deviceId || elements.personalHelperRevoke.disabled) return;
6337  if (!window.confirm("Revoke this helper? Unfinished jobs on that computer may stop.")) return;
6338  elements.personalHelperRevoke.disabled = true;
6339  setPersonalHelperMessage("");
6340  try {
6341    await api(`/api/download-devices/${encodeURIComponent(deviceId)}`, { method: "DELETE" });
6342    state.downloadDevices = [];
6343    state.personalHelperToken = "";
6344    renderPersonalHelper();
6345    setPersonalHelperMessage("Helper access revoked.");
6346  } catch (error) {
6347    setPersonalHelperMessage(error.message, "error");
6348  } finally {
6349    elements.personalHelperRevoke.disabled = false;
6350  }
6351}
6352
6353async function copyPersonalHelperToken() {
6354  if (!state.personalHelperToken) return;
6355  try {
6356    await navigator.clipboard.writeText(state.personalHelperToken);
6357    setPersonalHelperMessage("Setup token copied.");
6358  } catch (_error) {
6359    setPersonalHelperMessage("Copy the selected token manually.", "error");
6360  }
6361}
6362
6363function setPersonalHelperMessage(message, kind = "status") {
6364  if (!elements.personalHelperMessage) return;
6365  elements.personalHelperMessage.hidden = !message;
6366  elements.personalHelperMessage.textContent = message;
6367  elements.personalHelperMessage.classList.toggle("is-error", kind === "error");
6368}
6369
6370function editRecoveryEmail() {
6371  elements.profileEmailForm.hidden = false;
6372  elements.profileEmailEdit.hidden = true;
6373  elements.profileEmailCancel.hidden = false;
6374  elements.profileEmailSubmitLabel.textContent = "Save and send verification";
6375  setProfileEmailMessage("");
6376  elements.profileEmail.focus();
6377  elements.profileEmail.select();
6378}
6379
6380async function saveRecoveryEmail(event) {
6381  event.preventDefault();
6382  setProfileEmailMessage("");
6383  const email = elements.profileEmail.value.trim();
6384  const currentPassword = elements.profileEmailCurrentPassword.value;
6385  if (!email || !currentPassword) {
6386    setProfileEmailMessage("Enter an email and your current password.", "error");
6387    (!email ? elements.profileEmail : elements.profileEmailCurrentPassword).focus();
6388    return;
6389  }
6390
6391  elements.profileEmailSubmit.disabled = true;
6392  elements.profileEmailForm.setAttribute("aria-busy", "true");
6393  try {
6394    const data = await api("/api/auth/email", {
6395      method: "POST",
6396      body: JSON.stringify({ email, current_password: currentPassword }),
6397    });
6398    state.authUser.pending_email = Object.prototype.hasOwnProperty.call(data, "pending_email")
6399      ? data.pending_email
6400      : email;
6401    renderRecoveryEmail();
6402    setProfileEmailMessage(data.message || "Check your inbox for a verification link.");
6403  } catch (error) {
6404    setProfileEmailMessage(error.message, "error");
6405  } finally {
6406    elements.profileEmailSubmit.disabled = false;
6407    elements.profileEmailForm.setAttribute("aria-busy", "false");
6408  }
6409}
6410
6411function setProfileEmailMessage(message, kind = "status") {
6412  elements.profileEmailMessage.hidden = !message;
6413  elements.profileEmailMessage.textContent = message;
6414  elements.profileEmailMessage.classList.toggle("is-error", kind === "error");
6415  elements.profileEmailMessage.setAttribute("role", kind === "error" ? "alert" : "status");
6416}
6417
6418async function logoutFromProfile() {
6419  elements.profileLogout.disabled = true;
6420  try {
6421    await api("/api/auth/logout", { method: "POST" });
6422    window.location.assign("/");
6423  } catch (error) {
6424    showNotice(error.message, "error");
6425    elements.profileLogout.disabled = false;
6426  }
6427}
6428
6429async function loadAdminChannels(message = "") {
6430  if (!SHOW_ADMIN_CHANNEL_UI) return;
6431  if (!state.authUser?.is_admin || (state.adminChannelsLoaded && !message)) return;
6432  setChannelStatus(message || "Loading channels...");
6433  try {
6434    const data = await api("/api/admin/channels");
6435    renderChannels(data.channels || []);
6436    state.adminChannelsLoaded = true;
6437    setChannelStatus(message || `${data.channels.length} channels`);
6438  } catch (error) {
6439    setChannelStatus(error.message);
6440  }
6441}
6442
6443async function loadAdminReviews() {
6444  if (!state.authUser?.is_admin || !elements.adminReviewSection) return;
6445  elements.reviewStatus.textContent = "Checking for pending reviews...";
6446  elements.adminReviewSection.hidden = false;
6447  const communities = state.communities.length ? state.communities : [];
6448  if (!communities.length) {
6449    elements.adminReviewSection.hidden = true;
6450    return;
6451  }
6452  try {
6453    const results = await Promise.all(communities.map(async (community) => {
6454      const data = await api(`/api/communities/${encodeURIComponent(community.id)}/source-submissions?status=pending`);
6455      return { community, submissions: Array.isArray(data.submissions) ? data.submissions : Array.isArray(data.items) ? data.items : [] };
6456    }));
6457    const submissions = results.flatMap(({ community, submissions }) => submissions.map((item) => ({ ...item, community })));
6458    elements.reviewStatus.textContent = submissions.length
6459      ? `${submissions.length} pending submission${submissions.length === 1 ? "" : "s"}`
6460      : "No pending submissions.";
6461    elements.reviewList.innerHTML = submissions.length
6462      ? submissions.map((submission) => renderReviewCard(submission)).join("")
6463      : '<p class="settings-note">Nothing is waiting for review.</p>';
6464    elements.adminReviewSection.hidden = false;
6465  } catch (error) {
6466    elements.adminReviewSection.hidden = true;
6467    if (!String(error.message || "").includes("404")) {
6468      elements.reviewStatus.textContent = error.message;
6469    }
6470  }
6471}
6472
6473async function updateReviewSubmission(communityId, submissionId, status) {
6474  try {
6475    await api(`/api/communities/${encodeURIComponent(communityId)}/source-submissions/${encodeURIComponent(submissionId)}`, {
6476      method: "PATCH",
6477      body: JSON.stringify({ status }),
6478    });
6479    showNotice(status === "approved" ? "Submission approved." : "Submission rejected.", "success");
6480    await Promise.all([loadCommunities(), loadDiscover(), loadMySources(), loadChannels(), loadVideos(), loadAdminReviews()]);
6481  } catch (error) {
6482    showNotice(error.message, "error");
6483  }
6484}
6485
6486async function addAdminChannel(event) {
6487  event.preventDefault();
6488  if (channelBusy) return;
6489  channelBusy = true;
6490  setChannelStatus("Adding channel...");
6491  try {
6492    await api("/api/admin/channels", {
6493      method: "POST",
6494      body: JSON.stringify(channelPayloadFrom(elements.channelAddForm)),
6495    });
6496    elements.channelAddForm.reset();
6497    await loadAdminChannels("Channel added.");
6498  } catch (error) {
6499    setChannelStatus(error.message);
6500  } finally {
6501    channelBusy = false;
6502  }
6503}
6504
6505async function handleAdminChannelClick(event) {
6506  const button = event.target.closest("[data-channel-action]");
6507  if (!button || channelBusy) return;
6508  const row = button.closest("[data-source-key]");
6509  const sourceKey = row?.dataset.sourceKey;
6510  if (!sourceKey) return;
6511  channelBusy = true;
6512  button.disabled = true;
6513  try {
6514    if (button.dataset.channelAction === "save") {
6515      setChannelStatus("Saving channel...");
6516      await api(`/api/admin/channels/${encodeURIComponent(sourceKey)}`, {
6517        method: "PUT",
6518        body: JSON.stringify(channelPayloadFrom(row)),
6519      });
6520      await loadAdminChannels("Channel saved.");
6521    }
6522    if (button.dataset.channelAction === "remove") {
6523      setChannelStatus("Removing channel...");
6524      await api(`/api/admin/channels/${encodeURIComponent(sourceKey)}`, { method: "DELETE" });
6525      await loadAdminChannels("Channel removed from feed.");
6526    }
6527  } catch (error) {
6528    setChannelStatus(error.message);
6529  } finally {
6530    channelBusy = false;
6531    button.disabled = false;
6532  }
6533}
6534
6535function renderChannels(channels) {
6536  elements.channelList.replaceChildren();
6537  if (!channels.length) {
6538    const empty = document.createElement("p");
6539    empty.className = "settings-note";
6540    empty.textContent = "No channels yet.";
6541    elements.channelList.append(empty);
6542    return;
6543  }
6544  for (const channel of channels) elements.channelList.append(channelRow(channel));
6545}
6546
6547function channelRow(channel) {
6548  const row = document.createElement("article");
6549  const enabled = Boolean(Number(channel.enabled));
6550  row.className = enabled ? "channel-row" : "channel-row is-disabled";
6551  row.dataset.sourceKey = channel.source_key;
6552
6553  const header = document.createElement("div");
6554  header.className = "channel-row-header";
6555  const title = document.createElement("div");
6556  const name = document.createElement("strong");
6557  name.textContent = channel.display_name;
6558  const source = document.createElement("span");
6559  source.textContent = channel.source_key;
6560  title.append(name, source);
6561  const enabledLabel = document.createElement("label");
6562  enabledLabel.className = "channel-enabled";
6563  const checkbox = document.createElement("input");
6564  checkbox.type = "checkbox";
6565  checkbox.name = "enabled";
6566  checkbox.checked = enabled;
6567  enabledLabel.append(checkbox, document.createTextNode("In feed"));
6568  header.append(title, enabledLabel);
6569
6570  const fields = document.createElement("div");
6571  fields.className = "channel-fields";
6572  fields.append(
6573    textField("Name", "display_name", channel.display_name),
6574    textField("Handle", "handle", channel.handle || ""),
6575    textField("Channel ID", "channel_id", channel.channel_id || ""),
6576    textField("URL", "url", channel.source_url || ""),
6577  );
6578  const meta = document.createElement("p");
6579  meta.className = "channel-meta";
6580  meta.textContent = channel.last_error || "Ready";
6581  const actions = document.createElement("div");
6582  actions.className = "channel-actions";
6583  const save = document.createElement("button");
6584  save.className = "primary-button";
6585  save.type = "button";
6586  save.dataset.channelAction = "save";
6587  save.textContent = "Save";
6588  const remove = document.createElement("button");
6589  remove.className = "danger-button";
6590  remove.type = "button";
6591  remove.dataset.channelAction = "remove";
6592  remove.textContent = "Remove";
6593  actions.append(save, remove);
6594  row.append(header, fields, meta, actions);
6595  return row;
6596}
6597
6598function textField(labelText, name, value) {
6599  const label = document.createElement("label");
6600  label.textContent = labelText;
6601  const input = document.createElement("input");
6602  input.name = name;
6603  input.value = value;
6604  input.autocomplete = "off";
6605  label.append(input);
6606  return label;
6607}
6608
6609function channelPayloadFrom(container) {
6610  const field = (name) => container.querySelector(`[name="${name}"]`)?.value.trim() || "";
6611  return {
6612    display_name: field("display_name"),
6613    handle: field("handle"),
6614    channel_id: field("channel_id"),
6615    url: field("url"),
6616    enabled: container.querySelector('[name="enabled"]')?.checked ?? true,
6617  };
6618}
6619
6620function setChannelStatus(message) {
6621  elements.channelStatus.hidden = false;
6622  elements.channelStatus.textContent = message;
6623}
6624
6625function renderCommunityGate() {
6626  const signedIn = Boolean(state.authUser);
6627  const showGuestHome = !signedIn && state.activeSection === "feed";
6628  elements.guestBanner.hidden = !showGuestHome;
6629  elements.sourceComposerTrigger.hidden = !signedIn;
6630  elements.sourceComposerSignedOutCta.hidden = signedIn;
6631  elements.sourceForm.hidden = !signedIn;
6632  elements.mySourcesGate.hidden = signedIn;
6633  elements.guestClosingCta.hidden = !showGuestHome;
6634  if (signedIn && elements.guestHowItWorks?.classList.contains("is-open")) toggleGuestHow();
6635  document.querySelectorAll("[data-signup-link]").forEach((link) => { link.hidden = signedIn; });
6636}
6637
6638function renderRefreshControls() {
6639  elements.refreshButton.hidden = !canRefreshFeed() || state.health?.manual_refresh_enabled === false;
6640  elements.refreshStatus.hidden = !canRefreshFeed();
6641}
6642
6643function formatTabCount(value) {
6644  const count = Math.max(0, Number(value) || 0);
6645  return count > QUEUE_TAB_COUNT_CAP ? `${QUEUE_TAB_COUNT_CAP}+` : String(count);
6646}
6647
6648function renderTabs() {
6649  if (!state.authUser && state.activeTab !== "new") state.activeTab = "new";
6650  document.querySelectorAll("[data-tab]").forEach((button) => {
6651    const tab = tabs.find((item) => item.id === button.dataset.tab);
6652    button.disabled = !state.authUser && button.dataset.tab !== "new";
6653    button.classList.toggle("active", button.dataset.tab === state.activeTab);
6654    const count = button.querySelector("span");
6655    if (count && tab?.showCount !== false) count.textContent = formatTabCount(state.counts[button.dataset.tab]);
6656  });
6657  renderQueueCoach();
6658  renderFeedCutoff();
6659}
6660
6661function queueCoachDismissed() {
6662  try {
6663    return window.localStorage.getItem(QUEUE_COACH_STORAGE_KEY) === "1";
6664  } catch (_error) {
6665    return false;
6666  }
6667}
6668
6669function renderQueueCoach() {
6670  if (!elements.queueCoach) return;
6671  elements.queueCoach.hidden = !state.authUser || queueCoachDismissed();
6672}
6673
6674function dismissQueueCoach() {
6675  try {
6676    window.localStorage.setItem(QUEUE_COACH_STORAGE_KEY, "1");
6677  } catch (_error) {
6678    // The strip still hides for this visit when storage is unavailable.
6679  }
6680  renderQueueCoach();
6681}
6682
6683function renderFeedCutoff() {
6684  const notice = elements.feedCutoffNotice;
6685  const footer = elements.feedCutoffFooter;
6686  if (!notice || !footer) return;
6687  notice.hidden = true;
6688  notice.innerHTML = "";
6689  footer.hidden = true;
6690  footer.innerHTML = "";
6691  if (!state.authUser || state.activeTab !== "new") return;
6692  const cutoff = state.feedCutoff && typeof state.feedCutoff === "object" ? state.feedCutoff : null;
6693  const busy = state.feedCutoffInFlight ? " disabled" : "";
6694  if (state.feedCutoffConfirmed) {
6695    notice.hidden = false;
6696    notice.innerHTML = `<div class="feed-cutoff-banner is-confirmation"><p>You're caught up — New starts fresh from today. Watch Later and Watched kept everything you saved.</p><button class="text-button" data-feed-cutoff-action="clear" type="button"${busy}>Undo</button></div>`;
6697    return;
6698  }
6699  const hiddenCount = Number(cutoff?.hidden_count) || 0;
6700  if (cutoff?.active && hiddenCount > 0) {
6701    footer.hidden = false;
6702    footer.innerHTML = `<p class="feed-cutoff-footer-line">${hiddenCount} older video${hiddenCount === 1 ? "" : "s"} hidden · <button class="text-button" data-feed-cutoff-action="clear" type="button"${busy}>Show everything</button></p>`;
6703    return;
6704  }
6705  const newCount = Number(state.counts?.new) || 0;
6706  if ((!cutoff || !cutoff.active) && newCount > FEED_CUTOFF_PROMPT_THRESHOLD && !state.feedCutoffPromptDismissed) {
6707    notice.hidden = false;
6708    notice.innerHTML = `<div class="feed-cutoff-banner"><p>That's a lot of backlog — most of it predates you. <strong>Catch up to today</strong> and start fresh; Watch Later and Watched keep everything you saved.</p><div class="feed-cutoff-actions"><button class="accent-button small-action" data-feed-cutoff-action="catch_up" type="button"${busy}>Catch up</button><button class="feed-cutoff-dismiss" data-feed-cutoff-action="dismiss" type="button" aria-label="Dismiss catch-up suggestion">×</button></div></div>`;
6709  }
6710}
6711
6712async function applyFeedCutoff(action) {
6713  if (!state.authUser || state.feedCutoffInFlight) return;
6714  state.feedCutoffInFlight = true;
6715  renderFeedCutoff();
6716  try {
6717    await api("/api/me/feed-cutoff", {
6718      method: "POST",
6719      body: JSON.stringify({ action }),
6720    });
6721    state.feedCutoffConfirmed = action === "catch_up";
6722    state.feedCutoffPromptDismissed = action === "catch_up";
6723    state.feedCutoffInFlight = false;
6724    await loadVideos({ forceNetwork: true });
6725  } catch (error) {
6726    showNotice(error.message, "error");
6727  } finally {
6728    state.feedCutoffInFlight = false;
6729    renderFeedCutoff();
6730  }
6731}
6732
6733function renderGuestProof() {
6734  const video = state.featured;
6735  if (!elements.guestProofCard) return;
6736  if (!video) {
6737    elements.guestProofLink.hidden = true;
6738    elements.guestProofThumbnail.hidden = true;
6739    elements.guestProofThumbnail.removeAttribute("src");
6740    elements.guestProofPlaceholder.hidden = false;
6741    elements.guestProofDuration.hidden = true;
6742    elements.guestProofEyebrow.textContent = "Tonight's pick · community pick";
6743    elements.guestProofTitle.textContent = "Your next good watch is waiting.";
6744    elements.guestProofMeta.textContent = "A fresh pick for your next Japanese listening session.";
6745    return;
6746  }
6747  elements.guestProofLink.hidden = false;
6748  elements.guestProofLink.href = video.url;
6749  elements.guestProofThumbnail.hidden = !video.thumbnail_url;
6750  elements.guestProofPlaceholder.hidden = Boolean(video.thumbnail_url);
6751  if (video.thumbnail_url) {
6752    elements.guestProofThumbnail.src = video.thumbnail_url;
6753    elements.guestProofThumbnail.alt = video.title;
6754  }
6755  elements.guestProofDuration.hidden = false;
6756  elements.guestProofDuration.textContent = video.duration_label;
6757  const source = video.source_name || video.channel_name || "Japanese immersion";
6758  const learnerCount = Number(video.learner_count || 0);
6759  elements.guestProofEyebrow.textContent = `Tonight's pick · ${source}`;
6760  elements.guestProofTitle.textContent = video.title;
6761  elements.guestProofMeta.textContent = `${source} · ${learnerCount ? `${learnerCount} learner${learnerCount === 1 ? "" : "s"} using this channel` : "a fresh community pick"}`;
6762}
6763
6764function renderFeatured() {
6765  const video = state.featured;
6766  renderGuestProof();
6767  if (!video) {
6768    elements.featuredHero.hidden = false;
6769    delete elements.featuredHero.dataset.videoId;
6770    elements.featuredHero.classList.add("is-empty");
6771    elements.featuredMedia.classList.remove("has-image");
6772    elements.featuredThumbnail.hidden = true;
6773    elements.featuredThumbnail.removeAttribute("src");
6774    elements.featuredThumbnail.alt = "";
6775    elements.featuredPlaceholder.hidden = false;
6776    elements.featuredPlaceholder.textContent = "No matching video yet";
6777    elements.featuredWatch.hidden = true;
6778    elements.featuredWatch.removeAttribute("href");
6779    elements.featuredDuration.hidden = true;
6780    elements.featuredTitle.textContent = "No videos match this filter.";
6781    elements.featuredDescription.textContent = "Try another filter or check back after the feed refreshes.";
6782    elements.featuredTags.innerHTML = "";
6783    elements.featuredLater.hidden = true;
6784    return;
6785  }
6786  elements.featuredHero.hidden = false;
6787  elements.featuredHero.classList.remove("is-empty");
6788  elements.featuredHero.dataset.videoId = video.video_id;
6789  elements.featuredMedia.classList.toggle("has-image", Boolean(video.thumbnail_url));
6790  elements.featuredThumbnail.hidden = !video.thumbnail_url;
6791  elements.featuredPlaceholder.hidden = Boolean(video.thumbnail_url);
6792  if (video.thumbnail_url) {
6793    elements.featuredThumbnail.src = video.thumbnail_url;
6794    elements.featuredThumbnail.alt = video.title;
6795  }
6796  elements.featuredWatch.href = video.url;
6797  elements.featuredWatch.hidden = false;
6798  elements.featuredDuration.textContent = video.duration_label;
6799  elements.featuredDuration.hidden = false;
6800  elements.featuredTitle.textContent = video.title;
6801  elements.featuredDescription.textContent = `A ${video.duration_label} listening pick from ${video.source_name || video.channel_name || "your channels"}. ${video.learner_count ? `${video.learner_count} learner${video.learner_count === 1 ? "" : "s"} are using this channel.` : "A fresh community pick for your next session."}`;
6802  elements.featuredTags.innerHTML = `<span class="chip">${escapeHtml(video.source_name || "Japanese immersion")}</span><span class="chip">${video.learner_count ? `${video.learner_count} learners` : "community pick"}</span>`;
6803  elements.featuredLater.hidden = !state.authUser;
6804}
6805
6806function renderTopicAvailability() {
6807  const available = new Set(state.availableTopics);
6808  for (const topic of OPTIONAL_TOPICS) {
6809    const button = document.querySelector(`[data-topic="${topic}"]`);
6810    if (button) button.hidden = !available.has(topic);
6811  }
6812}
6813
6814function renderFeed() {
6815  elements.feed.innerHTML = state.videos.length ? state.videos.map(renderVideo).join("") : renderEmptyState();
6816}
6817
6818function appendVideos(videos) {
6819  if (!videos.length) return;
6820  const offset = Math.max(0, state.videos.length - videos.length);
6821  elements.feed.insertAdjacentHTML("beforeend", videos.map((video, index) => renderVideo(video, offset + index)).join(""));
6822}
6823
6824function renderFeedMeta() {
6825  if (!state.authUser && !countActiveFilters(state)) {
6826    elements.feedMeta.textContent = "real picks, updated today";
6827    return;
6828  }
6829  const label = tabs.find((tab) => tab.id === state.activeTab)?.label || "Videos";
6830  const total = typeof state.total === "number" ? state.total : state.videos.length;
6831  const filtered = countActiveFilters(state) ? " · filtered" : "";
6832  elements.feedMeta.textContent = `${total} ${label.toLowerCase()} video${total === 1 ? "" : "s"}${filtered}`;
6833}
6834
6835function renderFeedTitle() {
6836  if (!elements.feedTitle) return;
6837  if (!state.authUser && !state.channel) {
6838    elements.feedEyebrow.textContent = "What you'll get";
6839    elements.feedTitle.textContent = "Fresh from the community";
6840    return;
6841  }
6842  elements.feedEyebrow.textContent = "Your listening queue";
6843  const selectedName = state.channels.find((channel) => channel.source_key === state.channel)?.display_name;
6844  elements.feedTitle.textContent = state.channel
6845    ? `Videos from ${state.channelLabel || selectedName || state.channel}`
6846    : "For you";
6847}
6848
6849function renderVideo(video, cardIndex = 0) {
6850  const title = escapeHtml(video.title);
6851  const source = escapeHtml(video.source_name || video.channel_name);
6852  const sourceKey = video.source_key || video.channel_id || "";
6853  const sourceName = video.source_name || video.channel_name || "This source";
6854  const thumbnail = video.thumbnail_url ? `<img src="${escapeAttribute(video.thumbnail_url)}" alt="" loading="lazy" />` : '<span class="thumb-placeholder">
6854thumbnail</span>';
6855  const downloadButton = state.authUser
6856    ? `<button class="video-download-button" data-download-video="${escapeAttribute(video.video_id)}" type="button" aria-label="Download ${escapeAttribute(video.title || "video")} as MP4">
6857        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v10"></path><path d="m8 10 4 4 4-4"></path><path d="M5 19h14"></path></svg>
6858      </button>`
6859    : "";
6860  return `<article class="video-card" data-video-id="${escapeAttribute(video.video_id)}" data-source-key="${escapeAttribute(sourceKey)}" data-source-name="${escapeAttribute(sourceName)}">
6861    <div class="video-thumb-wrap">
6862      <a class="video-thumb" href="${escapeAttribute(video.url)}" target="_blank" rel="noopener noreferrer" aria-label="Open ${title} on YouTube">${thumbnail}<span class="duration-badge">${escapeHtml(video.duration_label)}</span>${video.state === "new" ? '<span class="new-badge">NEW</span>' : ""}</a>
6863      ${downloadButton}
6864    </div>
6865    <h3 class="video-title"><a href="${escapeAttribute(video.url)}" target="_blank" rel="noopener noreferrer">${title}</a></h3>
6866    <div class="video-meta"><span class="video-source">${source}</span>${renderVideoSourceMenu(video)}</div>
6867    <div class="video-actions">${renderStudyAction(video)}${renderActions(cardIndex)}</div>
6868    ${renderShareButton(video)}
6869  </article>`;
6870}
6871
6872function renderStudyAction(video) {
6873  if (!canAccessStudy()) return "";
6874  const videoId = String(video?.video_id || "").trim();
6875  const queueTab = tabs.some((item) => item.id === state.activeTab) ? state.activeTab : "new";
6876  const url = /^[A-Za-z0-9_-]{6,20}$/.test(videoId)
6877    ? `/study?video=${encodeURIComponent(videoId)}&queue=${encodeURIComponent(queueTab)}`
6878    : "";
6879  return url
6880    ? `<a class="ghost-button link-button video-study-link" href="${escapeAttribute(url)}" data-study-video="${escapeAttribute(videoId)}">Study</a>`
6881    : "";
6882}
6883
6884function renderVideoSourceMenu(video) {
6885  if (!state.authUser) return "";
6886  const sourceName = video.source_name || video.channel_name || "this source";
6887  const menuId = `video-source-options-${video.video_id}`;
6888  return `<div class="video-source-menu-wrap">
6889    <button class="video-source-menu-button" data-video-source-menu type="button" aria-label="More options for ${escapeAttribute(sourceName)}" aria-controls="${escapeAttribute(menuId)}" aria-expanded="false">•••</button>
6890    <div class="video-source-menu" id="${escapeAttribute(menuId)}" data-video-source-menu-panel hidden>
6891      <button data-video-source-action="view" type="button">View channel videos</button>
6892      <button data-video-source-action="dismiss" type="button">Not interested in this channel</button>
6893      <button data-video-source-action="manage" type="button">Manage channels</button>
6894    </div>
6895  </div>`;
6896}
6897
6898function renderActions(cardIndex = 0) {
6899  if (!state.authUser) return cardIndex < 6 ? '<a class="ghost-button action-link" href="/login">Log in to track</a>' : "";
6900  if (state.activeTab === "watched") return '<button class="ghost-button" data-next-state="new" type="button">New</button><button class="ghost-button" data-next-state="watch_later" type="button">Watch Later</button><button class="danger-ghost" data-next-state="skipped" type="button">Skip</button>';
6901  if (state.activeTab === "watch_later") return '<button class="ghost-button" data-next-state="new" type="button">New</button><button class="danger-ghost" data-next-state="skipped" type="button">Skip</button><button class="accent-button small-action" data-next-state="watched" type="button">Watched</button>';
6902  if (state.activeTab === "skipped") return '<button class="ghost-button" data-next-state="new" type="button">New</button><button class="ghost-button" data-next-state="watch_later" type="button">Watch Later</button>';
6903  return '<button class="accent-button small-action" data-next-state="watched" type="button">Watched</button><button class="ghost-button" data-next-state="watch_later" type="button">Watch Later</button><button class="danger-ghost" data-next-state="skipped" type="button">Skip</button>';
6904}
6905
6906function renderShareButton(video) {
6907  return `<button class="share-button" data-share-title="${escapeAttribute(video.title)}" data-share-url="${escapeAttribute(video.url)}" type="button" aria-label="Share ${escapeAttribute(video.title)}">
6908    <svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
6909      <path d="M12 3v11" />
6910      <path d="m7 8 5-5 5 5" />
6911      <path d="M6 12v7h12v-7" />
6912    </svg>
6913  </button>`;
6914}
6915
6916function renderCommunityPills(communities) {
6917  return communities.map((community) => `<span class="source-origin-pill">${escapeHtml(community.name)}</span>`).join("");
6918}
6919
6920function renderSourceOriginLine(variant, communityLabels, managed) {
6921  if (!managed) {
6922    return "";
6923  }
6924  if (variant === "direct") {
6925    if (communityLabels.length) {
6926      return `<p class="source-origin-line">Also from ${renderCommunityPills(communityLabels)}</p>`;
6927    }
6928    return '<p class="source-origin-line">Direct follow</p>';
6929  }
6930  if (variant === "community") {
6931    return `<p class="source-origin-line">From ${renderCommunityPills(communityLabels) || "your communities"}</p>`;
6932  }
6933  return `<p class="source-origin-line">Hidden from Home · ${renderCommunityPills(communityLabels) || "community source"}</p>`;
6934}
6935
6936function renderManagedSourceAction(source, variant) {
6937  if (variant === "direct") {
6938    return `<button class="source-view-link" data-source-action="view-feed" type="button">View feed</button><span class="privacy-pill ${source.is_private ? "is-private" : "is-shared"}">${source.is_private ? "PRIVATE" : "SHARED"}</span><button class="ghost-button" data-source-action="${source.is_private ? "public" : "private"}" type="button">${source.is_private ? "Share with group" : "Keep private"}</button><button class="danger-ghost" data-source-action="remove" type="button">Remove</button>`;
6939  }
6940  if (variant === "community") {
6941    return '<button class="source-view-link" data-source-action="view-feed" type="button">View feed</button><button class="ghost-button" data-source-action="hide" type="button">Hide from Home</button>';
6942  }
6943  return '<button class="accent-button small-action" data-source-action="unhide" type="button">Restore to Home</button>';
6944}
6945
6946function renderDiscoverSourceAction(source) {
6947  if (source.is_added) {
6948    return renderAddedSourceAction();
6949  }
6950  if (state.authUser) {
6951    return '<button class="accent-button small-action" data-source-action="add" type="button">+ Add to my feed</button>';
6952  }
6953  return '<a class="ghost-button action-link" href="/login">Join to add</a>';
6954}
6955
6956function sourceLevelBadge(source) {
6957  const values = new Set((source.tags || []).map((tag) => String(tag?.value || "").toLowerCase()));
6958  const badge = LEVEL_BADGES.find((level) => level.values.some((value) => values.has(value)));
6959  return badge ? `<span class="level-badge">${badge.label}</span>` : "";
6960}
6961
6962function renderSourceCard(source, options = {}) {
6963  const { variant = typeof options === "boolean" && options ? "direct" : "discover" } = typeof options === "object"
6964    ? options
6965    : {};
6966  const topicTags = (source.tags || []).filter((tag) => !LEVEL_TAG_VALUES.has(String(tag?.value || "").toLowerCase()));
6967  const tags = topicTags.slice(0, 3).map((tag) => `<span class="chip">${escapeHtml(tag.value)}</span>`).join("");
6968  const levelBadge = sourceLevelBadge(source);
6969  const description = String(source.description || "").trim();
6970  const contributors = (source.contributors || []).map((name) => `<span class="source-contributor">@${escapeHtml(name)}</span>`).join(" ");
6971  const learnerCount = Number(source.learner_count || 0);
6972  const learnerNote = learnerCount > 0 ? ` · ${learnerCount} learner${learnerCount === 1 ? "" : "s"}` : "";
6973  const communityLabels = communityLabelsFor(source, variant === "direct");
6974  const managed = variant !== "discover";
6975  const originLine = renderSourceOriginLine(variant, communityLabels, managed);
6976  const action = managed ? renderManagedSourceAction(source, variant) : renderDiscoverSourceAction(source);
6977  const sourceIcon = source.thumbnail_url
6978    ? `<img src="${escapeAttribute(source.thumbnail_url)}" alt="" loading="lazy" referrerpolicy="no-referrer">`
6979    : "";
6980  const addedClass = !managed && source.is_added ? "source-card--added" : "";
6981  const recentClass = recentlyAddedSources.has(source.source_key) ? "is-just-added" : "";
6982  return `<article class="source-card ${managed ? "source-card--managed" : ""} source-card--${variant} ${addedClass} ${recentClass}" data-source-key="${escapeAttribute(source.source_key)}" data-source-name="${escapeAttribute(source.display_name || source.source_key)}" data-source-url="${escapeAttribute(source.source_url || source.url || "")}">
6983    <div class="source-avatar" aria-hidden="true"><span>${initials(source.display_name)}</span>${sourceIcon}</div>
6984    <div class="source-main"><div class="source-header"><div><h3>${escapeHtml(source.display_name)}</h3>${levelBadge}<p>${escapeHtml(source.handle ? `@${source.handle}` : source.source_key)}${escapeHtml(learnerNote)}</p></div></div>${originLine}${description ? `<p class="source-description">${escapeHtml(description)}</p>` : ""}<div class="source-tags">${tags || '<span class="chip">Japanese immersion</span>'}</div>${contributors ? `<p class="source-contributors">Shared by ${contributors}</p>` : ""}</div>
6985    <div class="source-actions">${action}</div>
6986  </article>`;
6987}
6988
6989function renderAddedSourceAction() {
6990  return '<span class="source-added-status"><span aria-hidden="true">✓</span> Added to My Channels</span><button class="source-view-link" data-go-section="my-sources" type="button">View in My Channels</button>';
6991}
6992
6993document.addEventListener("error", (event) => {
6994  if (event.target.matches?.(".source-avatar img")) event.target.remove();
6995}, true);
6996
6997function setupFeedPager() {
6998  const pager = elements.feedPager;
6999  if (!("IntersectionObserver" in window) || !pager) return;
7000  const observer = new IntersectionObserver((entries) => {
7001    if (entries.some((entry) => entry.isIntersecting) && state.hasMore && !state.videosInFlight) loadVideos({ append: true });
7002  }, { rootMargin: "600px 0px" });
7003  observer.observe(pager);
7004}
7005
7006function renderEmptyState() {
7007  if (!state.authUser && state.activeTab !== "new") return '<div class="empty-state"><div class="empty-icon">⌕</div><h3>Log in to see your progress</h3><p>Your watched and saved lists are private to you.</p><a class="accent-button link-button" href="/login">Log in</a></div>';
7008  if (state.authUser && Number(state.sourceOnboarding?.direct_source_count || 0) === 0 && Number(state.sourceOnboarding?.joined_community_count || 0) === 0) {
7009    return '<div class="empty-state"><div class="empty-icon">+</div><h3>Your feed is waiting for its first picks.</h3><p>Join a community or follow a few channels in Discover, then Home will start filling in.</p><div class="empty-actions"><button class="accent-button" data-go-section="discover" type="button">Open Discover</button><button class="ghost-button" data-tab="watch_later" type="button">Watch Later</button></div></div>';
7010  }
7011  const hiddenByShortPreference = state.authUser
7012    && state.activeTab === "new"
7013    && state.hideShortVideos
7014    && state.hiddenShortVideosCount > 0;
7015  const hiddenByLongPreference = state.authUser
7016    && state.activeTab === "new"
7017    && state.hideLongVideos
7018    && state.hiddenLongVideosCount > 0;
7019  if (hiddenByShortPreference && hiddenByLongPreference) {
7020    return '<div class="empty-state"><div class="empty-icon">⌁</div><h3>Videos outside your preferred length are hidden</h3><p>Your saved preferences removed the remaining short and extra-long videos from this view.</p><div class="empty-actions"><button class="accent-button" data-show-short-videos type="button">Show short videos</button><button class="ghost-button" data-show-long-videos type="button">Show long videos</button></div></div>';
7021  }
7022  if (hiddenByShortPreference) {
7023    return '<div class="empty-state"><div class="empty-icon">⌁</div><h3>Short videos are hidden</h3><p>Your saved preference removed the remaining videos from this view.</p><div class="empty-actions"><button class="accent-button" data-show-short-videos type="button">Show short videos</button><button class="ghost-button" data-go-section="discover" type="button">Browse Discover</button></div></div>';
7024  }
7025  if (hiddenByLongPreference) {
7026    return '<div class="empty-state"><div class="empty-icon">⌁</div><h3>Long videos are hidden</h3><p>Your saved preference removed the remaining videos over 1 hour from this view.</p><div class="empty-actions"><button class="accent-button" data-show-long-videos type="button">Show long videos</button><button class="ghost-button" data-go-section="discover" type="button">Browse Discover</button></div></div>';
7027  }
7028  return `<div class="empty-state"><div class="empty-icon">✓</div><h3>You're all caught up</h3><p>${state.authUser ? "No new videos from your channels right now. Check Discover for channels other learners are sharing." : "No fresh videos match this view yet. Check back after the next refresh."}</p><div class="empty-actions"><button class="accent-button" data-go-section="discover" type="button">Browse Discover</button>${state.authUser ? '<button class="ghost-button" data-tab="watch_later" type="button">Watch Later</button>' : ""}</div></div>`;
7029}
7030
7031function renderErrorState(message) {
7032  return `<div class="empty-state error-state"><div class="empty-icon">!</div><h3>Something went wrong</h3><p>${escapeHtml(message)}</p></div>`;
7033}
7034
7035async function copyShareUrl(url) {
7036  if (typeof navigator.clipboard?.writeText === "function") {
7037    await navigator.clipboard.writeText(url);
7038    return;
7039  }
7040
7041  if (typeof document.execCommand !== "function") {
7042    throw new Error("Clipboard is not available");
7043  }
7044
7045  const textarea = document.createElement("textarea");
7046  textarea.value = url;
7047  textarea.setAttribute("readonly", "");
7048  textarea.style.position = "fixed";
7049  textarea.style.opacity = "0";
7050  document.body.append(textarea);
7051  textarea.select();
7052  const copied = document.execCommand("copy");
7053  textarea.remove();
7054  if (!copied) throw new Error("Clipboard copy failed");
7055}
7056
7057async function shareVideo(button) {
7058  const url = button.dataset.shareUrl;
7059  const title = button.dataset.shareTitle || "Immersion clip";
7060  if (!url) return;
7061
7062  let canUseNativeShare = typeof navigator.share === "function";
7063  if (canUseNativeShare && typeof navigator.canShare === "function") {
7064    try {
7065      canUseNativeShare = navigator.canShare({ url });
7066    } catch (_error) {
7067      canUseNativeShare = false;
7068    }
7069  }
7070
7071  if (canUseNativeShare) {
7072    try {
7073      await navigator.share({ title, url });
7074      return;
7075    } catch (error) {
7076      if (error?.name === "AbortError") return;
7077    }
7078  }
7079
7080  try {
7081    await copyShareUrl(url);
7082    showNotice("Copied the YouTube link.", "success");
7083  } catch (_error) {
7084    showNotice("Could not share this clip. Open the YouTube link to share it instead.", "error");
7085  }
7086}
7087
7088function showNotice(message, kind = "error") {
7089  elements.notice.hidden = false;
7090  elements.notice.className = `notice notice--${kind}`;
7091  const isError = kind !== "success";
7092  elements.notice.setAttribute("role", isError ? "alert" : "status");
7093  elements.notice.setAttribute("aria-live", isError ? "assertive" : "polite");
7094  elements.noticeMessage.textContent = message;
7095}
7096
7097function clearNotice() {
7098  elements.notice.hidden = true;
7099  elements.noticeMessage.textContent = "";
7100}
7101
7102function debounce(fn, delay) {
7103  let timer;
7104  return (...args) => {
7105    window.clearTimeout(timer);
7106    timer = window.setTimeout(() => fn(...args), delay);
7107  };
7108}
7109
7110function formatDateTime(value) {
7111  if (!value) return "never";
7112  const date = new Date(value);
7113  if (Number.isNaN(date.getTime())) return value;
7114  return new Intl.DateTimeFormat(undefined, { month: "short", day: "
7114numeric", hour: "numeric", minute: "2-digit" }).format(date);
7115}
7116
7117function initials(value) {
7118  return String(value || "?").replace(/^@/, "").split(/\s+/).map((part) => part[0]).join("").slice(0, 2).toUpperCase() || "?";
7119}
7120
7121function escapeHtml(value) {
7122  return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#039;");
7123}
7124
7125function escapeAttribute(value) {
7126  return escapeHtml(value).replaceAll("`", "&#096;");
7127}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.