diff --git a/.github/workflows/deploy-llink-web.yml b/.github/workflows/deploy-llink-web.yml
new file mode 100644
index 0000000..43087b6
--- /dev/null
+++ b/.github/workflows/deploy-llink-web.yml
@@ -0,0 +1,72 @@
+name: Deploy llink-web
+
+on:
+ workflow_dispatch:
+ inputs:
+ environment:
+ description: "Target environment"
+ required: true
+ type: choice
+ options: [dev, prod]
+
+concurrency:
+ group: deploy-llink-web-${{ inputs.environment }}
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment: ${{ inputs.environment }}
+ defaults:
+ run:
+ working-directory: js/desktop
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Resolve environment settings
+ id: env
+ run: |
+ case "${{ inputs.environment }}" in
+ dev)
+ echo "project=flowy-dev-440017" >> "$GITHUB_OUTPUT"
+ echo "cluster=cluster" >> "$GITHUB_OUTPUT"
+ ;;
+ prod)
+ echo "project=flowy-prod-440017" >> "$GITHUB_OUTPUT"
+ echo "cluster=prod-cluster" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "Unknown environment: ${{ inputs.environment }}" >&2; exit 1
+ ;;
+ esac
+ echo "region=us-west2" >> "$GITHUB_OUTPUT"
+
+ - name: Authenticate to Google Cloud
+ uses: google-github-actions/auth@v2
+ with:
+ credentials_json: ${{ inputs.environment == 'prod' && secrets.PROD_GKE_SERVICE_ACCOUNT_KEY || secrets.DEV_GKE_SERVICE_ACCOUNT_KEY }}
+
+ - uses: google-github-actions/setup-gcloud@v2
+
+ - name: Install gke-gcloud-auth-plugin
+ run: gcloud components install gke-gcloud-auth-plugin --quiet
+
+ - name: Configure Docker for Artifact Registry
+ run: gcloud auth configure-docker us-west2-docker.pkg.dev --quiet
+
+ - name: Get GKE credentials
+ run: |
+ gcloud container clusters get-credentials "${{ steps.env.outputs.cluster }}" \
+ --region "${{ steps.env.outputs.region }}" \
+ --project "${{ steps.env.outputs.project }}"
+
+ - name: Install skaffold
+ run: |
+ curl -fsSLo skaffold https://storage.googleapis.com/skaffold/releases/latest/skaffold-linux-amd64
+ sudo install skaffold /usr/local/bin/
+ skaffold version
+
+ - name: Deploy
+ env:
+ SKAFFOLD_DEFAULT_REPO: us-west2-docker.pkg.dev/${{ steps.env.outputs.project }}/deployments
+ run: skaffold run -p ${{ inputs.environment }}
diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go
index d6a5be6..b261048 100644
--- a/go/cmd/orion/main.go
+++ b/go/cmd/orion/main.go
@@ -6,6 +6,7 @@ import (
"log/slog"
"net/http"
"os"
+ "strings"
"cloud.google.com/go/firestore"
"cloud.google.com/go/storage"
@@ -173,6 +174,9 @@ func main() {
// Particles
mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia))
+ // Link metadata
+ mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata))
+
// Depot
mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload))
mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload))
@@ -185,8 +189,17 @@ func main() {
mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry))
mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant))
- // nil = allow all origins (Electron app needs it).
- muxWithCors := middleware.CORS(nil)(mux)
+ // CORS_ALLOWED_ORIGINS is a comma-separated whitelist for the web client.
+ // Empty / unset = allow all
+ var allowedOrigins []string
+ if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" {
+ for _, o := range strings.Split(raw, ",") {
+ if o = strings.TrimSpace(o); o != "" {
+ allowedOrigins = append(allowedOrigins, o)
+ }
+ }
+ }
+ muxWithCors := middleware.CORS(allowedOrigins)(mux)
addr := fmt.Sprintf("0.0.0.0:%s", port)
slog.Info("running server", "addr", addr)
diff --git a/go/internal/handler/metadata.go b/go/internal/handler/metadata.go
new file mode 100644
index 0000000..cccae0f
--- /dev/null
+++ b/go/internal/handler/metadata.go
@@ -0,0 +1,209 @@
+package handler
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+)
+
+// LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`.
+// All clients (web and Electron) call this endpoint — the Electron main-process
+// fetcher was retired so both clients share one parser and the server-side cache.
+type LinkMetadata struct {
+ URL string `json:"url"`
+ Title *string `json:"title"`
+ Description *string `json:"description"`
+ Image *string `json:"image"`
+ Favicon *string `json:"favicon"`
+ Domain string `json:"domain"`
+}
+
+const (
+ metadataFetchTimeout = 5 * time.Second
+ metadataMaxBytes = 50 * 1024
+ metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)"
+)
+
+// In-process cache, unbounded but only successful results are stored.
+// URL space is bounded in practice.
+var metadataCache sync.Map // map[string]LinkMetadata
+
+func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) {
+ raw := r.URL.Query().Get("url")
+ if raw == "" {
+ http.Error(w, "url is required", http.StatusBadRequest)
+ return
+ }
+
+ parsed, err := url.Parse(raw)
+ if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
+ http.Error(w, "invalid url", http.StatusBadRequest)
+ return
+ }
+
+ if cached, ok := metadataCache.Load(raw); ok {
+ writeJSON(w, cached)
+ return
+ }
+
+ if err := guardSSRF(parsed.Hostname()); err != nil {
+ // Treat unresolvable / private hosts as "no metadata" rather than 4xx — the
+ // client treats a null body as a progressive-enhancement miss.
+ writeJSON(w, nil)
+ return
+ }
+
+ meta, err := fetchLinkMetadata(r.Context(), parsed)
+ if err != nil {
+ slog.Warn("link metadata fetch failed", "url", raw, "error", err)
+ writeJSON(w, nil)
+ return
+ }
+
+ metadataCache.Store(raw, *meta)
+ writeJSON(w, meta)
+}
+
+// guardSSRF resolves the host and rejects loopback, private, link-local, and
+// unspecified addresses so the endpoint can't be turned into an internal-network
+// probe.
+func guardSSRF(host string) error {
+ ips, err := net.LookupIP(host)
+ if err != nil {
+ return err
+ }
+ if len(ips) == 0 {
+ return errors.New("no addresses for host")
+ }
+ for _, ip := range ips {
+ if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
+ ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
+ return errors.New("private or local address")
+ }
+ }
+ return nil
+}
+
+func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) {
+ ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", metadataUserAgent)
+ req.Header.Set("Accept", "text/html")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, errors.New("upstream non-2xx")
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes))
+ if err != nil {
+ return nil, err
+ }
+ html := string(body)
+
+ domain := strings.TrimPrefix(target.Hostname(), "www.")
+ title := firstNonNil(metaContent(html, "og:title"), parseTitle(html))
+ description := firstNonNil(
+ metaContent(html, "og:description"),
+ metaContent(html, "description"),
+ )
+ image := resolveURL(metaContent(html, "og:image"), target)
+ favicon := parseFavicon(html, target)
+
+ return &LinkMetadata{
+ URL: target.String(),
+ Title: title,
+ Description: description,
+ Image: image,
+ Favicon: favicon,
+ Domain: domain,
+ }, nil
+}
+
+var titleRe = regexp.MustCompile(`(?i)
]*>([^<]*)`)
+
+func parseTitle(html string) *string {
+ m := titleRe.FindStringSubmatch(html)
+ if len(m) < 2 {
+ return nil
+ }
+ s := strings.TrimSpace(m[1])
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
+// metaContent finds " content="..."> in either attribute order.
+func metaContent(html, prop string) *string {
+ escaped := regexp.QuoteMeta(prop)
+ re := regexp.MustCompile(
+ `(?i)]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` +
+ `|]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`,
+ )
+ m := re.FindStringSubmatch(html)
+ if len(m) == 0 {
+ return nil
+ }
+ for _, g := range m[1:] {
+ if g != "" {
+ return &g
+ }
+ }
+ return nil
+}
+
+var (
+ faviconRe1 = regexp.MustCompile(`(?i)]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`)
+ faviconRe2 = regexp.MustCompile(`(?i)]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`)
+)
+
+func parseFavicon(html string, base *url.URL) *string {
+ for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} {
+ if m := re.FindStringSubmatch(html); len(m) >= 2 {
+ return resolveURL(&m[1], base)
+ }
+ }
+ // Fall back to /favicon.ico
+ fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String()
+ return &fallback
+}
+
+func resolveURL(src *string, base *url.URL) *string {
+ if src == nil || *src == "" {
+ return nil
+ }
+ parsed, err := url.Parse(*src)
+ if err != nil {
+ return src
+ }
+ resolved := base.ResolveReference(parsed).String()
+ return &resolved
+}
+
+func firstNonNil(values ...*string) *string {
+ for _, v := range values {
+ if v != nil && *v != "" {
+ return v
+ }
+ }
+ return nil
+}
diff --git a/go/k8s/dev/orion.yaml b/go/k8s/dev/orion.yaml
index 0596ed6..e6f7b97 100644
--- a/go/k8s/dev/orion.yaml
+++ b/go/k8s/dev/orion.yaml
@@ -79,6 +79,9 @@ spec:
value: "llink://billing/success"
- name: "BILLING_CANCEL_URL"
value: "llink://billing/cancel"
+ # Web client origins
+ - name: "CORS_ALLOWED_ORIGINS"
+ value: "http://localhost:5173,http://localhost:5174,http://localhost:5175,https://llink.dev.flowy.live"
---
diff --git a/go/k8s/prod/orion.yaml b/go/k8s/prod/orion.yaml
index aaee2e2..b9b2dd2 100644
--- a/go/k8s/prod/orion.yaml
+++ b/go/k8s/prod/orion.yaml
@@ -76,6 +76,9 @@ spec:
value: "llink://billing/success"
- name: "BILLING_CANCEL_URL"
value: "llink://billing/cancel"
+ # Web client origins
+ - name: "CORS_ALLOWED_ORIGINS"
+ value: "https://llink.flowy.live"
---
diff --git a/js/desktop/.dockerignore b/js/desktop/.dockerignore
new file mode 100644
index 0000000..15d123e
--- /dev/null
+++ b/js/desktop/.dockerignore
@@ -0,0 +1,8 @@
+node_modules
+dist-web
+.vite
+out
+.git
+.gitignore
+.DS_Store
+*.log
diff --git a/js/desktop/Dockerfile b/js/desktop/Dockerfile
new file mode 100644
index 0000000..29e90f9
--- /dev/null
+++ b/js/desktop/Dockerfile
@@ -0,0 +1,17 @@
+# Two-stage build for the llink web SPA. APP_ENV is baked into the bundle
+# at build time via vite's `define` (see vite.env.ts).
+FROM node:22-alpine AS builder
+
+WORKDIR /app
+ARG APP_ENV=prod
+
+COPY package.json yarn.lock ./
+RUN yarn install --frozen-lockfile
+
+COPY . .
+RUN APP_ENV=$APP_ENV yarn web:build:ci
+
+FROM nginx:1.27-alpine
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=builder /app/dist-web /usr/share/nginx/html
+EXPOSE 80
diff --git a/js/desktop/k8s/dev/llink-web.yaml b/js/desktop/k8s/dev/llink-web.yaml
new file mode 100644
index 0000000..1f89a3e
--- /dev/null
+++ b/js/desktop/k8s/dev/llink-web.yaml
@@ -0,0 +1,98 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: llink-web
+spec:
+ selector:
+ matchLabels:
+ app: llink-web
+ replicas: 1
+ template:
+ metadata:
+ labels:
+ app: llink-web
+ spec:
+ serviceAccountName: default-service-account
+ nodeSelector:
+ cloud.google.com/gke-spot: "true"
+ terminationGracePeriodSeconds: 15
+ containers:
+ - name: llink-web
+ image: "llink-web"
+ ports:
+ - containerPort: 80
+ resources:
+ requests:
+ memory: "64Mi"
+ cpu: 20m
+ limits:
+ memory: "64Mi"
+ cpu: 50m
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 80
+ initialDelaySeconds: 2
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 80
+ initialDelaySeconds: 10
+ periodSeconds: 30
+
+---
+
+apiVersion: v1
+kind: Service
+metadata:
+ name: llink-web
+spec:
+ selector:
+ app: llink-web
+ ports:
+ - port: 80
+ targetPort: 80
+ protocol: TCP
+
+---
+
+kind: HTTPRoute
+apiVersion: gateway.networking.k8s.io/v1beta1
+metadata:
+ name: llink-web
+spec:
+ parentRefs:
+ - kind: Gateway
+ name: external-gateway
+ hostnames:
+ - llink.dev.flowy.live
+ rules:
+ - backendRefs:
+ - name: llink-web
+ port: 80
+
+---
+
+apiVersion: networking.gke.io/v1
+kind: HealthCheckPolicy
+metadata:
+ name: llink-web-service-health-check
+spec:
+ default:
+ checkIntervalSec: 15
+ timeoutSec: 15
+ healthyThreshold: 1
+ unhealthyThreshold: 2
+ logConfig:
+ enabled: true
+ config:
+ type: HTTP
+ httpHealthCheck:
+ portSpecification: USE_FIXED_PORT
+ port: 80
+ requestPath: /health
+ targetRef:
+ group: ""
+ kind: Service
+ name: llink-web
diff --git a/js/desktop/k8s/prod/llink-web.yaml b/js/desktop/k8s/prod/llink-web.yaml
new file mode 100644
index 0000000..d22514e
--- /dev/null
+++ b/js/desktop/k8s/prod/llink-web.yaml
@@ -0,0 +1,95 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: llink-web
+spec:
+ selector:
+ matchLabels:
+ app: llink-web
+ replicas: 2
+ template:
+ metadata:
+ labels:
+ app: llink-web
+ spec:
+ serviceAccountName: default-service-account
+ containers:
+ - name: llink-web
+ image: "llink-web"
+ ports:
+ - containerPort: 80
+ resources:
+ requests:
+ memory: "64Mi"
+ cpu: 20m
+ limits:
+ memory: "64Mi"
+ cpu: 50m
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 80
+ initialDelaySeconds: 2
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 80
+ initialDelaySeconds: 10
+ periodSeconds: 30
+
+---
+
+apiVersion: v1
+kind: Service
+metadata:
+ name: llink-web
+spec:
+ selector:
+ app: llink-web
+ ports:
+ - port: 80
+ targetPort: 80
+ protocol: TCP
+
+---
+
+kind: HTTPRoute
+apiVersion: gateway.networking.k8s.io/v1beta1
+metadata:
+ name: llink-web
+spec:
+ parentRefs:
+ - kind: Gateway
+ name: external-gateway
+ hostnames:
+ - llink.flowy.live
+ rules:
+ - backendRefs:
+ - name: llink-web
+ port: 80
+
+---
+
+apiVersion: networking.gke.io/v1
+kind: HealthCheckPolicy
+metadata:
+ name: llink-web-service-health-check
+spec:
+ default:
+ checkIntervalSec: 15
+ timeoutSec: 15
+ healthyThreshold: 1
+ unhealthyThreshold: 2
+ logConfig:
+ enabled: true
+ config:
+ type: HTTP
+ httpHealthCheck:
+ portSpecification: USE_FIXED_PORT
+ port: 80
+ requestPath: /health
+ targetRef:
+ group: ""
+ kind: Service
+ name: llink-web
diff --git a/js/desktop/nginx.conf b/js/desktop/nginx.conf
new file mode 100644
index 0000000..7be8dfe
--- /dev/null
+++ b/js/desktop/nginx.conf
@@ -0,0 +1,23 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location = /health {
+ access_log off;
+ add_header Content-Type text/plain;
+ return 200 "ok\n";
+ }
+
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ try_files $uri =404;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ add_header Cache-Control "no-cache";
+ }
+}
diff --git a/js/desktop/package.json b/js/desktop/package.json
index b14bf13..8ef87c0 100644
--- a/js/desktop/package.json
+++ b/js/desktop/package.json
@@ -14,7 +14,11 @@
"publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64",
"invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES",
"lint": "eslint --ext .ts,.tsx .",
- "compile": "npx tsc --noEmit 2>&1 | grep '^src/'"
+ "compile": "npx tsc --noEmit 2>&1 | grep '^src/'",
+ "web:dev": "cross-env APP_ENV=dev vite --config vite.web.config.mts",
+ "web:build": "cross-env APP_ENV=prod vite build --config vite.web.config.mts",
+ "web:build:ci": "vite build --config vite.web.config.mts",
+ "web:preview": "vite preview --config vite.web.config.mts"
},
"keywords": [],
"author": {
@@ -43,6 +47,7 @@
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react": "^5.1.4",
+ "cross-env": "^10.1.0",
"electron": "40.6.0",
"eslint": "^8.57.1",
"eslint-plugin-import": "^2.32.0",
@@ -54,6 +59,7 @@
"@livekit/components-react": "^2.9.20",
"@livekit/components-styles": "^1.2.0",
"@sentry/electron": "^7.11.0",
+ "@sentry/react": "^10.54.0",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/js/desktop/skaffold.yaml b/js/desktop/skaffold.yaml
new file mode 100644
index 0000000..992b41e
--- /dev/null
+++ b/js/desktop/skaffold.yaml
@@ -0,0 +1,38 @@
+apiVersion: skaffold/v4beta11
+kind: Config
+metadata:
+ name: llink-web
+build:
+ local: {}
+ tagPolicy:
+ gitCommit:
+ variant: AbbrevCommitSha
+profiles:
+ - name: dev
+ build:
+ artifacts:
+ - image: llink-web
+ context: .
+ docker:
+ dockerfile: Dockerfile
+ buildArgs:
+ APP_ENV: dev
+ manifests:
+ rawYaml:
+ - k8s/dev/llink-web.yaml
+ deploy:
+ kubectl: {}
+ - name: prod
+ build:
+ artifacts:
+ - image: llink-web
+ context: .
+ docker:
+ dockerfile: Dockerfile
+ buildArgs:
+ APP_ENV: prod
+ manifests:
+ rawYaml:
+ - k8s/prod/llink-web.yaml
+ deploy:
+ kubectl: {}
diff --git a/js/desktop/src/App.tsx b/js/desktop/src/App.tsx
index 7c131b1..aca5249 100644
--- a/js/desktop/src/App.tsx
+++ b/js/desktop/src/App.tsx
@@ -1,5 +1,6 @@
import { useEffect } from "react";
-import { HashRouter, Routes, Route, useNavigate } from "react-router-dom";
+import { Routes, Route, useNavigate } from "react-router-dom";
+import { RouterShell } from "@/lib/router-shell";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
@@ -19,6 +20,8 @@ import {
TopLevelErrorBoundary,
} from "@/components/app-error-boundary";
import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider";
+import { platform } from "@/lib/platform";
+import { InAppAutoplayCard } from "@/components/in-app-autoplay-card";
const queryClient = createQueryClient();
@@ -53,7 +56,7 @@ function AutoplayNavigationListener() {
const navigate = useNavigate();
useEffect(() => {
- return window.electronAutoplay.onNavigate((data) => {
+ return platform.autoplay.onNavigate((data) => {
navigate(`/${data.networkId}/${data.streamId}`);
});
}, [navigate]);
@@ -63,8 +66,9 @@ function AutoplayNavigationListener() {
function AuthenticatedApp() {
return (
-
+
+
} />
@@ -80,7 +84,7 @@ function AuthenticatedApp() {
-
+
);
}
diff --git a/js/desktop/src/api/client.ts b/js/desktop/src/api/client.ts
index 27ef599..08d5835 100644
--- a/js/desktop/src/api/client.ts
+++ b/js/desktop/src/api/client.ts
@@ -27,6 +27,7 @@ import type {
RevokeInvitationRequest,
SignInRequest,
} from "./types";
+import type { LinkMetadata } from "@/lib/link-metadata";
interface ApiClientConfig {
baseUrl: string;
@@ -256,6 +257,16 @@ class ApiClient {
`/networks/${networkId}/usage`,
);
}
+
+ // --- Link metadata ---
+
+ async getLinkMetadata(url: string): Promise {
+ const response = await this.fetch(
+ "GET",
+ `/metadata?url=${encodeURIComponent(url)}`,
+ );
+ return (await response.json()) as LinkMetadata | null;
+ }
}
export const apiClient = new ApiClient({
diff --git a/js/desktop/src/autoplay_window/AutoplayApp.tsx b/js/desktop/src/autoplay_window/AutoplayApp.tsx
index c2326d8..b6240ca 100644
--- a/js/desktop/src/autoplay_window/AutoplayApp.tsx
+++ b/js/desktop/src/autoplay_window/AutoplayApp.tsx
@@ -1,102 +1,43 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { X } from 'lucide-react';
-import type { AutoplayPayload } from '@/lib/autoplay-ipc';
+import { useCallback, useEffect, useState } from "react";
+import type { AutoplayPayload } from "@/lib/autoplay-ipc";
+import { AutoplayCardContent } from "@/components/autoplay-card-content";
export function AutoplayApp() {
const [payload, setPayload] = useState(null);
- const mediaRef = useRef(null);
useEffect(() => {
return window.electronAutoplay.onPlay((p) => setPayload(p));
}, []);
useEffect(() => {
- return window.electronAutoplay.onStop(() => {
- mediaRef.current?.pause();
- setPayload(null);
- });
+ return window.electronAutoplay.onStop(() => setPayload(null));
}, []);
- const stop = useCallback(() => {
- mediaRef.current?.pause();
+ const handleDismiss = useCallback(() => {
setPayload(null);
window.electronAutoplay.dismiss();
}, []);
+ const handleNavigate = useCallback(() => {
+ if (!payload) return;
+ window.electronAutoplay.navigate({
+ networkId: payload.networkId,
+ streamId: payload.streamId,
+ });
+ }, [payload]);
+
if (!payload) {
return ;
}
- const isVideo = payload.mimeType.startsWith('video/');
-
- const handleClick = () => {
- mediaRef.current?.pause();
- window.electronAutoplay.navigate({
- networkId: payload.networkId,
- streamId: payload.streamId,
- });
- };
-
- const handleClose = (e: React.MouseEvent) => {
- e.stopPropagation();
- stop();
- };
-
return (
-
- {isVideo ? (
- <>
-