0 },
get firstVideoIndex() { return this.hasVideos ? this.images.length : -1 },
get total() { return this.images.length + this.productVideos.length },
modalOpen: false,
modalActive: 0,
// Drag/swipe state
isDragging: false,
wasDragging: false,
startX: 0,
currentX: 0,
dragOffset: 0,
containerWidth: 0,
startY: 0,
directionLocked: null,
isRtl: false,
imageLoading: false,
// Zoom state
zoomScale: 1,
zoomPanX: 0,
zoomPanY: 0,
zoomPinching: false,
zoomPanning: false,
zoomInitialDistance: 0,
zoomInitialScale: 1,
zoomPanStartX: 0,
zoomPanStartY: 0,
zoomPanOffsetX: 0,
zoomPanOffsetY: 0,
zoomLastTap: 0,
zoomLastTouchX: 0,
zoomLastTouchY: 0,
zoomAnimating: false,
lastInputWasTouch: false,
init() {
this.isRtl = document.documentElement.dir === 'rtl';
this.$nextTick(() => {
this.containerWidth = this.$refs.carouselContainer?.offsetWidth || 0;
// Handle already-cached images where x-on:load won't fire
this.$el.querySelectorAll('img.lqip-placeholder').forEach(lqip => {
const realImg = lqip.nextElementSibling;
if (realImg && realImg.complete && realImg.naturalWidth > 0) {
realImg.dataset.lqipDone = '1';
lqip.style.display = 'none';
}
});
});
window.addEventListener('resize', () => {
this.containerWidth = this.$refs.carouselContainer?.offsetWidth || 0;
});
// Preload first image of each variant for instant switching
Object.values(this.variationImagesMap).forEach(variant => {
if (variant.images && variant.images[0]) { new Image().src = variant.images[0]; }
});
// Only the active slide may play. Clearing startedVideo also
// drops the bound src, so swiping away stops the download and
// not just the playback.
this.$watch('active', () => {
this.startedVideo = null;
this.$el.querySelectorAll('video[data-product-video]').forEach(v => v.pause());
});
},
handleDragStart(e) {
if (this.total <= 1) return;
this.isDragging = true;
this.directionLocked = null;
this.startX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX;
this.startY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY;
this.currentX = this.startX;
this.containerWidth = this.$refs.carouselContainer?.offsetWidth || 0;
},
handleDragMove(e) {
if (!this.isDragging) return;
const clientX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX;
const clientY = e.type.includes('mouse') ? e.clientY : e.touches[0].clientY;
if (this.directionLocked === null) {
const deltaX = Math.abs(clientX - this.startX);
const deltaY = Math.abs(clientY - this.startY);
if (deltaX < 5 && deltaY < 5) return;
if (deltaY > deltaX) {
this.directionLocked = 'vertical';
this.isDragging = false;
this.dragOffset = 0;
return;
}
this.directionLocked = 'horizontal';
}
if (this.directionLocked === 'vertical') return;
e.preventDefault();
this.currentX = clientX;
let diff = this.currentX - this.startX;
// Limit drag at edges
const atFirst = this.active === 0;
const atLast = this.active === this.total - 1;
const isAtEdge = this.isRtl ?
(atFirst && diff < 0) || (atLast && diff > 0) :
(atFirst && diff > 0) || (atLast && diff < 0);
if (isAtEdge) {
diff = diff * 0.3; // Resistance at edges
}
this.dragOffset = diff;
},
handleDragEnd() {
this.directionLocked = null;
if (!this.isDragging) return;
this.isDragging = false;
// Mark that we just finished dragging to prevent click from opening modal
if (Math.abs(this.dragOffset) > 5) {
this.wasDragging = true;
setTimeout(() => { this.wasDragging = false; }, 100);
}
const threshold = this.containerWidth * 0.2;
if (Math.abs(this.dragOffset) > threshold) {
if (this.isRtl) {
// RTL: drag left (negative) = prev, drag right (positive) = next
if (this.dragOffset < 0) {
this.prev();
} else {
this.next();
}
} else {
// LTR: drag right (positive) = prev, drag left (negative) = next
if (this.dragOffset > 0) {
this.prev();
} else {
this.next();
}
}
}
this.dragOffset = 0;
},
getSlideTransform() {
// RTL: images flow right-to-left, so we use positive offset
// LTR: images flow left-to-right, so we use negative offset
const baseOffset = this.isRtl ? this.active * 100 : -this.active * 100;
const dragPercent = this.containerWidth > 0 ? (this.dragOffset / this.containerWidth) * 100 : 0;
return `translateX(${baseOffset + dragPercent}%)`;
},
next() {
if (this.active < this.total - 1) {
this.active = this.active + 1;
}
},
prev() {
if (this.active > 0) {
this.active = this.active - 1;
}
},
goTo(idx) { this.active = idx },
openModal(idx) {
if (this.isDragging || this.wasDragging) return;
// The lightbox is images only. Its surface is touch-action:none
// with modalDragStart calling preventDefault() on every touch,
// which would swallow the native video control bar - and
// native fullscreen is one tap away from the inline player
// anyway. Video slides simply never open it.
if (this.hasVideos && idx >= this.firstVideoIndex) return;
this.modalActive = idx;
this.modalOpen = true;
document.body.style.overflow = 'hidden';
},
closeModal() {
this.resetZoom();
this.modalOpen = false;
document.body.style.overflow = 'auto';
},
// Wrap on the image count, not `total`: the modal never shows a
// video, so paging must not land on a video index.
get modalTotal() { return Math.max(1, this.images.length) },
modalNext() { this.resetZoom(); this.modalActive = (this.modalActive + 1) % this.modalTotal },
modalPrev() { this.resetZoom(); this.modalActive = (this.modalActive - 1 + this.modalTotal) % this.modalTotal },
modalGoTo(idx) { this.resetZoom(); this.modalActive = idx },
// Modal drag/swipe
modalDragging: false,
modalWasDragging: false,
modalStartX: 0,
modalDragOffset: 0,
// Zoom helpers
resetZoom() {
this.zoomScale = 1;
this.zoomPanX = 0;
this.zoomPanY = 0;
this.zoomPinching = false;
this.zoomPanning = false;
},
getTouchDistance(touches) {
return Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY
);
},
clampPan() {
const maxPanX = Math.max(0, (this.zoomScale - 1) * window.innerWidth / 2);
const maxPanY = Math.max(0, (this.zoomScale - 1) * window.innerHeight / 2);
this.zoomPanX = Math.max(-maxPanX, Math.min(maxPanX, this.zoomPanX));
this.zoomPanY = Math.max(-maxPanY, Math.min(maxPanY, this.zoomPanY));
},
modalDragStart(e) {
const isTouch = e.type.includes('touch');
this.lastInputWasTouch = isTouch;
// Store touch position for double-tap zoom center
if (isTouch && e.touches[0]) {
this.zoomLastTouchX = e.touches[0].clientX;
this.zoomLastTouchY = e.touches[0].clientY;
}
// Two fingers: start pinch-to-zoom
if (isTouch && e.touches.length === 2) {
this.zoomPinching = true;
this.zoomInitialDistance = this.getTouchDistance(e.touches);
this.zoomInitialScale = this.zoomScale;
this.modalDragging = false;
this.zoomPanning = false;
e.preventDefault();
return;
}
// Single finger/mouse while zoomed: start panning
if (this.zoomScale > 1) {
this.zoomPanning = true;
this.zoomPanStartX = isTouch ? e.touches[0].clientX : e.clientX;
this.zoomPanStartY = isTouch ? e.touches[0].clientY : e.clientY;
this.zoomPanOffsetX = this.zoomPanX;
this.zoomPanOffsetY = this.zoomPanY;
e.preventDefault();
return;
}
// Single finger not zoomed or mouse: existing swipe
// Prevent browser double-tap-to-zoom so our custom handler works
if (isTouch) {
e.preventDefault();
}
if (this.total <= 1) return;
this.modalDragging = true;
this.modalStartX = isTouch ? e.touches[0].clientX : e.clientX;
this.modalDragOffset = 0;
},
modalDragMove(e) {
const isTouch = e.type.includes('touch');
// Pinch-to-zoom
if (this.zoomPinching && isTouch && e.touches.length >= 2) {
e.preventDefault();
const newDist = this.getTouchDistance(e.touches);
let newScale = this.zoomInitialScale * (newDist / this.zoomInitialDistance);
this.zoomScale = Math.max(1, Math.min(4, newScale));
this.clampPan();
return;
}
// Panning while zoomed
if (this.zoomPanning) {
e.preventDefault();
const px = isTouch ? e.touches[0].clientX : e.clientX;
const py = isTouch ? e.touches[0].clientY : e.clientY;
const dx = px - this.zoomPanStartX;
const dy = py - this.zoomPanStartY;
this.zoomPanX = this.zoomPanOffsetX + dx;
this.zoomPanY = this.zoomPanOffsetY + dy;
this.clampPan();
return;
}
// Existing swipe logic
if (!this.modalDragging) return;
e.preventDefault();
const x = isTouch ? e.touches[0].clientX : e.clientX;
this.modalDragOffset = x - this.modalStartX;
},
modalDragEnd(e) {
const isTouch = e && e.type && e.type.includes('touch');
// End pinch
if (this.zoomPinching) {
this.zoomPinching = false;
// Snap to 1 if nearly unzoomed
if (this.zoomScale < 1.1) {
this.resetZoom();
}
this.modalWasDragging = true;
setTimeout(() => { this.modalWasDragging = false; }, 100);
// If one finger remains after pinch, start panning
if (isTouch && e.touches && e.touches.length === 1 && this.zoomScale > 1) {
this.zoomPanning = true;
this.zoomPanStartX = e.touches[0].clientX;
this.zoomPanStartY = e.touches[0].clientY;
this.zoomPanOffsetX = this.zoomPanX;
this.zoomPanOffsetY = this.zoomPanY;
}
return;
}
// End pan
if (this.zoomPanning) {
this.zoomPanning = false;
this.modalWasDragging = true;
setTimeout(() => { this.modalWasDragging = false; }, 100);
// Double-tap to zoom out while panned
if (isTouch) {
const now = Date.now();
if (now - this.zoomLastTap < 300) {
this.zoomAnimating = true;
setTimeout(() => { this.zoomAnimating = false; }, 300);
this.resetZoom();
this.zoomLastTap = 0;
} else {
this.zoomLastTap = now;
}
}
return;
}
// Existing swipe end logic
const wasDragging = this.modalDragging;
if (this.modalDragging) {
this.modalDragging = false;
const absDrag = Math.abs(this.modalDragOffset);
if (absDrag > 5) {
this.modalWasDragging = true;
setTimeout(() => { this.modalWasDragging = false; }, 100);
}
const threshold = 80;
if (absDrag > threshold) {
if (this.isRtl) {
this.modalDragOffset < 0 ? this.modalPrev() : this.modalNext();
} else {
this.modalDragOffset > 0 ? this.modalPrev() : this.modalNext();
}
this.modalDragOffset = 0;
this.zoomLastTap = 0;
return;
}
this.modalDragOffset = 0;
}
// Double-tap detection (works for both zoomed and not zoomed)
if (isTouch) {
const now = Date.now();
if (now - this.zoomLastTap < 300) {
this.zoomAnimating = true;
setTimeout(() => { this.zoomAnimating = false; }, 300);
this.modalWasDragging = true;
setTimeout(() => { this.modalWasDragging = false; }, 200);
if (this.zoomScale > 1) {
this.resetZoom();
} else {
const cx = this.zoomLastTouchX - window.innerWidth / 2;
const cy = this.zoomLastTouchY - window.innerHeight / 2;
this.zoomScale = 2.5;
this.zoomPanX = -cx * (this.zoomScale - 1) / this.zoomScale;
this.zoomPanY = -cy * (this.zoomScale - 1) / this.zoomScale;
this.clampPan();
}
this.zoomLastTap = 0;
return;
}
this.zoomLastTap = now;
// Single tap on backdrop: close modal (touch equivalent of @click.self)
if (e.target === e.currentTarget) {
this.closeModal();
}
}
},
switchToVariation(attrValueId) {
const varImages = this.variationImagesMap[attrValueId];
let newImages, newLargeImages, newLqipImages;
if (varImages && varImages.images && varImages.images.length > 0) {
newImages = varImages.images;
newLargeImages = varImages.largeImages || varImages.images;
newLqipImages = varImages.lqipImages || [];
} else {
newImages = this.defaultImages;
newLargeImages = this.defaultLargeImages;
newLqipImages = this.defaultLqipImages;
}
// Show loading state while first image loads
this.imageLoading = true;
const firstImg = new Image();
firstImg.onload = () => {
this.localImages = newImages;
this.localLargeImages = newLargeImages;
this.localLqipImages = newLqipImages;
this.active = 0;
this.modalActive = 0;
this.imageLoading = false;
};
firstImg.onerror = () => {
this.localImages = newImages;
this.localLargeImages = newLargeImages;
this.localLqipImages = newLqipImages;
this.active = 0;
this.modalActive = 0;
this.imageLoading = false;
};
firstImg.src = newImages[0];
// If already cached, onload fires synchronously or near-instantly
}
}" x-on:keydown.escape.window="closeModal()"
x-on:keydown.arrow-left.window="modalOpen && modalPrev()"
x-on:keydown.arrow-right.window="modalOpen && modalNext()"
x-on:switch-variation-images.window="switchToVariation($event.detail.attrValueId ?? $event.detail[0]?.attrValueId)">
/
0 ? -0.25 : 0.25;
const oldScale = zoomScale;
zoomScale = Math.max(1, Math.min(4, zoomScale + delta));
if (oldScale === 1 && zoomScale > 1) {
const r = $el.getBoundingClientRect();
const cx = $event.clientX - (r.left + r.width/2);
const cy = $event.clientY - (r.top + r.height/2);
zoomPanX = -cx * (zoomScale-1)/zoomScale;
zoomPanY = -cy * (zoomScale-1)/zoomScale;
} else if (zoomScale > 1) {
zoomPanX = zoomPanX * (zoomScale / oldScale);
zoomPanY = zoomPanY * (zoomScale / oldScale);
}
if (zoomScale <= 1) { resetZoom(); } else { clampPan(); }
"
class="relative w-full h-full flex items-center justify-center p-4 [touch-action:none]"
:class="zoomScale > 1 ? (zoomPanning ? 'cursor-grabbing' : 'cursor-grab') : (modalTotal > 1 ? (modalDragging ? 'cursor-grabbing' : 'cursor-grab') : '')"
@mousedown="modalDragStart($event)"
@mousemove="modalDragMove($event)"
@mouseup="modalDragEnd($event)"
@mouseleave="modalDragEnd($event)"
@touchstart="modalDragStart($event)"
@touchmove="modalDragMove($event)"
@touchend="modalDragEnd($event)"
:style="modalDragOffset ? `transform: translateX(${modalDragOffset}px); transition: none;` : 'transition: transform 0.2s ease-out;'">
1) { resetZoom(); } else { const r = $el.getBoundingClientRect(); const cx = $event.clientX - (r.left + r.width/2); const cy = $event.clientY - (r.top + r.height/2); zoomScale = 2.5; zoomPanX = -cx * (zoomScale-1)/zoomScale; zoomPanY = -cy * (zoomScale-1)/zoomScale; clampPan(); } setTimeout(() => { zoomAnimating = false; }, 300);"
draggable="false"
:style="modalActive === largeIdx
? 'position:relative;transform: scale(' + zoomScale + ') translate(' + (zoomPanX / zoomScale) + 'px, ' + (zoomPanY / zoomScale) + 'px); transform-origin: center center; will-change: transform; transition: ' + (zoomAnimating ? 'transform 0.3s ease-out' : (zoomScale > 1 ? 'none' : 'transform 0.2s ease-out')) + ';'
: ''" />
Govee Square Ceiling Lights
مصباح السقف المربع من Govee هو وحدة إنارة ذكية تجمع بين التصميم النحيف والأداء المتطور، حيث يوفر إضاءة أساسية بيضاء قوية (2400 لومن) مع إضاءة خلفية ملونة (RGBIC) لخلق تأثيرات بصرية على السقف. يدعم المصباح بروتوكول Matter، مما يجعله متوافقاً مع جميع منصات المنزل الذكي (Alexa, Google, Apple Home)، ويتميز بقدرته على عرض ملايين الألوان وتعديل درجة حرارة اللون الأبيض من الدافئ إلى البارد.
قم بترقية منزلك باستخدام مصباح السقف الذكي النهائي، المصمم للراحة والملاءمة والأناقة. مع سطوع قابل للتعديل (1٪ -100٪) وما يصل إلى 2400 لومن، فهو مثالي للغرف التي تبلغ مساحتها 15-20 مترا مربعا. سواء كنت تفضل توهجا دافئا ومريحا (2700K) أو ضوءا ساطعا وباردا (6500K) للإنتاجية، فإن هذا الضوء يتكيف مع احتياجاتك.
- درجات حرارة السطوع واللون القابلة للتعديل: يتراوح السطوع الواسع من 1٪ إلى 100٪، مما يوفر إخراجا قويا يبلغ 2400 لوم لإشراق أي مساحة. اضبط درجة حرارة اللون بسلاسة من 2700K إلى 6500K لضمان أجواء مثالية.
- تجربة إضاءة قابلة للتخصيص: الضوء الرئيسي RGBWW والإضاءة الخلفية RGBIC مع 16 مليون لون يخلق جوا شخصيا.
أكثر من 70 وضع مشهد متاح لتتناسب مع أي مزاج أو مناسبة. - التحكم الذكي والتكامل: متوافق مع Alexa وGoogle Assistant وMatter للتحكم الصوتي بدون استخدام اليدين.
يتيح تطبيق Govee Home التخصيص السهل لإعدادات الإضاءة والجداول الزمنية. - إضاءة أكثر ذكاء، حياة أكثر أمانا: صمم مصباح السقف هذا من أجل المتانة، ويتميز بلوحة خلفية مثبطة للهب 5VA وتصميم مربع أبيض أنيق يكمل أي ديكور، من الأنماط الحديثة إلى الحد الأدنى.
- مثالي للغرف متوسطة الحجم: مصباح السقف المثبت على التدفق مقاس 30 سم مثالي لغرف النوم والمكاتب ومساحات المعيشة؛ أي غرفة بحجم 15-20 مترا مربعا.
| Category | مصباح السقفية |
|---|---|
| Brand | Govee |
| SKU | H60A4 |
| Weight | 1.3 kg |
| Estimated Delivery | 1-2 days |
| Publication Date | 2026-03-05 |
| Edition | 2025 |
No reviews yet. Be the first to review this product!
Login to write a review