diff --git a/src/environment.dev.ts b/src/environment.dev.ts
index feed5bff..1db4c7c5 100644
--- a/src/environment.dev.ts
+++ b/src/environment.dev.ts
@@ -7,4 +7,5 @@ export const environment = {
},
reverse_watch_base_api_url: 'http://localhost:3434/api',
floatdb_gateway_url: 'https://gateway.floatdb.com',
+ skincraft_embed_origin: 'https://localhost:3000',
};
diff --git a/src/environment.staging.ts b/src/environment.staging.ts
index a5fd5eef..900749cf 100644
--- a/src/environment.staging.ts
+++ b/src/environment.staging.ts
@@ -7,4 +7,5 @@ export const environment = {
},
reverse_watch_base_api_url: 'https://reverse.watch/api',
floatdb_gateway_url: 'https://gateway.floatdb.com',
+ skincraft_embed_origin: 'https://skincraft.gg',
};
diff --git a/src/environment.ts b/src/environment.ts
index 8879f056..0ef408c8 100644
--- a/src/environment.ts
+++ b/src/environment.ts
@@ -7,4 +7,5 @@ export const environment = {
},
reverse_watch_base_api_url: 'https://reverse.watch/api',
floatdb_gateway_url: 'https://gateway.floatdb.com',
+ skincraft_embed_origin: 'https://skincraft.gg',
};
diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts
index cbb6a3e3..8fde88f9 100644
--- a/src/lib/components/inventory/selected_item_info.ts
+++ b/src/lib/components/inventory/selected_item_info.ts
@@ -23,6 +23,12 @@ import {Contract} from '../../types/float_market';
import '../common/ui/floatbar';
import {ClientSend} from '../../bridge/client';
import {FetchBluegem, FetchBluegemResponse} from '../../bridge/handlers/fetch_bluegem';
+import {environment} from '../../../environment';
+import {gSkinCraftEmbed} from '../../services/skincraft_embed';
+import {getActiveInventoryAssetProperties, toSkinCraftItem} from '../../services/skincraft_inventory_targets';
+import type {SkinCraftItem} from '../../services/skincraft_viewer_protocol';
+import {gWebGpuAvailability} from '../../services/webgpu_availability';
+import type {WebGpuAvailability} from '../../services/webgpu_availability';
import './list_item_modal';
/**
@@ -48,6 +54,13 @@ export class SelectedItemInfo extends FloatElement {
margin-bottom: 10px;
}
+ .market-btn-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ }
+
.market-btn-container {
margin: 10px 0 10px 0;
padding: 5px;
@@ -64,6 +77,43 @@ export class SelectedItemInfo extends FloatElement {
color: #ebebeb;
text-decoration: none;
}
+
+ .view-3d-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ padding: 0 12px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #f5f8ff;
+ background-color: #5155eb;
+ border: 0;
+ border-radius: 8px;
+ cursor: pointer;
+ font-family: inherit;
+ text-decoration: none;
+ transition:
+ filter 180ms ease,
+ transform 180ms ease;
+ box-shadow:
+ 0 4px 12px hsl(0 0% 0% / 0.22),
+ 0 1px 2px hsl(0 0% 0% / 0.16),
+ inset 0 1px 0 rgba(255, 255, 255, 0.22);
+ }
+
+ .view-3d-btn img {
+ filter: brightness(0) invert(1);
+ }
+
+ .view-3d-btn:hover {
+ filter: brightness(1.1);
+ }
+
+ .view-3d-btn:active {
+ transform: scale(0.98);
+ filter: brightness(0.95);
+ }
`,
];
@@ -79,6 +129,9 @@ export class SelectedItemInfo extends FloatElement {
@state()
private showListModal: boolean = false;
+ @state()
+ private webGpuStatus: WebGpuAvailability = gWebGpuAvailability.status;
+
private bluegemData: FetchBluegemResponse | undefined;
get asset(): InventoryAsset | undefined {
@@ -151,8 +204,14 @@ export class SelectedItemInfo extends FloatElement {
);
}
+ if (this.canListOnCSFloat || this.canViewInSkinCraft) {
+ containerChildren.push(
+ html`
${this.renderListOnCSFloat()} ${this.renderViewIn3D()}
`
+ );
+ }
+
if (isSellableOnCSFloat(this.asset.description)) {
- containerChildren.push(html`${this.renderListOnCSFloat()} ${this.renderFloatMarketListing()}`);
+ containerChildren.push(this.renderFloatMarketListing());
}
if (containerChildren.length === 0) {
@@ -216,19 +275,39 @@ export class SelectedItemInfo extends FloatElement {
`;
}
- renderListOnCSFloat(): TemplateResult<1> {
- if (this.stallListing) {
- // Don't tell them to list it if it's already listed...
- return html``;
- }
+ private get canListOnCSFloat(): boolean {
+ return (
+ !!this.asset?.description &&
+ isSellableOnCSFloat(this.asset.description) &&
+ !this.stallListing &&
+ g_ActiveInventory?.m_owner?.strSteamId === g_steamID &&
+ !!this.asset.description.tradable
+ );
+ }
- if (g_ActiveInventory?.m_owner?.strSteamId !== g_steamID) {
- // Not the signed-in user, don't show
+ private get skinCraftItem(): SkinCraftItem | undefined {
+ return toSkinCraftItem(this.asset, getActiveInventoryAssetProperties(g_ActiveInventory, this.asset?.assetid));
+ }
+
+ private get canViewInSkinCraft(): boolean {
+ return this.webGpuStatus === 'available' && !!this.skinCraftItem;
+ }
+
+ renderViewIn3D(): TemplateResult<1> {
+ if (!this.canViewInSkinCraft) {
return html``;
}
- if (!this.asset?.description?.tradable) {
- // Don't show if item isn't tradable
+ return html`
+
+ `;
+ }
+
+ renderListOnCSFloat(): TemplateResult<1> {
+ if (!this.canListOnCSFloat) {
return html``;
}
@@ -249,6 +328,11 @@ export class SelectedItemInfo extends FloatElement {
`;
}
+ private handleViewIn3D(): void {
+ const item = this.skinCraftItem;
+ if (item) gSkinCraftEmbed.open(item);
+ }
+
async processSelectChange() {
// Reset state in-case they swap between skin and non-skin
this.itemInfo = undefined;
@@ -289,9 +373,16 @@ export class SelectedItemInfo extends FloatElement {
this.loading = false;
}
+ private async resolveWebGpuStatus(): Promise {
+ if (this.webGpuStatus === 'checking') this.webGpuStatus = await gWebGpuAvailability.settled();
+ }
+
connectedCallback() {
super.connectedCallback();
+ // Settles once per session; the 3D button stays hidden until it does
+ void this.resolveWebGpuStatus();
+
// For the initial load, in case an item is pre-selected
this.processSelectChange();
diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts
new file mode 100644
index 00000000..f361196c
--- /dev/null
+++ b/src/lib/components/inventory/skincraft_viewer_modal.ts
@@ -0,0 +1,395 @@
+import {html, nothing, render} from 'lit';
+import type {TemplateResult} from 'lit';
+import {classMap} from 'lit/directives/class-map.js';
+import {guard} from 'lit/directives/guard.js';
+import {createRef, ref} from 'lit/directives/ref.js';
+import {styleMap} from 'lit/directives/style-map.js';
+import type {SkinCraftViewerTarget} from '../../services/skincraft_viewer_protocol';
+import {MODAL_TRANSITION_MS, skinCraftViewerModalStyles} from './skincraft_viewer_modal_styles';
+
+type LoadPhase = 'loading' | 'revealed' | 'error';
+
+function mixHexColors(base: string, tint: string, tintAmount: number): string {
+ const mixChannel = (offset: number): number => {
+ const baseChannel = Number.parseInt(base.slice(offset, offset + 2), 16);
+ const tintChannel = Number.parseInt(tint.slice(offset, offset + 2), 16);
+ return Math.round(baseChannel + (tintChannel - baseChannel) * tintAmount);
+ };
+
+ return `rgb(${mixChannel(0)} ${mixChannel(2)} ${mixChannel(4)})`;
+}
+
+function inventoryCardColors(target: SkinCraftViewerTarget): Record {
+ const base = target.backgroundColor || '2a2f3a';
+ const rarity = target.rarityColor || 'c1ceff';
+
+ return {
+ '--inventory-card-background': target.backgroundColor ? `#${base}` : mixHexColors(base, rarity, 0.1),
+ '--inventory-card-rarity': `#${rarity}`,
+ '--inventory-card-hover': mixHexColors(base, rarity, 0.16),
+ '--inventory-card-selected': mixHexColors(base, rarity, 0.26),
+ };
+}
+
+function targetKey(target: SkinCraftViewerTarget): string {
+ return target.assetId || target.inspect;
+}
+
+/**
+ * Renders with lit-html instead of extending `FloatElement`: the content script builds this modal,
+ * and Chrome leaves `customElements` null in isolated worlds, so no custom element can be defined
+ * here. `render()` needs nothing but the DOM, and the embed iframe is created once and then only
+ * ever diffed, so it is never re-`src`'d.
+ */
+export class SkinCraftViewerModal {
+ readonly element = document.createElement('div');
+
+ private readonly root: ShadowRoot;
+ private readonly dialogRef = createRef();
+ private readonly frameRef = createRef();
+
+ private target?: SkinCraftViewerTarget;
+ private inventoryTargets: SkinCraftViewerTarget[] = [];
+ private phase: LoadPhase = 'loading';
+ private progress: number | null = null;
+ private errorMessage = '';
+ private entering = false;
+ private closing = false;
+ private iconReady = false;
+ private backdropPressed = false;
+ private closeTimer?: number;
+ private entryFrame?: number;
+ private iconRequest = 0;
+
+ constructor(
+ private readonly embedSrc: string,
+ private readonly onClose: () => void,
+ private readonly onRetry: () => void,
+ private readonly onSelect: (target: SkinCraftViewerTarget) => void
+ ) {
+ // Closed so page scripts can't reach into the viewer we host on their document.
+ this.root = this.element.attachShadow({mode: 'closed'});
+ const style = document.createElement('style');
+ style.textContent = skinCraftViewerModalStyles;
+ this.root.appendChild(style);
+ this.update();
+ }
+
+ get frameWindow(): Window | null {
+ return this.frameRef.value?.contentWindow ?? null;
+ }
+
+ get isOpen(): boolean {
+ return this.dialogRef.value?.open ?? false;
+ }
+
+ setInventory(targets: SkinCraftViewerTarget[]): void {
+ this.inventoryTargets = targets;
+ this.update();
+ }
+
+ show(target: SkinCraftViewerTarget): void {
+ this.target = target;
+ this.iconReady = false;
+ const request = ++this.iconRequest;
+
+ this.cancelClose();
+ const opening = !this.isOpen;
+ if (opening) this.entering = true;
+ this.update();
+
+ void this.revealIconWhenDecoded(target.iconUrl, request);
+ this.scrollSelectedIntoView();
+ if (opening) this.openDialog();
+ }
+
+ hide(): void {
+ if (!this.isOpen || this.closing) return;
+
+ this.cancelEntry();
+ this.entering = false;
+ this.closing = true;
+ this.update();
+ this.closeTimer = window.setTimeout(() => this.finishClose(), MODAL_TRANSITION_MS);
+ }
+
+ setLoading(value: number | null): void {
+ this.phase = 'loading';
+ this.progress = value;
+ this.update();
+ }
+
+ /** Reveals the embed and fades the loading cover out, whether the load just finished or is still running. */
+ showFrame(): void {
+ this.phase = 'revealed';
+ this.update();
+ }
+
+ setError(message: string): void {
+ this.phase = 'error';
+ this.errorMessage = message;
+ this.update();
+ }
+
+ // `host` binds `this` inside the template's @event handlers, the way LitElement does it.
+ private update(): void {
+ render(this.template(), this.root, {host: this});
+ }
+
+ private template(): TemplateResult {
+ const revealed = this.phase === 'revealed';
+
+ return html`
+
+ `;
+ }
+
+ private get selectedKey(): string | undefined {
+ return this.target ? targetKey(this.target) : undefined;
+ }
+
+ // Both status blocks stay mounted and toggle `hidden` so the item icon survives an error →
+ // retry cycle; its fade-in is driven by a decode() that needs the
to already exist.
+ private renderLoading(): TemplateResult {
+ const determinate = this.progress !== null;
+ const percent = Math.round(this.progress ?? 0);
+
+ return html`
+
+ ${this.target?.iconUrl
+ ? html`

`
+ : nothing}
+
${this.target?.name ?? ''}
+
+
+
${determinate ? `${percent}%` : ''}
+
+
+ `;
+ }
+
+ private renderError(): TemplateResult {
+ return html`
+
+
${this.errorMessage}
+
+
+ `;
+ }
+
+ private renderInventoryCards(): TemplateResult[] {
+ const selectedKey = this.selectedKey;
+
+ return this.inventoryTargets.map((target, index) => {
+ const selected = targetKey(target) === selectedKey;
+
+ return html`
+
+ `;
+ });
+ }
+
+ private openDialog(): void {
+ const dialog = this.dialogRef.value;
+ if (!dialog || dialog.open) return;
+
+ dialog.showModal();
+ // Two frames: `entering` has to be painted before it is removed, or the transition never runs.
+ this.entryFrame = requestAnimationFrame(() => {
+ this.entryFrame = requestAnimationFrame(() => {
+ this.entryFrame = undefined;
+ this.entering = false;
+ this.update();
+ this.focusViewer();
+ });
+ });
+ }
+
+ private async revealIconWhenDecoded(iconUrl: string | undefined, request: number): Promise {
+ if (!iconUrl) return;
+
+ const icon = this.root.querySelector('.item-icon');
+ if (!icon || icon.src !== iconUrl) return;
+
+ const decoded = await icon.decode().then(
+ () => true,
+ () => false
+ );
+ if (!decoded || request !== this.iconRequest) return;
+
+ requestAnimationFrame(() => {
+ if (request !== this.iconRequest) return;
+ this.iconReady = true;
+ this.update();
+ });
+ }
+
+ private scrollSelectedIntoView(): void {
+ const card = this.root.querySelector('.inventory-card.selected');
+ if (card) requestAnimationFrame(() => card.scrollIntoView({block: 'nearest', inline: 'nearest'}));
+ }
+
+ private focusViewer(): void {
+ this.frameRef.value?.focus({preventScroll: true});
+ }
+
+ private handleCancel(event: Event): void {
+ event.preventDefault();
+ this.onClose();
+ }
+
+ // A backdrop hit lands on the