A stream.place VOD client inspired by icarly.com
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix CORS errors, add iSTREAM page, fix header layout

- Add CORS error handling with better error messages
- Create new iSTREAM page for live streams from stream.place
- Fix header layout to prevent button overlap with character image
- Add padding to header to accommodate character image
- Update grid layout with dedicated column spacing

jack 349f17f7 4c2f15fc

+351 -6
+5 -4
src/app/icarly.css
··· 70 70 min-height: 220px; 71 71 box-shadow: 0 0 20px rgba(0,0,0,0.3); 72 72 display: grid; 73 - grid-template-columns: 280px 1fr 280px; 73 + grid-template-columns: 280px 1fr 200px 160px; 74 74 grid-template-rows: auto auto 1fr; 75 75 grid-template-areas: 76 - "homepage homepage homepage" 77 - "logo search login" 78 - "date search info"; 76 + "homepage homepage homepage homepage" 77 + "logo search login ." 78 + "date search info ."; 79 79 gap: 10px 20px; 80 + padding-right: 200px; /* Make room for character image */ 80 81 } 81 82 82 83 /* Homepage button */
+6
src/app/stream/page.tsx
··· 1 + import HomeClient from '@/components/HomeClient'; 2 + import '../icarly.css'; 3 + 4 + export default function StreamPage() { 5 + return <HomeClient initialTab="istream" />; 6 + }
+19 -2
src/components/HomeClient.tsx
··· 11 11 import ISongs from '@/components/ISongs'; 12 12 import INeedHelp from '@/components/INeedHelp'; 13 13 import Settings from '@/components/Settings'; 14 + import IStream from '@/components/IStream'; 14 15 import { VideoRecord } from '@/lib/types'; 15 16 import '../app/icarly.css'; 16 17 ··· 37 38 setLoading(true); 38 39 39 40 // Use the stream.place VOD API directly 40 - const videosRes = await fetch('https://vod-beta.stream.place/xrpc/com.stream.place.vod.getVideos'); 41 + const videosRes = await fetch('https://vod-beta.stream.place/xrpc/com.stream.place.vod.getVideos', { 42 + method: 'GET', 43 + headers: { 44 + 'Accept': 'application/json', 45 + }, 46 + }); 47 + 41 48 if (!videosRes.ok) { 42 49 throw new Error(`Failed to fetch videos: ${videosRes.status}`); 43 50 } ··· 68 75 setVideos(videosWithHandles); 69 76 } catch (e) { 70 77 console.error('Failed to load videos:', e); 71 - setError(e instanceof Error ? e.message : 'Failed to load videos'); 78 + // Check if it's a CORS error 79 + if (e instanceof TypeError && e.message.includes('Failed to fetch')) { 80 + setError('CORS Error: The video API is not accessible. This is a server-side issue with stream.place. Please try again later or contact support.'); 81 + } else { 82 + setError(e instanceof Error ? e.message : 'Failed to load videos'); 83 + } 72 84 } finally { 73 85 setLoading(false); 74 86 } ··· 84 96 { id: 'isnaps', label: 'iSnaps', icon: '📷', href: '/snaps' }, 85 97 { id: 'inews', label: 'iNews', icon: '📢', href: '/news' }, 86 98 { id: 'ivideo', label: 'iVideo', icon: '📹', href: '/' }, 99 + { id: 'istream', label: 'iStream', icon: '🔴', href: '/stream' }, 87 100 { id: 'iplay', label: 'iPlay', icon: '🎮', href: '/play' }, 88 101 { id: 'isongs', label: 'iSongs', icon: '🎵', href: '/songs' }, 89 102 { id: 'ihelp', label: 'iNeed Help', icon: '❓', href: '/help' }, ··· 319 332 320 333 <div style={{ display: activeTab === 'isongs' ? 'block' : 'none' }}> 321 334 <ISongs /> 335 + </div> 336 + 337 + <div style={{ display: activeTab === 'istream' ? 'block' : 'none' }}> 338 + <IStream /> 322 339 </div> 323 340 324 341 <div style={{ display: activeTab === 'ihelp' ? 'block' : 'none' }}>
+321
src/components/IStream.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useEffect } from 'react'; 4 + import Image from 'next/image'; 5 + 6 + interface LiveUser { 7 + did: string; 8 + handle: string; 9 + displayName?: string; 10 + avatar?: string; 11 + description?: string; 12 + } 13 + 14 + interface LiveStream { 15 + uri: string; 16 + cid: string; 17 + author: LiveUser; 18 + record: { 19 + title?: string; 20 + createdAt?: string; 21 + canonicalUrl?: string; 22 + thumb?: { 23 + ref: string; 24 + mimeType: string; 25 + size: number; 26 + }; 27 + }; 28 + viewerCount?: number; 29 + } 30 + 31 + export default function IStream() { 32 + const [liveStreams, setLiveStreams] = useState<LiveStream[]>([]); 33 + const [loading, setLoading] = useState(true); 34 + const [error, setError] = useState<string | null>(null); 35 + 36 + useEffect(() => { 37 + const fetchLiveStreams = async () => { 38 + try { 39 + setLoading(true); 40 + 41 + // Fetch live streams from stream.place 42 + const response = await fetch('https://stream.place/xrpc/place.stream.live.getLiveUsers'); 43 + 44 + if (!response.ok) { 45 + throw new Error(`Failed to fetch live streams: ${response.status}`); 46 + } 47 + 48 + const data = await response.json(); 49 + 50 + // Transform the data into our format 51 + const streams: LiveStream[] = data.users?.map((user: LiveUser) => ({ 52 + uri: `at://${user.did}/place.stream.livestream/self`, 53 + cid: '', 54 + author: user, 55 + record: { 56 + title: `${user.displayName || user.handle} is live!`, 57 + createdAt: new Date().toISOString(), 58 + canonicalUrl: `https://stream.place/stream/${user.handle}`, 59 + }, 60 + })) || []; 61 + 62 + setLiveStreams(streams); 63 + } catch (e) { 64 + console.error('Failed to load live streams:', e); 65 + if (e instanceof TypeError && e.message.includes('Failed to fetch')) { 66 + setError('CORS Error: Unable to fetch live streams. The stream.place API may have restrictions.'); 67 + } else { 68 + setError(e instanceof Error ? e.message : 'Failed to load live streams'); 69 + } 70 + } finally { 71 + setLoading(false); 72 + } 73 + }; 74 + 75 + fetchLiveStreams(); 76 + 77 + // Refresh every 30 seconds 78 + const interval = setInterval(fetchLiveStreams, 30000); 79 + return () => clearInterval(interval); 80 + }, []); 81 + 82 + if (loading) { 83 + return ( 84 + <div style={{ textAlign: 'center', padding: '60px 20px' }}> 85 + <div className="loading-spinner"></div> 86 + <p style={{ color: '#62166F', fontSize: '20px', marginTop: '20px', fontWeight: 'bold' }}> 87 + 🔴 Checking for live streams... 88 + </p> 89 + </div> 90 + ); 91 + } 92 + 93 + if (error) { 94 + return ( 95 + <div style={{ 96 + background: '#FFE6E6', 97 + border: '4px solid #C71585', 98 + padding: '40px', 99 + textAlign: 'center', 100 + margin: '20px' 101 + }}> 102 + <h3 style={{ color: '#C71585', fontSize: '28px', marginBottom: '10px' }}>⚠️ Oops!</h3> 103 + <p style={{ color: '#62166F', fontSize: '18px' }}>{error}</p> 104 + <p style={{ color: '#666', fontSize: '14px', marginTop: '15px' }}> 105 + Try visiting <a href="https://stream.place" target="_blank" rel="noopener noreferrer" style={{ color: '#E91E8C', textDecoration: 'underline' }}>stream.place</a> directly! 106 + </p> 107 + </div> 108 + ); 109 + } 110 + 111 + if (liveStreams.length === 0) { 112 + return ( 113 + <div style={{ textAlign: 'center', padding: '60px 20px' }}> 114 + <div style={{ fontSize: '60px', marginBottom: '20px' }}>😴</div> 115 + <h3 style={{ color: '#62166F', fontSize: '28px', marginBottom: '10px' }}> 116 + No one is streaming right now 117 + </h3> 118 + <p style={{ color: '#666', fontSize: '18px' }}> 119 + Check back later or visit <a href="https://stream.place" target="_blank" rel="noopener noreferrer" style={{ color: '#E91E8C', textDecoration: 'underline' }}>stream.place</a> to see who's live! 120 + </p> 121 + </div> 122 + ); 123 + } 124 + 125 + return ( 126 + <div style={{ position: 'relative', zIndex: 1 }}> 127 + {/* Header */} 128 + <div style={{ 129 + background: 'linear-gradient(135deg, #FF4444, #C71585)', 130 + border: '4px solid #fff', 131 + boxShadow: '6px 6px 0 rgba(0,0,0,0.3)', 132 + padding: '25px', 133 + marginBottom: '30px', 134 + textAlign: 'center', 135 + }}> 136 + <h2 style={{ 137 + color: '#fff', 138 + fontSize: '32px', 139 + marginBottom: '10px', 140 + textShadow: '2px 2px 0 rgba(0,0,0,0.3)' 141 + }}> 142 + 🔴 LIVE NOW 143 + </h2> 144 + <p style={{ color: '#fff', fontSize: '18px' }}> 145 + {liveStreams.length} streamer{liveStreams.length !== 1 ? 's' : ''} currently live! 146 + </p> 147 + </div> 148 + 149 + {/* Live Streams Grid */} 150 + <div style={{ 151 + display: 'grid', 152 + gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', 153 + gap: '25px', 154 + }}> 155 + {liveStreams.map((stream) => ( 156 + <a 157 + key={stream.uri} 158 + href={stream.record.canonicalUrl || `https://stream.place/stream/${stream.author.handle}`} 159 + target="_blank" 160 + rel="noopener noreferrer" 161 + style={{ 162 + background: '#fff', 163 + border: '4px solid #62166F', 164 + boxShadow: '6px 6px 0 rgba(0,0,0,0.2)', 165 + overflow: 'hidden', 166 + display: 'block', 167 + textDecoration: 'none', 168 + color: 'inherit', 169 + transition: 'all 0.2s', 170 + }} 171 + onMouseEnter={(e) => { 172 + e.currentTarget.style.transform = 'translate(-3px, -3px)'; 173 + e.currentTarget.style.boxShadow = '9px 9px 0 rgba(0,0,0,0.3)'; 174 + }} 175 + onMouseLeave={(e) => { 176 + e.currentTarget.style.transform = 'translate(0, 0)'; 177 + e.currentTarget.style.boxShadow = '6px 6px 0 rgba(0,0,0,0.2)'; 178 + }} 179 + > 180 + {/* Thumbnail / Avatar */} 181 + <div style={{ 182 + position: 'relative', 183 + paddingTop: '56.25%', 184 + background: 'linear-gradient(135deg, #FF4444, #C71585)', 185 + borderBottom: '4px solid #62166F', 186 + }}> 187 + {stream.author.avatar ? ( 188 + <Image 189 + src={stream.author.avatar} 190 + alt={stream.author.displayName || stream.author.handle} 191 + fill 192 + style={{ objectFit: 'cover' }} 193 + /> 194 + ) : ( 195 + <div style={{ 196 + position: 'absolute', 197 + top: 0, 198 + left: 0, 199 + right: 0, 200 + bottom: 0, 201 + display: 'flex', 202 + alignItems: 'center', 203 + justifyContent: 'center', 204 + fontSize: '60px', 205 + }}> 206 + 📺 207 + </div> 208 + )} 209 + 210 + {/* Live Badge */} 211 + <div style={{ 212 + position: 'absolute', 213 + top: '10px', 214 + left: '10px', 215 + background: '#FF0000', 216 + color: '#fff', 217 + padding: '5px 15px', 218 + fontWeight: 'bold', 219 + fontSize: '14px', 220 + border: '2px solid #fff', 221 + boxShadow: '2px 2px 0 rgba(0,0,0,0.3)', 222 + display: 'flex', 223 + alignItems: 'center', 224 + gap: '5px', 225 + }}> 226 + <span style={{ 227 + width: '10px', 228 + height: '10px', 229 + background: '#fff', 230 + borderRadius: '50%', 231 + animation: 'pulse 1s infinite', 232 + }}></span> 233 + LIVE 234 + </div> 235 + 236 + {/* Viewer Count */} 237 + {stream.viewerCount !== undefined && ( 238 + <div style={{ 239 + position: 'absolute', 240 + bottom: '10px', 241 + right: '10px', 242 + background: 'rgba(0,0,0,0.7)', 243 + color: '#fff', 244 + padding: '5px 10px', 245 + fontSize: '14px', 246 + borderRadius: '4px', 247 + }}> 248 + 👥 {stream.viewerCount} 249 + </div> 250 + )} 251 + </div> 252 + 253 + {/* Stream Info */} 254 + <div style={{ padding: '20px' }}> 255 + <h3 style={{ 256 + fontSize: '20px', 257 + fontWeight: 'bold', 258 + color: '#333', 259 + marginBottom: '10px', 260 + lineHeight: 1.3, 261 + }}> 262 + {stream.record.title} 263 + </h3> 264 + 265 + <div style={{ 266 + display: 'flex', 267 + alignItems: 'center', 268 + gap: '10px', 269 + color: '#62166F', 270 + fontSize: '16px', 271 + }}> 272 + <span style={{ fontWeight: 'bold' }}> 273 + @{stream.author.handle} 274 + </span> 275 + </div> 276 + 277 + {stream.author.description && ( 278 + <p style={{ 279 + color: '#666', 280 + fontSize: '14px', 281 + marginTop: '10px', 282 + lineHeight: 1.4, 283 + }}> 284 + {stream.author.description.slice(0, 100)} 285 + {stream.author.description.length > 100 ? '...' : ''} 286 + </p> 287 + )} 288 + 289 + <div style={{ 290 + marginTop: '15px', 291 + padding: '10px', 292 + background: '#A6CC3A', 293 + color: '#fff', 294 + textAlign: 'center', 295 + fontWeight: 'bold', 296 + border: '2px solid #62166F', 297 + }}> 298 + Click to Watch! ▶️ 299 + </div> 300 + </div> 301 + </a> 302 + ))} 303 + </div> 304 + 305 + {/* Footer note */} 306 + <div style={{ 307 + textAlign: 'center', 308 + marginTop: '40px', 309 + padding: '20px', 310 + background: '#62166F', 311 + border: '3px solid #fff', 312 + boxShadow: '4px 4px 0 rgba(0,0,0,0.3)', 313 + color: '#fff', 314 + }}> 315 + <p style={{ fontSize: '16px' }}> 316 + 🎥 Want to stream? Visit <a href="https://stream.place" target="_blank" rel="noopener noreferrer" style={{ color: '#FFD700', textDecoration: 'underline' }}>stream.place</a> to start your own livestream! 317 + </p> 318 + </div> 319 + </div> 320 + ); 321 + }