|
109 | 109 | : 0 |
110 | 110 | ); |
111 | 111 |
|
| 112 | + const isAnyStreaming = $derived(receiveState.type === 'streaming'); |
| 113 | + const isAnyProcessing = $derived(receiveState.type === 'processing'); |
| 114 | + const currentTransferKey = $derived( |
| 115 | + receiveState.type === 'streaming' || receiveState.type === 'processing' |
| 116 | + ? receiveState.key |
| 117 | + : null |
| 118 | + ); |
| 119 | +
|
112 | 120 | async function loadRoom() { |
113 | 121 | loadStatus = 'loading'; |
114 | 122 | try { |
|
121 | 129 |
|
122 | 130 | const data: RoomOut = await res.json(); |
123 | 131 | room = data; |
124 | | - roomFiles = [...data.files]; |
| 132 | + roomFiles = structuredClone(data.files); |
125 | 133 | hostCount = data.host_count ?? 1; |
126 | 134 | loadStatus = 'loaded'; |
127 | 135 | connectWebSocket(); |
|
167 | 175 | case 'snapshot': { |
168 | 176 | const r = msg.room as RoomOut; |
169 | 177 | room = r; |
170 | | - roomFiles = [...r.files]; |
| 178 | + roomFiles = structuredClone(r.files); |
171 | 179 | hostCount = r.host_count ?? 1; |
172 | 180 | remoteUploads = |
173 | 181 | r.active_uploads?.map((u) => ({ |
|
232 | 240 | } |
233 | 241 | case 'file_start': |
234 | 242 | if (receiveState.type === 'idle') { |
| 243 | + // Skip if already in memory |
| 244 | + if (downloadedFiles.some((d) => d.key === msg.key)) { |
| 245 | + return; |
| 246 | + } |
235 | 247 | // server started streaming without an explicit client request |
236 | 248 | receiveState = { |
237 | 249 | type: 'streaming', |
|
250 | 262 | case 'file_end': |
251 | 263 | if (receiveState.type === 'streaming' && receiveState.key === msg.key) { |
252 | 264 | const { key, filename, size, chunks } = receiveState; |
| 265 | + // Guard against concurrent file_end processing (e.g. multiple hosts) |
| 266 | + receiveState = { type: 'processing', key, filename, size }; |
| 267 | +
|
253 | 268 | console.debug( |
254 | 269 | '[reverse/host] file_end for', |
255 | 270 | key, |
|
399 | 414 | if (!roomFiles.some((f) => f.key === fileEntry.key)) { |
400 | 415 | roomFiles = [...roomFiles, fileEntry]; |
401 | 416 | } |
| 417 | +
|
| 418 | + // Add to downloadedFiles so it shows as "Saved" instead of "Download" |
| 419 | + const objectUrl = URL.createObjectURL(entry.file); |
| 420 | + downloadedFiles = [ |
| 421 | + ...downloadedFiles, |
| 422 | + { |
| 423 | + key: fileEntry.key, |
| 424 | + filename: entry.file.name, |
| 425 | + size: entry.file.size, |
| 426 | + objectUrl |
| 427 | + } |
| 428 | + ]; |
402 | 429 | } catch (e: any) { |
403 | 430 | entry.status = 'error'; |
404 | 431 | toast.error(`Upload failed for ${entry.file.name}: ${e.message || String(e)}`); |
|
711 | 738 | <span class="shrink-0 text-xs text-muted-foreground" |
712 | 739 | >{formatFileSize(file.size)}</span |
713 | 740 | > |
714 | | - <button |
715 | | - class="shrink-0 text-muted-foreground hover:text-destructive" |
| 741 | + <Button |
| 742 | + variant="ghost" |
| 743 | + size="icon" |
| 744 | + class="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive" |
716 | 745 | onclick={() => removePendingFile(i)} |
717 | 746 | aria-label="Remove" |
718 | 747 | > |
719 | 748 | <X class="h-4 w-4" /> |
720 | | - </button> |
| 749 | + </Button> |
721 | 750 | </div> |
722 | 751 | {/each} |
723 | 752 | </div> |
|
827 | 856 |
|
828 | 857 | {#each roomFiles as f} |
829 | 858 | {@const downloaded = downloadedFiles.find((d) => d.key === f.key)} |
830 | | - {@const isStreaming = |
831 | | - receiveState.type === 'streaming' && receiveState.key === f.key} |
| 859 | + {@const isThisStreaming = receiveState.type === 'streaming' && receiveState.key === f.key} |
| 860 | + {@const isThisProcessing = receiveState.type === 'processing' && receiveState.key === f.key} |
| 861 | + {@const isAnyActive = isAnyStreaming || isAnyProcessing} |
832 | 862 | {@const displayName = getDisplayFilename(f.filename)} |
833 | 863 | <div class="rounded-md border px-3 py-2"> |
834 | 864 | <div class="flex items-center gap-3"> |
|
850 | 880 | <p class="text-xs text-muted-foreground">{formatFileSize(f.size)}</p> |
851 | 881 | </div> |
852 | 882 |
|
853 | | - {#if isStreaming} |
| 883 | + {#if isThisStreaming || isThisProcessing} |
854 | 884 | <div class="flex items-center gap-2 text-xs text-muted-foreground"> |
855 | | - {#if isDecrypting} |
| 885 | + {#if isDecrypting || isThisProcessing} |
856 | 886 | <span class="animate-pulse">Decrypting…</span> |
857 | 887 | <span class="font-mono">{decryptionProgress.current.toFixed(0)}%</span> |
858 | 888 | {:else} |
|
882 | 912 | variant="default" |
883 | 913 | class="h-7 shrink-0 gap-1 px-2 text-xs" |
884 | 914 | onclick={() => downloadFile(f)} |
885 | | - disabled={receiveState.type === 'streaming' && receiveState.key !== f.key} |
| 915 | + disabled={isAnyActive && currentTransferKey !== f.key} |
886 | 916 | > |
887 | 917 | <Download class="h-3.5 w-3.5" /> |
888 | 918 | Save |
|
893 | 923 | variant="outline" |
894 | 924 | class="h-7 shrink-0 gap-1 px-2 text-xs" |
895 | 925 | onclick={() => downloadFile(f)} |
896 | | - disabled={receiveState.type === 'streaming' && receiveState.key !== f.key} |
| 926 | + disabled={isAnyActive && currentTransferKey !== f.key} |
897 | 927 | > |
898 | 928 | <Download class="h-3.5 w-3.5" /> |
899 | 929 | Download |
|
909 | 939 | </a> |
910 | 940 | </div> |
911 | 941 | </div> |
912 | | - {#if isStreaming} |
913 | | - {#if isDecrypting} |
| 942 | + {#if isThisStreaming || isThisProcessing} |
| 943 | + {#if isDecrypting || isThisProcessing} |
914 | 944 | <Progress value={decryptionProgress.current} max={100} class="mt-2 h-1" /> |
915 | 945 | {:else} |
916 | 946 | <Progress value={streamProgress} max={100} class="mt-2 h-1" /> |
|
0 commit comments