Feature test 01
This commit is contained in:
parent
f9455c1a31
commit
2650d8e475
0
.perm_test_apache
Normal file
0
.perm_test_apache
Normal file
0
.perm_test_exec
Normal file
0
.perm_test_exec
Normal file
@ -395,6 +395,11 @@ router.get('/autocomplete', async (req, res) => {
|
|||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/song-match', wrapAsync(async (req, res) => {
|
||||||
|
const payload = await Station_logsService.findSongMatches(req.query);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/station_logs/{id}:
|
* /api/station_logs/{id}:
|
||||||
|
|||||||
@ -3,13 +3,110 @@ const Station_logsDBApi = require('../db/api/station_logs');
|
|||||||
const processFile = require("../middlewares/upload");
|
const processFile = require("../middlewares/upload");
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
const { Op } = db.Sequelize;
|
||||||
|
const FM_MIN = 88.1;
|
||||||
|
const FM_MAX = 102.0;
|
||||||
|
const DEFAULT_MATCH_LIMIT = 12;
|
||||||
|
const MAX_MATCH_LIMIT = 50;
|
||||||
|
const DEFAULT_LOOKBACK_HOURS = 12;
|
||||||
|
const MAX_LOOKBACK_HOURS = 72;
|
||||||
|
const frequencyPattern =
|
||||||
|
/(?:^|[^\d])((?:88\.[1-9])|(?:89\.\d)|(?:9\d\.\d)|(?:10[0-1]\.\d)|(?:102\.0))(?:\s?(?:fm))?(?=$|[^\d])/i;
|
||||||
|
const SUPPORTED_AREAS = Object.freeze({
|
||||||
|
greater_vancouver: {
|
||||||
|
label: 'Greater Vancouver',
|
||||||
|
terms: [
|
||||||
|
'greater vancouver',
|
||||||
|
'metro vancouver',
|
||||||
|
'vancouver',
|
||||||
|
'burnaby',
|
||||||
|
'richmond',
|
||||||
|
'new westminster',
|
||||||
|
'north vancouver',
|
||||||
|
'west vancouver',
|
||||||
|
'coquitlam',
|
||||||
|
'port coquitlam',
|
||||||
|
'port moody',
|
||||||
|
'delta',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
surrey: {
|
||||||
|
label: 'Surrey',
|
||||||
|
terms: ['surrey', 'cloverdale', 'fleetwood', 'guildford', 'newton', 'whalley'],
|
||||||
|
},
|
||||||
|
vancouver_island: {
|
||||||
|
label: 'Vancouver Island',
|
||||||
|
terms: [
|
||||||
|
'vancouver island',
|
||||||
|
'victoria',
|
||||||
|
'nanaimo',
|
||||||
|
'duncan',
|
||||||
|
'langford',
|
||||||
|
'sidney',
|
||||||
|
'courtenay',
|
||||||
|
'comox',
|
||||||
|
'campbell river',
|
||||||
|
'port alberni',
|
||||||
|
'parksville',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
toronto: {
|
||||||
|
label: 'Toronto',
|
||||||
|
terms: ['toronto', 'scarborough', 'etobicoke', 'north york', 'downtown toronto'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function toSearchText(...values) {
|
||||||
|
return values
|
||||||
|
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFrequency(...values) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = `${value}`.match(frequencyPattern);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frequency = Number.parseFloat(match[1]);
|
||||||
|
if (Number.isFinite(frequency) && frequency >= FM_MIN && frequency <= FM_MAX) {
|
||||||
|
return frequency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAreaOptions() {
|
||||||
|
return Object.entries(SUPPORTED_AREAS).map(([value, config]) => ({
|
||||||
|
value,
|
||||||
|
label: config.label,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesArea(areaConfig, stationLog) {
|
||||||
|
const searchText = toSearchText(
|
||||||
|
stationLog.city,
|
||||||
|
stationLog.stream_title_raw,
|
||||||
|
stationLog.source_stream_url,
|
||||||
|
stationLog.station?.name,
|
||||||
|
stationLog.station?.city,
|
||||||
|
stationLog.station?.state,
|
||||||
|
stationLog.station?.homepage,
|
||||||
|
stationLog.station?.stream_url,
|
||||||
|
);
|
||||||
|
|
||||||
|
return areaConfig.terms.some((term) => searchText.includes(term));
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = class Station_logsService {
|
module.exports = class Station_logsService {
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
@ -28,9 +125,9 @@ module.exports = class Station_logsService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -95,7 +192,7 @@ module.exports = class Station_logsService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -132,6 +229,120 @@ module.exports = class Station_logsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async findSongMatches(filters) {
|
||||||
|
const areaKey = `${filters?.area || ''}`.trim();
|
||||||
|
const areaConfig = SUPPORTED_AREAS[areaKey];
|
||||||
|
|
||||||
|
if (!areaConfig) {
|
||||||
|
throw new ValidationError('station_logsAreaUnsupported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const song = `${filters?.song || filters?.query || ''}`.trim();
|
||||||
|
const artist = `${filters?.artist || ''}`.trim();
|
||||||
|
|
||||||
|
if (!song && !artist) {
|
||||||
|
throw new ValidationError('station_logsSongMatchQueryRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedLimit = Number.parseInt(filters?.limit, 10);
|
||||||
|
const requestedLookbackHours = Number.parseInt(filters?.lookbackHours || filters?.hours, 10);
|
||||||
|
const limit = Number.isFinite(requestedLimit)
|
||||||
|
? Math.min(Math.max(requestedLimit, 1), MAX_MATCH_LIMIT)
|
||||||
|
: DEFAULT_MATCH_LIMIT;
|
||||||
|
const lookbackHours = Number.isFinite(requestedLookbackHours)
|
||||||
|
? Math.min(Math.max(requestedLookbackHours, 1), MAX_LOOKBACK_HOURS)
|
||||||
|
: DEFAULT_LOOKBACK_HOURS;
|
||||||
|
const playedAfter = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const andConditions = [{ played_at: { [Op.gte]: playedAfter } }];
|
||||||
|
|
||||||
|
if (song) {
|
||||||
|
andConditions.push({
|
||||||
|
[Op.or]: [
|
||||||
|
{ song: { [Op.iLike]: `%${song}%` } },
|
||||||
|
{ stream_title_raw: { [Op.iLike]: `%${song}%` } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artist) {
|
||||||
|
andConditions.push({
|
||||||
|
[Op.or]: [
|
||||||
|
{ artist: { [Op.iLike]: `%${artist}%` } },
|
||||||
|
{ stream_title_raw: { [Op.iLike]: `%${artist}%` } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.station_logs.findAll({
|
||||||
|
where: { [Op.and]: andConditions },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.stations,
|
||||||
|
as: 'station',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['played_at', 'desc']],
|
||||||
|
limit: Math.max(limit * 8, 40),
|
||||||
|
});
|
||||||
|
|
||||||
|
const matches = [];
|
||||||
|
const seenStations = new Set();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const stationLog = row.get({ plain: true });
|
||||||
|
|
||||||
|
if (!matchesArea(areaConfig, stationLog)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frequency = extractFrequency(
|
||||||
|
stationLog.station?.name,
|
||||||
|
stationLog.station?.homepage,
|
||||||
|
stationLog.station?.stream_url,
|
||||||
|
stationLog.stream_title_raw,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!frequency) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stationKey = stationLog.station?.id || stationLog.station?.station_uuid || stationLog.source_stream_url;
|
||||||
|
if (!stationKey || seenStations.has(stationKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenStations.add(stationKey);
|
||||||
|
matches.push({
|
||||||
|
id: stationKey,
|
||||||
|
stationId: stationLog.station?.id || null,
|
||||||
|
stationName: stationLog.station?.name || stationLog.city || 'Unknown station',
|
||||||
|
city: stationLog.station?.city || stationLog.city || null,
|
||||||
|
state: stationLog.station?.state || null,
|
||||||
|
country: stationLog.station?.country || 'Canada',
|
||||||
|
frequency,
|
||||||
|
song: stationLog.song || null,
|
||||||
|
artist: stationLog.artist || null,
|
||||||
|
playedAt: stationLog.played_at || null,
|
||||||
|
streamTitleRaw: stationLog.stream_title_raw || null,
|
||||||
|
streamUrl: stationLog.station?.stream_url || stationLog.source_stream_url || null,
|
||||||
|
area: areaConfig.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: matches,
|
||||||
|
count: matches.length,
|
||||||
|
area: areaConfig.label,
|
||||||
|
lookbackHours,
|
||||||
|
supportedAreas: getAreaOptions(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/radio',
|
||||||
|
label: 'Radio',
|
||||||
|
icon: icon.mdiRadio,
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
|
|||||||
807
frontend/src/pages/radio.tsx
Normal file
807
frontend/src/pages/radio.tsx
Normal file
@ -0,0 +1,807 @@
|
|||||||
|
import { mdiMagnify, mdiMapMarker, mdiMusicNote, mdiPlay, mdiRadio, mdiStop } from '@mdi/js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import React, { ReactElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
import SectionMain from '../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
|
||||||
|
type RadioBrowserStation = {
|
||||||
|
stationuuid: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
countrycode?: string;
|
||||||
|
state?: string;
|
||||||
|
homepage?: string;
|
||||||
|
tags?: string;
|
||||||
|
bitrate?: number;
|
||||||
|
codec?: string;
|
||||||
|
url?: string;
|
||||||
|
url_resolved?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StationWithFrequency = RadioBrowserStation & {
|
||||||
|
frequency: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SongSearchResult = {
|
||||||
|
trackId: number;
|
||||||
|
trackName: string;
|
||||||
|
artistName: string;
|
||||||
|
collectionName?: string;
|
||||||
|
artworkUrl100?: string;
|
||||||
|
primaryGenreName?: string;
|
||||||
|
releaseDate?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StationMatchResult = {
|
||||||
|
id: string;
|
||||||
|
stationId?: string | null;
|
||||||
|
stationName: string;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
frequency: number;
|
||||||
|
song?: string | null;
|
||||||
|
artist?: string | null;
|
||||||
|
playedAt?: string | null;
|
||||||
|
streamTitleRaw?: string | null;
|
||||||
|
streamUrl?: string | null;
|
||||||
|
area: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RadioMarket = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
terms: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const RADIO_BROWSER_BASE_URL = 'https://de1.api.radio-browser.info/json';
|
||||||
|
const CANADA = 'Canada';
|
||||||
|
const FM_MIN = 88.1;
|
||||||
|
const FM_MAX = 102.0;
|
||||||
|
const MAX_STATIONS = 36;
|
||||||
|
const MAX_SONG_RESULTS = 8;
|
||||||
|
const SONG_MATCH_LOOKBACK_HOURS = 24;
|
||||||
|
const DEFAULT_MARKET = 'greater_vancouver';
|
||||||
|
const collator = new Intl.Collator('en', { sensitivity: 'base' });
|
||||||
|
const frequencyPattern =
|
||||||
|
/(?:^|[^\d])((?:88\.[1-9])|(?:89\.\d)|(?:9\d\.\d)|(?:10[0-1]\.\d)|(?:102\.0))(?:\s?(?:fm))?(?=$|[^\d])/i;
|
||||||
|
const RADIO_MARKETS: RadioMarket[] = [
|
||||||
|
{
|
||||||
|
value: 'greater_vancouver',
|
||||||
|
label: 'Greater Vancouver',
|
||||||
|
terms: [
|
||||||
|
'greater vancouver',
|
||||||
|
'metro vancouver',
|
||||||
|
'vancouver',
|
||||||
|
'burnaby',
|
||||||
|
'richmond',
|
||||||
|
'new westminster',
|
||||||
|
'north vancouver',
|
||||||
|
'west vancouver',
|
||||||
|
'coquitlam',
|
||||||
|
'port coquitlam',
|
||||||
|
'port moody',
|
||||||
|
'delta',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'surrey',
|
||||||
|
label: 'Surrey',
|
||||||
|
terms: ['surrey', 'cloverdale', 'fleetwood', 'guildford', 'newton', 'whalley'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'vancouver_island',
|
||||||
|
label: 'Vancouver Island',
|
||||||
|
terms: [
|
||||||
|
'vancouver island',
|
||||||
|
'victoria',
|
||||||
|
'nanaimo',
|
||||||
|
'duncan',
|
||||||
|
'langford',
|
||||||
|
'sidney',
|
||||||
|
'courtenay',
|
||||||
|
'comox',
|
||||||
|
'campbell river',
|
||||||
|
'port alberni',
|
||||||
|
'parksville',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'toronto',
|
||||||
|
label: 'Toronto',
|
||||||
|
terms: ['toronto', 'scarborough', 'etobicoke', 'north york', 'downtown toronto'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function extractFrequency(...values: Array<string | undefined>) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.match(frequencyPattern);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frequency = Number.parseFloat(match[1]);
|
||||||
|
if (Number.isFinite(frequency) && frequency >= FM_MIN && frequency <= FM_MAX) {
|
||||||
|
return frequency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesMarket(station: RadioBrowserStation, market: RadioMarket) {
|
||||||
|
const searchText = [
|
||||||
|
station.name,
|
||||||
|
station.state,
|
||||||
|
station.tags,
|
||||||
|
station.homepage,
|
||||||
|
station.url_resolved,
|
||||||
|
station.url,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
return market.terms.some((term) => searchText.includes(term));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStations(stations: RadioBrowserStation[], market: RadioMarket) {
|
||||||
|
return stations
|
||||||
|
.map((station) => {
|
||||||
|
const frequency = extractFrequency(
|
||||||
|
station.name,
|
||||||
|
station.tags,
|
||||||
|
station.homepage,
|
||||||
|
station.url_resolved,
|
||||||
|
station.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!frequency || !station.url_resolved || !matchesMarket(station, market)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...station,
|
||||||
|
frequency,
|
||||||
|
} as StationWithFrequency;
|
||||||
|
})
|
||||||
|
.filter((station): station is StationWithFrequency => Boolean(station))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.frequency !== right.frequency) {
|
||||||
|
return left.frequency - right.frequency;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collator.compare(left.name, right.name);
|
||||||
|
})
|
||||||
|
.slice(0, MAX_STATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioPage = () => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [selectedMarket, setSelectedMarket] = useState<string>(DEFAULT_MARKET);
|
||||||
|
const [stations, setStations] = useState<StationWithFrequency[]>([]);
|
||||||
|
const [isStationsLoading, setIsStationsLoading] = useState<boolean>(false);
|
||||||
|
const [stationsErrorMessage, setStationsErrorMessage] = useState<string>('');
|
||||||
|
const [playingStation, setPlayingStation] = useState<StationWithFrequency | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [songQuery, setSongQuery] = useState<string>('');
|
||||||
|
const [songResults, setSongResults] = useState<SongSearchResult[]>([]);
|
||||||
|
const [isSongSearchLoading, setIsSongSearchLoading] = useState<boolean>(false);
|
||||||
|
const [songSearchErrorMessage, setSongSearchErrorMessage] = useState<string>('');
|
||||||
|
const [selectedSong, setSelectedSong] = useState<SongSearchResult | null>(null);
|
||||||
|
const [stationMatches, setStationMatches] = useState<StationMatchResult[]>([]);
|
||||||
|
const [isSongMatchLoading, setIsSongMatchLoading] = useState<boolean>(false);
|
||||||
|
const [songMatchErrorMessage, setSongMatchErrorMessage] = useState<string>('');
|
||||||
|
|
||||||
|
const market = useMemo(
|
||||||
|
() => RADIO_MARKETS.find((radioMarket) => radioMarket.value === selectedMarket) ?? RADIO_MARKETS[0],
|
||||||
|
[selectedMarket],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nowPlayingLabel = useMemo(() => {
|
||||||
|
if (!playingStation) {
|
||||||
|
return '--.-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${playingStation.frequency.toFixed(1)} FM`;
|
||||||
|
}, [playingStation]);
|
||||||
|
|
||||||
|
const playedAtFormatter = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat('en-CA', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.removeAttribute('src');
|
||||||
|
audioRef.current.load();
|
||||||
|
setIsPlaying(false);
|
||||||
|
setPlayingStation(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = async (station: StationWithFrequency) => {
|
||||||
|
if (!audioRef.current || !station.url_resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioRef.current.src = station.url_resolved;
|
||||||
|
audioRef.current.load();
|
||||||
|
await audioRef.current.play();
|
||||||
|
setPlayingStation(station);
|
||||||
|
setIsPlaying(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start radio stream:', error);
|
||||||
|
setStationsErrorMessage(`Could not start ${station.name}. Please try a different station.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleStop();
|
||||||
|
}, [selectedMarket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const fetchStations = async () => {
|
||||||
|
try {
|
||||||
|
setStationsErrorMessage('');
|
||||||
|
setIsStationsLoading(true);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
tag: 'fm',
|
||||||
|
country: CANADA,
|
||||||
|
hidebroken: 'true',
|
||||||
|
limit: '250',
|
||||||
|
order: 'clickcount',
|
||||||
|
reverse: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${RADIO_BROWSER_BASE_URL}/stations/search?${params.toString()}`);
|
||||||
|
const data = (await response.json()) as RadioBrowserStation[];
|
||||||
|
|
||||||
|
if (!isMounted || !Array.isArray(data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStations(normalizeStations(data, market));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load area stations:', error);
|
||||||
|
if (isMounted) {
|
||||||
|
setStations([]);
|
||||||
|
setStationsErrorMessage('Could not load the radio dial right now. Please try again in a moment.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsStationsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchStations();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [market]);
|
||||||
|
|
||||||
|
const runSongSearch = async () => {
|
||||||
|
const trimmedQuery = songQuery.trim();
|
||||||
|
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
setSongSearchErrorMessage('Type a song title, lyric clue, or artist to start SongCatch search.');
|
||||||
|
setSongResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSongSearchLoading(true);
|
||||||
|
setSongSearchErrorMessage('');
|
||||||
|
setSelectedSong(null);
|
||||||
|
setStationMatches([]);
|
||||||
|
setSongMatchErrorMessage('');
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
term: trimmedQuery,
|
||||||
|
media: 'music',
|
||||||
|
entity: 'song',
|
||||||
|
country: 'CA',
|
||||||
|
limit: `${MAX_SONG_RESULTS}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`https://itunes.apple.com/search?${params.toString()}`);
|
||||||
|
const data = (await response.json()) as { results?: SongSearchResult[] };
|
||||||
|
const results = Array.isArray(data.results)
|
||||||
|
? data.results.filter((result) => result.trackId && result.trackName && result.artistName)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
setSongResults(results);
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
setSongSearchErrorMessage('No song results came back. Try a more specific title or artist.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Song search failed:', error);
|
||||||
|
setSongResults([]);
|
||||||
|
setSongSearchErrorMessage('SongCatch search could not reach the music catalog right now.');
|
||||||
|
} finally {
|
||||||
|
setIsSongSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findStationsPlayingSong = async (song: SongSearchResult) => {
|
||||||
|
try {
|
||||||
|
setSelectedSong(song);
|
||||||
|
setStationMatches([]);
|
||||||
|
setSongMatchErrorMessage('');
|
||||||
|
setIsSongMatchLoading(true);
|
||||||
|
|
||||||
|
const response = await axios.get('/station_logs/song-match', {
|
||||||
|
params: {
|
||||||
|
area: market.value,
|
||||||
|
song: song.trackName,
|
||||||
|
artist: song.artistName,
|
||||||
|
lookbackHours: SONG_MATCH_LOOKBACK_HOURS,
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = Array.isArray(response.data?.rows) ? (response.data.rows as StationMatchResult[]) : [];
|
||||||
|
setStationMatches(rows);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
setSongMatchErrorMessage(
|
||||||
|
`No recent ${market.label} station logs matched this song in the last ${SONG_MATCH_LOOKBACK_HOURS} hours.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Station match lookup failed:', error);
|
||||||
|
setStationMatches([]);
|
||||||
|
setSongMatchErrorMessage('We could not search your station logs right now.');
|
||||||
|
} finally {
|
||||||
|
setIsSongMatchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Radio')}</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton icon={mdiRadio} title="SongCatch Radio" main>
|
||||||
|
<div className="text-xs uppercase tracking-[0.25em] text-emerald-400">Dark market-limited radio</div>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.95fr)]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CardBox className="border border-slate-700 bg-slate-950 text-slate-100 shadow-2xl shadow-slate-950/60">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.25fr)_minmax(280px,0.95fr)]">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.35em] text-emerald-400">Allowed radio markets</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-white">Only your approved Canadian dial zones</h2>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-slate-400">
|
||||||
|
SongCatch is locked to Greater Vancouver, Surrey, Vancouver Island, and Toronto, and only shows
|
||||||
|
stations with a confirmed {FM_MIN.toFixed(1)}–{FM_MAX.toFixed(1)} FM frequency.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-[minmax(0,240px)_1fr]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
Market
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white focus:border-emerald-400 focus:outline-none"
|
||||||
|
value={selectedMarket}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSelectedMarket(event.target.value);
|
||||||
|
setSelectedSong(null);
|
||||||
|
setStationMatches([]);
|
||||||
|
setSongMatchErrorMessage('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{RADIO_MARKETS.map((radioMarket) => (
|
||||||
|
<option key={radioMarket.value} value={radioMarket.value}>
|
||||||
|
{radioMarket.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-3 rounded-2xl border border-slate-800 bg-slate-900/60 p-4 text-sm text-slate-300 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Country</p>
|
||||||
|
<p className="mt-1 font-semibold text-white">Canada only</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">FM band</p>
|
||||||
|
<p className="mt-1 font-semibold text-white">88.1–102.0</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Song match window</p>
|
||||||
|
<p className="mt-1 font-semibold text-white">Last {SONG_MATCH_LOOKBACK_HOURS} hours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-slate-700 bg-gradient-to-br from-slate-900 via-slate-950 to-black p-5 shadow-inner shadow-black/50">
|
||||||
|
<p className="text-xs uppercase tracking-[0.35em] text-slate-500">Live dashboard</p>
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Market</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-emerald-300">{market.label}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Stations on dial</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-emerald-300">{stations.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-4">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Song matches</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-emerald-300">{stationMatches.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className="border border-slate-700 bg-slate-950 text-slate-100 shadow-2xl shadow-slate-950/60">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.35em] text-emerald-400">SongCatch search</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-white">Search a song, then find matching stations</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">
|
||||||
|
This is the practical first version of your Shazam-like flow: search a title or artist, pick the
|
||||||
|
best result, then ask the app to check recent station logs for the selected market.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
|
||||||
|
Song or artist
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:border-emerald-400 focus:outline-none"
|
||||||
|
placeholder="Try: Blinding Lights, Drake, or a lyric clue"
|
||||||
|
value={songQuery}
|
||||||
|
onChange={(event) => setSongQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void runSongSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<BaseButton
|
||||||
|
color="success"
|
||||||
|
icon={mdiMagnify}
|
||||||
|
label={isSongSearchLoading ? 'Searching…' : 'Search songs'}
|
||||||
|
onClick={() => {
|
||||||
|
void runSongSearch();
|
||||||
|
}}
|
||||||
|
disabled={isSongSearchLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{songSearchErrorMessage && (
|
||||||
|
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
|
||||||
|
{songSearchErrorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/80 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full border border-slate-700 bg-slate-900 p-2 text-emerald-300">
|
||||||
|
<span className="sr-only">Song results</span>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Results</p>
|
||||||
|
<p className="text-sm text-white">Pick a result to find stations in {market.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{isSongSearchLoading ? (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-5 text-sm text-slate-400">
|
||||||
|
Searching the music catalog…
|
||||||
|
</div>
|
||||||
|
) : songResults.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-5 text-sm text-slate-400">
|
||||||
|
Search results will appear here.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
songResults.map((song) => {
|
||||||
|
const isSelectedSong = selectedSong?.trackId === song.trackId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={song.trackId}
|
||||||
|
className={`rounded-2xl border px-4 py-4 transition ${
|
||||||
|
isSelectedSong
|
||||||
|
? 'border-emerald-400 bg-emerald-500/10'
|
||||||
|
: 'border-slate-800 bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-base font-semibold text-white">{song.trackName}</p>
|
||||||
|
<p className="truncate text-sm text-slate-300">{song.artistName}</p>
|
||||||
|
<p className="truncate text-xs text-slate-500">
|
||||||
|
{song.collectionName || 'Unknown release'}
|
||||||
|
{song.primaryGenreName ? ` • ${song.primaryGenreName}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
color="success"
|
||||||
|
small
|
||||||
|
icon={mdiRadio}
|
||||||
|
label={isSongMatchLoading && isSelectedSong ? 'Checking…' : `Find in ${market.label}`}
|
||||||
|
onClick={() => {
|
||||||
|
void findStationsPlayingSong(song);
|
||||||
|
}}
|
||||||
|
disabled={isSongMatchLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/80 p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full border border-slate-700 bg-slate-900 p-2 text-emerald-300">
|
||||||
|
<span className="sr-only">Station matches</span>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Station matches</p>
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
{selectedSong
|
||||||
|
? `${selectedSong.trackName} • ${selectedSong.artistName}`
|
||||||
|
: 'Choose a song result to check live station logs'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{isSongMatchLoading ? (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-5 text-sm text-slate-400">
|
||||||
|
Checking recent station logs…
|
||||||
|
</div>
|
||||||
|
) : stationMatches.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-5 text-sm text-slate-400">
|
||||||
|
{songMatchErrorMessage ||
|
||||||
|
'When logs are available, matching stations in the selected market will appear here.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
stationMatches.map((match) => (
|
||||||
|
<div key={match.id} className="rounded-2xl border border-slate-800 bg-slate-900/60 px-4 py-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-base font-semibold text-white">{match.stationName}</p>
|
||||||
|
<p className="truncate text-sm text-slate-300">
|
||||||
|
{match.artist || 'Unknown artist'} • {match.song || 'Unknown song'}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-slate-500">
|
||||||
|
{[match.city, match.state, match.country].filter(Boolean).join(', ') || 'Area not listed'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-700 bg-slate-950/70 px-3 py-2 text-right text-sm text-emerald-300">
|
||||||
|
<div className="font-mono text-lg font-semibold">{match.frequency.toFixed(1)} FM</div>
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">Matched</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-2 text-xs text-slate-400 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Last detected:</span>{' '}
|
||||||
|
{match.playedAt ? playedAtFormatter.format(new Date(match.playedAt)) : 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Area:</span> {match.area}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox className="border border-slate-700 bg-slate-950 text-slate-100 shadow-2xl shadow-slate-950/60">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.35em] text-emerald-400">Area station dial</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold text-white">Tune approved stations in {market.label}</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">
|
||||||
|
Every station below passed the same frequency rule and market filter used by the song-match flow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stationsErrorMessage && (
|
||||||
|
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
|
||||||
|
{stationsErrorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-slate-800">
|
||||||
|
<div className="grid grid-cols-[110px_minmax(0,1.5fr)_minmax(0,1fr)_90px_120px] gap-3 border-b border-slate-800 bg-slate-900 px-4 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
<span>Freq</span>
|
||||||
|
<span>Station</span>
|
||||||
|
<span>Market hint</span>
|
||||||
|
<span>Bitrate</span>
|
||||||
|
<span className="text-right">Action</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-800 bg-slate-950">
|
||||||
|
{isStationsLoading ? (
|
||||||
|
<div className="px-4 py-8 text-sm text-slate-400">Scanning the dial for {market.label}…</div>
|
||||||
|
) : stations.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-sm text-slate-400">
|
||||||
|
No stations with a confirmed {FM_MIN.toFixed(1)}–{FM_MAX.toFixed(1)} FM frequency were returned
|
||||||
|
for {market.label}.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
stations.map((station) => {
|
||||||
|
const isCurrentStation = playingStation?.stationuuid === station.stationuuid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={station.stationuuid}
|
||||||
|
className={`grid grid-cols-[110px_minmax(0,1.5fr)_minmax(0,1fr)_90px_120px] gap-3 px-4 py-3 text-sm transition ${
|
||||||
|
isCurrentStation ? 'bg-emerald-500/10' : 'hover:bg-slate-900/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-lg font-semibold text-emerald-300">
|
||||||
|
{station.frequency.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold text-white">{station.name}</p>
|
||||||
|
<p className="truncate text-xs text-slate-500">{station.codec || 'Unknown codec'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-slate-200">{station.state || market.label}</p>
|
||||||
|
<p className="truncate text-xs text-slate-500">{station.country}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-300">{station.bitrate ? `${station.bitrate} kbps` : '—'}</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<BaseButton
|
||||||
|
color={isCurrentStation && isPlaying ? 'danger' : 'success'}
|
||||||
|
small
|
||||||
|
icon={isCurrentStation && isPlaying ? mdiStop : mdiPlay}
|
||||||
|
label={isCurrentStation && isPlaying ? 'Stop' : 'Play'}
|
||||||
|
onClick={() => {
|
||||||
|
if (isCurrentStation && isPlaying) {
|
||||||
|
handleStop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void handlePlay(station);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardBox className="border border-slate-700 bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-emerald-300 shadow-2xl shadow-slate-950/50">
|
||||||
|
<div className="flex h-full flex-col justify-between gap-6">
|
||||||
|
<div className="rounded-[2rem] border-4 border-slate-700 bg-slate-900/70 p-6 text-center shadow-inner shadow-black/40">
|
||||||
|
<p className="text-xs uppercase tracking-[0.35em] text-slate-500">Now tuned</p>
|
||||||
|
<div className="mt-6 text-4xl font-bold tracking-[0.18em] text-emerald-300">{nowPlayingLabel}</div>
|
||||||
|
<p className="mt-3 min-h-[3rem] text-sm text-slate-300">
|
||||||
|
{playingStation
|
||||||
|
? `${playingStation.name} • ${playingStation.state || market.label}`
|
||||||
|
: 'Pick a station from the list to start streaming.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3 text-left text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||||
|
<div className="rounded-2xl border border-slate-700 bg-slate-950/70 px-4 py-3">
|
||||||
|
<span className="block text-slate-500">Band</span>
|
||||||
|
<span className="mt-1 block text-sm text-emerald-300">88.1–102.0 FM</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-slate-700 bg-slate-950/70 px-4 py-3">
|
||||||
|
<span className="block text-slate-500">Status</span>
|
||||||
|
<span className="mt-1 block text-sm text-emerald-300">{isPlaying ? 'On air' : 'Idle'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 rounded-2xl border border-slate-800 bg-slate-950/80 p-4 text-sm text-slate-300">
|
||||||
|
<div className="flex items-start gap-3 rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||||
|
<div className="mt-0.5 text-emerald-300">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-current">
|
||||||
|
<path d={mdiMapMarker} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Selected market</p>
|
||||||
|
<p className="mt-1 font-semibold text-white">{market.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
|
||||||
|
<div className="mt-0.5 text-emerald-300">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-current">
|
||||||
|
<path d={mdiMusicNote} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">SongCatch tip</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-300">
|
||||||
|
If no match appears, the usual reason is simple: the app does not yet have a fresh station log for
|
||||||
|
that song in this market.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/80 p-4">
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
className="w-full accent-emerald-400"
|
||||||
|
controls
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</audio>
|
||||||
|
<p className="mt-3 text-xs text-slate-500">
|
||||||
|
Streaming is enabled only for stations whose FM frequency and market could be verified from upstream
|
||||||
|
radio data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
RadioPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RadioPage;
|
||||||
Loading…
x
Reference in New Issue
Block a user