Add TuneIn search/browse/playback

We might peek into https://github.com/core-hacked/tunein-api for more advanced use cases
This commit is contained in:
Tobias Gesellchen
2026-04-19 21:59:55 +02:00
parent 56256de47b
commit 56e82d5a01
14 changed files with 1430 additions and 784 deletions
+148
View File
@@ -12,6 +12,7 @@ import (
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/gorilla/websocket"
)
@@ -557,3 +558,150 @@ func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
client.Close()
}
}
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
resp, err := bmxpkg.TuneInSearch(query)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
// Supported path suffixes (relative to /api/tunein/navigate):
// - (empty) → top-level browse
// - /{encodedURI} → browse the given TuneIn URI
// - /sub/{n}/{encodedURI} → single subsection
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
const navPrefix = "/api/tunein/navigate"
path := r.URL.Path
wildcard := ""
if len(path) > len(navPrefix) {
wildcard = strings.TrimPrefix(path[len(navPrefix):], "/")
}
var (
resp interface{}
err error
)
if wildcard == "" {
resp, err = bmxpkg.TuneInNavigate("", nil)
} else {
firstSlash := strings.Index(wildcard, "/")
if firstSlash == -1 {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
pfx := wildcard[:firstSlash]
rest := wildcard[firstSlash+1:]
switch pfx {
case "sub":
secondSlash := strings.Index(rest, "/")
if secondSlash == -1 {
resp, err = bmxpkg.TuneInNavigate(rest, nil)
} else {
n, parseErr := strconv.Atoi(rest[:secondSlash])
if parseErr != nil {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
}
}
case "profiles":
parts := strings.SplitN(rest, "/", 3)
if len(parts) < 3 {
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
} else {
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
}
default:
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
}
}
}
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimPrefix(r.URL.Path, "/api/tunein/play/")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.Devices[deviceID]
if !exists {
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
return
}
var req struct {
Location string `json:"location"`
Name string `json:"name"`
Type string `json:"type"`
ContainerArt string `json:"containerArt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Location == "" {
app.sendError(w, "location is required", http.StatusBadRequest)
return
}
itemType := req.Type
if itemType == "" {
itemType = "stationurl"
}
contentItem := &models.ContentItem{
Source: "TUNEIN",
Type: itemType,
Location: req.Location,
ItemName: req.Name,
IsPresetable: true,
ContainerArt: req.ContainerArt,
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
+6
View File
@@ -100,6 +100,12 @@ func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscov
// Device control endpoints
http.HandleFunc("/api/control/", app.HandleAPIControl)
// TuneIn browse, search, and playback
http.HandleFunc("/api/tunein/search", app.HandleTuneInSearch)
http.HandleFunc("/api/tunein/navigate", app.HandleTuneInNavigate)
http.HandleFunc("/api/tunein/navigate/", app.HandleTuneInNavigate)
http.HandleFunc("/api/tunein/play/", app.HandlePlayTuneIn)
// Enhanced device control endpoints with specific patterns
http.HandleFunc("/api/device-key/", app.HandleDeviceKey)
http.HandleFunc("/api/device-volume/", app.HandleDirectVolumeControl)
+320
View File
@@ -810,6 +810,326 @@ body {
}
}
/* TuneIn brand icons */
.tunein-nav-icon {
width: 28px;
height: auto;
vertical-align: middle;
display: block;
}
.tunein-heading-icon {
width: 40px;
height: auto;
vertical-align: middle;
}
[data-theme="dark"] .tunein-heading-icon {
filter: invert(1);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .tunein-heading-icon {
filter: invert(1);
}
}
/* TuneIn Browse */
.tunein-search-bar .form-control {
background-color: var(--card-bg);
color: var(--text-primary);
border-color: var(--border-color);
}
.tunein-search-bar .form-control:focus {
background-color: var(--card-bg);
color: var(--text-primary);
border-color: var(--bose-accent);
box-shadow: 0 0 0 0.2rem rgba(0, 102, 204, 0.25);
}
.tunein-section {
margin-bottom: 2rem;
}
.tunein-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.75rem;
padding-bottom: 0.4rem;
border-bottom: 2px solid var(--bose-accent);
}
/* Ribbon — horizontal scroll row */
.tunein-ribbon {
display: flex;
overflow-x: auto;
gap: 12px;
padding-bottom: 8px;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.tunein-ribbon::-webkit-scrollbar {
height: 4px;
}
.tunein-ribbon::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.tunein-ribbon-item {
flex: 0 0 120px;
text-align: center;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 8px;
}
/* Grid layout */
.tunein-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
}
.tunein-grid-item {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 8px;
text-align: center;
}
/* List layout */
.tunein-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.tunein-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
}
/* Hero layout */
.tunein-hero {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 1rem;
}
.tunein-hero-item {
position: relative;
border-radius: 10px;
overflow: hidden;
background: var(--card-bg);
border: 1px solid var(--border-color);
aspect-ratio: 16/9;
}
.tunein-hero-item .tunein-item-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0;
}
.tunein-hero-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 10px 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: #fff;
}
.tunein-hero-name {
font-weight: 600;
font-size: 0.9rem;
line-height: 1.3;
}
.tunein-hero-subtitle {
font-size: 0.78rem;
opacity: 0.85;
}
.tunein-hero-play {
position: absolute;
top: 8px;
right: 8px;
}
/* Shared item styles */
.tunein-item-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
.tunein-ribbon-item .tunein-item-image,
.tunein-grid-item .tunein-item-image {
width: 100%;
height: 88px;
margin-bottom: 6px;
border-radius: 6px;
}
.tunein-item-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
color: var(--text-muted);
font-size: 1.4rem;
}
.tunein-item-info {
flex: 1;
min-width: 0;
}
.tunein-item-name {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tunein-item-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
margin-top: 2px;
}
.tunein-item-subtitle {
font-size: 0.78rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.tunein-item-chevron {
color: var(--text-muted);
flex-shrink: 0;
font-size: 0.85rem;
}
/* Clickable items */
.tunein-nav-item {
cursor: pointer;
}
.tunein-nav-item:hover,
.tunein-nav-item:focus {
border-color: var(--bose-accent);
outline: none;
}
.tunein-list-item.tunein-nav-item:hover {
transform: translateX(2px);
}
.tunein-ribbon-item.tunein-nav-item:hover,
.tunein-grid-item.tunein-nav-item:hover,
.tunein-hero-item.tunein-nav-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px var(--shadow-color);
}
/* Play button on TuneIn items */
.tunein-play-btn {
background: var(--bose-accent);
color: #fff;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
min-width: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
padding: 0;
}
.tunein-play-btn:hover {
background: #0056b3;
transform: scale(1.1);
}
.tunein-play-btn:active {
transform: scale(0.95);
}
.tunein-ribbon-item .tunein-play-btn,
.tunein-grid-item .tunein-play-btn {
width: 28px;
height: 28px;
min-width: 28px;
font-size: 0.8rem;
margin: 4px auto 0;
}
/* Modal theming */
.modal-content {
background-color: var(--card-bg);
color: var(--text-primary);
border-color: var(--border-color);
}
.modal-header {
border-bottom-color: var(--border-color);
}
/* Play icon badge on audio-only items */
.tunein-item-play-badge {
font-size: 0.7rem;
color: var(--bose-accent);
margin-left: 4px;
}
@media (max-width: 576px) {
.tunein-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 8px;
}
.tunein-hero {
grid-template-columns: 1fr;
}
.tunein-ribbon-item {
flex: 0 0 100px;
}
}
/* Reduced Motion Support */
@media (prefers-reduced-motion: reduce) {
*,
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 80 56" style="enable-background:new 0 0 80 56;" xml:space="preserve">
<title>Artboard Copy 9</title>
<desc>Created with Sketch.</desc>
<g id="Page-1">
<g id="Artboard-Copy-9">
<path id="TI_Badge_Black-Copy-2" d="M63.9,27.7c0-0.3-0.2-0.5-0.5-0.5h-2.1c-0.1,0-0.2-0.1-0.2-0.2V16.8c0-0.1,0.1-0.2,0.2-0.2
h1.8c0.3,0,0.5-0.2,0.5-0.5v-2.3c0-0.3-0.2-0.5-0.5-0.5h-7.6c-0.3,0-0.5,0.2-0.5,0.5v2.3c0,0.3,0.2,0.5,0.5,0.5h1.8
c0.1,0,0.2,0.1,0.2,0.2v10.1c0,0.1-0.1,0.2-0.2,0.2h-2.1c-0.3,0-0.5,0.2-0.5,0.5V30c0,0.3,0.2,0.5,0.5,0.5h8.1
c0.3,0,0.5-0.2,0.5-0.5V27.7z M38.2,17H4c-0.2,0-0.3,0.1-0.3,0.3v33.8c0,0.2,0.1,0.3,0.3,0.3h33.8c0.2,0,0.3-0.1,0.3-0.3V17z
M80,2.8V41c0,1-0.8,1.8-1.8,1.8H41.8v10.5c0,1-0.8,1.8-1.8,1.8H1.8c-1,0-1.8-0.8-1.8-1.8V15.1c0-1,0.8-1.8,1.8-1.8h36.3V2.8
C38.2,1.8,39,1,40,1h38.2C79.2,1,80,1.8,80,2.8z M14.8,28.5v-2.5c0-0.3,0.2-0.5,0.5-0.5h10.1c0.3,0,0.5,0.2,0.5,0.5v2.5
c0,0.3-0.2,0.5-0.5,0.5h-3.1c-0.1,0-0.2,0.1-0.2,0.2v13c0,0.3-0.2,0.5-0.5,0.5h-2.5c-0.3,0-0.5-0.2-0.5-0.5v-13
c0-0.1-0.1-0.2-0.2-0.2h-3.1C15,29,14.8,28.8,14.8,28.5L14.8,28.5z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 80 56" style="enable-background:new 0 0 80 56;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<title>Artboard Copy 9</title>
<desc>Created with Sketch.</desc>
<g id="Page-1">
<g id="Artboard-Copy-9">
<path id="TI_Badge_Black-Copy-2" class="st0" d="M63.9,27.7c0-0.3-0.2-0.5-0.5-0.5h-2.1c-0.1,0-0.2-0.1-0.2-0.2V16.8
c0-0.1,0.1-0.2,0.2-0.2h1.8c0.3,0,0.5-0.2,0.5-0.5v-2.3c0-0.3-0.2-0.5-0.5-0.5h-7.6c-0.3,0-0.5,0.2-0.5,0.5v2.3
c0,0.3,0.2,0.5,0.5,0.5h1.8c0.1,0,0.2,0.1,0.2,0.2v10.1c0,0.1-0.1,0.2-0.2,0.2h-2.1c-0.3,0-0.5,0.2-0.5,0.5V30
c0,0.3,0.2,0.5,0.5,0.5h8.1c0.3,0,0.5-0.2,0.5-0.5L63.9,27.7L63.9,27.7z M38.2,17H4c-0.2,0-0.3,0.1-0.3,0.3v33.8
c0,0.2,0.1,0.3,0.3,0.3h33.8c0.2,0,0.3-0.1,0.3-0.3V17H38.2z M80,2.8V41c0,1-0.8,1.8-1.8,1.8H41.8v10.5c0,1-0.8,1.8-1.8,1.8H1.8
c-1,0-1.8-0.8-1.8-1.8V15.1c0-1,0.8-1.8,1.8-1.8h36.3V2.8C38.2,1.8,39,1,40,1h38.2C79.2,1,80,1.8,80,2.8z M14.8,28.5V26
c0-0.3,0.2-0.5,0.5-0.5h10.1c0.3,0,0.5,0.2,0.5,0.5v2.5c0,0.3-0.2,0.5-0.5,0.5h-3.1c-0.1,0-0.2,0.1-0.2,0.2v13
c0,0.3-0.2,0.5-0.5,0.5h-2.5c-0.3,0-0.5-0.2-0.5-0.5v-13c0-0.1-0.1-0.2-0.2-0.2h-3.1C15,29,14.8,28.8,14.8,28.5L14.8,28.5z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+69
View File
@@ -30,6 +30,18 @@
>
<i class="bi bi-house"></i>
</a>
<a
class="nav-link tunein-nav-link"
href="#"
onclick="showPage('tunein')"
title="TuneIn Browse"
>
<img
src="/static/img/tunein-mono.svg"
alt="TuneIn"
class="tunein-nav-icon"
/>
</a>
<a
class="nav-link"
href="#"
@@ -86,6 +98,46 @@
</div>
</div>
<!-- TuneIn Browse Page -->
<div id="tunein-page" class="page">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
</div>
<div class="tunein-search-bar mb-3">
<div class="input-group">
<input
type="text"
id="tunein-search-input"
class="form-control"
placeholder="Search stations, podcasts..."
/>
<button
class="btn btn-primary"
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
>
<i class="bi bi-search"></i>
Search
</button>
<button
class="btn btn-outline-secondary"
onclick="tuneInBrowse()"
title="Browse top level"
>
<i class="bi bi-house"></i>
</button>
</div>
</div>
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
<!-- filled by JavaScript -->
</nav>
<div id="tunein-results">
<!-- filled by JavaScript -->
</div>
</div>
<!-- Device Control Page -->
<div id="device-page" class="page">
<div class="back-button">
@@ -122,6 +174,23 @@
<!-- Toast container for notifications -->
<div class="toast-container"></div>
<!-- Device picker for TuneIn playback -->
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="devicePickerLabel">
<i class="bi bi-speaker me-2"></i>Play on device
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-2" id="devicePickerList">
<!-- device buttons filled by JavaScript -->
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+252
View File
@@ -8,6 +8,8 @@ let reconnectAttempts = 0;
let maxReconnectAttempts = 5;
let devices = {};
let currentDeviceId = null;
let tuneInNavStack = [];
let tuneInPendingPlay = null;
// Page navigation
function showPage(pageId) {
@@ -19,9 +21,216 @@ function showPage(pageId) {
if (pageId === "devices") {
currentDeviceId = null;
loadDevices();
} else if (pageId === "tunein" && tuneInNavStack.length === 0) {
tuneInBrowse();
}
}
// ── TuneIn Browse ──────────────────────────────────────────────────────────────
function tuneInBrowse() {
tuneInNavStack = [{ fetchUrl: "/api/tunein/navigate", label: "TuneIn" }];
tuneInRenderBreadcrumb();
tuneInFetchAndRender("/api/tunein/navigate");
}
function tuneInSearch(query) {
if (!query || !query.trim()) return;
const q = query.trim();
document.getElementById("tunein-search-input").value = q;
const url = "/api/tunein/search?q=" + encodeURIComponent(q);
tuneInNavStack = [
{ fetchUrl: "/api/tunein/navigate", label: "TuneIn" },
{ fetchUrl: url, label: "Search: " + q },
];
tuneInRenderBreadcrumb();
tuneInFetchAndRender(url);
}
function tuneInNavigate(navPath, label) {
const url = "/api/tunein/navigate/" + navPath;
tuneInNavStack.push({ fetchUrl: url, label: label || "Browse" });
tuneInRenderBreadcrumb();
tuneInFetchAndRender(url);
}
function tuneInNavTo(index) {
tuneInNavStack = tuneInNavStack.slice(0, index + 1);
tuneInRenderBreadcrumb();
tuneInFetchAndRender(tuneInNavStack[tuneInNavStack.length - 1].fetchUrl);
}
function tuneInRenderBreadcrumb() {
const nav = document.getElementById("tunein-breadcrumb");
if (tuneInNavStack.length <= 1) {
nav.style.display = "none";
return;
}
nav.style.display = "";
const items = tuneInNavStack
.map((entry, i) => {
if (i === tuneInNavStack.length - 1) {
return `<li class="breadcrumb-item active" aria-current="page">${escapeHtml(entry.label)}</li>`;
}
return `<li class="breadcrumb-item"><a href="#" onclick="tuneInNavTo(${i}); return false;">${escapeHtml(entry.label)}</a></li>`;
})
.join("");
nav.innerHTML = `<ol class="breadcrumb mb-0">${items}</ol>`;
}
function tuneInFetchAndRender(url) {
const el = document.getElementById("tunein-results");
el.innerHTML = '<div class="loading-spinner mx-auto mt-4"></div>';
fetch(url)
.then((r) => r.json())
.then((data) => {
if (data.success) {
renderTuneInResponse(data.data);
} else {
el.innerHTML = `<div class="alert alert-danger mt-3">${escapeHtml(data.error || "Failed to load TuneIn content")}</div>`;
}
})
.catch(() => {
el.innerHTML =
'<div class="alert alert-danger mt-3">Failed to load TuneIn content. Check your connection.</div>';
});
}
function renderTuneInResponse(data) {
const el = document.getElementById("tunein-results");
if (!data || !data.bmx_sections || data.bmx_sections.length === 0) {
el.innerHTML =
'<div class="text-center text-muted py-5"><i class="bi bi-music-note display-4"></i><p class="mt-2">No results found</p></div>';
return;
}
el.innerHTML = data.bmx_sections.map(renderTuneInSection).join("");
}
function renderTuneInSection(section) {
const layout = section.layout || "list";
const items = section.items || [];
if (items.length === 0) return "";
const titleHtml = section.name
? `<h5 class="tunein-section-title">${escapeHtml(section.name)}</h5>`
: "";
let itemsHtml;
if (layout === "ribbon") {
itemsHtml = `<div class="tunein-ribbon">${items.map((item) => renderTuneInItem(item, "ribbon")).join("")}</div>`;
} else if (layout === "hero") {
itemsHtml = `<div class="tunein-hero">${items.map((item) => renderTuneInItem(item, "hero")).join("")}</div>`;
} else if (layout === "responsiveGrid") {
itemsHtml = `<div class="tunein-grid">${items.map((item) => renderTuneInItem(item, "grid")).join("")}</div>`;
} else {
itemsHtml = `<div class="tunein-list">${items.map((item) => renderTuneInItem(item, "list")).join("")}</div>`;
}
return `<div class="tunein-section">${titleHtml}${itemsHtml}</div>`;
}
function tuneInNavPath(item) {
const href = item._links?.bmx_navigate?.href;
return href ? href.replace(/^\/v1\/navigate\/?/, "") : null;
}
function renderTuneInItem(item, layout) {
const navPath = tuneInNavPath(item);
const isNavigable = !!navPath;
const playHref = item._links?.bmx_playback?.href;
const playType = item._links?.bmx_playback?.type || "stationurl";
const isPlayable = !!playHref;
const name = item.name || "";
const subtitle = item.subtitle || "";
const imageUrl = item.imageUrl || "";
const navAttrs = isNavigable
? `data-nav-path="${escapeHtml(navPath)}" data-nav-label="${escapeHtml(name)}" role="button" tabindex="0"`
: "";
const navClass = isNavigable ? " tunein-nav-item" : "";
const playBtn = isPlayable
? `<button class="tunein-play-btn" data-play-location="${escapeHtml(playHref)}" data-play-name="${escapeHtml(name)}" data-play-type="${escapeHtml(playType)}" data-play-art="${escapeHtml(imageUrl)}" title="Play ${escapeHtml(name)}" aria-label="Play ${escapeHtml(name)}"><i class="bi bi-play-fill"></i></button>`
: "";
const imgHtml = imageUrl
? `<img src="${escapeHtml(imageUrl)}" alt="" class="tunein-item-image" loading="lazy" onerror="this.style.display='none'">`
: `<div class="tunein-item-image tunein-item-placeholder"><i class="bi bi-music-note-beamed"></i></div>`;
if (layout === "ribbon") {
return `<div class="tunein-ribbon-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-item-label">${escapeHtml(name)}</div>${playBtn}</div>`;
}
if (layout === "hero") {
return `<div class="tunein-hero-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-hero-overlay"><div class="tunein-hero-name">${escapeHtml(name)}</div>${subtitle ? `<div class="tunein-hero-subtitle">${escapeHtml(subtitle)}</div>` : ""}</div>${playBtn ? `<div class="tunein-hero-play">${playBtn}</div>` : ""}</div>`;
}
if (layout === "grid") {
return `<div class="tunein-grid-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-item-label">${escapeHtml(name)}</div>${subtitle ? `<div class="tunein-item-subtitle">${escapeHtml(subtitle)}</div>` : ""}${playBtn ? `<div class="mt-1 text-center">${playBtn}</div>` : ""}</div>`;
}
// list / shortList / default
return `<div class="tunein-list-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-item-info"><div class="tunein-item-name">${escapeHtml(name)}</div>${subtitle ? `<div class="tunein-item-subtitle">${escapeHtml(subtitle)}</div>` : ""}</div>${isNavigable ? '<i class="bi bi-chevron-right tunein-item-chevron ms-auto"></i>' : ""}${playBtn}</div>`;
}
function tuneInPlayClick(location, name, type, art) {
const deviceIds = Object.keys(devices);
if (deviceIds.length === 0) {
showToast("No Devices", "No SoundTouch devices found. Try discovering devices first.", "warning");
return;
}
if (deviceIds.length === 1) {
tuneInPlay(deviceIds[0], location, name, type, art);
} else {
tuneInShowDevicePicker(location, name, type, art);
}
}
function tuneInPlay(deviceId, location, name, type, art) {
fetch(`/api/tunein/play/${deviceId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ location, name, type, containerArt: art }),
})
.then((r) => r.json())
.then((data) => {
if (data.success) {
showToast("Now Playing", data.data?.message || name, "success");
} else {
showToast("Playback Failed", data.error || "Could not play station", "error");
}
})
.catch(() => showToast("Playback Failed", "Could not reach device", "error"));
}
function tuneInShowDevicePicker(location, name, type, art) {
tuneInPendingPlay = { location, name, type, art };
const list = document.getElementById("devicePickerList");
list.innerHTML = Object.entries(devices)
.map(
([id, dev]) =>
`<button class="btn btn-outline-secondary w-100 text-start mb-1" data-device-id="${escapeHtml(id)}" onclick="tuneInPlayOnDevice('${escapeHtml(id)}')"><i class="bi bi-speaker me-2"></i>${escapeHtml(dev.info?.Name || id)}</button>`,
)
.join("");
new bootstrap.Modal(document.getElementById("devicePickerModal")).show();
}
function tuneInPlayOnDevice(deviceId) {
if (!tuneInPendingPlay) return;
const { location, name, type, art } = tuneInPendingPlay;
tuneInPendingPlay = null;
bootstrap.Modal.getInstance(document.getElementById("devicePickerModal")).hide();
tuneInPlay(deviceId, location, name, type, art);
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// WebSocket connection management
function connectWebSocket() {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
@@ -843,4 +1052,47 @@ document.addEventListener("DOMContentLoaded", function () {
initializeTheme();
connectWebSocket();
loadDevices();
// TuneIn: keyboard search
document
.getElementById("tunein-search-input")
.addEventListener("keydown", function (e) {
if (e.key === "Enter") tuneInSearch(this.value);
});
// TuneIn: event delegation — play buttons take priority over navigation
document
.getElementById("tunein-results")
.addEventListener("click", function (e) {
const playBtn = e.target.closest(".tunein-play-btn");
if (playBtn) {
e.preventDefault();
e.stopPropagation();
tuneInPlayClick(
playBtn.dataset.playLocation,
playBtn.dataset.playName,
playBtn.dataset.playType || "stationurl",
playBtn.dataset.playArt || "",
);
return;
}
const item = e.target.closest("[data-nav-path]");
if (item) {
e.preventDefault();
tuneInNavigate(item.dataset.navPath, item.dataset.navLabel || "");
}
});
// TuneIn: keyboard activation for navigable items
document
.getElementById("tunein-results")
.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
const item = e.target.closest("[data-nav-path]");
if (item) {
e.preventDefault();
tuneInNavigate(item.dataset.navPath, item.dataset.navLabel || "");
}
}
});
});