mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// 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 || "");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user