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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); 7123} 7124 7125function escapeAttribute(value) { 7126 return escapeHtml(value).replaceAll("`", "`"); 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.