try { performance.mark('cgvTourScriptStart'); } catch (e) {} const isIOSEmulator = navigator.appVersion.includes('iPad') || navigator.appVersion.includes('iPhone') || navigator.appVersion.includes('iPod') ? true : false; const isIOSDevice = (/iPad|iPhone|iPod/.test(navigator.platform) || isIOSEmulator || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) && !window.MSStream; const campgroundName = 'Brunswick Beaches Campground (NC)'; const datePicker = document.getElementById("cgvDatesPicker"); const search = document.getElementById("cgvSearch"); const cgvCInfo = document.getElementById("cgvCampgroundInfo"); const playerDiv = document.getElementById('playerContainer'); let granted = false; function iOSPermission() { if (typeof (DeviceOrientationEvent) !== "undefined" && typeof (DeviceOrientationEvent).requestPermission === "function") { if (granted == false) { DeviceOrientationEvent.requestPermission() .then(response => { if (response == "granted") { window.addEventListener("deviceorientation", (e) => { }); granted = true; } }).catch(console.error) } } } function setupBigPlayButton(){ player.addChild('BigPlayButton'); // Show it explicitly rather than waiting for the pause handler below. // // `controls: false` (Plan D) makes video.js add `vjs-controls-disabled`, and its own stylesheet // hides `.vjs-controls-disabled .vjs-big-play-button`. Measured on the dev tier the button IS // still visible to a guest - but only because the startup play->pause hitch fires the handler // below within the first second, writing an inline `display: block` that beats that rule. // // So the tour's only start affordance currently depends on a bug we have an open item to FIX. // Setting it here makes the intent explicit and survives that fix. const bigPlay = document.querySelector('.vjs-big-play-button'); if (bigPlay) bigPlay.style.display = 'block'; // TODO: quick fix for vjs start-stop on load. player.on('pause', function () { if (player.currentTime() < 1) { document.querySelector(".vjs-big-play-button").style.display = "block"; } }); player.on('play', function () { document.querySelector(".vjs-big-play-button").style.display = "none"; }); } function setupIosFullscreen(){ player.on('enterFullWindow', function (e) { var renderedCanvas = document.getElementsByTagName("canvas")[0]; if (player.isFullscreen()) { var parentEl = renderedCanvas.parentElement; var postionInfo = parentEl.getBoundingClientRect(); renderedCanvas.setAttribute("width", postionInfo.width.toString()); renderedCanvas.setAttribute("height", postionInfo.height.toString()); } }); player.on('exitFullWindow', function (e) { var renderedCanvas = document.getElementsByTagName("canvas")[0]; if (!player.isFullscreen()) { var parentEl = renderedCanvas.parentElement; var postionInfo = parentEl.getBoundingClientRect(); renderedCanvas.setAttribute("width", postionInfo.width.toString()); renderedCanvas.setAttribute("height", postionInfo.height.toString()); } }); } function setupPlayerComponents(){ if (isIOSDevice) { var btnOrientation = document.createElement('button'); btnOrientation.setAttribute('id', 'request'); btnOrientation.setAttribute('class', 'orientation-button'); btnOrientation.innerHTML = 'Request device orientation access'; btnOrientation.addEventListener('click', iOSPermission); playerDiv.append(btnOrientation); } cgvCInfo.style.display = 'block'; cgvCInfo.innerHTML = campgroundName; cgvCInfo.style.paddingInlineStart = '1rem'; datePicker.style.height = '100%'; datePicker.style.width = document.getElementById("playerContainer").style.width; setupBigPlayButton(); } function setupThreeSixtyPlugin() { const pluginInData = { parentId: 4347, campgroundName: 'Brunswick Beaches Campground (NC)', // CAM-189. A Unix instant here is read back through LOCAL getters by siteInfoDlg and the // picker, so a UTC midnight resolved to the previous day everywhere west of UTC and the // default range came out a day early in every timezone but UTC itself - including for the // AutoCache job, which was warming a key no browser ever asked for. GetDates() has always // pinned these to midnight at zero offset, so .Year/.Month/.Day are the intended calendar // parts, and the three-argument Date constructor gives local midnight everywhere. startDate: new Date(2026, 9, 2), endDate: new Date(2026, 9, 4), datePicker: datePicker, search: search, resourcesCdnEndpoint: 'https://campgroundviews-assets-ctaufjdtd3hgfzhy.z01.azurefd.net', playerHost: 'https://360.campgroundviews.com', supportsAvailability: false, pinTimelineRangeSec: 5, isPhone: isIOSDevice, isAudioEnable: 1 === 1, showAds: 1 === 2, editMode: false, bookingComponentSettings: { enabled: false, campgroundIntegrationId: "", apiBaseUrl: "", componentRootId: "" }, // Plan F, Task 2. Campground.BrandAccent is a nullable C# string; null means the // CampgroundViews default (see cgv360.ts's ICgvSettings.accent doc comment and // ui/theme.ts's ACCENTS). @(Model.Campground.BrandAccent) - Razor's normal HTML-encoding // interpolation used everywhere else on this page - would silently turn a null into an // EMPTY STRING (Razor writes nothing for a null value), and a naive `'...'` wrapper would // do the same or, worse, could be made to emit the literal text "null" as a JS STRING by a // careless ?? fallback. Either failure mode is silent: theme.ts falls back to the brand // default either way, so nothing looks broken today, but a park that later picks a real // accent would keep silently getting the default. JsonSerializer.Serialize is the // one call that gets both cases right without a hand-rolled branch: null serializes to the // bare JS token `null`, and any of the seven accent names serializes to a properly quoted // JS string - and Html.Raw is required here (matching _TourSeoContent.cshtml's // @Html.Raw(...ToJsonString())) so Razor does not then HTML-encode those quotes into // " and corrupt the object literal. accent: null } // THE SOURCE GOES FIRST, and the 360 machinery follows when the bundle has landed. // // player.src() is what fetches the HLS manifest and the first segments. It used to sit behind // player.threeSixty(), which cannot be called until cgv360.js has finished evaluating - about // two seconds of Babylon module code. Measured: manifest at 550ms via the preload link, first // segment not requested until ~3,065ms. Nothing was waiting on the network; it was waiting on // JavaScript. Swapping these two lines lets the video buffer DURING that evaluation. videoUrl = 'https://campgroundviews-vod-h8emgteqergqcecv.z01.azurefd.net/2bfa6c4c73da4beaa1679fc4e6575ee0/5de9c9e7-79bd-41b1-87a6-51e41119466c.m3u8'; player.src({ src: videoUrl, type: 'application/vnd.apple.mpegurl' }); try { performance.mark('cgvAfterSrc'); } catch (e) {} attachThreeSixty(pluginInData); } // Calls player.threeSixty() as soon as the plugin exists. // // This script now runs BEFORE cgv360.js (see the comment on the script tags in // TourCampground.cshtml), so the plugin is usually not registered yet at this point. video.js // registers plugins onto Player.prototype, so a player constructed beforehand gains the method // the moment registerPlugin runs - there is no need to rebuild or re-src the player. // // The bundle's load event is the signal rather than a poll: `defer` scripts fire load AFTER they // execute, and this script is guaranteed to have run first, so the listener cannot be attached // too late to see it. The typeof check ahead of it covers the reverse case - a cached bundle // that somehow evaluated first - so neither ordering silently produces a tour with no 360. function attachThreeSixty(pluginInData) { if (typeof player.threeSixty === 'function') { player.threeSixty(pluginInData); return; } const bundle = document.getElementById('cgv360Bundle'); if (bundle == null) { // The id is gone from the view. Fall back to polling rather than leaving the guest with a // video and no tour - a silent no-op here is the whole player. console.warn('[360] #cgv360Bundle not found; polling for the threeSixty plugin instead.'); const started = Date.now(); const poll = setInterval(function () { if (typeof player.threeSixty === 'function') { clearInterval(poll); player.threeSixty(pluginInData); } else if (Date.now() - started > 30000) { clearInterval(poll); console.error('[360] threeSixty plugin never registered.'); } }, 50); return; } bundle.addEventListener('load', function () { if (typeof player.threeSixty === 'function') player.threeSixty(pluginInData); else console.error('[360] cgv360.js loaded but registered no threeSixty plugin.'); }, { once: true }); bundle.addEventListener('error', function () { console.error('[360] cgv360.js failed to load; the tour will play as flat video.'); }, { once: true }); } const vjsOptions = { // PLAN D, TASK 2. video.js's own control bar is off; replaces it, mounted // into the overlay's shadow root by cgv360.ts (search that file for "PLAN D, TASK 2"). The old // bar had a 3px seek target, showed only the time remaining, and painted white icons straight // onto whatever the sky was doing. // // `controls: false` does NOT remove the ControlBar component in video.js 8.9.0 - it swaps // vjs-controls-enabled for vjs-controls-disabled and video-js.css hides it. Every // player.controlBar.* reference in this repo therefore still resolves (verified in Chromium // against the vendored build), which is what lets the DOM bar forward share/search/calendar to // the video.js Buttons that already own those behaviours instead of reimplementing them. The // `controlBar: {...}` block below is kept for the same reason: those children still exist, and // the hidden fullscreenToggle is the anchor every custom button inserts itself before. controls: false, nativeControlsForTouch: false, fill: true, playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], inactivityTimeout: 0, controlBar: { pictureInPictureToggle: false, volumePanel: { inline: false }, fullscreenToggle: true, subsCapsButton: false }, techOrder: ["html5"], html5: { vhs: { smoothQualityChange: true, // Deliberately false: for 360, the player window is NOT the picture. A guest sees // about a twelfth of the frame, so limiting the rendition to the element's pixel // size would pick a rung far too low. limitRenditionByPlayerDimensions: false, blacklistDuration: 15, // ABR starts from this estimate rather than from the lowest rung and climbing. 6 Mbps // puts the first segment on 1920x960 (5.2 Mbps) instead of 960x480 (2.5 Mbps), which // is what made a guest's first impression a smear. If the connection cannot sustain // it, VHS steps down within a segment or two - the floor below stops it going too far. bandwidth: 6000000 } }, preferFullWindow: isIOSDevice, fullscreen: { options: { navigationUI: 'show' } } }; // PRD P3. The floor below (applied on loadedmetadata) cannot govern the FIRST pick, because // by then VHS has already chosen and fetched a playlist - measured: two 480p segments before // it climbed. The correct hook is called for every selection including the first, so this is // where a 360-specific floor actually belongs. This must be set before videojs(...) constructs // the player. // // NOTE ON THE HOOK NAME: `videojs.Vhs.selectPlaylist` does not exist on either vendored VHS // build and is never read by them - confirmed by reading source, and by two live measurements // (see below). What VHS actually consults - traced through wwwroot/resources/lib/videojs/ // http-streaming/dist/videojs-http-streaming.min.js, the copy this view actually