diff --git a/shell.nix b/shell.nix index 8dedbc1..d049064 100644 --- a/shell.nix +++ b/shell.nix @@ -3,6 +3,7 @@ pkgs.mkShell { packages = [ pkgs.openssl + pkgs.electron pkgs.openjdk pkgs.maven pkgs.nodejs diff --git a/src/main/java/com/imgfloat/app/config/SecurityConfig.java b/src/main/java/com/imgfloat/app/config/SecurityConfig.java index 42908d4..0418d91 100644 --- a/src/main/java/com/imgfloat/app/config/SecurityConfig.java +++ b/src/main/java/com/imgfloat/app/config/SecurityConfig.java @@ -29,9 +29,11 @@ public class SecurityConfig { "/actuator/health", "/v3/api-docs/**", "/swagger-ui.html", - "/swagger-ui/**" + "/swagger-ui/**", + "/channels" ).permitAll() .requestMatchers(HttpMethod.GET, "/view/*/broadcast").permitAll() + .requestMatchers(HttpMethod.GET, "/api/channels").permitAll() .requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible").permitAll() .requestMatchers(HttpMethod.GET, "/api/channels/*/canvas").permitAll() .requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content").permitAll() diff --git a/src/main/java/com/imgfloat/app/controller/ChannelDirectoryApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelDirectoryApiController.java new file mode 100644 index 0000000..d1ee528 --- /dev/null +++ b/src/main/java/com/imgfloat/app/controller/ChannelDirectoryApiController.java @@ -0,0 +1,25 @@ +package com.imgfloat.app.controller; + +import com.imgfloat.app.service.ChannelDirectoryService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/channels") +public class ChannelDirectoryApiController { + + private final ChannelDirectoryService channelDirectoryService; + + public ChannelDirectoryApiController(ChannelDirectoryService channelDirectoryService) { + this.channelDirectoryService = channelDirectoryService; + } + + @GetMapping + public List listChannels(@RequestParam(value = "q", required = false) String query) { + return channelDirectoryService.searchBroadcasters(query); + } +} diff --git a/src/main/java/com/imgfloat/app/controller/ViewController.java b/src/main/java/com/imgfloat/app/controller/ViewController.java index deeafdf..ac6c6da 100644 --- a/src/main/java/com/imgfloat/app/controller/ViewController.java +++ b/src/main/java/com/imgfloat/app/controller/ViewController.java @@ -32,6 +32,12 @@ public class ViewController { return "index"; } + @org.springframework.web.bind.annotation.GetMapping("/channels") + public String channelDirectory() { + LOG.info("Rendering channel directory"); + return "channels"; + } + @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin") public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, OAuth2AuthenticationToken authentication, diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index 225ab83..4c7b180 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -39,6 +39,8 @@ import java.util.Base64; import java.util.Collection; import java.util.Comparator; import java.util.List; +import java.util.Locale; +import java.util.Objects; import java.util.Optional; import javax.imageio.ImageIO; import javax.imageio.ImageReader; @@ -75,6 +77,18 @@ public class ChannelDirectoryService { .orElseGet(() -> channelRepository.save(new Channel(normalized))); } + public List searchBroadcasters(String query) { + String normalizedQuery = normalize(query); + return channelRepository.findAll().stream() + .map(Channel::getBroadcaster) + .map(this::normalize) + .filter(Objects::nonNull) + .filter(name -> normalizedQuery == null || normalizedQuery.isBlank() || name.contains(normalizedQuery)) + .sorted() + .limit(50) + .toList(); + } + public boolean addAdmin(String broadcaster, String username) { Channel channel = getOrCreateChannel(broadcaster); boolean added = channel.addAdmin(username); @@ -298,7 +312,7 @@ public class ChannelDirectoryService { } private String normalize(String value) { - return value == null ? null : value.toLowerCase(); + return value == null ? null : value.toLowerCase(Locale.ROOT); } private List sortAndMapAssets(String broadcaster, Collection assets) { diff --git a/src/main/node/app.js b/src/main/node/app.js new file mode 100644 index 0000000..3c65dba --- /dev/null +++ b/src/main/node/app.js @@ -0,0 +1,24 @@ +const { app, BrowserWindow } = require('electron'); + +function createWindow() { + const url = process.env.ELECTRON_START_URL || "https://imgfloat.kruhlmann.dev/channels"; + const width = Number.parseInt(process.env.ELECTRON_WINDOW_WIDTH, 10) || 1920; + const height = Number.parseInt(process.env.ELECTRON_WINDOW_HEIGHT, 10) || 1080; + + const win = new BrowserWindow({ + width: width, + height: height, + transparent: true, + frame: false, + alwaysOnTop: false, + webPreferences: { + backgroundThrottling: false + } + }); + + win.loadURL(url); +} + +app.whenReady().then(() => { + createWindow(); +}); diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index bc4f6cc..a331a6b 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -188,6 +188,50 @@ body { box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); } +.search-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.text-input { + width: 100%; + padding: 12px; + border-radius: 10px; + border: 1px solid #1f2937; + background: #0f172a; + color: #e2e8f0; + font-size: 15px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.text-input:focus { + outline: none; + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); +} + +.search-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.search-row { + display: flex; + gap: 10px; + align-items: stretch; +} + +.search-row .text-input { + flex: 1; +} + +.search-row .button { + padding: 0 16px; + height: 46px; +} + .badge { display: inline-flex; align-items: center; @@ -1133,6 +1177,10 @@ body { font-size: 0.9em; } +.tiny { + font-size: 13px; +} + .range-value { color: #a5b4fc; font-size: 12px; diff --git a/src/main/resources/static/js/landing.js b/src/main/resources/static/js/landing.js new file mode 100644 index 0000000..236aa45 --- /dev/null +++ b/src/main/resources/static/js/landing.js @@ -0,0 +1,54 @@ +document.addEventListener("DOMContentLoaded", () => { + const searchForm = document.getElementById("channel-search-form"); + const searchInput = document.getElementById("channel-search"); + const suggestions = document.getElementById("channel-suggestions"); + + if (!searchForm || !searchInput || !suggestions) { + return; + } + + let channels = []; + + function updateSuggestions(term) { + const normalizedTerm = term.trim().toLowerCase(); + const filtered = channels + .filter((name) => !normalizedTerm || name.includes(normalizedTerm)) + .slice(0, 20); + + suggestions.innerHTML = ""; + filtered.forEach((name) => { + const option = document.createElement("option"); + option.value = name; + suggestions.appendChild(option); + }); + } + + async function loadChannels() { + try { + const response = await fetch("/api/channels"); + if (!response.ok) { + throw new Error(`Failed to load channels: ${response.status}`); + } + channels = await response.json(); + updateSuggestions(searchInput.value || ""); + } catch (error) { + console.error("Could not load channel directory", error); + } + } + + searchInput.addEventListener("input", (event) => { + updateSuggestions(event.target.value || ""); + }); + + searchForm.addEventListener("submit", (event) => { + event.preventDefault(); + const broadcaster = (searchInput.value || "").trim().toLowerCase(); + if (!broadcaster) { + searchInput.focus(); + return; + } + window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`; + }); + + loadChannels(); +}); diff --git a/src/main/resources/templates/channels.html b/src/main/resources/templates/channels.html new file mode 100644 index 0000000..46b9800 --- /dev/null +++ b/src/main/resources/templates/channels.html @@ -0,0 +1,65 @@ + + + + + Browse channels - Imgfloat + + + +
+
+
+
IF
+
+
Imgfloat
+
Twitch overlay manager
+
+
+
+ Home + Login +
+
+ +
+
+

Broadcast views

+

Open a public channel overlay.

+

Search any broadcaster with published overlays and jump straight to their on-stream view without logging in.

+
    +
  • Autocomplete from all published overlays
  • +
  • Direct broadcast view links
  • +
+
+
+
+
+

Search

+

Find a broadcaster

+
+ Live +
+

Type a channel name to get autocomplete suggestions and open the broadcast overlay.

+
+ +
+ + + +
+

Autocomplete shows all broadcasters with published overlays.

+
+
+
+ +
+
+
Imgfloat
+
Ready when you go live.
+
+ Login +
+
+ + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index f540c0f..d44685f 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -25,7 +25,7 @@

Upload artwork, drop it into a shared dashboard, and stay in sync with your mods.

Login with Twitch - Secure OAuth login. No bots needed. + Browse channels
  • Instant overlay updates