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

Configure Feed

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

Initial iStream release with full iCarly-style design, plyr.fm integration, and all features

jack 208ca953 1bf0e7a3

+3591 -313
+13 -20
.gitignore
··· 1 - # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 - 3 - # dependencies 4 - /node_modules 1 + # Dependencies 2 + node_modules 5 3 /.pnp 6 - .pnp.* 7 - .yarn/* 8 - !.yarn/patches 9 - !.yarn/plugins 10 - !.yarn/releases 11 - !.yarn/versions 4 + .pnp.js 12 5 13 - # testing 6 + # Testing 14 7 /coverage 15 8 16 - # next.js 9 + # Next.js 17 10 /.next/ 18 11 /out/ 19 12 20 - # production 13 + # Production 21 14 /build 22 15 23 - # misc 16 + # Misc 24 17 .DS_Store 25 18 *.pem 26 19 27 - # debug 20 + # Debug 28 21 npm-debug.log* 29 22 yarn-debug.log* 30 23 yarn-error.log* 31 - .pnpm-debug.log* 32 24 33 - # env files (can opt-in for committing if needed) 34 - .env* 25 + # Local env files 26 + .env*.local 27 + .env 35 28 36 - # vercel 29 + # Vercel 37 30 .vercel 38 31 39 - # typescript 32 + # TypeScript 40 33 *.tsbuildinfo 41 34 next-env.d.ts
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 j4ckxyz 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+31 -23
README.md
··· 1 - This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 1 + # iStream 2 2 3 - ## Getting Started 3 + A fun, iCarly-inspired video-on-demand viewer for stream.place built with Next.js and TypeScript. 4 4 5 - First, run the development server: 5 + ![iStream Preview](/public/istream-og-image-card.png) 6 6 7 - ```bash 8 - npm run dev 9 - # or 10 - yarn dev 11 - # or 12 - pnpm dev 13 - # or 14 - bun dev 15 - ``` 7 + ## Features 16 8 17 - Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 9 + - 📹 **iVideo** - Browse and watch videos from stream.place 10 + - 💬 **iBlogs** - Read Bluesky posts from streamers 11 + - 📷 **iSnaps** - View photos from Bluesky 12 + - 📢 **iNews** - Fun headlines about AtmosphereConf 13 + - 🎮 **iPlay** - Play random videos 14 + - 🎵 **iSongs** - Music powered by plyr.fm 15 + - ❓ **iNeed Help** - Site map and FAQ 18 16 19 - You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 17 + ## Tech Stack 20 18 21 - This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 19 + - Next.js 16 20 + - TypeScript 21 + - HLS.js for video playback 22 + - Bluesky API integration 23 + - plyr.fm API for music 22 24 23 - ## Learn More 25 + ## Development 26 + 27 + ```bash 28 + npm install 29 + npm run dev 30 + ``` 31 + 32 + Open [http://localhost:3000](http://localhost:3000) to view the site. 24 33 25 - To learn more about Next.js, take a look at the following resources: 34 + ## Deployment 26 35 27 - - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 - - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 36 + This project is deployed on Cloudflare Pages. 29 37 30 - You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 38 + ## License 31 39 32 - ## Deploy on Vercel 40 + MIT License - See LICENSE file for details 33 41 34 - The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 42 + ## Credits 35 43 36 - Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 44 + Inspired by iCarly and built for the stream.place community!
+54
__tests__/playback.test.ts
··· 1 + import { describe, it, expect } from '@jest/globals'; 2 + 3 + const VOD_BETA_BASE = 'https://vod-beta.stream.place/xrpc'; 4 + const TEST_VIDEO_URI = 'at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miah6keewv2n'; 5 + 6 + describe('Video Playback API', () => { 7 + it('should fetch video playlist', async () => { 8 + const url = `${VOD_BETA_BASE}/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(TEST_VIDEO_URI)}`; 9 + const response = await fetch(url); 10 + 11 + expect(response.ok).toBe(true); 12 + 13 + const playlist = await response.text(); 14 + expect(playlist).toContain('#EXTM3U'); 15 + expect(playlist).toContain('#EXT-X-STREAM-INF'); 16 + expect(playlist).toContain('place.stream.playback.getVideoPlaylist'); 17 + }); 18 + 19 + it('should fetch video track playlist', async () => { 20 + const trackUrl = `${VOD_BETA_BASE}/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(TEST_VIDEO_URI)}&track=1`; 21 + const response = await fetch(trackUrl); 22 + 23 + expect(response.ok).toBe(true); 24 + 25 + const playlist = await response.text(); 26 + expect(playlist).toContain('#EXTM3U'); 27 + expect(playlist).toContain('#EXT-X-PLAYLIST-TYPE:VOD'); 28 + expect(playlist).toContain('#EXTINF'); 29 + expect(playlist).toContain('place.stream.playback.getVideoBlob'); 30 + }); 31 + 32 + it('should fetch video blob', async () => { 33 + const blobUrl = `${VOD_BETA_BASE}/place.stream.playback.getVideoBlob?uri=${encodeURIComponent(TEST_VIDEO_URI)}&cid=bafkr4ifnmhcszlq7bjshfcmodai56wfbbfobqkyrbdkfekrjkc64tmx3fm.mp4`; 34 + const response = await fetch(blobUrl, { method: 'HEAD' }); 35 + 36 + expect(response.ok).toBe(true); 37 + expect(response.headers.get('content-type')).toContain('video/mp4'); 38 + }); 39 + 40 + it('should have proper CORS headers', async () => { 41 + const url = `${VOD_BETA_BASE}/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(TEST_VIDEO_URI)}`; 42 + const response = await fetch(url); 43 + 44 + expect(response.headers.get('access-control-allow-origin')).toBe('*'); 45 + }); 46 + }); 47 + 48 + describe('Playlist URL Resolution', () => { 49 + it('should construct correct playlist URL', () => { 50 + const videoUri = 'at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miah6keewv2n'; 51 + const expectedUrl = `${VOD_BETA_BASE}/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(videoUri)}`; 52 + expect(expectedUrl).toBe('https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist?uri=at%3A%2F%2Fdid%3Aplc%3Arbvrr34edl5ddpuwcubjiost%2Fplace.stream.video%2F3miah6keewv2n'); 53 + }); 54 + });
iStream-upper-right-image.png

This is a binary file and will not be displayed.

iStream_Logo.png

This is a binary file and will not be displayed.

istream-og-image-card.png

This is a binary file and will not be displayed.

+129
package-lock.json
··· 8 8 "name": "streamplacevod", 9 9 "version": "0.1.0", 10 10 "dependencies": { 11 + "@atproto/lexicon": "^0.6.2", 12 + "@atproto/xrpc": "^0.7.7", 13 + "hls.js": "^1.6.15", 11 14 "next": "16.2.1", 12 15 "react": "19.2.4", 13 16 "react-dom": "19.2.4" ··· 19 22 "eslint": "^9", 20 23 "eslint-config-next": "16.2.1", 21 24 "typescript": "^5" 25 + } 26 + }, 27 + "node_modules/@atproto/common-web": { 28 + "version": "0.4.19", 29 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.19.tgz", 30 + "integrity": "sha512-3BTi58p5WpT+9/zb6UZrdsXcfPo5P45UJm0E4iwHLILr+jc37CuBj9JReDSZ4U0i9RTrI3ZkfySyZ9bd+LnMsw==", 31 + "license": "MIT", 32 + "dependencies": { 33 + "@atproto/lex-data": "^0.0.14", 34 + "@atproto/lex-json": "^0.0.14", 35 + "@atproto/syntax": "^0.5.1", 36 + "zod": "^3.23.8" 37 + } 38 + }, 39 + "node_modules/@atproto/common-web/node_modules/zod": { 40 + "version": "3.25.76", 41 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 42 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 43 + "license": "MIT", 44 + "funding": { 45 + "url": "https://github.com/sponsors/colinhacks" 46 + } 47 + }, 48 + "node_modules/@atproto/lex-data": { 49 + "version": "0.0.14", 50 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.14.tgz", 51 + "integrity": "sha512-53DUa9664SS76nGAMYopWsO10OH0AAdf7P/HSKB6Wzx3iqe6lk/K61QZnKxOG1LreYl5CfvIJU6eNf4txI6GlQ==", 52 + "license": "MIT", 53 + "dependencies": { 54 + "multiformats": "^9.9.0", 55 + "tslib": "^2.8.1", 56 + "uint8arrays": "3.0.0", 57 + "unicode-segmenter": "^0.14.0" 58 + } 59 + }, 60 + "node_modules/@atproto/lex-json": { 61 + "version": "0.0.14", 62 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.14.tgz", 63 + "integrity": "sha512-6lPkDKqe7teEu4WrN5q7400cvZKgYS3uwUMvzG3F9XkgVYhOwSDCtouV/nSLBbpvo3l9OP0kiigtclcNcyekww==", 64 + "license": "MIT", 65 + "dependencies": { 66 + "@atproto/lex-data": "^0.0.14", 67 + "tslib": "^2.8.1" 68 + } 69 + }, 70 + "node_modules/@atproto/lexicon": { 71 + "version": "0.6.2", 72 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.2.tgz", 73 + "integrity": "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==", 74 + "license": "MIT", 75 + "dependencies": { 76 + "@atproto/common-web": "^0.4.18", 77 + "@atproto/syntax": "^0.5.0", 78 + "iso-datestring-validator": "^2.2.2", 79 + "multiformats": "^9.9.0", 80 + "zod": "^3.23.8" 81 + } 82 + }, 83 + "node_modules/@atproto/lexicon/node_modules/zod": { 84 + "version": "3.25.76", 85 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 86 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 87 + "license": "MIT", 88 + "funding": { 89 + "url": "https://github.com/sponsors/colinhacks" 90 + } 91 + }, 92 + "node_modules/@atproto/syntax": { 93 + "version": "0.5.2", 94 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.2.tgz", 95 + "integrity": "sha512-W41szOnkppoHr0iCUrzL8gy3OD6qmDyp1UvUgmTx2oFQfgbudpz51T/gznesiCcqiUT5obfHdx4PJ+WdlEOE7Q==", 96 + "license": "MIT", 97 + "dependencies": { 98 + "tslib": "^2.8.1" 99 + } 100 + }, 101 + "node_modules/@atproto/xrpc": { 102 + "version": "0.7.7", 103 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz", 104 + "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", 105 + "license": "MIT", 106 + "dependencies": { 107 + "@atproto/lexicon": "^0.6.0", 108 + "zod": "^3.23.8" 109 + } 110 + }, 111 + "node_modules/@atproto/xrpc/node_modules/zod": { 112 + "version": "3.25.76", 113 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 114 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 115 + "license": "MIT", 116 + "funding": { 117 + "url": "https://github.com/sponsors/colinhacks" 22 118 } 23 119 }, 24 120 "node_modules/@babel/code-frame": { ··· 3703 3799 "hermes-estree": "0.25.1" 3704 3800 } 3705 3801 }, 3802 + "node_modules/hls.js": { 3803 + "version": "1.6.15", 3804 + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", 3805 + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", 3806 + "license": "Apache-2.0" 3807 + }, 3706 3808 "node_modules/ignore": { 3707 3809 "version": "5.3.2", 3708 3810 "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", ··· 4184 4286 "dev": true, 4185 4287 "license": "ISC" 4186 4288 }, 4289 + "node_modules/iso-datestring-validator": { 4290 + "version": "2.2.2", 4291 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 4292 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 4293 + "license": "MIT" 4294 + }, 4187 4295 "node_modules/iterator.prototype": { 4188 4296 "version": "1.1.5", 4189 4297 "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", ··· 4438 4546 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 4439 4547 "dev": true, 4440 4548 "license": "MIT" 4549 + }, 4550 + "node_modules/multiformats": { 4551 + "version": "9.9.0", 4552 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 4553 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 4554 + "license": "(Apache-2.0 AND MIT)" 4441 4555 }, 4442 4556 "node_modules/nanoid": { 4443 4557 "version": "3.3.11", ··· 5775 5889 "typescript": ">=4.8.4 <6.1.0" 5776 5890 } 5777 5891 }, 5892 + "node_modules/uint8arrays": { 5893 + "version": "3.0.0", 5894 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 5895 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 5896 + "license": "MIT", 5897 + "dependencies": { 5898 + "multiformats": "^9.4.2" 5899 + } 5900 + }, 5778 5901 "node_modules/unbox-primitive": { 5779 5902 "version": "1.1.0", 5780 5903 "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", ··· 5799 5922 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 5800 5923 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 5801 5924 "dev": true, 5925 + "license": "MIT" 5926 + }, 5927 + "node_modules/unicode-segmenter": { 5928 + "version": "0.14.5", 5929 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 5930 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 5802 5931 "license": "MIT" 5803 5932 }, 5804 5933 "node_modules/unrs-resolver": {
+6 -1
package.json
··· 6 6 "dev": "next dev", 7 7 "build": "next build", 8 8 "start": "next start", 9 - "lint": "eslint" 9 + "lint": "eslint", 10 + "test:api": "npx tsx scripts/test-vod-chain.ts", 11 + "test:cli": "./scripts/test-playback-cli.sh" 10 12 }, 11 13 "dependencies": { 14 + "@atproto/lexicon": "^0.6.2", 15 + "@atproto/xrpc": "^0.7.7", 16 + "hls.js": "^1.6.15", 12 17 "next": "16.2.1", 13 18 "react": "19.2.4", 14 19 "react-dom": "19.2.4"
public/character-image.png

This is a binary file and will not be displayed.

public/iStream_Logo.png

This is a binary file and will not be displayed.

public/istream-og-image-card.png

This is a binary file and will not be displayed.

+32
scripts/check-thumbnails.sh
··· 1 + #!/bin/bash 2 + 3 + # Check for thumbnail support in stream.place VOD API 4 + 5 + BASE_URL="https://vod-beta.stream.place/xrpc" 6 + VIDEO_URI="at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miah6keewv2n" 7 + 8 + echo "Testing thumbnail-related endpoints..." 9 + echo "" 10 + 11 + ENDPOINTS=( 12 + "place.stream.playback.getVideoThumbnail" 13 + "place.stream.playback.getThumbnail" 14 + "place.stream.playback.getVideoDetails" 15 + "place.stream.playback.getDetails" 16 + "place.stream.playback.getInfo" 17 + ) 18 + 19 + for endpoint in "${ENDPOINTS[@]}"; do 20 + echo -n "Testing $endpoint: " 21 + RESPONSE=$(curl -s -w "%{http_code}" -o /dev/null "${BASE_URL}/${endpoint}?uri=$(echo "$VIDEO_URI" | jq -sRr @uri)") 22 + if [ "$RESPONSE" = "200" ]; then 23 + echo "✓ Found!" 24 + curl -s "${BASE_URL}/${endpoint}?uri=$(echo "$VIDEO_URI" | jq -sRr @uri)" | head -5 25 + else 26 + echo "✗ Not found ($RESPONSE)" 27 + fi 28 + done 29 + 30 + echo "" 31 + echo "Checking video record for thumbnail fields..." 32 + curl -s "https://iameli.com/xrpc/com.atproto.repo.listRecords?repo=did:plc:rbvrr34edl5ddpuwcubjiost&collection=place.stream.video&limit=1" | jq '.records[0].value | keys'
+95
scripts/test-playback-cli.sh
··· 1 + #!/bin/bash 2 + 3 + # Test script for stream.place VOD playback 4 + # Usage: ./scripts/test-playback-cli.sh 5 + 6 + set -e 7 + 8 + BASE_URL="https://vod-beta.stream.place/xrpc" 9 + VIDEO_URI="at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miah6keewv2n" 10 + 11 + echo "==========================================" 12 + echo "stream.place VOD Playback Test" 13 + echo "==========================================" 14 + echo "" 15 + 16 + echo "Testing: Video Playlist (Master)" 17 + echo "URL: ${BASE_URL}/place.stream.playback.getVideoPlaylist?uri=..." 18 + echo "" 19 + 20 + PLAYLIST=$(curl -s "${BASE_URL}/place.stream.playback.getVideoPlaylist?uri=$(echo "$VIDEO_URI" | jq -sRr @uri)") 21 + 22 + if echo "$PLAYLIST" | grep -q "#EXTM3U"; then 23 + echo "✓ Valid HLS playlist" 24 + else 25 + echo "✗ Invalid playlist" 26 + exit 1 27 + fi 28 + 29 + if echo "$PLAYLIST" | grep -q "#EXT-X-STREAM-INF"; then 30 + echo "✓ Contains stream variants" 31 + else 32 + echo "✗ No stream variants found" 33 + fi 34 + 35 + TRACK_URL=$(echo "$PLAYLIST" | grep -A1 "#EXT-X-STREAM-INF" | tail -1) 36 + echo "" 37 + echo "Track URL (relative): $TRACK_URL" 38 + 39 + if [[ "$TRACK_URL" == place.stream.playback.* ]]; then 40 + FULL_TRACK_URL="${BASE_URL}/${TRACK_URL}" 41 + echo "Full Track URL: $FULL_TRACK_URL" 42 + else 43 + FULL_TRACK_URL="${BASE_URL}/${TRACK_URL}" 44 + echo "Track URL (absolute): $FULL_TRACK_URL" 45 + fi 46 + 47 + echo "" 48 + echo "Testing: Video Track Playlist (track=1)" 49 + echo "" 50 + 51 + TRACK1=$(curl -s "$FULL_TRACK_URL") 52 + 53 + if echo "$TRACK1" | grep -q "#EXT-X-PLAYLIST-TYPE:VOD"; then 54 + echo "✓ Valid VOD playlist" 55 + else 56 + echo "✗ Not a VOD playlist" 57 + fi 58 + 59 + if echo "$TRACK1" | grep -q "#EXTINF"; then 60 + SEGMENTS=$(echo "$TRACK1" | grep -c "#EXTINF") 61 + echo "✓ Contains $SEGMENTS segments" 62 + else 63 + echo "✗ No segments found" 64 + fi 65 + 66 + BLOB_URL=$(echo "$TRACK1" | grep "place.stream.playback.getVideoBlob" | head -1) 67 + echo "" 68 + echo "First segment URL: $BLOB_URL" 69 + 70 + if [[ -n "$BLOB_URL" ]]; then 71 + FULL_BLOB_URL="${BASE_URL}/${BLOB_URL}" 72 + echo "Full blob URL: $FULL_BLOB_URL" 73 + 74 + echo "" 75 + echo "Testing: Video Blob (HEAD request)" 76 + RESPONSE=$(curl -s -I "$FULL_BLOB_URL") 77 + 78 + if echo "$RESPONSE" | grep -q "HTTP/.* 200"; then 79 + echo "✓ Blob accessible" 80 + 81 + CONTENT_TYPE=$(echo "$RESPONSE" | grep -i "content-type:" | tr -d '\r') 82 + echo " Content-Type: $CONTENT_TYPE" 83 + 84 + CONTENT_LENGTH=$(echo "$RESPONSE" | grep -i "content-length:" | tr -d '\r') 85 + echo " Content-Length: $CONTENT_LENGTH" 86 + else 87 + echo "✗ Blob not accessible" 88 + echo "$RESPONSE" 89 + fi 90 + fi 91 + 92 + echo "" 93 + echo "==========================================" 94 + echo "Test complete!" 95 + echo "=========================================="
+46
scripts/test-playback.ts
··· 1 + import { getVideoPlaylist } from '../src/lib/vod'; 2 + 3 + const videoUri = process.argv[2]; 4 + 5 + if (!videoUri) { 6 + console.error('Usage: npx ts-node scripts/test-playback.ts <at-uri>'); 7 + console.error('Example: npx ts-node scripts/test-playback.ts at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3mi2ikg6gij26'); 8 + process.exit(1); 9 + } 10 + 11 + async function testPlayback() { 12 + console.log('Testing video playback...\n'); 13 + console.log('Video URI:', videoUri); 14 + console.log(''); 15 + 16 + try { 17 + const playlist = await getVideoPlaylist(videoUri); 18 + console.log('SUCCESS: Got playlist!\n'); 19 + console.log('First 2000 chars of playlist:'); 20 + console.log(playlist.substring(0, 2000)); 21 + console.log('\n...'); 22 + 23 + if (playlist.includes('#EXTM3U')) { 24 + console.log('\nValid HLS playlist (has #EXTM3U)'); 25 + } else { 26 + console.log('\nWARNING: Not a valid HLS playlist - missing #EXTM3U'); 27 + } 28 + 29 + const lines = playlist.split('\n'); 30 + const codecs = lines.filter(l => l.includes('CODECS')); 31 + const resolution = lines.filter(l => l.includes('RESOLUTION')); 32 + 33 + console.log('\nCodec info found:', codecs.length); 34 + console.log('Resolution info found:', resolution.length); 35 + 36 + if (codecs.length > 0) { 37 + console.log('Codec details:', codecs[0].substring(0, 100)); 38 + } 39 + 40 + } catch (error) { 41 + console.error('FAILED:', error instanceof Error ? error.message : error); 42 + process.exit(1); 43 + } 44 + } 45 + 46 + testPlayback();
+55
scripts/test-vod-chain.ts
··· 1 + import { getStreamPlacePdsUrl, listVideos } from '../src/lib/pds'; 2 + import { getVideoPlaylist } from '../src/lib/vod'; 3 + 4 + async function testFullChain() { 5 + console.log('Testing full VOD chain...\n'); 6 + 7 + try { 8 + console.log('1. Resolving PDS URL...'); 9 + const pdsUrl = await getStreamPlacePdsUrl(); 10 + console.log(' PDS URL:', pdsUrl); 11 + console.log(''); 12 + 13 + console.log('2. Listing videos...'); 14 + const videos = await listVideos(pdsUrl); 15 + console.log(' Found videos:', videos.length); 16 + console.log(''); 17 + 18 + if (videos.length === 0) { 19 + console.log('No videos found!'); 20 + return; 21 + } 22 + 23 + console.log('3. Testing playback for first video...'); 24 + const first = videos[0]; 25 + console.log(' URI:', first.uri); 26 + console.log(' Title:', (first.value as { title?: string }).title); 27 + console.log(''); 28 + 29 + const playlist = await getVideoPlaylist(first.uri); 30 + console.log(' Got playlist:', playlist.length, 'chars'); 31 + 32 + const isValidHLS = playlist.includes('#EXTM3U'); 33 + console.log(' Valid HLS:', isValidHLS); 34 + 35 + if (!isValidHLS) { 36 + console.log('\n Playlist content (first 500 chars):'); 37 + console.log(playlist.substring(0, 500)); 38 + } 39 + 40 + const lines = playlist.split('\n'); 41 + const segUrls = lines.filter(l => l.startsWith('https://') || l.startsWith('http://')); 42 + 43 + console.log('\n Playlist contains', segUrls.length, 'segment URLs'); 44 + 45 + if (segUrls.length > 0) { 46 + console.log(' First segment URL:', segUrls[0].substring(0, 100)); 47 + } 48 + 49 + } catch (error) { 50 + console.error('ERROR:', error instanceof Error ? error.message : error); 51 + process.exit(1); 52 + } 53 + } 54 + 55 + testFullChain();
+27
src/app/blogs/page.tsx
··· 1 + import { getStreamPlacePdsUrl, listVideos, resolveHandleCached } from '@/lib/pds'; 2 + import HomeClient from '@/components/HomeClient'; 3 + import { VideoRecord } from '@/lib/types'; 4 + import '../icarly.css'; 5 + 6 + export const dynamic = 'force-dynamic'; 7 + 8 + export default async function BlogsPage() { 9 + let videos: { uri: string; cid: string; value: VideoRecord; handle: string }[] = []; 10 + 11 + try { 12 + const pdsUrl = await getStreamPlacePdsUrl(); 13 + const data = await listVideos(pdsUrl); 14 + const allVideos = data as { uri: string; cid: string; value: VideoRecord }[]; 15 + 16 + videos = await Promise.all( 17 + allVideos.map(async (video) => ({ 18 + ...video, 19 + handle: await resolveHandleCached(video.value.creator), 20 + })) 21 + ); 22 + } catch (e) { 23 + console.error('Failed to load videos:', e); 24 + } 25 + 26 + return <HomeClient videos={videos} initialTab="iblogs" />; 27 + }
-49
src/app/globals.css
··· 1 - :root { 2 - --background: #ffffff; 3 - --foreground: #171717; 4 - } 5 - 6 - @media (prefers-color-scheme: dark) { 7 - :root { 8 - --background: #0a0a0a; 9 - --foreground: #ededed; 10 - } 11 - } 12 - 13 - html { 14 - height: 100%; 15 - } 16 - 17 - html, 18 - body { 19 - max-width: 100vw; 20 - overflow-x: hidden; 21 - } 22 - 23 - body { 24 - min-height: 100%; 25 - display: flex; 26 - flex-direction: column; 27 - color: var(--foreground); 28 - background: var(--background); 29 - font-family: Arial, Helvetica, sans-serif; 30 - -webkit-font-smoothing: antialiased; 31 - -moz-osx-font-smoothing: grayscale; 32 - } 33 - 34 - * { 35 - box-sizing: border-box; 36 - padding: 0; 37 - margin: 0; 38 - } 39 - 40 - a { 41 - color: inherit; 42 - text-decoration: none; 43 - } 44 - 45 - @media (prefers-color-scheme: dark) { 46 - html { 47 - color-scheme: dark; 48 - } 49 - }
+27
src/app/help/page.tsx
··· 1 + import { getStreamPlacePdsUrl, listVideos, resolveHandleCached } from '@/lib/pds'; 2 + import HomeClient from '@/components/HomeClient'; 3 + import { VideoRecord } from '@/lib/types'; 4 + import '../icarly.css'; 5 + 6 + export const dynamic = 'force-dynamic'; 7 + 8 + export default async function HelpPage() { 9 + let videos: { uri: string; cid: string; value: VideoRecord; handle: string }[] = []; 10 + 11 + try { 12 + const pdsUrl = await getStreamPlacePdsUrl(); 13 + const data = await listVideos(pdsUrl); 14 + const allVideos = data as { uri: string; cid: string; value: VideoRecord }[]; 15 + 16 + videos = await Promise.all( 17 + allVideos.map(async (video) => ({ 18 + ...video, 19 + handle: await resolveHandleCached(video.value.creator), 20 + })) 21 + ); 22 + } catch (e) { 23 + console.error('Failed to load videos:', e); 24 + } 25 + 26 + return <HomeClient videos={videos} initialTab="ihelp" />; 27 + }
+760
src/app/icarly.css
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Comic+Neue:wght@400;700&family=Fredoka+One&display=swap'); 2 + 3 + :root { 4 + --icarly-blue: #384C8B; 5 + --icarly-green: #A6CC3A; 6 + --icarly-purple: #62166F; 7 + --icarly-purple-light: #8B2F9B; 8 + --icarly-orange: #F5A623; 9 + --icarly-pink: #E91E8C; 10 + --icarly-magenta: #C71585; 11 + --icarly-yellow: #FFD700; 12 + } 13 + 14 + * { 15 + margin: 0; 16 + padding: 0; 17 + box-sizing: border-box; 18 + } 19 + 20 + html { 21 + font-size: 14px; 22 + } 23 + 24 + body { 25 + font-family: 'Comic Neue', cursive; 26 + background-color: var(--icarly-blue); 27 + min-height: 100vh; 28 + overflow-x: hidden; 29 + } 30 + 31 + /* Animations - Only for floating elements, not text */ 32 + @keyframes float { 33 + 0%, 100% { transform: translateY(0) rotate(0deg); } 34 + 50% { transform: translateY(-15px) rotate(3deg); } 35 + } 36 + 37 + @keyframes bounce { 38 + 0%, 100% { transform: translateY(0); } 39 + 50% { transform: translateY(-10px); } 40 + } 41 + 42 + @keyframes wiggle { 43 + 0%, 25%, 50%, 75%, 100% { transform: rotate(0deg); } 44 + 12.5% { transform: rotate(-5deg); } 45 + 37.5% { transform: rotate(5deg); } 46 + 62.5% { transform: rotate(-3deg); } 47 + 87.5% { transform: rotate(3deg); } 48 + } 49 + 50 + @keyframes pulse { 51 + 0%, 100% { transform: scale(1); } 52 + 50% { transform: scale(1.05); } 53 + } 54 + 55 + @keyframes spin { 56 + from { transform: rotate(0deg); } 57 + to { transform: rotate(360deg); } 58 + } 59 + 60 + @keyframes shake { 61 + 0%, 100% { transform: translateX(0) rotate(0deg); } 62 + 25% { transform: translateX(-3px) rotate(-2deg); } 63 + 75% { transform: translateX(3px) rotate(2deg); } 64 + } 65 + 66 + @keyframes glow { 67 + 0%, 100% { box-shadow: 0 0 5px #FFD700; } 68 + 50% { box-shadow: 0 0 20px #FFD700, 0 0 40px #FFD700; } 69 + } 70 + 71 + @keyframes arrow-point { 72 + 0%, 100% { transform: rotate(-10deg) translateX(0); } 73 + 50% { transform: rotate(-10deg) translateX(10px); } 74 + } 75 + 76 + .icarly-container { 77 + max-width: 1400px; 78 + margin: 0 auto; 79 + position: relative; 80 + transform-origin: top center; 81 + } 82 + 83 + /* Header Section */ 84 + .icarly-header { 85 + background: linear-gradient(180deg, #4A5A8A 0%, #7BA05B 40%, #A6CC3A 100%); 86 + border-radius: 30px 30px 0 0; 87 + padding: 30px; 88 + position: relative; 89 + min-height: 260px; 90 + } 91 + 92 + /* Homepage button - floating */ 93 + .homepage-btn { 94 + position: absolute; 95 + top: 15px; 96 + left: 50%; 97 + transform: translateX(-50%); 98 + background: #000; 99 + color: #fff; 100 + padding: 8px 20px; 101 + border-radius: 8px; 102 + font-weight: bold; 103 + font-size: 16px; 104 + display: flex; 105 + align-items: center; 106 + gap: 8px; 107 + cursor: pointer; 108 + text-decoration: none; 109 + border: none; 110 + animation: glow 2s infinite; 111 + z-index: 20; 112 + } 113 + 114 + .homepage-btn:hover { 115 + animation: shake 0.5s infinite; 116 + } 117 + 118 + .homepage-btn::before { 119 + content: "✦"; 120 + color: var(--icarly-green); 121 + animation: spin 3s linear infinite; 122 + } 123 + 124 + /* Logo styling - floating */ 125 + .icarly-logo { 126 + position: absolute; 127 + left: 30px; 128 + top: 50px; 129 + background: var(--icarly-magenta); 130 + padding: 15px 30px; 131 + border-radius: 15px; 132 + transform: rotate(-3deg); 133 + box-shadow: 4px 4px 0 rgba(0,0,0,0.3); 134 + z-index: 10; 135 + } 136 + 137 + .icarly-logo:hover { 138 + animation: wiggle 0.5s ease-in-out; 139 + } 140 + 141 + .icarly-logo img { 142 + height: 70px; 143 + width: auto; 144 + } 145 + 146 + /* Date display - NO ANIMATION */ 147 + .date-display { 148 + position: absolute; 149 + left: 30px; 150 + top: 160px; 151 + color: #fff; 152 + font-size: 20px; 153 + font-weight: bold; 154 + text-shadow: 2px 2px 0 rgba(0,0,0,0.3); 155 + z-index: 5; 156 + } 157 + 158 + .lemon-tube { 159 + color: #FF6347; 160 + font-size: 28px; 161 + font-weight: bold; 162 + text-shadow: 2px 2px 0 #fff; 163 + margin-top: 5px; 164 + } 165 + 166 + /* Search section */ 167 + .search-section { 168 + position: absolute; 169 + left: 50%; 170 + top: 90px; 171 + transform: translateX(-50%); 172 + text-align: center; 173 + z-index: 5; 174 + } 175 + 176 + /* Search label - floating animation */ 177 + .search-label { 178 + color: #fff; 179 + font-size: 32px; 180 + font-weight: bold; 181 + text-shadow: 2px 2px 0 rgba(0,0,0,0.3); 182 + margin-bottom: 12px; 183 + display: inline-block; 184 + animation: float 4s ease-in-out infinite; 185 + } 186 + 187 + .search-box { 188 + display: flex; 189 + align-items: center; 190 + gap: 12px; 191 + } 192 + 193 + .search-input { 194 + width: 300px; 195 + padding: 10px 20px; 196 + border: 3px solid #fff; 197 + border-radius: 25px; 198 + font-size: 18px; 199 + outline: none; 200 + font-family: 'Comic Neue', cursive; 201 + transition: all 0.3s; 202 + } 203 + 204 + .search-input:focus { 205 + transform: scale(1.02); 206 + box-shadow: 0 0 15px rgba(255,255,255,0.5); 207 + } 208 + 209 + .search-btn { 210 + background: #fff; 211 + border: none; 212 + border-radius: 50%; 213 + width: 40px; 214 + height: 40px; 215 + cursor: pointer; 216 + display: flex; 217 + align-items: center; 218 + justify-content: center; 219 + font-size: 18px; 220 + animation: bounce 2s infinite; 221 + } 222 + 223 + .search-btn:hover { 224 + transform: scale(1.2) rotate(15deg); 225 + } 226 + 227 + /* Login button - floating */ 228 + .login-btn { 229 + position: absolute; 230 + right: 250px; 231 + top: 100px; 232 + background: linear-gradient(180deg, #666 0%, #333 100%); 233 + color: #fff; 234 + padding: 12px 35px; 235 + border-radius: 50%; 236 + font-size: 22px; 237 + font-weight: bold; 238 + border: 3px solid #fff; 239 + cursor: pointer; 240 + box-shadow: 0 4px 0 rgba(0,0,0,0.3); 241 + text-decoration: none; 242 + display: flex; 243 + flex-direction: column; 244 + align-items: center; 245 + justify-content: center; 246 + line-height: 1; 247 + z-index: 5; 248 + } 249 + 250 + .login-btn:hover { 251 + animation: bounce 0.5s infinite; 252 + } 253 + 254 + .login-btn::after { 255 + content: "→"; 256 + display: block; 257 + font-size: 16px; 258 + margin-top: 2px; 259 + } 260 + 261 + /* Info button - floating */ 262 + .info-btn { 263 + position: absolute; 264 + right: 380px; 265 + top: 20px; 266 + background: #FF6B6B; 267 + color: #fff; 268 + padding: 12px 25px; 269 + border-radius: 12px; 270 + font-weight: bold; 271 + font-size: 16px; 272 + text-align: center; 273 + line-height: 1.3; 274 + box-shadow: 0 4px 0 rgba(0,0,0,0.2); 275 + animation: float 5s ease-in-out infinite; 276 + z-index: 5; 277 + } 278 + 279 + .info-btn:hover { 280 + animation: shake 0.5s infinite; 281 + } 282 + 283 + .info-btn span { 284 + color: var(--icarly-yellow); 285 + } 286 + 287 + /* Feedback button - MOVED to not overlap */ 288 + .feedback-btn { 289 + position: absolute; 290 + right: 30px; 291 + top: 180px; 292 + background: #FF4444; 293 + color: #fff; 294 + padding: 12px 20px; 295 + border-radius: 12px; 296 + font-weight: bold; 297 + font-size: 18px; 298 + text-align: center; 299 + line-height: 1.2; 300 + box-shadow: 0 4px 0 rgba(0,0,0,0.2); 301 + cursor: pointer; 302 + z-index: 5; 303 + } 304 + 305 + .feedback-btn:hover { 306 + animation: wiggle 0.5s ease-in-out; 307 + } 308 + 309 + .feedback-btn small { 310 + font-size: 13px; 311 + color: var(--icarly-yellow); 312 + } 313 + 314 + /* Character image - MOVED left and floating */ 315 + .character-image { 316 + position: absolute; 317 + right: 30px; 318 + top: 30px; 319 + width: 200px; 320 + height: 200px; 321 + border-radius: 25px; 322 + border: 6px solid #fff; 323 + box-shadow: 0 6px 20px rgba(0,0,0,0.3); 324 + overflow: hidden; 325 + animation: float 6s ease-in-out infinite; 326 + z-index: 4; 327 + } 328 + 329 + .character-image:hover { 330 + animation: shake 0.5s infinite; 331 + } 332 + 333 + .character-image img { 334 + width: 100%; 335 + height: 100%; 336 + object-fit: cover; 337 + } 338 + 339 + /* Fun floating labels */ 340 + .fun-label { 341 + animation: float 3s ease-in-out infinite; 342 + } 343 + 344 + .fun-label:hover { 345 + animation: shake 0.5s infinite; 346 + } 347 + 348 + /* Decorative floating shapes */ 349 + .floating-shape { 350 + animation: float 5s ease-in-out infinite; 351 + } 352 + 353 + .floating-arrow { 354 + animation: arrow-point 2s ease-in-out infinite; 355 + } 356 + 357 + /* Navigation Tabs */ 358 + .nav-tabs { 359 + display: flex; 360 + justify-content: center; 361 + background: linear-gradient(180deg, #A6CC3A 0%, #fff 100%); 362 + padding: 15px 30px 0; 363 + gap: 8px; 364 + position: relative; 365 + } 366 + 367 + .nav-tab { 368 + background: linear-gradient(180deg, var(--icarly-purple-light) 0%, var(--icarly-purple) 100%); 369 + color: #fff; 370 + padding: 18px 30px; 371 + border-radius: 20px 20px 0 0; 372 + font-weight: bold; 373 + font-size: 16px; 374 + text-transform: uppercase; 375 + cursor: pointer; 376 + border: none; 377 + position: relative; 378 + top: 8px; 379 + box-shadow: 0 -3px 0 rgba(0,0,0,0.2) inset; 380 + transition: all 0.2s; 381 + display: flex; 382 + align-items: center; 383 + gap: 8px; 384 + font-family: 'Comic Neue', cursive; 385 + text-decoration: none; 386 + } 387 + 388 + .nav-tab:hover { 389 + transform: translateY(-3px); 390 + } 391 + 392 + .nav-tab.active { 393 + background: linear-gradient(180deg, #fff 0%, #f0f0f0 100%); 394 + color: var(--icarly-purple); 395 + top: 0; 396 + padding-bottom: 26px; 397 + box-shadow: 0 -5px 10px rgba(0,0,0,0.1); 398 + } 399 + 400 + .nav-tab-icon { 401 + font-size: 18px; 402 + } 403 + 404 + /* Content Area */ 405 + .content-area { 406 + background: var(--icarly-orange); 407 + min-height: 500px; 408 + position: relative; 409 + overflow: hidden; 410 + padding: 50px; 411 + border-radius: 0 0 30px 30px; 412 + } 413 + 414 + /* Decorative circles - floating */ 415 + .decorative-circles { 416 + position: absolute; 417 + top: 0; 418 + left: 0; 419 + right: 0; 420 + bottom: 0; 421 + pointer-events: none; 422 + } 423 + 424 + .circle { 425 + position: absolute; 426 + border-radius: 50%; 427 + background: #90EE90; 428 + border: 6px solid #7CFC00; 429 + animation: float 4s ease-in-out infinite; 430 + } 431 + 432 + .circle-1 { width: 100px; height: 100px; top: 15%; left: 8%; animation-delay: 0s; } 433 + .circle-2 { width: 60px; height: 60px; top: 35%; left: 22%; animation-delay: 0.5s; } 434 + .circle-3 { width: 75px; height: 75px; top: 55%; left: 12%; animation-delay: 1s; } 435 + .circle-4 { width: 85px; height: 85px; top: 25%; right: 18%; animation-delay: 1.5s; } 436 + .circle-5 { width: 55px; height: 55px; top: 45%; right: 28%; animation-delay: 2s; } 437 + .circle-6 { width: 70px; height: 70px; top: 65%; right: 12%; animation-delay: 2.5s; } 438 + .circle-7 { width: 50px; height: 50px; top: 10%; left: 38%; animation-delay: 3s; } 439 + .circle-8 { width: 80px; height: 80px; top: 70%; left: 32%; animation-delay: 3.5s; } 440 + 441 + /* Additional shapes for iSNAPS */ 442 + .snap-shape-1 { 443 + position: absolute; 444 + width: 0; 445 + height: 0; 446 + border-left: 50px solid transparent; 447 + border-right: 50px solid transparent; 448 + border-bottom: 80px solid #62166F; 449 + animation: float 5s ease-in-out infinite; 450 + } 451 + 452 + .snap-shape-2 { 453 + position: absolute; 454 + width: 80px; 455 + height: 80px; 456 + background: #E91E8C; 457 + transform: rotate(45deg); 458 + animation: float 6s ease-in-out infinite; 459 + } 460 + 461 + .snap-shape-3 { 462 + position: absolute; 463 + width: 100px; 464 + height: 60px; 465 + background: #A6CC3A; 466 + border-radius: 50px; 467 + animation: float 4s ease-in-out infinite; 468 + } 469 + 470 + /* Arrow decorations */ 471 + .arrow-pointer { 472 + font-size: 40px; 473 + animation: arrow-point 1.5s ease-in-out infinite; 474 + } 475 + 476 + /* Video Grid */ 477 + .video-grid { 478 + display: grid; 479 + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 480 + gap: 25px; 481 + position: relative; 482 + z-index: 1; 483 + } 484 + 485 + /* Video Card - hover effects but NO constant animation */ 486 + .video-card { 487 + background: #fff; 488 + border-radius: 20px; 489 + overflow: hidden; 490 + box-shadow: 0 6px 20px rgba(0,0,0,0.2); 491 + transition: all 0.3s; 492 + cursor: pointer; 493 + } 494 + 495 + .video-card:hover { 496 + transform: scale(1.05) translateY(-5px); 497 + box-shadow: 0 12px 30px rgba(0,0,0,0.3); 498 + } 499 + 500 + .video-thumbnail { 501 + position: relative; 502 + padding-top: 56.25%; 503 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 504 + } 505 + 506 + .video-thumbnail img { 507 + position: absolute; 508 + top: 0; 509 + left: 0; 510 + width: 100%; 511 + height: 100%; 512 + object-fit: cover; 513 + } 514 + 515 + .play-overlay { 516 + position: absolute; 517 + top: 50%; 518 + left: 50%; 519 + transform: translate(-50%, -50%); 520 + width: 70px; 521 + height: 70px; 522 + background: rgba(255,255,255,0.9); 523 + border-radius: 50%; 524 + display: flex; 525 + align-items: center; 526 + justify-content: center; 527 + font-size: 28px; 528 + color: var(--icarly-pink); 529 + transition: all 0.3s; 530 + } 531 + 532 + .video-card:hover .play-overlay { 533 + transform: translate(-50%, -50%) scale(1.1); 534 + } 535 + 536 + .duration-badge { 537 + position: absolute; 538 + bottom: 12px; 539 + right: 12px; 540 + background: rgba(0,0,0,0.8); 541 + color: #fff; 542 + padding: 4px 10px; 543 + border-radius: 6px; 544 + font-size: 14px; 545 + font-weight: bold; 546 + } 547 + 548 + .video-info { 549 + padding: 18px; 550 + } 551 + 552 + .video-title { 553 + font-size: 18px; 554 + font-weight: bold; 555 + color: #333; 556 + margin-bottom: 10px; 557 + line-height: 1.3; 558 + } 559 + 560 + .video-creator { 561 + font-size: 16px; 562 + color: var(--icarly-purple); 563 + font-weight: bold; 564 + } 565 + 566 + .video-creator:hover { 567 + color: var(--icarly-pink); 568 + } 569 + 570 + /* Blog Posts Styles - NO constant animations */ 571 + .blog-posts-container { 572 + position: relative; 573 + z-index: 1; 574 + max-width: 800px; 575 + margin: 0 auto; 576 + } 577 + 578 + .blog-post { 579 + background: #fff; 580 + border-radius: 20px; 581 + padding: 25px; 582 + margin-bottom: 25px; 583 + box-shadow: 0 6px 20px rgba(0,0,0,0.2); 584 + transition: all 0.3s; 585 + } 586 + 587 + .blog-post:hover { 588 + transform: translateY(-3px); 589 + box-shadow: 0 10px 25px rgba(0,0,0,0.25); 590 + } 591 + 592 + .blog-post-header { 593 + display: flex; 594 + align-items: center; 595 + gap: 15px; 596 + margin-bottom: 15px; 597 + } 598 + 599 + .profile-pic-weird { 600 + width: 60px; 601 + height: 60px; 602 + border-radius: 30% 70% 70% 30% / 30% 30% 70% 70%; 603 + border: 4px solid var(--icarly-purple); 604 + overflow: hidden; 605 + background: var(--icarly-green); 606 + transition: all 0.3s; 607 + } 608 + 609 + .profile-pic-weird:hover { 610 + transform: scale(1.1) rotate(5deg); 611 + } 612 + 613 + .profile-pic-weird img { 614 + width: 100%; 615 + height: 100%; 616 + object-fit: cover; 617 + } 618 + 619 + .blog-post-author { 620 + font-weight: bold; 621 + color: var(--icarly-purple); 622 + font-size: 18px; 623 + } 624 + 625 + .blog-post-date { 626 + color: #666; 627 + font-size: 14px; 628 + } 629 + 630 + .blog-post-content { 631 + font-size: 17px; 632 + line-height: 1.6; 633 + color: #333; 634 + margin-bottom: 15px; 635 + } 636 + 637 + .blog-post-content a { 638 + color: var(--icarly-purple); 639 + text-decoration: underline; 640 + } 641 + 642 + .thread-indicator { 643 + background: linear-gradient(135deg, var(--icarly-green), var(--icarly-purple)); 644 + color: #fff; 645 + padding: 8px 15px; 646 + border-radius: 20px; 647 + font-size: 14px; 648 + font-weight: bold; 649 + display: inline-block; 650 + margin-bottom: 10px; 651 + } 652 + 653 + .repost-badge { 654 + background: var(--icarly-purple); 655 + color: #fff; 656 + padding: 5px 12px; 657 + border-radius: 15px; 658 + font-size: 13px; 659 + font-weight: bold; 660 + display: inline-block; 661 + margin-bottom: 10px; 662 + } 663 + 664 + /* Snaps Grid with different shapes */ 665 + .snaps-grid { 666 + display: grid; 667 + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 668 + gap: 25px; 669 + position: relative; 670 + z-index: 1; 671 + } 672 + 673 + .snap-item { 674 + background: #fff; 675 + overflow: hidden; 676 + box-shadow: 0 6px 20px rgba(0,0,0,0.2); 677 + aspect-ratio: 1; 678 + transition: all 0.3s; 679 + cursor: pointer; 680 + } 681 + 682 + /* Different shapes for snaps */ 683 + .snap-item:nth-child(4n+1) { 684 + border-radius: 20px; 685 + } 686 + 687 + .snap-item:nth-child(4n+2) { 688 + border-radius: 50%; 689 + } 690 + 691 + .snap-item:nth-child(4n+3) { 692 + border-radius: 20px 50% 20px 50%; 693 + } 694 + 695 + .snap-item:nth-child(4n+4) { 696 + border-radius: 50% 20px 50% 20px; 697 + } 698 + 699 + .snap-item:hover { 700 + transform: scale(1.08) rotate(2deg); 701 + box-shadow: 0 12px 30px rgba(0,0,0,0.3); 702 + z-index: 10; 703 + } 704 + 705 + .snap-item img { 706 + width: 100%; 707 + height: 100%; 708 + object-fit: cover; 709 + transition: all 0.3s; 710 + } 711 + 712 + .snap-item:hover img { 713 + transform: scale(1.1); 714 + } 715 + 716 + /* Random Play Button - floating */ 717 + .random-play-container { 718 + display: flex; 719 + flex-direction: column; 720 + align-items: center; 721 + justify-content: center; 722 + min-height: 400px; 723 + position: relative; 724 + z-index: 1; 725 + } 726 + 727 + .random-play-btn { 728 + background: linear-gradient(180deg, var(--icarly-purple-light) 0%, var(--icarly-purple) 100%); 729 + color: #fff; 730 + padding: 40px 70px; 731 + border-radius: 50%; 732 + font-size: 36px; 733 + font-weight: bold; 734 + border: 5px solid #fff; 735 + cursor: pointer; 736 + box-shadow: 0 8px 0 rgba(0,0,0,0.3); 737 + transition: all 0.3s; 738 + font-family: 'Comic Neue', cursive; 739 + animation: float 4s ease-in-out infinite; 740 + } 741 + 742 + .random-play-btn:hover { 743 + transform: scale(1.1); 744 + animation: shake 0.5s infinite; 745 + } 746 + 747 + .random-play-btn:active { 748 + transform: scale(0.95); 749 + box-shadow: 0 4px 0 rgba(0,0,0,0.3); 750 + } 751 + 752 + /* Floating elements animation */ 753 + .floating-star { 754 + animation: float 4s ease-in-out infinite; 755 + } 756 + 757 + /* Pulse animation for emphasis */ 758 + .pulse-animation { 759 + animation: pulse 2s infinite; 760 + }
+26 -15
src/app/layout.tsx
··· 1 1 import type { Metadata } from "next"; 2 - import { Geist, Geist_Mono } from "next/font/google"; 3 - import "./globals.css"; 4 - 5 - const geistSans = Geist({ 6 - variable: "--font-geist-sans", 7 - subsets: ["latin"], 8 - }); 9 - 10 - const geistMono = Geist_Mono({ 11 - variable: "--font-geist-mono", 12 - subsets: ["latin"], 13 - }); 2 + import "./icarly.css"; 14 3 15 4 export const metadata: Metadata = { 16 - title: "Create Next App", 17 - description: "Generated by create next app", 5 + title: "iStream - Video on Demand", 6 + description: "Watch videos from the stream.place community! The coolest VOD site inspired by iCarly.", 7 + icons: { 8 + icon: "/iStream_Logo.png", 9 + }, 10 + openGraph: { 11 + title: "iStream - Video on Demand", 12 + description: "Watch videos from the stream.place community! The coolest VOD site inspired by iCarly.", 13 + images: [ 14 + { 15 + url: "/istream-og-image-card.png", 16 + width: 1200, 17 + height: 630, 18 + alt: "iStream - VOD Viewer for stream.place", 19 + }, 20 + ], 21 + type: "website", 22 + }, 23 + twitter: { 24 + card: "summary_large_image", 25 + title: "iStream - Video on Demand", 26 + description: "Watch videos from the stream.place community!", 27 + images: ["/istream-og-image-card.png"], 28 + }, 18 29 }; 19 30 20 31 export default function RootLayout({ ··· 23 34 children: React.ReactNode; 24 35 }>) { 25 36 return ( 26 - <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}> 37 + <html lang="en"> 27 38 <body>{children}</body> 28 39 </html> 29 40 );
+27
src/app/news/page.tsx
··· 1 + import { getStreamPlacePdsUrl, listVideos, resolveHandleCached } from '@/lib/pds'; 2 + import HomeClient from '@/components/HomeClient'; 3 + import { VideoRecord } from '@/lib/types'; 4 + import '../icarly.css'; 5 + 6 + export const dynamic = 'force-dynamic'; 7 + 8 + export default async function NewsPage() { 9 + let videos: { uri: string; cid: string; value: VideoRecord; handle: string }[] = []; 10 + 11 + try { 12 + const pdsUrl = await getStreamPlacePdsUrl(); 13 + const data = await listVideos(pdsUrl); 14 + const allVideos = data as { uri: string; cid: string; value: VideoRecord }[]; 15 + 16 + videos = await Promise.all( 17 + allVideos.map(async (video) => ({ 18 + ...video, 19 + handle: await resolveHandleCached(video.value.creator), 20 + })) 21 + ); 22 + } catch (e) { 23 + console.error('Failed to load videos:', e); 24 + } 25 + 26 + return <HomeClient videos={videos} initialTab="inews" />; 27 + }
-142
src/app/page.module.css
··· 1 - .page { 2 - --background: #fafafa; 3 - --foreground: #fff; 4 - 5 - --text-primary: #000; 6 - --text-secondary: #666; 7 - 8 - --button-primary-hover: #383838; 9 - --button-secondary-hover: #f2f2f2; 10 - --button-secondary-border: #ebebeb; 11 - 12 - display: flex; 13 - flex: 1; 14 - flex-direction: column; 15 - align-items: center; 16 - justify-content: center; 17 - font-family: var(--font-geist-sans); 18 - background-color: var(--background); 19 - } 20 - 21 - .main { 22 - display: flex; 23 - flex: 1; 24 - width: 100%; 25 - max-width: 800px; 26 - flex-direction: column; 27 - align-items: flex-start; 28 - justify-content: space-between; 29 - background-color: var(--foreground); 30 - padding: 120px 60px; 31 - } 32 - 33 - .intro { 34 - display: flex; 35 - flex-direction: column; 36 - align-items: flex-start; 37 - text-align: left; 38 - gap: 24px; 39 - } 40 - 41 - .intro h1 { 42 - max-width: 320px; 43 - font-size: 40px; 44 - font-weight: 600; 45 - line-height: 48px; 46 - letter-spacing: -2.4px; 47 - text-wrap: balance; 48 - color: var(--text-primary); 49 - } 50 - 51 - .intro p { 52 - max-width: 440px; 53 - font-size: 18px; 54 - line-height: 32px; 55 - text-wrap: balance; 56 - color: var(--text-secondary); 57 - } 58 - 59 - .intro a { 60 - font-weight: 500; 61 - color: var(--text-primary); 62 - } 63 - 64 - .ctas { 65 - display: flex; 66 - flex-direction: row; 67 - width: 100%; 68 - max-width: 440px; 69 - gap: 16px; 70 - font-size: 14px; 71 - } 72 - 73 - .ctas a { 74 - display: flex; 75 - justify-content: center; 76 - align-items: center; 77 - height: 40px; 78 - padding: 0 16px; 79 - border-radius: 128px; 80 - border: 1px solid transparent; 81 - transition: 0.2s; 82 - cursor: pointer; 83 - width: fit-content; 84 - font-weight: 500; 85 - } 86 - 87 - a.primary { 88 - background: var(--text-primary); 89 - color: var(--background); 90 - gap: 8px; 91 - } 92 - 93 - a.secondary { 94 - border-color: var(--button-secondary-border); 95 - } 96 - 97 - /* Enable hover only on non-touch devices */ 98 - @media (hover: hover) and (pointer: fine) { 99 - a.primary:hover { 100 - background: var(--button-primary-hover); 101 - border-color: transparent; 102 - } 103 - 104 - a.secondary:hover { 105 - background: var(--button-secondary-hover); 106 - border-color: transparent; 107 - } 108 - } 109 - 110 - @media (max-width: 600px) { 111 - .main { 112 - padding: 48px 24px; 113 - } 114 - 115 - .intro { 116 - gap: 16px; 117 - } 118 - 119 - .intro h1 { 120 - font-size: 32px; 121 - line-height: 40px; 122 - letter-spacing: -1.92px; 123 - } 124 - } 125 - 126 - @media (prefers-color-scheme: dark) { 127 - .logo { 128 - filter: invert(); 129 - } 130 - 131 - .page { 132 - --background: #000; 133 - --foreground: #000; 134 - 135 - --text-primary: #ededed; 136 - --text-secondary: #999; 137 - 138 - --button-primary-hover: #ccc; 139 - --button-secondary-hover: #1a1a1a; 140 - --button-secondary-border: #1a1a1a; 141 - } 142 - }
+42 -63
src/app/page.tsx
··· 1 - import Image from "next/image"; 2 - import styles from "./page.module.css"; 1 + import { getStreamPlacePdsUrl, listVideos, resolveHandleCached } from '@/lib/pds'; 2 + import HomeClient from '@/components/HomeClient'; 3 + import { VideoRecord } from '@/lib/types'; 4 + import './icarly.css'; 5 + 6 + export const dynamic = 'force-dynamic'; 7 + 8 + export default async function Home() { 9 + let error: string | null = null; 10 + let videos: { uri: string; cid: string; value: VideoRecord; handle: string }[] = []; 11 + 12 + try { 13 + const pdsUrl = await getStreamPlacePdsUrl(); 14 + const data = await listVideos(pdsUrl); 15 + const allVideos = data as { uri: string; cid: string; value: VideoRecord }[]; 16 + 17 + videos = await Promise.all( 18 + allVideos.map(async (video) => ({ 19 + ...video, 20 + handle: await resolveHandleCached(video.value.creator), 21 + })) 22 + ); 23 + } catch (e) { 24 + error = e instanceof Error ? e.message : 'Failed to load videos'; 25 + } 3 26 4 - export default function Home() { 5 - return ( 6 - <div className={styles.page}> 7 - <main className={styles.main}> 8 - <Image 9 - className={styles.logo} 10 - src="/next.svg" 11 - alt="Next.js logo" 12 - width={100} 13 - height={20} 14 - priority 15 - /> 16 - <div className={styles.intro}> 17 - <h1>To get started, edit the page.tsx file.</h1> 18 - <p> 19 - Looking for a starting point or more instructions? Head over to{" "} 20 - <a 21 - href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" 22 - target="_blank" 23 - rel="noopener noreferrer" 24 - > 25 - Templates 26 - </a>{" "} 27 - or the{" "} 28 - <a 29 - href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" 30 - target="_blank" 31 - rel="noopener noreferrer" 32 - > 33 - Learning 34 - </a>{" "} 35 - center. 36 - </p> 27 + if (error) { 28 + return ( 29 + <div className="icarly-container"> 30 + <div style={{ 31 + background: '#F5A623', 32 + padding: '40px', 33 + textAlign: 'center', 34 + color: '#fff', 35 + fontSize: '24px' 36 + }}> 37 + <h2 style={{ color: '#E91E8C' }}>Oops!</h2> 38 + <p>{error}</p> 37 39 </div> 38 - <div className={styles.ctas}> 39 - <a 40 - className={styles.primary} 41 - href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" 42 - target="_blank" 43 - rel="noopener noreferrer" 44 - > 45 - <Image 46 - className={styles.logo} 47 - src="/vercel.svg" 48 - alt="Vercel logomark" 49 - width={16} 50 - height={16} 51 - /> 52 - Deploy Now 53 - </a> 54 - <a 55 - className={styles.secondary} 56 - href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" 57 - target="_blank" 58 - rel="noopener noreferrer" 59 - > 60 - Documentation 61 - </a> 62 - </div> 63 - </main> 64 - </div> 65 - ); 40 + </div> 41 + ); 42 + } 43 + 44 + return <HomeClient videos={videos} />; 66 45 }
+27
src/app/play/page.tsx
··· 1 + import { getStreamPlacePdsUrl, listVideos, resolveHandleCached } from '@/lib/pds'; 2 + import HomeClient from '@/components/HomeClient'; 3 + import { VideoRecord } from '@/lib/types'; 4 + import '../icarly.css'; 5 + 6 + export const dynamic = 'force-dynamic'; 7 + 8 + export default async function PlayPage() { 9 + let videos: { uri: string; cid: string; value: VideoRecord; handle: string }[] = []; 10 + 11 + try { 12 + const pdsUrl = await getStreamPlacePdsUrl(); 13 + const data = await listVideos(pdsUrl); 14 + const allVideos = data as { uri: string; cid: string; value: VideoRecord }[]; 15 + 16 + videos = await Promise.all( 17 + allVideos.map(async (video) => ({ 18 + ...video, 19 + handle: await resolveHandleCached(video.value.creator), 20 + })) 21 + ); 22 + } catch (e) { 23 + console.error('Failed to load videos:', e); 24 + } 25 + 26 + return <HomeClient videos={videos} initialTab="iplay" />; 27 + }
+27
src/app/snaps/page.tsx
··· 1 + import { getStreamPlacePdsUrl, listVideos, resolveHandleCached } from '@/lib/pds'; 2 + import HomeClient from '@/components/HomeClient'; 3 + import { VideoRecord } from '@/lib/types'; 4 + import '../icarly.css'; 5 + 6 + export const dynamic = 'force-dynamic'; 7 + 8 + export default async function SnapsPage() { 9 + let videos: { uri: string; cid: string; value: VideoRecord; handle: string }[] = []; 10 + 11 + try { 12 + const pdsUrl = await getStreamPlacePdsUrl(); 13 + const data = await listVideos(pdsUrl); 14 + const allVideos = data as { uri: string; cid: string; value: VideoRecord }[]; 15 + 16 + videos = await Promise.all( 17 + allVideos.map(async (video) => ({ 18 + ...video, 19 + handle: await resolveHandleCached(video.value.creator), 20 + })) 21 + ); 22 + } catch (e) { 23 + console.error('Failed to load videos:', e); 24 + } 25 + 26 + return <HomeClient videos={videos} initialTab="isnaps" />; 27 + }
+27
src/app/songs/page.tsx
··· 1 + import { getStreamPlacePdsUrl, listVideos, resolveHandleCached } from '@/lib/pds'; 2 + import HomeClient from '@/components/HomeClient'; 3 + import { VideoRecord } from '@/lib/types'; 4 + import '../icarly.css'; 5 + 6 + export const dynamic = 'force-dynamic'; 7 + 8 + export default async function SongsPage() { 9 + let videos: { uri: string; cid: string; value: VideoRecord; handle: string }[] = []; 10 + 11 + try { 12 + const pdsUrl = await getStreamPlacePdsUrl(); 13 + const data = await listVideos(pdsUrl); 14 + const allVideos = data as { uri: string; cid: string; value: VideoRecord }[]; 15 + 16 + videos = await Promise.all( 17 + allVideos.map(async (video) => ({ 18 + ...video, 19 + handle: await resolveHandleCached(video.value.creator), 20 + })) 21 + ); 22 + } catch (e) { 23 + console.error('Failed to load videos:', e); 24 + } 25 + 26 + return <HomeClient videos={videos} initialTab="isongs" />; 27 + }
+331
src/app/watch/[id]/page.tsx
··· 1 + import { getStreamPlacePdsUrl, listVideos, resolveHandleCached } from '@/lib/pds'; 2 + import VideoPlayer from '@/components/VideoPlayer'; 3 + import { VideoRecord } from '@/lib/types'; 4 + import Image from 'next/image'; 5 + import Link from 'next/link'; 6 + import '../../icarly.css'; 7 + 8 + export const dynamic = 'force-dynamic'; 9 + 10 + interface WatchPageProps { 11 + params: Promise<{ id: string }>; 12 + } 13 + 14 + function formatDuration(ns: number): string { 15 + const totalSeconds = Math.floor(ns / 1_000_000_000); 16 + const hours = Math.floor(totalSeconds / 3600); 17 + const minutes = Math.floor((totalSeconds % 3600) / 60); 18 + const seconds = totalSeconds % 60; 19 + 20 + if (hours > 0) { 21 + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 22 + } 23 + return `${minutes}:${seconds.toString().padStart(2, '0')}`; 24 + } 25 + 26 + const STREAM_DISPLAY_NAMES: Record<string, string> = { 27 + 'stream1.atmosphereconf.org': 'Stream 1', 28 + 'stream2.atmosphereconf.org': 'Stream 2', 29 + 'stream3.atmosphereconf.org': 'Stream 3', 30 + }; 31 + 32 + function getDisplayName(handle: string): string { 33 + return STREAM_DISPLAY_NAMES[handle] || handle; 34 + } 35 + 36 + export default async function WatchPage({ params }: WatchPageProps) { 37 + const { id } = await params; 38 + 39 + try { 40 + const pdsUrl = await getStreamPlacePdsUrl(); 41 + const videos = await listVideos(pdsUrl); 42 + const allVideos = videos as { uri: string; cid: string; value: VideoRecord }[]; 43 + 44 + const video = allVideos.find((v) => v.uri.endsWith(`/${id}`)); 45 + 46 + if (!video) { 47 + return ( 48 + <div className="icarly-container"> 49 + <div style={{ 50 + background: '#F5A623', 51 + padding: '40px', 52 + textAlign: 'center', 53 + color: '#fff', 54 + fontSize: '24px' 55 + }}> 56 + <h2 style={{ color: '#E91E8C' }}>Video not found!</h2> 57 + </div> 58 + </div> 59 + ); 60 + } 61 + 62 + const handle = await resolveHandleCached(video.value.creator); 63 + const shareUrl = typeof window !== 'undefined' ? window.location.href : ''; 64 + const shareText = `Check out "${video.value.title}" on iStream! 🎬`; 65 + const blueskyShareUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent(shareText + ' ' + shareUrl)}`; 66 + 67 + return ( 68 + <div className="icarly-container"> 69 + {/* Header */} 70 + <header className="icarly-header" style={{ minHeight: '160px' }}> 71 + {/* Back button */} 72 + <Link href="/" className="homepage-btn" style={{ left: '250px', transform: 'none' }}> 73 + ← Back to iVideo 74 + </Link> 75 + 76 + {/* Logo */} 77 + <Link href="/" className="icarly-logo" style={{ left: '30px', top: '50px', position: 'absolute' }}> 78 + <Image 79 + src="/iStream_Logo.png" 80 + alt="iStream" 81 + width={180} 82 + height={75} 83 + style={{ objectFit: 'contain' }} 84 + /> 85 + </Link> 86 + 87 + {/* Fun floating label */} 88 + <div className="fun-label" style={{ 89 + position: 'absolute', 90 + top: '20px', 91 + right: '300px', 92 + background: '#FFD700', 93 + color: '#62166F', 94 + padding: '10px 20px', 95 + borderRadius: '20px', 96 + fontWeight: 'bold', 97 + fontSize: '16px', 98 + boxShadow: '3px 3px 0 rgba(0,0,0,0.2)', 99 + zIndex: 5 100 + }}> 101 + 🎬 Now Playing! 102 + </div> 103 + </header> 104 + 105 + {/* Navigation */} 106 + <nav className="nav-tabs"> 107 + <Link href="/" className="nav-tab active" style={{ textDecoration: 'none' }}> 108 + iVideo 109 + </Link> 110 + </nav> 111 + 112 + {/* Content */} 113 + <main className="content-area"> 114 + {/* Fun decorative elements */} 115 + <div className="floating-star" style={{ 116 + position: 'absolute', 117 + top: '30px', 118 + left: '30px', 119 + fontSize: '40px', 120 + zIndex: 2 121 + }}> 122 + 123 + </div> 124 + 125 + <div className="floating-star" style={{ 126 + position: 'absolute', 127 + top: '60px', 128 + right: '40px', 129 + fontSize: '50px', 130 + zIndex: 2 131 + }}> 132 + 133 + </div> 134 + 135 + <div style={{ 136 + position: 'absolute', 137 + bottom: '100px', 138 + left: '20px', 139 + fontSize: '60px', 140 + zIndex: 2, 141 + animation: 'float 4s ease-in-out infinite' 142 + }}> 143 + 📺 144 + </div> 145 + 146 + <div style={{ 147 + position: 'absolute', 148 + bottom: '150px', 149 + right: '30px', 150 + fontSize: '45px', 151 + zIndex: 2, 152 + animation: 'float 5s ease-in-out infinite' 153 + }}> 154 + 🍿 155 + </div> 156 + 157 + {/* Arrow pointing to video */} 158 + <div style={{ 159 + position: 'absolute', 160 + top: '200px', 161 + left: '-20px', 162 + fontSize: '70px', 163 + zIndex: 2, 164 + transform: 'rotate(45deg)', 165 + animation: 'arrow-point 2s ease-in-out infinite' 166 + }}> 167 + ➡️ 168 + </div> 169 + 170 + <div style={{ position: 'relative', zIndex: 1, maxWidth: '1000px', margin: '0 auto' }}> 171 + <VideoPlayer videoUri={video.uri} /> 172 + 173 + <div style={{ 174 + background: '#fff', 175 + padding: '30px', 176 + borderRadius: '25px', 177 + marginTop: '30px', 178 + boxShadow: '0 8px 25px rgba(0,0,0,0.2)', 179 + position: 'relative' 180 + }}> 181 + {/* Fun label on info box */} 182 + <div className="fun-label" style={{ 183 + position: 'absolute', 184 + top: '-15px', 185 + right: '30px', 186 + background: '#E91E8C', 187 + color: '#fff', 188 + padding: '8px 20px', 189 + borderRadius: '20px', 190 + fontWeight: 'bold', 191 + fontSize: '14px', 192 + boxShadow: '0 4px 10px rgba(0,0,0,0.2)', 193 + zIndex: 10 194 + }}> 195 + 📺 Video Info 196 + </div> 197 + 198 + <h1 style={{ 199 + fontSize: '28px', 200 + fontWeight: 'bold', 201 + color: '#333', 202 + marginBottom: '15px', 203 + fontFamily: "'Comic Neue', cursive" 204 + }}> 205 + {video.value.title} 206 + </h1> 207 + <div style={{ 208 + display: 'flex', 209 + alignItems: 'center', 210 + gap: '18px', 211 + color: '#62166F', 212 + fontWeight: 'bold', 213 + fontSize: '18px', 214 + marginBottom: '25px' 215 + }}> 216 + <span style={{ color: '#E91E8C' }}> 217 + {getDisplayName(handle)} 218 + </span> 219 + <span>•</span> 220 + <span>{formatDuration(video.value.duration)}</span> 221 + <span>•</span> 222 + <span>{new Date(video.value.createdAt).toLocaleDateString()}</span> 223 + </div> 224 + 225 + {/* Share buttons section */} 226 + <div style={{ 227 + borderTop: '3px dashed #A6CC3A', 228 + paddingTop: '25px', 229 + marginTop: '20px' 230 + }}> 231 + <h3 style={{ 232 + fontSize: '22px', 233 + color: '#62166F', 234 + marginBottom: '15px', 235 + display: 'flex', 236 + alignItems: 'center', 237 + gap: '10px' 238 + }}> 239 + <span>📢</span> Share this video! 240 + </h3> 241 + 242 + <div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap' }}> 243 + {/* Bluesky share button */} 244 + <a 245 + href={blueskyShareUrl} 246 + target="_blank" 247 + rel="noopener noreferrer" 248 + style={{ 249 + display: 'inline-flex', 250 + alignItems: 'center', 251 + gap: '10px', 252 + background: '#0085FF', 253 + color: '#fff', 254 + padding: '15px 30px', 255 + borderRadius: '30px', 256 + fontWeight: 'bold', 257 + fontSize: '18px', 258 + textDecoration: 'none', 259 + boxShadow: '0 4px 10px rgba(0,0,0,0.2)', 260 + transition: 'all 0.3s' 261 + }} 262 + > 263 + <span style={{ fontSize: '24px' }}>🦋</span> 264 + Share on Bluesky 265 + </a> 266 + 267 + {/* Copy link button */} 268 + <button 269 + onClick={() => { 270 + navigator.clipboard.writeText(shareUrl); 271 + alert('Link copied to clipboard! 📋'); 272 + }} 273 + style={{ 274 + display: 'inline-flex', 275 + alignItems: 'center', 276 + gap: '10px', 277 + background: '#62166F', 278 + color: '#fff', 279 + padding: '15px 30px', 280 + borderRadius: '30px', 281 + fontWeight: 'bold', 282 + fontSize: '18px', 283 + border: 'none', 284 + cursor: 'pointer', 285 + boxShadow: '0 4px 10px rgba(0,0,0,0.2)' 286 + }} 287 + > 288 + <span style={{ fontSize: '24px' }}>📋</span> 289 + Copy Link 290 + </button> 291 + </div> 292 + </div> 293 + 294 + {/* Random fun element */} 295 + <div style={{ 296 + marginTop: '25px', 297 + padding: '20px', 298 + background: 'linear-gradient(135deg, #A6CC3A, #62166F)', 299 + borderRadius: '20px', 300 + color: '#fff', 301 + textAlign: 'center' 302 + }}> 303 + <p style={{ fontSize: '20px', fontWeight: 'bold' }}> 304 + 🎉 Thanks for watching! 🎉 305 + </p> 306 + <p style={{ fontSize: '16px', marginTop: '10px' }}> 307 + Check out more videos on iStream! 308 + </p> 309 + </div> 310 + </div> 311 + </div> 312 + </main> 313 + </div> 314 + ); 315 + } catch (e) { 316 + return ( 317 + <div className="icarly-container"> 318 + <div style={{ 319 + background: '#F5A623', 320 + padding: '40px', 321 + textAlign: 'center', 322 + color: '#fff', 323 + fontSize: '24px' 324 + }}> 325 + <h2 style={{ color: '#E91E8C' }}>Oops!</h2> 326 + <p>Failed to load video</p> 327 + </div> 328 + </div> 329 + ); 330 + } 331 + }
+160
src/components/BlogPosts.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useEffect } from 'react'; 4 + 5 + interface Post { 6 + uri: string; 7 + cid: string; 8 + author: { 9 + did: string; 10 + handle: string; 11 + displayName?: string; 12 + avatar?: string; 13 + }; 14 + record: { 15 + text: string; 16 + createdAt: string; 17 + reply?: unknown; 18 + }; 19 + replyCount?: number; 20 + repostCount?: number; 21 + likeCount?: number; 22 + indexedAt: string; 23 + isRepost?: boolean; 24 + isThread?: boolean; 25 + } 26 + 27 + export default function BlogPosts() { 28 + const [posts, setPosts] = useState<Post[]>([]); 29 + const [loading, setLoading] = useState(true); 30 + 31 + useEffect(() => { 32 + async function fetchPosts() { 33 + try { 34 + const handles = [ 35 + 'stream1.atmosphereconf.org', 36 + 'stream2.atmosphereconf.org', 37 + 'stream3.atmosphereconf.org', 38 + 'iame.li', 39 + 'stream.place' 40 + ]; 41 + const allPosts: Post[] = []; 42 + const seenUris = new Set<string>(); 43 + 44 + for (const handle of handles) { 45 + const response = await fetch(`https://api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}&limit=20`); 46 + if (!response.ok) continue; 47 + 48 + const data = await response.json(); 49 + 50 + for (const item of data.feed) { 51 + // Skip duplicates 52 + if (seenUris.has(item.post.uri)) { 53 + continue; 54 + } 55 + 56 + if (item.reply && item.reply.parent?.author?.did !== item.post.author.did) { 57 + continue; 58 + } 59 + 60 + seenUris.add(item.post.uri); 61 + 62 + const post: Post = { 63 + uri: item.post.uri, 64 + cid: item.post.cid, 65 + author: item.post.author, 66 + record: item.post.record, 67 + replyCount: item.post.replyCount, 68 + repostCount: item.post.repostCount, 69 + likeCount: item.post.likeCount, 70 + indexedAt: item.post.indexedAt, 71 + isRepost: item.reason?.$type === 'app.bsky.feed.defs#reasonRepost', 72 + isThread: item.reply && item.reply.parent?.author?.did === item.post.author.did, 73 + }; 74 + 75 + allPosts.push(post); 76 + } 77 + } 78 + 79 + allPosts.sort((a, b) => new Date(b.indexedAt).getTime() - new Date(a.indexedAt).getTime()); 80 + 81 + setPosts(allPosts.slice(0, 50)); 82 + } catch (error) { 83 + console.error('Failed to fetch posts:', error); 84 + } finally { 85 + setLoading(false); 86 + } 87 + } 88 + 89 + fetchPosts(); 90 + }, []); 91 + 92 + if (loading) { 93 + return ( 94 + <div style={{ textAlign: 'center', padding: '50px', color: '#fff', fontSize: '20px' }}> 95 + Loading iBlogs... 96 + </div> 97 + ); 98 + } 99 + 100 + return ( 101 + <div className="blog-posts-container"> 102 + {posts.map((post) => ( 103 + <div key={post.uri} className="blog-post"> 104 + <div className="blog-post-header"> 105 + <div className="profile-pic-weird"> 106 + {post.author.avatar ? ( 107 + <img src={post.author.avatar} alt={post.author.handle} /> 108 + ) : ( 109 + <div style={{ 110 + width: '100%', 111 + height: '100%', 112 + background: '#A6CC3A', 113 + display: 'flex', 114 + alignItems: 'center', 115 + justifyContent: 'center', 116 + fontSize: '24px', 117 + fontWeight: 'bold', 118 + color: '#62166F' 119 + }}> 120 + {post.author.handle[0].toUpperCase()} 121 + </div> 122 + )} 123 + </div> 124 + <div> 125 + <div className="blog-post-author"> 126 + {post.author.displayName || post.author.handle} 127 + </div> 128 + <div className="blog-post-date"> 129 + {new Date(post.record.createdAt).toLocaleDateString('en-US', { 130 + month: 'short', 131 + day: 'numeric', 132 + year: 'numeric', 133 + hour: '2-digit', 134 + minute: '2-digit', 135 + })} 136 + </div> 137 + </div> 138 + </div> 139 + 140 + {post.isRepost && ( 141 + <div className="repost-badge">↻ REPOST</div> 142 + )} 143 + 144 + {post.isThread && ( 145 + <div className="thread-indicator">🧵 THREAD</div> 146 + )} 147 + 148 + <div 149 + className="blog-post-content" 150 + dangerouslySetInnerHTML={{ 151 + __html: post.record.text 152 + .replace(/\n/g, '<br />') 153 + .replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>') 154 + }} 155 + /> 156 + </div> 157 + ))} 158 + </div> 159 + ); 160 + }
+318
src/components/HomeClient.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useMemo } from 'react'; 4 + import Image from 'next/image'; 5 + import Link from 'next/link'; 6 + import VideoCard from '@/components/VideoCard'; 7 + import BlogPosts from '@/components/BlogPosts'; 8 + import SnapsGrid from '@/components/SnapsGrid'; 9 + import RandomPlay from '@/components/RandomPlay'; 10 + import INews from '@/components/INews'; 11 + import ISongs from '@/components/ISongs'; 12 + import INeedHelp from '@/components/INeedHelp'; 13 + import { VideoRecord } from '@/lib/types'; 14 + import '../app/icarly.css'; 15 + 16 + interface VideoWithHandle { 17 + uri: string; 18 + cid: string; 19 + value: VideoRecord; 20 + handle: string; 21 + } 22 + 23 + interface HomeClientProps { 24 + videos: VideoWithHandle[]; 25 + initialTab?: string; 26 + } 27 + 28 + export default function HomeClient({ videos, initialTab = 'ivideo' }: HomeClientProps) { 29 + const [activeTab, setActiveTab] = useState(initialTab); 30 + const [searchQuery, setSearchQuery] = useState(''); 31 + const [showYouAreHere, setShowYouAreHere] = useState(true); 32 + 33 + const tabs = [ 34 + { id: 'iblogs', label: 'iBlogs', icon: '💬', href: '/blogs' }, 35 + { id: 'isnaps', label: 'iSnaps', icon: '📷', href: '/snaps' }, 36 + { id: 'inews', label: 'iNews', icon: '📢', href: '/news' }, 37 + { id: 'ivideo', label: 'iVideo', icon: '📹', href: '/' }, 38 + { id: 'iplay', label: 'iPlay', icon: '🎮', href: '/play' }, 39 + { id: 'isongs', label: 'iSongs', icon: '🎵', href: '/songs' }, 40 + { id: 'ihelp', label: 'iNeed Help', icon: '❓', href: '/help' }, 41 + { id: 'isend', label: 'Send Us Stuff', icon: '✉️', href: 'https://tangled.org/stream.place/streamplace', external: true }, 42 + ]; 43 + 44 + const today = new Date().toLocaleDateString('en-US', { 45 + weekday: 'long', 46 + month: 'long', 47 + day: 'numeric', 48 + }); 49 + 50 + const filteredVideos = useMemo(() => { 51 + if (!searchQuery.trim()) return videos; 52 + const query = searchQuery.toLowerCase(); 53 + return videos.filter(video => 54 + video.value.title.toLowerCase().includes(query) 55 + ); 56 + }, [videos, searchQuery]); 57 + 58 + const handleTabClick = (tabId: string) => { 59 + setActiveTab(tabId); 60 + setShowYouAreHere(true); 61 + setTimeout(() => setShowYouAreHere(false), 3000); 62 + }; 63 + 64 + return ( 65 + <div className="icarly-container"> 66 + {/* Floating decorative elements */} 67 + <div className="floating-star" style={{ 68 + position: 'fixed', 69 + top: '100px', 70 + left: '20px', 71 + fontSize: '40px', 72 + transform: 'rotate(-20deg)', 73 + opacity: 0.6, 74 + pointerEvents: 'none', 75 + zIndex: 0 76 + }}> 77 + 78 + </div> 79 + <div className="floating-star" style={{ 80 + position: 'fixed', 81 + top: '300px', 82 + right: '30px', 83 + fontSize: '50px', 84 + transform: 'rotate(15deg)', 85 + opacity: 0.5, 86 + pointerEvents: 'none', 87 + zIndex: 0 88 + }}> 89 + 90 + </div> 91 + <div className="floating-star" style={{ 92 + position: 'fixed', 93 + bottom: '200px', 94 + left: '50px', 95 + fontSize: '35px', 96 + transform: 'rotate(-10deg)', 97 + opacity: 0.6, 98 + pointerEvents: 'none', 99 + zIndex: 0 100 + }}> 101 + 🎵 102 + </div> 103 + 104 + {/* Header */} 105 + <header className="icarly-header"> 106 + {/* Homepage button */} 107 + <Link href="/" className="homepage-btn"> 108 + Homepage 109 + </Link> 110 + 111 + {/* Logo */} 112 + <Link href="/" className="icarly-logo"> 113 + <Image 114 + src="/iStream_Logo.png" 115 + alt="iStream" 116 + width={220} 117 + height={90} 118 + style={{ objectFit: 'contain' }} 119 + /> 120 + </Link> 121 + 122 + {/* Date */} 123 + <div className="date-display"> 124 + <div>Today is {today}</div> 125 + <div className="lemon-tube">&quot;LEMON TUBE&quot;</div> 126 + </div> 127 + 128 + {/* Search */} 129 + <div className="search-section"> 130 + <div className="search-label">search our site</div> 131 + <div className="search-box"> 132 + <input 133 + type="text" 134 + className="search-input" 135 + value={searchQuery} 136 + onChange={(e) => setSearchQuery(e.target.value)} 137 + placeholder="Search videos..." 138 + /> 139 + <button className="search-btn">🔍</button> 140 + </div> 141 + </div> 142 + 143 + {/* Login - goes to stream.place/login */} 144 + <a 145 + href="https://stream.place/login" 146 + target="_blank" 147 + rel="noopener noreferrer" 148 + className="login-btn" 149 + > 150 + login 151 + </a> 152 + 153 + {/* Info button */} 154 + <div className="info-btn"> 155 + click for <span>INFO</span><br />about iStream! 156 + </div> 157 + 158 + {/* Feedback button */} 159 + <div className="feedback-btn"> 160 + feedback<br /><small>click &apos;til it hurts &gt;&gt;</small> 161 + </div> 162 + 163 + {/* Character image with actual photo */} 164 + <div className="character-image"> 165 + <Image 166 + src="/character-image.png" 167 + alt="iStream Team" 168 + width={200} 169 + height={200} 170 + style={{ objectFit: 'cover' }} 171 + /> 172 + </div> 173 + 174 + {/* Fun floating labels */} 175 + <div className="fun-label" style={{ 176 + position: 'absolute', 177 + top: '20px', 178 + right: '450px', 179 + background: '#FFD700', 180 + padding: '10px 20px', 181 + borderRadius: '20px', 182 + transform: 'rotate(-10deg)', 183 + fontWeight: 'bold', 184 + color: '#62166F', 185 + boxShadow: '3px 3px 0 rgba(0,0,0,0.2)', 186 + zIndex: 5 187 + }}> 188 + ✨ NEW! ✨ 189 + </div> 190 + </header> 191 + 192 + {/* Navigation Tabs */} 193 + <nav className="nav-tabs" style={{ position: 'relative' }}> 194 + {/* You are here indicator */} 195 + {showYouAreHere && ( 196 + <div style={{ 197 + position: 'absolute', 198 + top: '-45px', 199 + left: '50%', 200 + transform: 'translateX(-50%)', 201 + background: '#FF6B6B', 202 + color: '#fff', 203 + padding: '10px 25px', 204 + borderRadius: '25px', 205 + fontWeight: 'bold', 206 + fontSize: '16px', 207 + boxShadow: '0 4px 15px rgba(0,0,0,0.3)', 208 + animation: 'pulse 1s infinite', 209 + zIndex: 100, 210 + whiteSpace: 'nowrap' 211 + }}> 212 + 📍 You are here! 213 + </div> 214 + )} 215 + 216 + {tabs.map((tab) => ( 217 + tab.external ? ( 218 + <a 219 + key={tab.id} 220 + href={tab.href} 221 + target="_blank" 222 + rel="noopener noreferrer" 223 + className="nav-tab" 224 + > 225 + <span className="nav-tab-icon">{tab.icon}</span> 226 + {tab.label} 227 + </a> 228 + ) : ( 229 + <Link 230 + key={tab.id} 231 + href={tab.href} 232 + className={`nav-tab ${activeTab === tab.id ? 'active' : ''}`} 233 + onClick={() => handleTabClick(tab.id)} 234 + > 235 + <span className="nav-tab-icon">{tab.icon}</span> 236 + {tab.label} 237 + </Link> 238 + ) 239 + ))} 240 + </nav> 241 + 242 + {/* Content Area */} 243 + <main className="content-area"> 244 + {/* Decorative circles */} 245 + <div className="decorative-circles"> 246 + <div className="circle circle-1" /> 247 + <div className="circle circle-2" /> 248 + <div className="circle circle-3" /> 249 + <div className="circle circle-4" /> 250 + <div className="circle circle-5" /> 251 + <div className="circle circle-6" /> 252 + <div className="circle circle-7" /> 253 + <div className="circle circle-8" /> 254 + </div> 255 + 256 + {/* Fun badges and labels */} 257 + <div className="fun-label" style={{ 258 + position: 'absolute', 259 + top: '30px', 260 + right: '50px', 261 + background: '#62166F', 262 + color: '#fff', 263 + padding: '15px 25px', 264 + borderRadius: '50%', 265 + fontWeight: 'bold', 266 + fontSize: '18px', 267 + boxShadow: '0 4px 10px rgba(0,0,0,0.3)', 268 + zIndex: 2 269 + }}> 270 + COOL! 271 + </div> 272 + 273 + <div className="fun-label" style={{ 274 + position: 'absolute', 275 + bottom: '50px', 276 + left: '40px', 277 + background: '#A6CC3A', 278 + color: '#62166F', 279 + padding: '15px 25px', 280 + borderRadius: '30px', 281 + fontWeight: 'bold', 282 + fontSize: '18px', 283 + boxShadow: '0 4px 10px rgba(0,0,0,0.3)', 284 + zIndex: 2 285 + }}> 286 + 🎉 Have fun! 🎉 287 + </div> 288 + 289 + {/* iVideo Grid */} 290 + {activeTab === 'ivideo' && ( 291 + <div className="video-grid"> 292 + {filteredVideos.map((video) => ( 293 + <VideoCard key={video.uri} video={video} /> 294 + ))} 295 + </div> 296 + )} 297 + 298 + {/* iBlogs */} 299 + {activeTab === 'iblogs' && <BlogPosts />} 300 + 301 + {/* iSnaps */} 302 + {activeTab === 'isnaps' && <SnapsGrid />} 303 + 304 + {/* iNews */} 305 + {activeTab === 'inews' && <INews />} 306 + 307 + {/* iSongs */} 308 + {activeTab === 'isongs' && <ISongs />} 309 + 310 + {/* iNeed Help */} 311 + {activeTab === 'ihelp' && <INeedHelp />} 312 + 313 + {/* iPlay - Random Video */} 314 + {activeTab === 'iplay' && <RandomPlay videos={videos} />} 315 + </main> 316 + </div> 317 + ); 318 + }
+227
src/components/INeedHelp.tsx
··· 1 + 'use client'; 2 + 3 + import Link from 'next/link'; 4 + 5 + interface SitemapItem { 6 + title: string; 7 + href: string; 8 + icon: string; 9 + description: string; 10 + } 11 + 12 + const sitemapItems: SitemapItem[] = [ 13 + { 14 + title: 'iVideo', 15 + href: '/', 16 + icon: '📹', 17 + description: 'Watch all the latest videos from AtmosphereConf!' 18 + }, 19 + { 20 + title: 'iBlogs', 21 + href: '/?tab=iblogs', 22 + icon: '💬', 23 + description: 'Read posts from all our streamers on Bluesky!' 24 + }, 25 + { 26 + title: 'iSnaps', 27 + href: '/?tab=isnaps', 28 + icon: '📷', 29 + description: 'Check out photos from the streams!' 30 + }, 31 + { 32 + title: 'iNews', 33 + href: '/?tab=inews', 34 + icon: '📢', 35 + description: 'Get the latest breaking news about AtmosphereConf!' 36 + }, 37 + { 38 + title: 'iPlay', 39 + href: '/?tab=iplay', 40 + icon: '🎮', 41 + description: 'Play a random video - surprise yourself!' 42 + }, 43 + { 44 + title: 'iSongs', 45 + href: '/?tab=isongs', 46 + icon: '🎵', 47 + description: 'Listen to music powered by plyr.fm!' 48 + }, 49 + { 50 + title: 'Send Us Stuff', 51 + href: 'https://tangled.org/stream.place/streamplace', 52 + icon: '✉️', 53 + description: 'Visit our code repo on Tangled!' 54 + }, 55 + { 56 + title: 'About iStream', 57 + href: '#', 58 + icon: 'ℹ️', 59 + description: 'Learn more about the iStream project!' 60 + }, 61 + { 62 + title: 'Login', 63 + href: '#', 64 + icon: '🔑', 65 + description: 'Sign in to your account (coming soon)!' 66 + }, 67 + { 68 + title: 'Feedback', 69 + href: '#', 70 + icon: '💭', 71 + description: 'Tell us what you think - click til it hurts!' 72 + } 73 + ]; 74 + 75 + const faqItems = [ 76 + { 77 + question: "What's AtmosphereConf?", 78 + answer: "The coolest conference about AT Protocol, streaming, and all things decentralized!" 79 + }, 80 + { 81 + question: "Who are the streamers?", 82 + answer: "We have Stream 1, Stream 2, and Stream 3 - plus special appearances by @iame.li and @stream.place!" 83 + }, 84 + { 85 + question: "Why does Gibby post random stuff?", 86 + answer: "That's just Gibby being Gibby! ¯\\_(ツ)_/¯" 87 + }, 88 + { 89 + question: "Can I upload my own videos?", 90 + answer: "Coming soon! We're working on making iStream even more awesome!" 91 + }, 92 + { 93 + question: "Is this really iCarly?", 94 + answer: "No, but we were definitely inspired by the greatest web show of all time!" 95 + } 96 + ]; 97 + 98 + export default function INeedHelp() { 99 + return ( 100 + <div style={{ position: 'relative', zIndex: 1, maxWidth: '1000px', margin: '0 auto' }}> 101 + <div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap' }}> 102 + {/* Sitemap Section */} 103 + <div style={{ flex: 1, minWidth: '300px' }}> 104 + <h2 style={{ 105 + color: '#fff', 106 + fontSize: '32px', 107 + marginBottom: '25px', 108 + textShadow: '3px 3px 0 rgba(0,0,0,0.3)' 109 + }}> 110 + 📍 Site Map 111 + </h2> 112 + 113 + <div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}> 114 + {sitemapItems.map((item, index) => ( 115 + <Link 116 + key={item.title} 117 + href={item.href} 118 + style={{ textDecoration: 'none' }} 119 + > 120 + <div style={{ 121 + background: '#fff', 122 + borderRadius: '15px', 123 + padding: '20px', 124 + display: 'flex', 125 + alignItems: 'center', 126 + gap: '15px', 127 + boxShadow: '0 4px 15px rgba(0,0,0,0.2)', 128 + transform: index % 2 === 0 ? 'rotate(-1deg)' : 'rotate(1deg)', 129 + transition: 'transform 0.2s', 130 + cursor: 'pointer' 131 + }}> 132 + <span style={{ fontSize: '35px' }}>{item.icon}</span> 133 + <div> 134 + <h3 style={{ 135 + fontSize: '20px', 136 + color: '#62166F', 137 + fontWeight: 'bold', 138 + marginBottom: '5px' 139 + }}> 140 + {item.title} 141 + </h3> 142 + <p style={{ color: '#666', fontSize: '14px' }}> 143 + {item.description} 144 + </p> 145 + </div> 146 + </div> 147 + </Link> 148 + ))} 149 + </div> 150 + </div> 151 + 152 + {/* FAQ Section */} 153 + <div style={{ flex: 1, minWidth: '300px' }}> 154 + <h2 style={{ 155 + color: '#fff', 156 + fontSize: '32px', 157 + marginBottom: '25px', 158 + textShadow: '3px 3px 0 rgba(0,0,0,0.3)' 159 + }}> 160 + ❓ FAQ 161 + </h2> 162 + 163 + <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> 164 + {faqItems.map((item, index) => ( 165 + <div 166 + key={index} 167 + style={{ 168 + background: '#fff', 169 + borderRadius: '20px', 170 + padding: '25px', 171 + boxShadow: '0 6px 20px rgba(0,0,0,0.2)', 172 + transform: index % 2 === 0 ? 'rotate(1deg)' : 'rotate(-1deg)' 173 + }} 174 + > 175 + <h3 style={{ 176 + fontSize: '20px', 177 + color: '#62166F', 178 + fontWeight: 'bold', 179 + marginBottom: '10px', 180 + display: 'flex', 181 + alignItems: 'center', 182 + gap: '10px' 183 + }}> 184 + <span style={{ fontSize: '25px' }}>❓</span> 185 + {item.question} 186 + </h3> 187 + <p style={{ color: '#333', fontSize: '16px', lineHeight: 1.5 }}> 188 + {item.answer} 189 + </p> 190 + </div> 191 + ))} 192 + </div> 193 + 194 + {/* Contact Box */} 195 + <div style={{ 196 + background: 'linear-gradient(135deg, #62166F, #8B2F9B)', 197 + borderRadius: '20px', 198 + padding: '30px', 199 + marginTop: '30px', 200 + boxShadow: '0 6px 20px rgba(0,0,0,0.3)', 201 + color: '#fff', 202 + textAlign: 'center' 203 + }}> 204 + <h3 style={{ fontSize: '24px', marginBottom: '15px' }}> 205 + 🆘 Still Need Help? 206 + </h3> 207 + <p style={{ fontSize: '18px', marginBottom: '20px' }}> 208 + Try asking Lewbot - he knows everything! 209 + </p> 210 + <div style={{ 211 + fontSize: '60px', 212 + animation: 'bounce 1s infinite' 213 + }}> 214 + 🤖 215 + </div> 216 + <style jsx>{` 217 + @keyframes bounce { 218 + 0%, 100% { transform: translateY(0); } 219 + 50% { transform: translateY(-10px); } 220 + } 221 + `}</style> 222 + </div> 223 + </div> 224 + </div> 225 + </div> 226 + ); 227 + }
+221
src/components/INews.tsx
··· 1 + 'use client'; 2 + 3 + import { useState } from 'react'; 4 + 5 + interface NewsItem { 6 + id: number; 7 + headline: string; 8 + category: string; 9 + time: string; 10 + icon: string; 11 + } 12 + 13 + const newsItems: NewsItem[] = [ 14 + { 15 + id: 1, 16 + headline: "SPENCER'S SCULPTURE ACCIDENTALLY STREAMS TO ATMOSPHERECONF!", 17 + category: "BREAKING", 18 + time: "2 mins ago", 19 + icon: "🔥" 20 + }, 21 + { 22 + id: 2, 23 + headline: "GIBBY DROPS MIC DURING LIVESTREAM - VIEWERS GO WILD!", 24 + category: "VIRAL", 25 + time: "15 mins ago", 26 + icon: "🎤" 27 + }, 28 + { 29 + id: 3, 30 + headline: "ATPROTO PRESENTS: GIBBY VS THE TECHNICAL DIFFICULTIES", 31 + category: "TECH", 32 + time: "1 hour ago", 33 + icon: "⚡" 34 + }, 35 + { 36 + id: 4, 37 + headline: "LEWBOT HOSTS SURPRISE DANCE PARTY IN STREAM 2 CHAT!", 38 + category: "ENTERTAINMENT", 39 + time: "2 hours ago", 40 + icon: "💃" 41 + }, 42 + { 43 + id: 5, 44 + headline: "STREAM.PLACE TEAM ACCIDENTALLY BUILDS WORKING VIDEO APP", 45 + category: "WOW", 46 + time: "3 hours ago", 47 + icon: "🎬" 48 + }, 49 + { 50 + id: 6, 51 + headline: "GIBBY'S RANDOM SPAM BREAKS THE INTERNET (AGAIN)", 52 + category: "TECH", 53 + time: "4 hours ago", 54 + icon: "📱" 55 + }, 56 + { 57 + id: 7, 58 + headline: "ATMOSPHERECONF ATTENDEES SPOTTED EATING SPAGHETTI TACOS!", 59 + category: "FOOD", 60 + time: "5 hours ago", 61 + icon: "🌮" 62 + }, 63 + { 64 + id: 8, 65 + headline: "LEWBOT SINGS HAPPY BIRTHDAY TO EVERYONE AT ONCE", 66 + category: "MUSIC", 67 + time: "6 hours ago", 68 + icon: "🎂" 69 + }, 70 + { 71 + id: 9, 72 + headline: "STREAM 3 ACHIEVES WORLD RECORD FOR MOST RANDOM CONVERSATIONS", 73 + category: "RECORDS", 74 + time: "8 hours ago", 75 + icon: "🏆" 76 + }, 77 + { 78 + id: 10, 79 + headline: "GIBBY'S NEW CATCHPHRASE TRENDS WORLDWIDE: 'I'M GIBBY!'", 80 + category: "CULTURE", 81 + time: "10 hours ago", 82 + icon: "🌍" 83 + }, 84 + { 85 + id: 11, 86 + headline: "ATPROTO DEVS DISCOVER VIDEO IS JUST FAST PICTURES", 87 + category: "SCIENCE", 88 + time: "12 hours ago", 89 + icon: "🔬" 90 + }, 91 + { 92 + id: 12, 93 + headline: "SAM'S REMOTE CONTROL PRANK GOES WRONG DURING LIVE STREAM", 94 + category: "DRAMA", 95 + time: "1 day ago", 96 + icon: "📺" 97 + } 98 + ]; 99 + 100 + export default function INews() { 101 + const [selectedCategory, setSelectedCategory] = useState<string>('ALL'); 102 + 103 + const categories = ['ALL', ...Array.from(new Set(newsItems.map(item => item.category)))]; 104 + 105 + const filteredNews = selectedCategory === 'ALL' 106 + ? newsItems 107 + : newsItems.filter(item => item.category === selectedCategory); 108 + 109 + return ( 110 + <div style={{ position: 'relative', zIndex: 1, maxWidth: '900px', margin: '0 auto' }}> 111 + {/* Category filters */} 112 + <div style={{ 113 + display: 'flex', 114 + gap: '10px', 115 + marginBottom: '30px', 116 + flexWrap: 'wrap', 117 + justifyContent: 'center' 118 + }}> 119 + {categories.map(cat => ( 120 + <button 121 + key={cat} 122 + onClick={() => setSelectedCategory(cat)} 123 + style={{ 124 + padding: '10px 20px', 125 + background: selectedCategory === cat ? '#62166F' : '#fff', 126 + color: selectedCategory === cat ? '#fff' : '#62166F', 127 + border: '3px solid #62166F', 128 + borderRadius: '25px', 129 + fontWeight: 'bold', 130 + fontSize: '14px', 131 + cursor: 'pointer', 132 + fontFamily: "'Comic Neue', cursive", 133 + textTransform: 'uppercase' 134 + }} 135 + > 136 + {cat} 137 + </button> 138 + ))} 139 + </div> 140 + 141 + {/* News items */} 142 + <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> 143 + {filteredNews.map((item, index) => ( 144 + <div 145 + key={item.id} 146 + style={{ 147 + background: '#fff', 148 + borderRadius: '20px', 149 + padding: '25px', 150 + boxShadow: '0 6px 20px rgba(0,0,0,0.2)', 151 + transform: index % 2 === 0 ? 'rotate(-1deg)' : 'rotate(1deg)', 152 + position: 'relative', 153 + overflow: 'hidden' 154 + }} 155 + > 156 + {/* Breaking news banner */} 157 + {item.category === 'BREAKING' && ( 158 + <div style={{ 159 + position: 'absolute', 160 + top: '10px', 161 + right: '-30px', 162 + background: '#ff0000', 163 + color: '#fff', 164 + padding: '5px 40px', 165 + transform: 'rotate(45deg)', 166 + fontWeight: 'bold', 167 + fontSize: '12px', 168 + boxShadow: '0 2px 5px rgba(0,0,0,0.3)' 169 + }}> 170 + BREAKING! 171 + </div> 172 + )} 173 + 174 + <div style={{ display: 'flex', alignItems: 'flex-start', gap: '20px' }}> 175 + <div style={{ 176 + fontSize: '50px', 177 + lineHeight: 1 178 + }}> 179 + {item.icon} 180 + </div> 181 + <div style={{ flex: 1 }}> 182 + <div style={{ 183 + display: 'flex', 184 + gap: '15px', 185 + marginBottom: '10px', 186 + alignItems: 'center' 187 + }}> 188 + <span style={{ 189 + background: '#62166F', 190 + color: '#fff', 191 + padding: '5px 15px', 192 + borderRadius: '15px', 193 + fontSize: '12px', 194 + fontWeight: 'bold' 195 + }}> 196 + {item.category} 197 + </span> 198 + <span style={{ 199 + color: '#666', 200 + fontSize: '14px' 201 + }}> 202 + ⏱️ {item.time} 203 + </span> 204 + </div> 205 + <h3 style={{ 206 + fontSize: '22px', 207 + fontWeight: 'bold', 208 + color: '#333', 209 + lineHeight: 1.3, 210 + fontFamily: "'Comic Neue', cursive" 211 + }}> 212 + {item.headline} 213 + </h3> 214 + </div> 215 + </div> 216 + </div> 217 + ))} 218 + </div> 219 + </div> 220 + ); 221 + }
+296
src/components/ISongs.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useEffect, useRef } from 'react'; 4 + 5 + interface Track { 6 + id: number; 7 + title: string; 8 + artist: string; 9 + album?: string; 10 + duration?: number; 11 + play_count?: number; 12 + stream_url?: string; 13 + cover_url?: string; 14 + } 15 + 16 + export default function ISongs() { 17 + const [tracks, setTracks] = useState<Track[]>([]); 18 + const [currentTrack, setCurrentTrack] = useState<Track | null>(null); 19 + const [isPlaying, setIsPlaying] = useState(false); 20 + const [loading, setLoading] = useState(true); 21 + const [searchQuery, setSearchQuery] = useState(''); 22 + const [progress, setProgress] = useState(0); 23 + const audioRef = useRef<HTMLAudioElement | null>(null); 24 + 25 + useEffect(() => { 26 + async function fetchTracks() { 27 + try { 28 + // Fetch top tracks from plyr.fm 29 + const response = await fetch('https://api.plyr.fm/tracks?limit=20'); 30 + if (response.ok) { 31 + const data = await response.json(); 32 + setTracks(data.tracks || []); 33 + } else { 34 + // Fallback mock data 35 + setTracks([ 36 + { id: 1, title: 'Discover', artist: 'Daft Punk', album: 'Random Access Memories', play_count: 15000 }, 37 + { id: 2, title: 'Get Lucky', artist: 'Daft Punk ft. Pharrell', album: 'Random Access Memories', play_count: 25000 }, 38 + { id: 3, title: 'One More Time', artist: 'Daft Punk', album: 'Discovery', play_count: 30000 }, 39 + { id: 4, title: 'Harder Better Faster', artist: 'Daft Punk', album: 'Discovery', play_count: 22000 }, 40 + { id: 5, title: 'Around the World', artist: 'Daft Punk', album: 'Homework', play_count: 18000 }, 41 + ]); 42 + } 43 + } catch (error) { 44 + console.error('Failed to fetch tracks:', error); 45 + setTracks([ 46 + { id: 1, title: 'Stream On', artist: 'iStream', album: 'AtmosphereConf', play_count: 9999 }, 47 + { id: 2, title: 'Random Gibby', artist: 'Gibby', album: 'Chaos', play_count: 42 }, 48 + ]); 49 + } finally { 50 + setLoading(false); 51 + } 52 + } 53 + 54 + fetchTracks(); 55 + }, []); 56 + 57 + useEffect(() => { 58 + if (audioRef.current) { 59 + if (isPlaying) { 60 + audioRef.current.play(); 61 + } else { 62 + audioRef.current.pause(); 63 + } 64 + } 65 + }, [isPlaying, currentTrack]); 66 + 67 + const handleSearch = async () => { 68 + if (!searchQuery.trim()) return; 69 + setLoading(true); 70 + try { 71 + const response = await fetch(`https://api.plyr.fm/search?q=${encodeURIComponent(searchQuery)}&limit=20`); 72 + if (response.ok) { 73 + const data = await response.json(); 74 + setTracks(data.tracks || []); 75 + } 76 + } catch (error) { 77 + console.error('Search failed:', error); 78 + } finally { 79 + setLoading(false); 80 + } 81 + }; 82 + 83 + const playTrack = async (track: Track) => { 84 + setCurrentTrack(track); 85 + setIsPlaying(true); 86 + // Get stream URL 87 + try { 88 + const response = await fetch(`https://api.plyr.fm/tracks/${track.id}/stream`); 89 + if (response.ok && audioRef.current) { 90 + audioRef.current.src = response.url; 91 + audioRef.current.play(); 92 + } 93 + } catch (error) { 94 + console.error('Failed to get stream:', error); 95 + } 96 + }; 97 + 98 + const togglePlay = () => { 99 + setIsPlaying(!isPlaying); 100 + }; 101 + 102 + const formatDuration = (seconds?: number) => { 103 + if (!seconds) return '0:00'; 104 + const mins = Math.floor(seconds / 60); 105 + const secs = Math.floor(seconds % 60); 106 + return `${mins}:${secs.toString().padStart(2, '0')}`; 107 + }; 108 + 109 + if (loading) { 110 + return ( 111 + <div style={{ textAlign: 'center', padding: '50px', color: '#fff', fontSize: '20px' }}> 112 + 🎵 Loading iSongs from plyr.fm... 113 + </div> 114 + ); 115 + } 116 + 117 + return ( 118 + <div style={{ position: 'relative', zIndex: 1 }}> 119 + {/* Hidden audio element */} 120 + <audio ref={audioRef} onTimeUpdate={(e) => setProgress(e.currentTarget.currentTime)} /> 121 + 122 + {/* Music Player */} 123 + {currentTrack && ( 124 + <div style={{ 125 + background: 'linear-gradient(135deg, #62166F, #8B2F9B)', 126 + borderRadius: '20px', 127 + padding: '25px', 128 + marginBottom: '30px', 129 + boxShadow: '0 6px 20px rgba(0,0,0,0.3)', 130 + }}> 131 + <div style={{ display: 'flex', alignItems: 'center', gap: '25px' }}> 132 + {/* Album Art */} 133 + <div style={{ 134 + width: '100px', 135 + height: '100px', 136 + background: currentTrack.cover_url ? `url(${currentTrack.cover_url})` : '#A6CC3A', 137 + backgroundSize: 'cover', 138 + borderRadius: '15px', 139 + display: 'flex', 140 + alignItems: 'center', 141 + justifyContent: 'center', 142 + fontSize: '50px', 143 + border: '4px solid #fff' 144 + }}> 145 + {!currentTrack.cover_url && '🎵'} 146 + </div> 147 + 148 + <div style={{ flex: 1 }}> 149 + <h3 style={{ color: '#fff', fontSize: '24px', marginBottom: '5px' }}> 150 + {currentTrack.title} 151 + </h3> 152 + <p style={{ color: '#A6CC3A', fontSize: '18px' }}> 153 + {currentTrack.artist} 154 + </p> 155 + {/* Progress bar */} 156 + <div style={{ 157 + width: '100%', 158 + height: '8px', 159 + background: 'rgba(255,255,255,0.3)', 160 + borderRadius: '4px', 161 + marginTop: '10px', 162 + overflow: 'hidden' 163 + }}> 164 + <div style={{ 165 + width: `${(progress / (currentTrack.duration || 180)) * 100}%`, 166 + height: '100%', 167 + background: '#A6CC3A', 168 + borderRadius: '4px' 169 + }} /> 170 + </div> 171 + </div> 172 + 173 + <button 174 + onClick={togglePlay} 175 + style={{ 176 + width: '70px', 177 + height: '70px', 178 + borderRadius: '50%', 179 + background: '#A6CC3A', 180 + border: 'none', 181 + fontSize: '30px', 182 + cursor: 'pointer', 183 + display: 'flex', 184 + alignItems: 'center', 185 + justifyContent: 'center', 186 + boxShadow: '0 4px 10px rgba(0,0,0,0.3)' 187 + }} 188 + > 189 + {isPlaying ? '⏸️' : '▶️'} 190 + </button> 191 + </div> 192 + </div> 193 + )} 194 + 195 + {/* Powered by plyr.fm badge */} 196 + <div style={{ 197 + textAlign: 'center', 198 + marginBottom: '25px', 199 + padding: '10px', 200 + background: 'rgba(98, 22, 111, 0.8)', 201 + borderRadius: '30px', 202 + color: '#fff', 203 + fontWeight: 'bold' 204 + }}> 205 + 🎵 Powered by <a href="https://plyr.fm" target="_blank" rel="noopener noreferrer" style={{ color: '#A6CC3A' }}>plyr.fm</a> 206 + </div> 207 + 208 + {/* Search */} 209 + <div style={{ display: 'flex', gap: '10px', marginBottom: '25px' }}> 210 + <input 211 + type="text" 212 + placeholder="Search tracks on plyr.fm..." 213 + value={searchQuery} 214 + onChange={(e) => setSearchQuery(e.target.value)} 215 + onKeyPress={(e) => e.key === 'Enter' && handleSearch()} 216 + style={{ 217 + flex: 1, 218 + padding: '15px 25px', 219 + border: '3px solid #62166F', 220 + borderRadius: '30px', 221 + fontSize: '18px', 222 + fontFamily: "'Comic Neue', cursive", 223 + outline: 'none' 224 + }} 225 + /> 226 + <button 227 + onClick={handleSearch} 228 + style={{ 229 + padding: '15px 30px', 230 + background: '#62166F', 231 + color: '#fff', 232 + border: 'none', 233 + borderRadius: '30px', 234 + fontSize: '18px', 235 + fontWeight: 'bold', 236 + cursor: 'pointer' 237 + }} 238 + > 239 + 🔍 Search 240 + </button> 241 + </div> 242 + 243 + {/* Tracks List */} 244 + <div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}> 245 + {tracks.map((track, index) => ( 246 + <div 247 + key={track.id} 248 + onClick={() => playTrack(track)} 249 + style={{ 250 + background: '#fff', 251 + borderRadius: '15px', 252 + padding: '20px', 253 + display: 'flex', 254 + alignItems: 'center', 255 + gap: '20px', 256 + cursor: 'pointer', 257 + boxShadow: '0 4px 15px rgba(0,0,0,0.1)', 258 + }} 259 + > 260 + <div style={{ 261 + width: '60px', 262 + height: '60px', 263 + background: '#A6CC3A', 264 + borderRadius: '10px', 265 + display: 'flex', 266 + alignItems: 'center', 267 + justifyContent: 'center', 268 + fontSize: '28px', 269 + border: '3px solid #62166F' 270 + }}> 271 + {currentTrack?.id === track.id && isPlaying ? '⏸️' : '▶️'} 272 + </div> 273 + <div style={{ flex: 1 }}> 274 + <h4 style={{ fontSize: '20px', color: '#333', marginBottom: '5px' }}> 275 + {track.title} 276 + </h4> 277 + <p style={{ color: '#62166F', fontSize: '16px' }}> 278 + {track.artist} {track.album && `• ${track.album}`} 279 + </p> 280 + </div> 281 + <div style={{ textAlign: 'right' }}> 282 + <p style={{ color: '#666', fontSize: '14px' }}> 283 + ▶️ {track.play_count?.toLocaleString()} plays 284 + </p> 285 + {track.duration && ( 286 + <p style={{ color: '#999', fontSize: '14px' }}> 287 + {formatDuration(track.duration)} 288 + </p> 289 + )} 290 + </div> 291 + </div> 292 + ))} 293 + </div> 294 + </div> 295 + ); 296 + }
+42
src/components/RandomPlay.tsx
··· 1 + 'use client'; 2 + 3 + import { useRouter } from 'next/navigation'; 4 + import { VideoRecord } from '@/lib/types'; 5 + 6 + interface VideoWithHandle { 7 + uri: string; 8 + cid: string; 9 + value: VideoRecord; 10 + handle: string; 11 + } 12 + 13 + interface RandomPlayProps { 14 + videos: VideoWithHandle[]; 15 + } 16 + 17 + export default function RandomPlay({ videos }: RandomPlayProps) { 18 + const router = useRouter(); 19 + 20 + const playRandom = () => { 21 + if (videos.length === 0) return; 22 + const randomVideo = videos[Math.floor(Math.random() * videos.length)]; 23 + const id = randomVideo.uri.split('/').pop(); 24 + router.push(`/watch/${id}`); 25 + }; 26 + 27 + return ( 28 + <div className="random-play-container"> 29 + <button className="random-play-btn" onClick={playRandom}> 30 + ▶ Play Random! 31 + </button> 32 + <p style={{ 33 + marginTop: '30px', 34 + color: '#fff', 35 + fontSize: '20px', 36 + textShadow: '2px 2px 0 rgba(0,0,0,0.3)' 37 + }}> 38 + Click to watch a random video from any stream! 39 + </p> 40 + </div> 41 + ); 42 + }
+212
src/components/SnapsGrid.tsx
··· 1 + 'use client'; 2 + 3 + import { useState, useEffect } from 'react'; 4 + 5 + interface ImagePost { 6 + uri: string; 7 + cid: string; 8 + author: { 9 + did: string; 10 + handle: string; 11 + displayName?: string; 12 + }; 13 + images: { 14 + thumb: string; 15 + fullsize: string; 16 + alt: string; 17 + }[]; 18 + text: string; 19 + createdAt: string; 20 + } 21 + 22 + export default function SnapsGrid() { 23 + const [images, setImages] = useState<ImagePost[]>([]); 24 + const [loading, setLoading] = useState(true); 25 + 26 + useEffect(() => { 27 + async function fetchImages() { 28 + try { 29 + const handles = [ 30 + 'stream1.atmosphereconf.org', 31 + 'stream2.atmosphereconf.org', 32 + 'stream3.atmosphereconf.org', 33 + 'iame.li', 34 + 'stream.place' 35 + ]; 36 + const allImages: ImagePost[] = []; 37 + const seenUris = new Set<string>(); 38 + 39 + for (const handle of handles) { 40 + const response = await fetch(`https://api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}&limit=50`); 41 + if (!response.ok) continue; 42 + 43 + const data = await response.json(); 44 + 45 + for (const item of data.feed) { 46 + if (seenUris.has(item.post.uri)) continue; 47 + 48 + const embed = item.post.embed; 49 + if (embed?.$type === 'app.bsky.embed.images#view' && embed.images?.length > 0) { 50 + seenUris.add(item.post.uri); 51 + allImages.push({ 52 + uri: item.post.uri, 53 + cid: item.post.cid, 54 + author: item.post.author, 55 + images: embed.images.map((img: { thumb: string; fullsize: string; alt?: string }) => ({ 56 + thumb: img.thumb, 57 + fullsize: img.fullsize, 58 + alt: img.alt || 'Image', 59 + })), 60 + text: item.post.record.text || '', 61 + createdAt: item.post.record.createdAt, 62 + }); 63 + } 64 + } 65 + } 66 + 67 + allImages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 68 + setImages(allImages.slice(0, 50)); 69 + } catch (error) { 70 + console.error('Failed to fetch images:', error); 71 + } finally { 72 + setLoading(false); 73 + } 74 + } 75 + 76 + fetchImages(); 77 + }, []); 78 + 79 + if (loading) { 80 + return ( 81 + <div style={{ textAlign: 'center', padding: '50px', color: '#fff', fontSize: '20px' }}> 82 + 📸 Loading iSnaps... 83 + </div> 84 + ); 85 + } 86 + 87 + return ( 88 + <div style={{ position: 'relative' }}> 89 + {/* Floating decorative elements */} 90 + <div className="floating-shape" style={{ 91 + position: 'absolute', 92 + top: '-40px', 93 + left: '10%', 94 + fontSize: '60px', 95 + zIndex: 2, 96 + animationDelay: '0s' 97 + }}> 98 + 📸 99 + </div> 100 + 101 + <div className="floating-shape" style={{ 102 + position: 'absolute', 103 + top: '20%', 104 + right: '-30px', 105 + fontSize: '50px', 106 + zIndex: 2, 107 + animationDelay: '1s' 108 + }}> 109 + 🎯 110 + </div> 111 + 112 + <div className="floating-arrow" style={{ 113 + position: 'absolute', 114 + top: '50%', 115 + left: '-50px', 116 + fontSize: '60px', 117 + zIndex: 2, 118 + transform: 'rotate(-30deg)' 119 + }}> 120 + ➡️ 121 + </div> 122 + 123 + <div className="floating-arrow" style={{ 124 + position: 'absolute', 125 + bottom: '20%', 126 + right: '-40px', 127 + fontSize: '50px', 128 + zIndex: 2, 129 + transform: 'rotate(135deg)' 130 + }}> 131 + ➡️ 132 + </div> 133 + 134 + {/* Labels pointing to things */} 135 + <div className="fun-label" style={{ 136 + position: 'absolute', 137 + top: '10%', 138 + left: '-80px', 139 + background: '#62166F', 140 + color: '#fff', 141 + padding: '10px 20px', 142 + borderRadius: '20px', 143 + fontWeight: 'bold', 144 + fontSize: '14px', 145 + boxShadow: '0 4px 10px rgba(0,0,0,0.3)', 146 + zIndex: 2, 147 + transform: 'rotate(-10deg)' 148 + }}> 149 + ← Click me! 150 + </div> 151 + 152 + <div className="fun-label" style={{ 153 + position: 'absolute', 154 + bottom: '30%', 155 + right: '-100px', 156 + background: '#E91E8C', 157 + color: '#fff', 158 + padding: '10px 20px', 159 + borderRadius: '20px', 160 + fontWeight: 'bold', 161 + fontSize: '14px', 162 + boxShadow: '0 4px 10px rgba(0,0,0,0.3)', 163 + zIndex: 2, 164 + transform: 'rotate(10deg)' 165 + }}> 166 + Cool pics! → 167 + </div> 168 + 169 + <div className="snaps-grid"> 170 + {images.map((post, index) => 171 + post.images.map((image, imgIndex) => ( 172 + <div 173 + key={`${post.uri}-${imgIndex}`} 174 + className="snap-item" 175 + title={post.text} 176 + style={{ 177 + transform: `rotate(${index % 2 === 0 ? -2 : 2}deg)` 178 + }} 179 + > 180 + <img 181 + src={image.thumb} 182 + alt={image.alt} 183 + loading="lazy" 184 + onClick={() => window.open(image.fullsize, '_blank')} 185 + style={{ cursor: 'pointer' }} 186 + /> 187 + </div> 188 + )) 189 + )} 190 + </div> 191 + 192 + {/* More floating fun elements */} 193 + <div style={{ 194 + position: 'absolute', 195 + bottom: '-30px', 196 + left: '50%', 197 + transform: 'translateX(-50%)', 198 + background: '#A6CC3A', 199 + color: '#62166F', 200 + padding: '15px 30px', 201 + borderRadius: '30px', 202 + fontWeight: 'bold', 203 + fontSize: '18px', 204 + boxShadow: '0 6px 15px rgba(0,0,0,0.3)', 205 + zIndex: 2, 206 + animation: 'float 3s ease-in-out infinite' 207 + }}> 208 + 🌟 Awesome Photos! 🌟 209 + </div> 210 + </div> 211 + ); 212 + }
+82
src/components/VideoCard.tsx
··· 1 + 'use client'; 2 + 3 + import { useState } from 'react'; 4 + import Link from 'next/link'; 5 + import { VideoRecord } from '@/lib/types'; 6 + 7 + function formatDuration(ns: number): string { 8 + const totalSeconds = Math.floor(ns / 1_000_000_000); 9 + const hours = Math.floor(totalSeconds / 3600); 10 + const minutes = Math.floor((totalSeconds % 3600) / 60); 11 + const seconds = totalSeconds % 60; 12 + 13 + if (hours > 0) { 14 + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 15 + } 16 + return `${minutes}:${seconds.toString().padStart(2, '0')}`; 17 + } 18 + 19 + const STREAM_DISPLAY_NAMES: Record<string, string> = { 20 + 'stream1.atmosphereconf.org': 'Stream 1', 21 + 'stream2.atmosphereconf.org': 'Stream 2', 22 + 'stream3.atmosphereconf.org': 'Stream 3', 23 + }; 24 + 25 + function getDisplayName(handle: string): string { 26 + return STREAM_DISPLAY_NAMES[handle] || handle.split('.')[0]; 27 + } 28 + 29 + interface VideoCardProps { 30 + video: { 31 + uri: string; 32 + value: VideoRecord; 33 + handle: string; 34 + }; 35 + } 36 + 37 + export default function VideoCard({ video }: VideoCardProps) { 38 + const [showHandle, setShowHandle] = useState(false); 39 + const id = video.uri.split('/').pop(); 40 + const displayName = getDisplayName(video.handle); 41 + 42 + // Generate gradient based on title 43 + const colors = [ 44 + ['#FF6B6B', '#4ECDC4'], 45 + ['#667eea', '#764ba2'], 46 + ['#f093fb', '#f5576c'], 47 + ['#4facfe', '#00f2fe'], 48 + ['#43e97b', '#38f9d7'], 49 + ]; 50 + let hash = 0; 51 + for (let i = 0; i < video.value.title.length; i++) { 52 + hash = video.value.title.charCodeAt(i) + ((hash << 5) - hash); 53 + } 54 + const [color1, color2] = colors[Math.abs(hash) % colors.length]; 55 + 56 + return ( 57 + <Link 58 + href={`/watch/${id}`} 59 + style={{ textDecoration: 'none' }} 60 + onMouseEnter={() => setShowHandle(true)} 61 + onMouseLeave={() => setShowHandle(false)} 62 + > 63 + <div className="video-card"> 64 + <div 65 + className="video-thumbnail" 66 + style={{ background: `linear-gradient(135deg, ${color1}, ${color2})` }} 67 + > 68 + <div className="play-overlay">▶</div> 69 + <div className="duration-badge"> 70 + {formatDuration(video.value.duration)} 71 + </div> 72 + </div> 73 + <div className="video-info"> 74 + <h3 className="video-title">{video.value.title}</h3> 75 + <p className="video-creator"> 76 + {showHandle ? video.handle : displayName} 77 + </p> 78 + </div> 79 + </div> 80 + </Link> 81 + ); 82 + }
+40
src/components/VideoPlayer.tsx
··· 1 + 'use client'; 2 + 3 + import { useEffect, useRef } from 'react'; 4 + import Hls from 'hls.js'; 5 + 6 + const VOD_BETA_BASE = 'https://vod-beta.stream.place/xrpc'; 7 + 8 + interface VideoPlayerProps { 9 + videoUri: string; 10 + } 11 + 12 + export default function VideoPlayer({ videoUri }: VideoPlayerProps) { 13 + const videoRef = useRef<HTMLVideoElement>(null); 14 + 15 + useEffect(() => { 16 + const video = videoRef.current; 17 + if (!video) return; 18 + 19 + if (Hls.isSupported()) { 20 + const hls = new Hls(); 21 + const playlistUrl = `${VOD_BETA_BASE}/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(videoUri)}`; 22 + hls.loadSource(playlistUrl); 23 + hls.attachMedia(video); 24 + return () => hls.destroy(); 25 + } 26 + 27 + if (video.canPlayType('application/vnd.apple.mpegurl')) { 28 + const playlistUrl = `${VOD_BETA_BASE}/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(videoUri)}`; 29 + video.src = playlistUrl; 30 + } 31 + }, [videoUri]); 32 + 33 + return ( 34 + <video 35 + ref={videoRef} 36 + controls 37 + style={{ width: '100%', maxHeight: '70vh', backgroundColor: '#000' }} 38 + /> 39 + ); 40 + }
+84
src/components/VideoThumbnail.tsx
··· 1 + 'use client'; 2 + 3 + interface VideoThumbnailProps { 4 + title: string; 5 + duration: number; 6 + } 7 + 8 + function formatDuration(ns: number): string { 9 + const totalSeconds = Math.floor(ns / 1_000_000_000); 10 + const hours = Math.floor(totalSeconds / 3600); 11 + const minutes = Math.floor((totalSeconds % 3600) / 60); 12 + const seconds = totalSeconds % 60; 13 + 14 + if (hours > 0) { 15 + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 16 + } 17 + return `${minutes}:${seconds.toString().padStart(2, '0')}`; 18 + } 19 + 20 + export default function VideoThumbnail({ title, duration }: VideoThumbnailProps) { 21 + const colors = [ 22 + ['#667eea', '#764ba2'], 23 + ['#f093fb', '#f5576c'], 24 + ['#4facfe', '#00f2fe'], 25 + ['#43e97b', '#38f9d7'], 26 + ['#fa709a', '#fee140'], 27 + ['#a8edea', '#fed6e3'], 28 + ['#ff9a9e', '#fecfef'], 29 + ['#ffecd2', '#fcb69f'], 30 + ['#6B8DD6', '#8E37D7'], 31 + ['#F97794', '#F9C80E'], 32 + ]; 33 + 34 + let hash = 0; 35 + for (let i = 0; i < title.length; i++) { 36 + hash = title.charCodeAt(i) + ((hash << 5) - hash); 37 + } 38 + const index = Math.abs(hash) % colors.length; 39 + const gradient = `linear-gradient(135deg, ${colors[index][0]}, ${colors[index][1]})`; 40 + 41 + const initials = title.split(' ') 42 + .slice(0, 2) 43 + .map(w => w[0]) 44 + .join('') 45 + .toUpperCase(); 46 + 47 + return ( 48 + <div style={{ position: 'relative', paddingTop: '56.25%', background: gradient }}> 49 + <div style={{ 50 + position: 'absolute', 51 + top: '50%', 52 + left: '50%', 53 + transform: 'translate(-50%, -50%)', 54 + width: '64px', 55 + height: '64px', 56 + borderRadius: '50%', 57 + background: 'rgba(0,0,0,0.6)', 58 + display: 'flex', 59 + alignItems: 'center', 60 + justifyContent: 'center', 61 + color: 'white', 62 + fontSize: '20px', 63 + fontWeight: 'bold', 64 + }}> 65 + <svg width="24" height="24" viewBox="0 0 24 24" fill="white"> 66 + <path d="M8 5v14l11-7z"/> 67 + </svg> 68 + </div> 69 + <div style={{ 70 + position: 'absolute', 71 + bottom: '8px', 72 + right: '8px', 73 + background: 'rgba(0,0,0,0.8)', 74 + color: 'white', 75 + padding: '2px 6px', 76 + borderRadius: '4px', 77 + fontSize: '12px', 78 + fontWeight: '500', 79 + }}> 80 + {formatDuration(duration)} 81 + </div> 82 + </div> 83 + ); 84 + }
+69
src/lib/pds.ts
··· 1 + import { cache } from 'react'; 2 + 3 + const STREAM_PLACE_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost'; 4 + const PLC_DIRECTORY_URL = 'https://plc.directory'; 5 + 6 + const STREAM_DISPLAY_NAMES: Record<string, string> = { 7 + 'stream1.atmosphereconf.org': 'Stream 1', 8 + 'stream2.atmosphereconf.org': 'Stream 2', 9 + 'stream3.atmosphereconf.org': 'Stream 3', 10 + }; 11 + 12 + export async function resolvePdsUrl(did: string): Promise<string> { 13 + const response = await fetch(`${PLC_DIRECTORY_URL}/${did}`, { 14 + next: { revalidate: 3600 } 15 + }); 16 + if (!response.ok) { 17 + throw new Error(`Failed to resolve DID: ${response.statusText}`); 18 + } 19 + const doc = await response.json(); 20 + const pdsService = (doc.service as Array<{ id?: string; type?: string; serviceEndpoint?: string }> | undefined)?.find( 21 + (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' 22 + ); 23 + if (!pdsService?.serviceEndpoint) { 24 + throw new Error('No PDS endpoint found in DID document'); 25 + } 26 + return pdsService.serviceEndpoint.replace(/\/$/, ''); 27 + } 28 + 29 + export async function resolveHandle(did: string): Promise<string> { 30 + const response = await fetch(`${PLC_DIRECTORY_URL}/${did}`, { 31 + next: { revalidate: 3600 } 32 + }); 33 + if (!response.ok) { 34 + return did; 35 + } 36 + const doc = await response.json(); 37 + const alsoKnownAs = doc.alsoKnownAs; 38 + if (alsoKnownAs && alsoKnownAs.length > 0) { 39 + const atproto = alsoKnownAs.find((uri: string) => uri.startsWith('at://')); 40 + if (atproto) { 41 + return atproto.replace('at://', '').split('/')[0]; 42 + } 43 + } 44 + return did; 45 + } 46 + 47 + export function getStreamDisplayName(handle: string): string | null { 48 + return STREAM_DISPLAY_NAMES[handle] || null; 49 + } 50 + 51 + export const getStreamPlacePdsUrl = cache(async (): Promise<string> => { 52 + return resolvePdsUrl(STREAM_PLACE_DID); 53 + }); 54 + 55 + export const resolveHandleCached = cache(async (did: string): Promise<string> => { 56 + return resolveHandle(did); 57 + }); 58 + 59 + export const listVideos = cache(async (pdsUrl: string): Promise<{ uri: string; cid: string; value: unknown }[]> => { 60 + const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${STREAM_PLACE_DID}&collection=place.stream.video`; 61 + const response = await fetch(url, { 62 + next: { revalidate: 60 } 63 + }); 64 + if (!response.ok) { 65 + throw new Error(`Failed to list videos: ${response.statusText}`); 66 + } 67 + const data = await response.json(); 68 + return data.records; 69 + });
+27
src/lib/types.ts
··· 1 + export interface VideoRecord { 2 + $type: string; 3 + title: string; 4 + source: { 5 + ref: string; 6 + size: number; 7 + mimeType: string; 8 + }; 9 + creator: string; 10 + duration: number; 11 + createdAt: string; 12 + livestream?: { 13 + cid: string; 14 + uri: string; 15 + }; 16 + } 17 + 18 + export interface VideoListItem { 19 + uri: string; 20 + cid: string; 21 + value: VideoRecord; 22 + } 23 + 24 + export interface VideoListResponse { 25 + records: VideoListItem[]; 26 + cursor?: string; 27 + }
+10
src/lib/vod.ts
··· 1 + const VOD_BETA_URL = 'https://vod-beta.stream.place'; 2 + 3 + export async function getVideoPlaylist(videoUri: string): Promise<string> { 4 + const url = `${VOD_BETA_URL}/xrpc/place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(videoUri)}`; 5 + const response = await fetch(url); 6 + if (!response.ok) { 7 + throw new Error(`Failed to get video playlist: ${response.statusText}`); 8 + } 9 + return response.text(); 10 + }