/** * Setup wizard — admin.php?page=streamsera#/setup. * * Three steps and a finish screen: Station (stream link, details) → Look (layout, style, * colour) → Publish (add to a page, listen page, or by hand). A live preview of the real * player sits beside every step. Finishing creates a station and a player, places the * player where the user chose and marks `setup_completed`. */ import { __, sprintf } from '@wordpress/i18n'; import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import type { PlayerConfig } from '@shared/types'; import Player from '../../player/Player'; import { registerPage } from '../registry'; import { navigate } from '../router'; import { config, ss, stations as stationsApi, players as playersApi, stationToPublic, openPreview, } from '../api'; import { I, CopyCode } from '../ui'; import { LayoutThumb } from './appearance'; import './setup.scss'; const cfgAny = config as any; const STEPS = [ { id: 'station', label: __( 'Station', 'streamsera' ) }, { id: 'look', label: __( 'Look', 'streamsera' ) }, { id: 'publish', label: __( 'Publish', 'streamsera' ) }, ]; /** One-click colours for the play button and highlights. */ const COLORS: Array< [ string, string ] > = [ [ '#5b4cf5', __( 'Violet', 'streamsera' ) ], [ '#2563eb', __( 'Blue', 'streamsera' ) ], [ '#0e9f6e', __( 'Green', 'streamsera' ) ], [ '#e8590c', __( 'Orange', 'streamsera' ) ], [ '#d6336c', __( 'Pink', 'streamsera' ) ], [ '#17171c', __( 'Black', 'streamsera' ) ], ]; /** How many styles show before "More styles". */ const SKINS_FIRST = 6; type LogoMeta = { id: number; url: string; name?: string }; type Placement = 'page' | 'listen' | 'manual'; type Placed = { kind: Placement; view?: string; edit?: string; title?: string; draft?: boolean; error?: string; }; const CONFETTI_COLORS = [ '#5b4cf5', '#b6a6ff', '#ffb020', '#1faf5f', '#e5484d', '#2563eb', ]; /** One-shot confetti burst; unmounts itself after the animation. Hidden with reduced motion (CSS). */ function Confetti( { color }: { color: string } ) { const [ gone, setGone ] = useState( false ); const pieces = useMemo( () => { const colors = [ ...CONFETTI_COLORS, color ]; return Array.from( { length: 80 }, ( _, i ) => { const strip = i % 3 === 0; const size = 7 + Math.random() * 5; return { id: i, left: Math.random() * 100, w: strip ? 6 : size, h: strip ? 12 + Math.random() * 6 : size, round: i % 4 === 1, color: colors[ i % colors.length ], delay: Math.random() * 0.9, dur: 2.6 + Math.random() * 1.8, rot: ( Math.random() - 0.5 ) * 1440, drift: ( Math.random() - 0.5 ) * 160, }; } ); }, [ color ] ); useEffect( () => { const t = window.setTimeout( () => setGone( true ), 5400 ); return () => window.clearTimeout( t ); }, [] ); if ( gone ) { return null; } return ( ); } function useMediaPicker( onSelect: ( logo: LogoMeta ) => void ) { return () => { const wp = window.wp; if ( ! wp?.media ) { return; } const frame = wp.media( { title: __( 'Station logo', 'streamsera' ), multiple: false, library: { type: 'image' }, } ); frame.on( 'select', () => { const a = frame.state().get( 'selection' ).first().toJSON(); onSelect( { id: a.id, url: a.sizes?.medium?.url || a.url, name: a.filename || a.title || '', } ); } ); frame.open(); }; } /** Upload a dropped image through the REST media endpoint. */ async function uploadImage( file: File ): Promise< LogoMeta > { const body = new FormData(); body.append( 'file', file, file.name ); const a: any = await apiFetch( { path: '/wp/v2/media', method: 'POST', body, } ); const sizes = a.media_details?.sizes || {}; return { id: a.id, url: sizes.medium?.source_url || a.source_url, name: file.name, }; } function detectFormat( detect: any, url: string ): string { const ct = detect?.content_type || ''; if ( /aac/i.test( ct ) || /\.aac\b/i.test( url ) ) { return 'aac'; } if ( /ogg|opus/i.test( ct ) ) { return 'ogg'; } if ( /mpegurl|m3u8/i.test( ct ) || /\.m3u8/i.test( url ) ) { return 'hls'; } if ( /dash\+xml/i.test( ct ) || /\.mpd/i.test( url ) ) { return 'dash'; } return 'mp3'; } /** The facts a detect result proves, as chip labels. Empty when the probe learned nothing. */ function detectFacts( detect: any, url: string ): string[] { if ( ! detect || detect.failed ) { return []; } const facts: string[] = []; if ( detect.content_type || detect.server ) { facts.push( detectFormat( detect, url ).toUpperCase() ); } if ( detect.bitrate ) { // translators: %d: stream bitrate in kilobits per second facts.push( sprintf( __( '%d kbps', 'streamsera' ), detect.bitrate ) ); } return facts; } /** "Artist — Title" from a detect result's metadata, if it carried one. */ function detectNowPlaying( detect: any ): string { const m = detect?.metadata || {}; const title = typeof m.title === 'string' ? m.title : ''; const artist = typeof m.artist === 'string' ? m.artist : ''; return [ artist, title ].filter( Boolean ).join( ' — ' ); } /** * A friendlier default station name from the stream's own name: Icecast names often carry a * tagline ("Groove Salad: a nicely chilled …") or a host suffix ("… [SomaFM]"). */ export function cleanStreamName( raw: string ): string { let n = String( raw || '' ) .replace( /\s*[\[(][^\])]*[\])]\s*$/, '' ) .trim(); const cut = n.search( /\s*(?::|\s[-–—|]\s)/ ); if ( cut > 2 ) { n = n.slice( 0, cut ); } return n.slice( 0, 80 ).trim(); } /** A section header inside a step card: small icon tile + label. */ function FieldLabel( { icon, htmlFor, children, optional, }: { icon: string; htmlFor?: string; children: any; optional?: boolean; } ) { const Tag: any = htmlFor ? 'label' : 'span'; return ( { children } { optional ? ( { __( '(optional)', 'streamsera' ) } ) : null } ); } function StepHead( { n, title, text, }: { n: number; title: string; text: string; } ) { return (

{ sprintf( // translators: 1: current step number, 2: number of steps __( 'Step %1$d of %2$d', 'streamsera' ), n, STEPS.length ) }

{ title }

{ text }

); } export default function SetupWizard() { const [ step, setStep ] = useState( 0 ); // Station const [ url, setUrl ] = useState( '' ); const [ autoUrl, setAutoUrl ] = useState( '' ); const [ name, setName ] = useState( '' ); const [ genre, setGenre ] = useState( '' ); const [ logo, setLogo ] = useState< LogoMeta >( { id: 0, url: '' } ); const [ uploading, setUploading ] = useState( false ); const [ dragOver, setDragOver ] = useState( false ); const [ detect, setDetect ] = useState< any >( null ); const [ detecting, setDetecting ] = useState( false ); const [ existing, setExisting ] = useState< any[] | null >( null ); // Look const [ layout, setLayout ] = useState< string >( config.defaults.layout ); const [ skin, setSkin ] = useState< string >( config.defaults.skin ); const [ color, setColor ] = useState( '' ); const [ allSkins, setAllSkins ] = useState( false ); // Publish const canPages = !! cfgAny.canEditPages; const canListen = !! config.canManage; const [ placement, setPlacement ] = useState< Placement >( canPages ? 'page' : 'manual' ); const [ pages, setPages ] = useState< any[] | null >( null ); const [ pageId, setPageId ] = useState< string >( 'new' ); const [ newTitle, setNewTitle ] = useState< string >( __( 'Listen live', 'streamsera' ) ); const [ position, setPosition ] = useState< 'prepend' | 'append' >( 'prepend' ); const [ slug, setSlug ] = useState( 'listen' ); // Preview + finish const [ device, setDevice ] = useState< 'desktop' | 'phone' >( 'desktop' ); const [ busy, setBusy ] = useState( false ); const [ error, setError ] = useState( '' ); const [ result, setResult ] = useState< { station: number; player: number; } | null >( null ); const [ placed, setPlaced ] = useState< Placed | null >( null ); const done = !! result; const scrollRef = useRef< HTMLDivElement | null >( null ); // Every step (and the finish screen) opens at its top. useEffect( () => { scrollRef.current?.scrollTo( { top: 0 } ); window.scrollTo( 0, 0 ); }, [ step, done ] ); const pickLogo = useMediaPicker( setLogo ); useEffect( () => { stationsApi .list() .then( setExisting ) .catch( () => setExisting( [] ) ); }, [] ); // Pages for "Add it to a page", loaded the first time the Publish step opens. useEffect( () => { if ( step !== 2 || pages !== null || ! canPages ) { return; } ss.searchPages( '' ) .then( ( list ) => { const items = list || []; setPages( items ); // Never pre-select an arbitrary page: only an obvious home page, otherwise a new page. const home = items.find( ( p ) => p?.status === 'publish' && /^(home|front page|homepage)$/i.test( ( p?.title?.raw || '' ).trim() ) ); setPageId( home ? String( home.id ) : 'new' ); } ) .catch( () => setPages( [] ) ); }, [ step, pages, canPages ] ); // Each check gets a number; a reply for an older link (typing, a second click) is dropped. const detectRun = useRef( 0 ); const runDetect = async ( target?: string ) => { const u = ( typeof target === 'string' ? target : url ).trim(); if ( ! u ) { return; } const run = ++detectRun.current; setDetecting( true ); setDetect( null ); setError( '' ); let r: any; try { r = ( await ss.detect( u ) ) || { failed: true }; } catch ( e: any ) { r = { failed: true, message: e?.message || '' }; } if ( run !== detectRun.current ) { return; } setDetect( r ); if ( r?.name ) { setName( ( n ) => n || cleanStreamName( r.name ) ); } setDetecting( false ); }; // Check automatically once a complete-looking link has been pasted or typed. useEffect( () => { if ( ! autoUrl || ! /^https?:\/\/\S+\.\S+/i.test( autoUrl ) ) { return; } const t = window.setTimeout( () => runDetect( autoUrl ), 500 ); return () => window.clearTimeout( t ); }, [ autoUrl ] ); // eslint-disable-line react-hooks/exhaustive-deps const dropLogo = async ( e: any ) => { e.preventDefault(); setDragOver( false ); const file: File | undefined = e.dataTransfer?.files?.[ 0 ]; if ( ! file || ! /^image\//.test( file.type ) ) { return; } setUploading( true ); try { setLogo( await uploadImage( file ) ); } catch ( err: any ) { setError( err?.message || __( 'Could not upload that image.', 'streamsera' ) ); } setUploading( false ); }; const stationName = name.trim() || __( 'My Station', 'streamsera' ); const facts = detectFacts( detect, url ); const nowPlaying = detectNowPlaying( detect ); const checked = !! detect && ! detecting; const found = checked && ( facts.length > 0 || !! detect.name || detect.reachable === true ); const previewStation = useMemo( () => stationToPublic( { id: 0, title: { raw: stationName }, meta: { _streamsera_streams: [ { url, label: '', format: detectFormat( detect, url ), bitrate: detect?.bitrate || 0, }, ], _streamsera_genre: genre, _streamsera_slogan: '', _streamsera_social: {}, }, }, logo.url ), [ stationName, url, detect, genre, logo.url ] ); // The player config the wizard saves: defaults + layout + style, with the chosen colour on top. const cfg: PlayerConfig = useMemo( () => { const sk: any = config.skins?.[ skin ] || {}; return { ...config.defaults, layout, skin, tokens: { ...config.defaults.tokens, ...( sk.tokens || {} ), ...( color ? { primary: color } : {} ), }, }; }, [ layout, skin, color ] ); const effectiveColor = color || ( cfg.tokens as any )?.primary || ( config.defaults.tokens as any )?.primary || '#5b4cf5'; const skinEntries = Object.entries( config.skins || {} ) as Array< [ string, any ] >; const shownSkins = useMemo( () => { if ( allSkins ) { return skinEntries; } const first = skinEntries.slice( 0, SKINS_FIRST ); // Keep the chosen style visible when the list is collapsed. if ( ! first.some( ( [ k ] ) => k === skin ) ) { const cur = skinEntries.find( ( [ k ] ) => k === skin ); if ( cur ) { first.splice( SKINS_FIRST - 1, 1, cur ); } } return first; }, [ allSkins, skin ] ); // eslint-disable-line react-hooks/exhaustive-deps const home = String( cfgAny.siteUrl || '' ).replace( /\/$/, '' ); const host = home.replace( /^https?:\/\//, '' ); const pretty = cfgAny.permalinks !== false; const cleanSlug = slug .toLowerCase() .replace( /[^a-z0-9-]+/g, '-' ) .replace( /^-+|-+$/g, '' ) || 'listen'; const listenUrl = pretty ? `${ home }/${ cleanSlug }/` : `${ home }/?streamsera_listen=1`; const chosenPage = pages?.find( ( p ) => String( p.id ) === pageId ); const pagePath = ( p: any ) => { try { const u = new URL( p.link ); return u.host + u.pathname.replace( /\/$/, '' ) + u.search; } catch ( e ) { return host; } }; const previewAddress = step < 2 && ! done ? host : placement === 'listen' ? listenUrl.replace( /^https?:\/\//, '' ).replace( /\/$/, '' ) : placement === 'page' ? chosenPage ? pagePath( chosenPage ) : `${ host }/${ newTitle .toLowerCase() .replace( /[^a-z0-9]+/g, '-' ) .replace( /^-|-$/g, '' ) }` : host; const finish = async () => { setBusy( true ); setError( '' ); const status = config.canPublish === false ? 'draft' : 'publish'; let created: { station: number; player: number }; try { const station = await stationsApi.create( { title: stationName, status, meta: { _streamsera_streams: [ { url: url.trim(), label: detect?.bitrate ? `${ detect.bitrate } kbps` : '', format: detectFormat( detect, url ), bitrate: detect?.bitrate || 0, }, ], _streamsera_genre: genre.trim(), _streamsera_logo_id: logo.id || 0, _streamsera_metadata: { provider: 'auto', url: '', mount: '', fields: {}, }, }, } ); const player = await playersApi.create( { title: stationName, status, meta: { _streamsera_config: { ...cfg, station: station.id, stations: [], }, }, } ); created = { station: station.id, player: player.id }; } catch ( e: any ) { setError( e?.message || __( 'Something went wrong while saving.', 'streamsera' ) ); setBusy( false ); return; } // Place the player. A failure here keeps the station and player; the finish screen says what happened. let where: Placed = { kind: placement }; try { if ( placement === 'page' ) { const res = await ss.embedToPage( { player: created.player, ...( chosenPage ? { page_id: chosenPage.id, position } : { title: newTitle.trim() || __( 'Listen live', 'streamsera' ), status: 'publish' as const, } ), } ); where = { kind: 'page', view: res?.view_link, edit: res?.edit_link, title: res?.title, draft: res?.status && res.status !== 'publish', }; } else if ( placement === 'listen' ) { await ss.saveSettings( { ...( await ss.getSettings() ), listen_enabled: true, listen_slug: cleanSlug, listen_player: created.player, } ); where = { kind: 'listen', view: listenUrl }; } } catch ( e: any ) { where = { kind: placement, error: e?.message || __( 'The player could not be placed.', 'streamsera' ), }; } try { await ss.saveSettings( { ...( await ss.getSettings() ), setup_completed: true, } ); } catch ( e ) { // Only managers can save settings; the checklist simply stays open for everyone else. } setPlaced( where ); setResult( created ); setBusy( false ); }; const next = () => { if ( step < 2 ) { setStep( step + 1 ); } else { finish(); } }; const canNext = step === 0 ? !! url.trim() && ! detecting : ! busy; const nextHint = step === 0 && ! url.trim() ? __( 'Paste a stream link to continue', 'streamsera' ) : step === 2 ? __( 'Finishing creates one station and one player', 'streamsera' ) : ''; const previewNote = done ? __( 'Listeners can keep playing while they browse your site.', 'streamsera' ) : step === 0 ? __( "This is how visitors will see your player. You'll pick the look in the next step.", 'streamsera' ) : step === 1 ? __( 'Want more control? Colours, components and behaviour are all in the builder after setup.', 'streamsera' ) : __( 'Listeners can keep playing while they browse your site.', 'streamsera' ); const renderPlayer = ( key: string ) => ( ); const doneLine = (): string => { if ( ! placed || placed.error || placed.kind === 'manual' ) { return __( 'Your station and player are saved. Copy the shortcode below to place it anywhere.', 'streamsera' ); } if ( placed.kind === 'listen' ) { return sprintf( // translators: %s: listen page address __( 'Your listen page is live at %s.', 'streamsera' ), listenUrl.replace( /^https?:\/\//, '' ) ); } return placed.draft ? sprintf( // translators: %s: page title __( 'Your player is on the “%s” page, saved as a draft — publish it when you’re ready.', 'streamsera' ), placed.title || '' ) : sprintf( // translators: %s: page title __( 'Your player is now on the “%s” page.', 'streamsera' ), placed.title || '' ); }; return (
StreamSera
{ ! done ? ( ) : null }
{ ! done ? (
{ step === 0 && ( <>
{ __( 'Stream link', 'streamsera' ) }
{ detectRun.current++; setDetecting( false ); setUrl( e.target.value ); setDetect( null ); setAutoUrl( e.target.value ); } } onKeyDown={ ( e ) => e.key === 'Enter' && runDetect() } />

{ __( 'Works with Icecast, Shoutcast, HLS and most hosted streams. You’ll find it in your radio host’s dashboard, usually called “stream URL”.', 'streamsera' ) }

{ checked && (
{ found ? __( 'Stream found', 'streamsera' ) : __( "We couldn't read the stream details", 'streamsera' ) } { facts.map( ( f ) => ( { f } ) ) }

{ found && nowPlaying ? ( <> { __( 'Now playing:', 'streamsera' ) } { ' ' } { nowPlaying } ) : found ? ( __( 'Your stream is reachable.', 'streamsera' ) ) : ( __( 'You can still continue — check that the link plays in your browser.', 'streamsera' ) ) }

) } { ( checked || !! name ) && ( <>
{ __( 'Station name', 'streamsera' ) } setName( e.target .value ) } />
{ __( 'Genre', 'streamsera' ) } setGenre( e.target .value ) } />
{ e.preventDefault(); setDragOver( true ); } } onDragLeave={ () => setDragOver( false ) } onDrop={ dropLogo } > { logo.url ? ( ) : ( ) } { __( 'Station logo', 'streamsera' ) } { uploading ? __( 'Uploading…', 'streamsera' ) : logo.url ? logo.name || __( 'Shown when a song has no artwork.', 'streamsera' ) : __( 'Square image, 300 px or larger. Shown when a song has no artwork.', 'streamsera' ) } { logo.url ? ( ) : null }
) }
{ existing && existing.length > 0 && (

{ __( 'You already have stations. This adds a new one — to build a player for an existing station, use the Players page.', 'streamsera' ) }

) } ) } { step === 1 && ( <>
{ __( 'Layout', 'streamsera' ) }
{ Object.entries( config.layouts || {} ).map( ( [ id, def ] ) => ( ) ) }
{ __( 'Style', 'streamsera' ) }
{ shownSkins.map( ( [ key, sk ] ) => { const t = sk.tokens || {}; return ( ); } ) } { skinEntries.length > SKINS_FIRST && ( ) }
{ __( 'Accent colour', 'streamsera' ) }
{ COLORS.map( ( [ hex, label ] ) => ( ) : ( { __( 'Used for the play button and highlights.', 'streamsera' ) } ) }
) } { step === 2 && ( <>
{ ( [ canPages && { id: 'page', icon: 'pages', label: __( 'Add it to a page', 'streamsera' ), desc: __( 'Place the player on a page you already have, or a new one.', 'streamsera' ), }, canListen && { id: 'listen', icon: 'headphones', label: __( 'Create a listen page', 'streamsera' ), desc: __( 'A ready-made, full-screen page just for your station.', 'streamsera' ), }, { id: 'manual', icon: 'code', label: __( 'I’ll add it myself', 'streamsera' ), desc: __( 'Use the block, a shortcode or your page builder.', 'streamsera' ), }, ].filter( Boolean ) as Array< { id: Placement; icon: string; label: string; desc: string; } > ).map( ( o ) => ( ) ) }
{ placement === 'page' && ( <>
{ __( 'Page', 'streamsera' ) }
{ pageId === 'new' ? (
{ __( 'New page title', 'streamsera' ) } setNewTitle( e.target .value ) } />
) : (
{ __( 'Where on the page', 'streamsera' ) }
) }

{ pageId === 'new' ? __( 'We’ll create and publish the page with your player on it.', 'streamsera' ) : __( 'We’ll add the StreamSera Player block. Nothing else on the page changes.', 'streamsera' ) }

) } { placement === 'listen' && ( <>
{ __( 'Page address', 'streamsera' ) } { pretty ? (
{ `${ host }/` } setSlug( e.target .value ) } />
) : ( { listenUrl } ) }

{ __( 'A full-screen page with your player, what’s playing and share buttons — handy to link from social media.', 'streamsera' ) }

) } { placement === 'manual' && (
{ __( 'Block editor', 'streamsera' ) } { __( 'Search for “StreamSera Player” in the block inserter.', 'streamsera' ) }
{ __( 'Page builders', 'streamsera' ) } { __( 'Elementor, Divi, Bricks, Beaver Builder, Breakdance and Oxygen have a StreamSera element.', 'streamsera' ) }
{ __( 'Shortcode', 'streamsera' ) } { __( 'You’ll get it to copy on the next screen.', 'streamsera' ) }
) }
) } { error && (
{ error }
) }
{ step > 0 ? ( ) : null } { nextHint } { step === 1 ? ( ) : null }