A stream.place client in a single index.html
12
fork

Configure Feed

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

home page

+274 -1
+274 -1
index.html
··· 687 687 background: var(--border); 688 688 } 689 689 690 + /* ---- Browse view (live streamers directory) ---- */ 691 + .browse-view { 692 + display: none; 693 + padding: 0 2rem 2rem; 694 + } 695 + 696 + .browse-view.visible { 697 + display: block; 698 + } 699 + 700 + .browse-header { 701 + display: flex; 702 + align-items: center; 703 + gap: 0.75rem; 704 + margin-bottom: 1.25rem; 705 + } 706 + 707 + .browse-live-count { 708 + font-family: "JetBrains Mono", monospace; 709 + font-size: 0.8rem; 710 + color: var(--accent); 711 + display: flex; 712 + align-items: center; 713 + gap: 0.4rem; 714 + } 715 + 716 + .browse-live-count .live-pulse { 717 + width: 8px; 718 + height: 8px; 719 + border-radius: 50%; 720 + background: var(--accent); 721 + animation: pulse 2s ease-in-out infinite; 722 + } 723 + 724 + .browse-grid { 725 + display: grid; 726 + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 727 + gap: 1rem; 728 + } 729 + 730 + .stream-tile { 731 + background: var(--surface); 732 + border: 1px solid var(--border); 733 + border-radius: 12px; 734 + overflow: hidden; 735 + cursor: pointer; 736 + transition: 737 + border-color 0.2s, 738 + transform 0.15s; 739 + text-decoration: none; 740 + color: inherit; 741 + display: block; 742 + } 743 + 744 + .stream-tile:hover { 745 + border-color: var(--accent); 746 + transform: translateY(-2px); 747 + } 748 + 749 + .tile-thumb { 750 + width: 100%; 751 + aspect-ratio: 16 / 9; 752 + object-fit: cover; 753 + display: block; 754 + background: var(--bg); 755 + } 756 + 757 + .tile-body { 758 + padding: 0.75rem; 759 + display: flex; 760 + gap: 0.6rem; 761 + align-items: flex-start; 762 + } 763 + 764 + .tile-avatar { 765 + width: 36px; 766 + height: 36px; 767 + border-radius: 50%; 768 + flex-shrink: 0; 769 + object-fit: cover; 770 + background: var(--border); 771 + } 772 + 773 + .tile-info { 774 + min-width: 0; 775 + flex: 1; 776 + } 777 + 778 + .tile-title { 779 + font-weight: 600; 780 + font-size: 0.85rem; 781 + color: var(--text); 782 + white-space: nowrap; 783 + overflow: hidden; 784 + text-overflow: ellipsis; 785 + line-height: 1.3; 786 + } 787 + 788 + .tile-handle { 789 + font-family: "JetBrains Mono", monospace; 790 + font-size: 0.7rem; 791 + color: var(--text-dim); 792 + white-space: nowrap; 793 + overflow: hidden; 794 + text-overflow: ellipsis; 795 + } 796 + 797 + .tile-meta { 798 + display: flex; 799 + align-items: center; 800 + gap: 0.35rem; 801 + margin-top: 0.25rem; 802 + } 803 + 804 + .tile-viewers { 805 + font-family: "JetBrains Mono", monospace; 806 + font-size: 0.65rem; 807 + color: var(--text-dim); 808 + display: flex; 809 + align-items: center; 810 + gap: 0.3rem; 811 + } 812 + 813 + .tile-viewers .viewer-dot { 814 + width: 5px; 815 + height: 5px; 816 + border-radius: 50%; 817 + background: var(--accent); 818 + } 819 + 690 820 /* ---- Responsive ---- */ 691 821 @media (max-width: 800px) { 822 + .browse-view { 823 + padding: 0 1rem 1rem; 824 + } 825 + .browse-grid { 826 + grid-template-columns: 1fr; 827 + } 692 828 .main-layout { 693 829 padding: 0 1rem 1rem; 694 830 } ··· 784 920 </button> 785 921 </div> 786 922 787 - <div class="main-layout"> 923 + <div class="browse-view" id="browseView"> 924 + <div class="browse-header"> 925 + <div class="browse-live-count"> 926 + <span class="live-pulse"></span> 927 + <span id="browseCount">0 streamers live</span> 928 + </div> 929 + </div> 930 + <div class="browse-grid" id="browseGrid"></div> 931 + </div> 932 + 933 + <div class="main-layout" id="mainLayout"> 788 934 <div class="stream-info" id="streamInfo"> 789 935 <div class="stream-info-text"> 790 936 <div class="stream-title" id="streamTitle"></div> ··· 1567 1713 connectBtn.style.display = ""; 1568 1714 disconnectBtn.style.display = "none"; 1569 1715 log("Disconnected"); 1716 + window.history.pushState({}, "", "/"); 1717 + showBrowseView(); 1570 1718 } 1571 1719 1572 1720 function startStats() { ··· 1616 1764 if (maybeAprofile) { 1617 1765 usernameInput.value = maybeAprofile; 1618 1766 connect(); 1767 + } else { 1768 + showBrowseView(); 1619 1769 } 1770 + } 1771 + 1772 + // ---- Browse view (live streamers directory) ---- 1773 + const browseView = document.getElementById("browseView"); 1774 + const browseGrid = document.getElementById("browseGrid"); 1775 + const browseCount = document.getElementById("browseCount"); 1776 + const mainLayout = document.getElementById("mainLayout"); 1777 + 1778 + function getThumbnailUrl(did, thumb) { 1779 + if (!thumb || !thumb.ref || !thumb.ref.$link) return ""; 1780 + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${thumb.ref.$link}@jpeg`; 1781 + } 1782 + 1783 + async function fetchLiveUsers() { 1784 + try { 1785 + const res = await fetch( 1786 + "https://stream.place/xrpc/place.stream.live.getLiveUsers", 1787 + ); 1788 + if (!res.ok) throw new Error(`HTTP ${res.status}`); 1789 + const data = await res.json(); 1790 + return data.streams || []; 1791 + } catch (err) { 1792 + console.error("Failed to fetch live users:", err); 1793 + return []; 1794 + } 1795 + } 1796 + 1797 + async function fetchAvatars(handles) { 1798 + if (!handles.length) return {}; 1799 + try { 1800 + // getProfiles is just TOO convient 1801 + const params = handles 1802 + .map((h) => `actors=${encodeURIComponent(h)}`) 1803 + .join("&"); 1804 + const res = await fetch( 1805 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${params}`, 1806 + ); 1807 + if (!res.ok) return {}; 1808 + const data = await res.json(); 1809 + const map = {}; 1810 + for (const profile of data.profiles || []) { 1811 + if (profile.avatar) { 1812 + map[profile.handle] = profile.avatar; 1813 + } 1814 + } 1815 + return map; 1816 + } catch { 1817 + return {}; 1818 + } 1819 + } 1820 + 1821 + function renderBrowseView(streams, avatarMap) { 1822 + browseGrid.innerHTML = ""; 1823 + const total = streams.length; 1824 + browseCount.textContent = `${total} streamer${total !== 1 ? "s" : ""} live`; 1825 + 1826 + for (const stream of streams) { 1827 + const handle = stream.author?.handle || "unknown"; 1828 + const did = stream.author?.did || ""; 1829 + const cid = stream.cid; 1830 + const title = stream.record?.title || "Untitled stream"; 1831 + const viewers = stream.viewerCount?.count ?? 0; 1832 + const thumbUrl = getThumbnailUrl(did, stream.record?.thumb); 1833 + const avatarUrl = avatarMap[handle] || ""; 1834 + 1835 + const tile = document.createElement("a"); 1836 + tile.className = "stream-tile"; 1837 + tile.href = `/${handle}`; 1838 + tile.onclick = (e) => { 1839 + e.preventDefault(); 1840 + browseView.classList.remove("visible"); 1841 + mainLayout.style.display = ""; 1842 + usernameInput.value = handle; 1843 + connect(); 1844 + }; 1845 + 1846 + const thumbImg = thumbUrl 1847 + ? `<img class="tile-thumb" src="${thumbUrl}" alt="" loading="lazy" onerror="this.style.display='none'">` 1848 + : `<div class="tile-thumb"></div>`; 1849 + 1850 + const avatarImg = avatarUrl 1851 + ? `<img class="tile-avatar" src="${avatarUrl}" alt="" loading="lazy" onerror="this.style.display='none'">` 1852 + : `<div class="tile-avatar"></div>`; 1853 + 1854 + tile.innerHTML = ` 1855 + ${thumbImg} 1856 + <div class="tile-body"> 1857 + ${avatarImg} 1858 + <div class="tile-info"> 1859 + <div class="tile-title">${title.replace(/</g, "&lt;")}</div> 1860 + <div class="tile-handle">@${handle.replace(/</g, "&lt;")}</div> 1861 + <div class="tile-meta"> 1862 + <div class="tile-viewers"> 1863 + <span class="viewer-dot"></span> 1864 + ${viewers} watching 1865 + </div> 1866 + </div> 1867 + </div> 1868 + </div> 1869 + `; 1870 + 1871 + browseGrid.appendChild(tile); 1872 + } 1873 + } 1874 + 1875 + async function showBrowseView() { 1876 + mainLayout.style.display = "none"; 1877 + browseView.classList.add("visible"); 1878 + 1879 + const streams = await fetchLiveUsers(); 1880 + // Sort by viewer count descending 1881 + streams.sort( 1882 + (a, b) => 1883 + (b.viewerCount?.count ?? 0) - 1884 + (a.viewerCount?.count ?? 0), 1885 + ); 1886 + 1887 + const handles = streams 1888 + .map((s) => s.author?.handle) 1889 + .filter(Boolean); 1890 + const avatarMap = await fetchAvatars(handles); 1891 + 1892 + renderBrowseView(streams, avatarMap); 1620 1893 } 1621 1894 1622 1895 // ---- Expose to onclick handlers ----