diff --git a/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java b/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java index 9613365..97b9fd2 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java +++ b/src/main/java/dev/kruhlmann/imgfloat/ImgfloatApplication.java @@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.EnableAsync; @EnableAsync @SpringBootApplication public class ImgfloatApplication { + public static void main(String[] args) { SpringApplication.run(ImgfloatApplication.class, args); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2AuthorizedClientPersistenceConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2AuthorizedClientPersistenceConfig.java index 5da7b36..9c58d1e 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2AuthorizedClientPersistenceConfig.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2AuthorizedClientPersistenceConfig.java @@ -12,8 +12,10 @@ import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepo public class OAuth2AuthorizedClientPersistenceConfig { @Bean - OAuth2AuthorizedClientService oauth2AuthorizedClientService(JdbcOperations jdbcOperations, - ClientRegistrationRepository clientRegistrationRepository) { + OAuth2AuthorizedClientService oauth2AuthorizedClientService( + JdbcOperations jdbcOperations, + ClientRegistrationRepository clientRegistrationRepository + ) { return new SQLiteOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2RestTemplateFactory.java b/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2RestTemplateFactory.java index e64d9ad..fd2a0b7 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2RestTemplateFactory.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/OAuth2RestTemplateFactory.java @@ -1,7 +1,6 @@ package dev.kruhlmann.imgfloat.config; import java.util.Arrays; - import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.converter.FormHttpMessageConverter; @@ -10,14 +9,12 @@ import org.springframework.web.client.RestTemplate; final class OAuth2RestTemplateFactory { - private OAuth2RestTemplateFactory() { - } + private OAuth2RestTemplateFactory() {} static RestTemplate create() { - RestTemplate restTemplate = new RestTemplate(Arrays.asList( - new FormHttpMessageConverter(), - new OAuth2AccessTokenResponseHttpMessageConverter() - )); + RestTemplate restTemplate = new RestTemplate( + Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()) + ); ClientHttpRequestFactory requestFactory = restTemplate.getRequestFactory(); if (requestFactory instanceof SimpleClientHttpRequestFactory simple) { simple.setConnectTimeout(30_000); diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/OpenApiConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/OpenApiConfig.java index c961e0c..ae17f1e 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/OpenApiConfig.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/OpenApiConfig.java @@ -1,7 +1,7 @@ package dev.kruhlmann.imgfloat.config; -import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.security.OAuthFlow; import io.swagger.v3.oas.models.security.OAuthFlows; @@ -18,21 +18,26 @@ public class OpenApiConfig { @Bean public OpenAPI imgfloatOpenAPI() { return new OpenAPI() - .components(new Components().addSecuritySchemes(TWITCH_OAUTH_SCHEME, twitchOAuthScheme())) - .addSecurityItem(new SecurityRequirement().addList(TWITCH_OAUTH_SCHEME)) - .info(new Info() - .title("Imgfloat API") - .description("OpenAPI documentation for Imgfloat admin and broadcaster APIs.") - .version("v1")); + .components(new Components().addSecuritySchemes(TWITCH_OAUTH_SCHEME, twitchOAuthScheme())) + .addSecurityItem(new SecurityRequirement().addList(TWITCH_OAUTH_SCHEME)) + .info( + new Info() + .title("Imgfloat API") + .description("OpenAPI documentation for Imgfloat admin and broadcaster APIs.") + .version("v1") + ); } private SecurityScheme twitchOAuthScheme() { return new SecurityScheme() - .name(TWITCH_OAUTH_SCHEME) - .type(SecurityScheme.Type.OAUTH2) - .flows(new OAuthFlows() - .authorizationCode(new OAuthFlow() - .authorizationUrl("https://id.twitch.tv/oauth2/authorize") - .tokenUrl("https://id.twitch.tv/oauth2/token"))); + .name(TWITCH_OAUTH_SCHEME) + .type(SecurityScheme.Type.OAUTH2) + .flows( + new OAuthFlows().authorizationCode( + new OAuthFlow() + .authorizationUrl("https://id.twitch.tv/oauth2/authorize") + .tokenUrl("https://id.twitch.tv/oauth2/token") + ) + ); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java b/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java index 68feda2..aa34c8f 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java @@ -5,7 +5,8 @@ import java.time.Instant; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.RowMapper; @@ -18,6 +19,7 @@ import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2RefreshToken; public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService { + private static final Logger LOG = LoggerFactory.getLogger(SQLiteOAuth2AuthorizedClientService.class); private static final String TABLE_NAME = "oauth2_authorized_client"; @@ -25,8 +27,10 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie private final ClientRegistrationRepository clientRegistrationRepository; private final RowMapper rowMapper; - public SQLiteOAuth2AuthorizedClientService(JdbcOperations jdbcOperations, - ClientRegistrationRepository clientRegistrationRepository) { + public SQLiteOAuth2AuthorizedClientService( + JdbcOperations jdbcOperations, + ClientRegistrationRepository clientRegistrationRepository + ) { this.jdbcOperations = jdbcOperations; this.clientRegistrationRepository = clientRegistrationRepository; this.rowMapper = (rs, rowNum) -> { @@ -38,35 +42,37 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie } OAuth2AccessToken accessToken = new OAuth2AccessToken( - OAuth2AccessToken.TokenType.BEARER, - rs.getString("access_token_value"), - toInstant(rs.getObject("access_token_issued_at")), - toInstant(rs.getObject("access_token_expires_at")), - scopesFrom(rs.getString("access_token_scopes")) + OAuth2AccessToken.TokenType.BEARER, + rs.getString("access_token_value"), + toInstant(rs.getObject("access_token_issued_at")), + toInstant(rs.getObject("access_token_expires_at")), + scopesFrom(rs.getString("access_token_scopes")) ); Object refreshValue = rs.getObject("refresh_token_value"); OAuth2RefreshToken refreshToken = refreshValue == null - ? null - : new OAuth2RefreshToken( - refreshValue.toString(), - toInstant(rs.getObject("refresh_token_issued_at")) - ); + ? null + : new OAuth2RefreshToken(refreshValue.toString(), toInstant(rs.getObject("refresh_token_issued_at"))); return new OAuth2AuthorizedClient(registration, principalName, accessToken, refreshToken); }; } @Override - public T loadAuthorizedClient(String clientRegistrationId, String principalName) { + public T loadAuthorizedClient( + String clientRegistrationId, + String principalName + ) { return jdbcOperations.query( - "SELECT client_registration_id, principal_name, access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, refresh_token_value, refresh_token_issued_at " + - "FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?", - ps -> { - ps.setString(1, clientRegistrationId); - ps.setString(2, principalName); - }, - rs -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null + "SELECT client_registration_id, principal_name, access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, refresh_token_value, refresh_token_issued_at " + + "FROM " + + TABLE_NAME + + " WHERE client_registration_id = ? AND principal_name = ?", + (ps) -> { + ps.setString(1, clientRegistrationId); + ps.setString(2, principalName); + }, + (rs) -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null ); } @@ -74,51 +80,60 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) { try { jdbcOperations.update(""" - INSERT INTO oauth2_authorized_client ( - client_registration_id, principal_name, - access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, - refresh_token_value, refresh_token_issued_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(client_registration_id, principal_name) DO UPDATE SET - access_token_value=excluded.access_token_value, - access_token_issued_at=excluded.access_token_issued_at, - access_token_expires_at=excluded.access_token_expires_at, - access_token_scopes=excluded.access_token_scopes, - refresh_token_value=excluded.refresh_token_value, - refresh_token_issued_at=excluded.refresh_token_issued_at - """, - preparedStatement -> { - preparedStatement.setString(1, authorizedClient.getClientRegistration().getRegistrationId()); - preparedStatement.setString(2, principal.getName()); - setToken(preparedStatement, 3, authorizedClient.getAccessToken()); - preparedStatement.setObject(5, toEpochMillis(authorizedClient.getAccessToken().getExpiresAt()), java.sql.Types.BIGINT); - preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes())); - OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken(); - if (refreshToken != null) { - preparedStatement.setString(7, refreshToken.getTokenValue()); - preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT); - } else { - preparedStatement.setNull(7, java.sql.Types.VARCHAR); - preparedStatement.setNull(8, java.sql.Types.BIGINT); - } - }); + INSERT INTO oauth2_authorized_client ( + client_registration_id, principal_name, + access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, + refresh_token_value, refresh_token_issued_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(client_registration_id, principal_name) DO UPDATE SET + access_token_value=excluded.access_token_value, + access_token_issued_at=excluded.access_token_issued_at, + access_token_expires_at=excluded.access_token_expires_at, + access_token_scopes=excluded.access_token_scopes, + refresh_token_value=excluded.refresh_token_value, + refresh_token_issued_at=excluded.refresh_token_issued_at + """, (preparedStatement) -> { + preparedStatement.setString(1, authorizedClient.getClientRegistration().getRegistrationId()); + preparedStatement.setString(2, principal.getName()); + setToken(preparedStatement, 3, authorizedClient.getAccessToken()); + preparedStatement.setObject( + 5, + toEpochMillis(authorizedClient.getAccessToken().getExpiresAt()), + java.sql.Types.BIGINT + ); + preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes())); + OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken(); + if (refreshToken != null) { + preparedStatement.setString(7, refreshToken.getTokenValue()); + preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT); + } else { + preparedStatement.setNull(7, java.sql.Types.VARCHAR); + preparedStatement.setNull(8, java.sql.Types.BIGINT); + } + }); } catch (DataAccessException ex) { - LOG.error("Failed to save authorized client for registration ID '{}' and principal '{}'", - authorizedClient.getClientRegistration().getRegistrationId(), - principal.getName(), ex); + LOG.error( + "Failed to save authorized client for registration ID '{}' and principal '{}'", + authorizedClient.getClientRegistration().getRegistrationId(), + principal.getName(), + ex + ); } } @Override public void removeAuthorizedClient(String clientRegistrationId, String principalName) { - jdbcOperations.update("DELETE FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?", - preparedStatement -> { - preparedStatement.setString(1, clientRegistrationId); - preparedStatement.setString(2, principalName); - }); + jdbcOperations.update( + "DELETE FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?", + (preparedStatement) -> { + preparedStatement.setString(1, clientRegistrationId); + preparedStatement.setString(2, principalName); + } + ); } - private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token) throws java.sql.SQLException { + private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token) + throws java.sql.SQLException { ps.setString(startIndex, token.getTokenValue()); ps.setObject(startIndex + 1, toEpochMillis(token.getIssuedAt()), java.sql.Types.BIGINT); } @@ -151,9 +166,9 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie return Set.of(); } return Stream.of(scopeString.split(" ")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); + .map(String::trim) + .filter((s) -> !s.isEmpty()) + .collect(Collectors.toSet()); } private String scopesToString(Set scopes) { diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java index 488a671..118ba8e 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java @@ -1,5 +1,6 @@ package dev.kruhlmann.imgfloat.config; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; @@ -8,8 +9,6 @@ import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; -import java.util.List; - @Component public class SchemaMigration implements ApplicationRunner { @@ -32,7 +31,8 @@ public class SchemaMigration implements ApplicationRunner { private void ensureSessionAttributeUpsertTrigger() { try { - jdbcTemplate.execute(""" + jdbcTemplate.execute( + """ CREATE TRIGGER IF NOT EXISTS SPRING_SESSION_ATTRIBUTES_UPSERT BEFORE INSERT ON SPRING_SESSION_ATTRIBUTES FOR EACH ROW @@ -41,7 +41,8 @@ public class SchemaMigration implements ApplicationRunner { WHERE SESSION_PRIMARY_ID = NEW.SESSION_PRIMARY_ID AND ATTRIBUTE_NAME = NEW.ATTRIBUTE_NAME; END; - """); + """ + ); logger.info("Ensured SPRING_SESSION_ATTRIBUTES upsert trigger exists"); } catch (DataAccessException ex) { logger.warn("Unable to ensure SPRING_SESSION_ATTRIBUTES upsert trigger", ex); @@ -91,14 +92,32 @@ public class SchemaMigration implements ApplicationRunner { addColumnIfMissing(table, columns, "preview", "TEXT", "NULL"); } - private void addColumnIfMissing(String tableName, List existingColumns, String columnName, String dataType, String defaultValue) { + private void addColumnIfMissing( + String tableName, + List existingColumns, + String columnName, + String dataType, + String defaultValue + ) { if (existingColumns.contains(columnName)) { return; } try { - jdbcTemplate.execute("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue); - jdbcTemplate.execute("UPDATE " + tableName + " SET " + columnName + " = " + defaultValue + " WHERE " + columnName + " IS NULL"); + jdbcTemplate.execute( + "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue + ); + jdbcTemplate.execute( + "UPDATE " + + tableName + + " SET " + + columnName + + " = " + + defaultValue + + " WHERE " + + columnName + + " IS NULL" + ); logger.info("Added missing column '{}' to {} table", columnName, tableName); } catch (DataAccessException ex) { logger.warn("Failed to add column '{}' to {} table", columnName, tableName, ex); @@ -107,7 +126,8 @@ public class SchemaMigration implements ApplicationRunner { private void ensureAuthorizedClientTable() { try { - jdbcTemplate.execute(""" + jdbcTemplate.execute( + """ CREATE TABLE IF NOT EXISTS oauth2_authorized_client ( client_registration_id VARCHAR(100) NOT NULL, principal_name VARCHAR(200) NOT NULL, @@ -120,7 +140,8 @@ public class SchemaMigration implements ApplicationRunner { refresh_token_issued_at INTEGER, PRIMARY KEY (client_registration_id, principal_name) ) - """); + """ + ); logger.info("Ensured oauth2_authorized_client table exists"); } catch (DataAccessException ex) { logger.warn("Unable to ensure oauth2_authorized_client table", ex); @@ -136,13 +157,34 @@ public class SchemaMigration implements ApplicationRunner { private void normalizeTimestampColumn(String column) { try { int updated = jdbcTemplate.update( - "UPDATE oauth2_authorized_client " + - "SET " + column + " = CASE " + - "WHEN " + column + " LIKE '%-%' THEN CAST(strftime('%s', " + column + ") AS INTEGER) * 1000 " + - "WHEN typeof(" + column + ") = 'text' AND " + column + " GLOB '[0-9]*' THEN CAST(" + column + " AS INTEGER) " + - "WHEN typeof(" + column + ") = 'integer' THEN " + column + " " + - "ELSE " + column + " END " + - "WHERE " + column + " IS NOT NULL"); + "UPDATE oauth2_authorized_client " + + "SET " + + column + + " = CASE " + + "WHEN " + + column + + " LIKE '%-%' THEN CAST(strftime('%s', " + + column + + ") AS INTEGER) * 1000 " + + "WHEN typeof(" + + column + + ") = 'text' AND " + + column + + " GLOB '[0-9]*' THEN CAST(" + + column + + " AS INTEGER) " + + "WHEN typeof(" + + column + + ") = 'integer' THEN " + + column + + " " + + "ELSE " + + column + + " END " + + "WHERE " + + column + + " IS NOT NULL" + ); if (updated > 0) { logger.info("Normalized {} rows in oauth2_authorized_client.{}", updated, column); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java index ec8e78d..ad761a0 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java @@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -14,7 +15,6 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.client.RestTemplate; -import org.springframework.http.HttpStatus; @Configuration @EnableWebSecurity @@ -22,11 +22,14 @@ import org.springframework.http.HttpStatus; public class SecurityConfig { @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http, - OAuth2AuthorizedClientRepository authorizedClientRepository) throws Exception { + SecurityFilterChain securityFilterChain( + HttpSecurity http, + OAuth2AuthorizedClientRepository authorizedClientRepository + ) throws Exception { http - .authorizeHttpRequests(auth -> auth - .requestMatchers( + .authorizeHttpRequests((auth) -> + auth + .requestMatchers( "/", "/favicon.ico", "/img/**", @@ -38,26 +41,37 @@ public class SecurityConfig { "/swagger-ui.html", "/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() - .requestMatchers("/ws/**").permitAll() - .anyRequest().authenticated() + ) + .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() + .requestMatchers("/ws/**") + .permitAll() + .anyRequest() + .authenticated() ) - .oauth2Login(oauth -> oauth - .authorizedClientRepository(authorizedClientRepository) - .tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient())) - .userInfoEndpoint(user -> user.userService(twitchOAuth2UserService()))) - .logout(logout -> logout.logoutSuccessUrl("/").permitAll()) - .exceptionHandling(exceptions -> exceptions - .defaultAuthenticationEntryPointFor( + .oauth2Login((oauth) -> + oauth + .authorizedClientRepository(authorizedClientRepository) + .tokenEndpoint((token) -> token.accessTokenResponseClient(twitchAccessTokenResponseClient())) + .userInfoEndpoint((user) -> user.userService(twitchOAuth2UserService())) + ) + .logout((logout) -> logout.logoutSuccessUrl("/").permitAll()) + .exceptionHandling((exceptions) -> + exceptions.defaultAuthenticationEntryPointFor( new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), new AntPathRequestMatcher("/api/**") - )) - .csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**")); + ) + ) + .csrf((csrf) -> csrf.ignoringRequestMatchers("/ws/**", "/api/**")); return http.build(); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java index f8da9b3..faffbcd 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java @@ -4,31 +4,39 @@ import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.util.unit.DataSize; -import org.springframework.core.env.Environment; @Component public class SystemEnvironmentValidator { + private static final Logger log = LoggerFactory.getLogger(SystemEnvironmentValidator.class); private final Environment environment; @Value("${spring.security.oauth2.client.registration.twitch.client-id:#{null}}") private String twitchClientId; + @Value("${spring.security.oauth2.client.registration.twitch.client-secret:#{null}}") private String twitchClientSecret; + @Value("${spring.servlet.multipart.max-file-size:#{null}}") private String springMaxFileSize; + @Value("${spring.servlet.multipart.max-request-size:#{null}}") private String springMaxRequestSize; + @Value("${IMGFLOAT_ASSETS_PATH:#{null}}") private String assetsPath; + @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") private String previewsPath; + @Value("${IMGFLOAT_DB_PATH:#{null}}") private String dbPath; + @Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") private String initialSysadmin; @@ -41,7 +49,11 @@ public class SystemEnvironmentValidator { @PostConstruct public void validate() { - if (Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper"))) { + if ( + Boolean.parseBoolean( + environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper") + ) + ) { log.info("Skipping environment validation in test context"); return; } @@ -60,9 +72,7 @@ public class SystemEnvironmentValidator { checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing); if (!missing.isEmpty()) { - throw new IllegalStateException( - "Missing or invalid environment variables:\n" + missing - ); + throw new IllegalStateException("Missing or invalid environment variables:\n" + missing); } log.info("Environment validation successful:"); @@ -93,7 +103,7 @@ public class SystemEnvironmentValidator { private String redact(String value) { if (value != null && StringUtils.hasText(value)) { return "**************"; - }; + } return ""; } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java index 38d0163..c1300b2 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java @@ -3,7 +3,6 @@ package dev.kruhlmann.imgfloat.config; import java.net.URI; import java.util.ArrayList; import java.util.List; - import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpMethod; import org.springframework.http.RequestEntity; @@ -19,11 +18,11 @@ import org.springframework.util.MultiValueMap; * request body. Twitch ignores HTTP Basic authentication and responds with "missing client id" if * those parameters are absent. */ -final class TwitchAuthorizationCodeGrantRequestEntityConverter implements - Converter> { +final class TwitchAuthorizationCodeGrantRequestEntityConverter + implements Converter> { private final Converter> delegate = - new OAuth2AuthorizationCodeGrantRequestEntityConverter(); + new OAuth2AuthorizationCodeGrantRequestEntityConverter(); @Override public RequestEntity convert(OAuth2AuthorizationCodeGrantRequest request) { @@ -50,8 +49,7 @@ final class TwitchAuthorizationCodeGrantRequestEntityConverter implements private MultiValueMap cloneBody(MultiValueMap existingBody) { MultiValueMap copy = new LinkedMultiValueMap<>(); - existingBody.forEach((key, value) -> - copy.put(String.valueOf(key), new ArrayList<>((List) value))); + existingBody.forEach((key, value) -> copy.put(String.valueOf(key), new ArrayList<>((List) value))); return copy; } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchClientRegistrationConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchClientRegistrationConfig.java index 2b073fa..59e789a 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchClientRegistrationConfig.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchClientRegistrationConfig.java @@ -3,7 +3,6 @@ package dev.kruhlmann.imgfloat.config; import java.util.ArrayList; import java.util.List; import java.util.Map; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; @@ -24,6 +23,7 @@ import org.springframework.security.oauth2.core.ClientAuthenticationMethod; @Configuration @EnableConfigurationProperties(OAuth2ClientProperties.class) class TwitchClientRegistrationConfig { + private static final Logger LOG = LoggerFactory.getLogger(TwitchClientRegistrationConfig.class); @Bean @@ -37,7 +37,8 @@ class TwitchClientRegistrationConfig { OAuth2ClientProperties.Provider provider = properties.getProvider().get(providerId); if (provider == null) { throw new IllegalStateException( - "Missing OAuth2 provider configuration for registration '" + registrationId + "'."); + "Missing OAuth2 provider configuration for registration '" + registrationId + "'." + ); } if (!"twitch".equals(registrationId)) { LOG.warn("Unexpected OAuth2 registration '{}' found; only Twitch is supported.", registrationId); @@ -49,24 +50,25 @@ class TwitchClientRegistrationConfig { } private ClientRegistration buildTwitchRegistration( - String registrationId, - OAuth2ClientProperties.Registration registration, - OAuth2ClientProperties.Provider provider) { + String registrationId, + OAuth2ClientProperties.Registration registration, + OAuth2ClientProperties.Provider provider + ) { String clientId = sanitize(registration.getClientId(), "TWITCH_CLIENT_ID"); String clientSecret = sanitize(registration.getClientSecret(), "TWITCH_CLIENT_SECRET"); return ClientRegistration.withRegistrationId(registrationId) - .clientName(registration.getClientName()) - .clientId(clientId) - .clientSecret(clientSecret) - .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) - .authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType())) - .redirectUri(registration.getRedirectUri()) - .scope(registration.getScope()) - .authorizationUri(provider.getAuthorizationUri()) - .tokenUri(provider.getTokenUri()) - .userInfoUri(provider.getUserInfoUri()) - .userNameAttributeName(provider.getUserNameAttribute()) - .build(); + .clientName(registration.getClientName()) + .clientId(clientId) + .clientSecret(clientSecret) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType())) + .redirectUri(registration.getRedirectUri()) + .scope(registration.getScope()) + .authorizationUri(provider.getAuthorizationUri()) + .tokenUri(provider.getTokenUri()) + .userInfoUri(provider.getUserInfoUri()) + .userNameAttributeName(provider.getUserNameAttribute()) + .build(); } private String sanitize(String value, String name) { @@ -74,7 +76,9 @@ class TwitchClientRegistrationConfig { return null; } String trimmed = value.trim(); - if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { String unquoted = trimmed.substring(1, trimmed.length() - 1).trim(); LOG.info("Sanitizing {} by stripping surrounding quotes.", name); return unquoted; diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2ErrorResponseErrorHandler.java b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2ErrorResponseErrorHandler.java index 37cf694..88659a3 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2ErrorResponseErrorHandler.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2ErrorResponseErrorHandler.java @@ -1,9 +1,8 @@ package dev.kruhlmann.imgfloat.config; -import java.io.IOException; import java.io.ByteArrayInputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.client.ClientHttpResponse; @@ -31,20 +30,24 @@ class TwitchOAuth2ErrorResponseErrorHandler extends OAuth2ErrorResponseErrorHand String body = new String(bodyBytes, StandardCharsets.UTF_8); if (body.isBlank()) { - LOG.warn("Failed to parse Twitch OAuth error response (status: {}, headers: {}): ", - response.getStatusCode(), - response.getHeaders()); + LOG.warn( + "Failed to parse Twitch OAuth error response (status: {}, headers: {}): ", + response.getStatusCode(), + response.getHeaders() + ); throw asAuthorizationException(body, null); } try { super.handleError(new CachedBodyClientHttpResponse(response, bodyBytes)); } catch (HttpMessageNotReadableException | IllegalArgumentException ex) { - LOG.warn("Failed to parse Twitch OAuth error response (status: {}, headers: {}): {}", - response.getStatusCode(), - response.getHeaders(), - body, - ex); + LOG.warn( + "Failed to parse Twitch OAuth error response (status: {}, headers: {}): {}", + response.getStatusCode(), + response.getHeaders(), + body, + ex + ); throw asAuthorizationException(body, ex); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2UserService.java b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2UserService.java index f7a8073..6f7bbfd 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2UserService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2UserService.java @@ -5,7 +5,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; - import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.SimpleClientHttpRequestFactory; @@ -46,18 +45,19 @@ class TwitchOAuth2UserService implements OAuth2UserService addAdmin(@PathVariable("broadcaster") String broadcaster, - @Valid @RequestBody AdminRequest request, - OAuth2AuthenticationToken oauthToken) { + public ResponseEntity addAdmin( + @PathVariable("broadcaster") String broadcaster, + @Valid @RequestBody AdminRequest request, + OAuth2AuthenticationToken oauthToken + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.info("User {} adding admin {} to {}", sessionUsername, request.getUsername(), broadcaster); @@ -85,32 +86,34 @@ public class ChannelApiController { } @GetMapping("/admins") - public Collection listAdmins(@PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken oauthToken, - HttpServletRequest request) { + public Collection listAdmins( + @PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken oauthToken, + HttpServletRequest request + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.debug("Listing admins for {} by {}", broadcaster, sessionUsername); var channel = channelDirectoryService.getOrCreateChannel(broadcaster); - List admins = channel.getAdmins().stream() - .sorted(Comparator.naturalOrder()) - .toList(); + List admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList(); OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request); String accessToken = Optional.ofNullable(authorizedClient) - .map(OAuth2AuthorizedClient::getAccessToken) - .map(token -> token.getTokenValue()) - .orElse(null); + .map(OAuth2AuthorizedClient::getAccessToken) + .map((token) -> token.getTokenValue()) + .orElse(null); String clientId = Optional.ofNullable(authorizedClient) - .map(OAuth2AuthorizedClient::getClientRegistration) - .map(registration -> registration.getClientId()) - .orElse(null); + .map(OAuth2AuthorizedClient::getClientRegistration) + .map((registration) -> registration.getClientId()) + .orElse(null); return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId); } @GetMapping("/admins/suggestions") - public Collection listAdminSuggestions(@PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken oauthToken, - HttpServletRequest request) { + public Collection listAdminSuggestions( + @PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken oauthToken, + HttpServletRequest request + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.debug("Listing admin suggestions for {} by {}", broadcaster, sessionUsername); @@ -118,28 +121,38 @@ public class ChannelApiController { OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request); if (authorizedClient == null) { - LOG.warn("No authorized Twitch client found for {} while fetching admin suggestions for {}", sessionUsername, broadcaster); + LOG.warn( + "No authorized Twitch client found for {} while fetching admin suggestions for {}", + sessionUsername, + broadcaster + ); return List.of(); } String accessToken = Optional.ofNullable(authorizedClient) - .map(OAuth2AuthorizedClient::getAccessToken) - .map(token -> token.getTokenValue()) - .orElse(null); + .map(OAuth2AuthorizedClient::getAccessToken) + .map((token) -> token.getTokenValue()) + .orElse(null); String clientId = Optional.ofNullable(authorizedClient) - .map(OAuth2AuthorizedClient::getClientRegistration) - .map(registration -> registration.getClientId()) - .orElse(null); + .map(OAuth2AuthorizedClient::getClientRegistration) + .map((registration) -> registration.getClientId()) + .orElse(null); if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) { - LOG.warn("Missing Twitch credentials for {} while fetching admin suggestions for {}", sessionUsername, broadcaster); + LOG.warn( + "Missing Twitch credentials for {} while fetching admin suggestions for {}", + sessionUsername, + broadcaster + ); return List.of(); } return twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId); } @DeleteMapping("/admins/{username}") - public ResponseEntity removeAdmin(@PathVariable("broadcaster") String broadcaster, - @PathVariable("username") String username, - OAuth2AuthenticationToken oauthToken) { + public ResponseEntity removeAdmin( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("username") String username, + OAuth2AuthenticationToken oauthToken + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.info("User {} removing admin {} from {}", sessionUsername, username, broadcaster); @@ -163,30 +176,44 @@ public class ChannelApiController { } @PutMapping("/canvas") - public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster, - @Valid @RequestBody CanvasSettingsRequest request, - OAuth2AuthenticationToken oauthToken) { + public CanvasSettingsRequest updateCanvas( + @PathVariable("broadcaster") String broadcaster, + @Valid @RequestBody CanvasSettingsRequest request, + OAuth2AuthenticationToken oauthToken + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); - LOG.info("Updating canvas for {} by {}: {}x{}", broadcaster, sessionUsername, request.getWidth(), request.getHeight()); + LOG.info( + "Updating canvas for {} by {}: {}x{}", + broadcaster, + sessionUsername, + request.getWidth(), + request.getHeight() + ); return channelDirectoryService.updateCanvasSettings(broadcaster, request); } @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity createAsset(@PathVariable("broadcaster") String broadcaster, - @org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file, - OAuth2AuthenticationToken oauthToken) { + public ResponseEntity createAsset( + @PathVariable("broadcaster") String broadcaster, + @org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file, + OAuth2AuthenticationToken oauthToken + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); if (file == null || file.isEmpty()) { LOG.warn("User {} attempted to upload empty file to {}", sessionUsername, broadcaster); throw new ResponseStatusException(BAD_REQUEST, "Asset file is required"); } try { LOG.info("User {} uploading asset {} to {}", sessionUsername, file.getOriginalFilename(), broadcaster); - return channelDirectoryService.createAsset(broadcaster, file) - .map(ResponseEntity::ok) - .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image")); + return channelDirectoryService + .createAsset(broadcaster, file) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image")); } catch (IOException e) { LOG.error("Failed to process asset upload for {} by {}", broadcaster, sessionUsername, e); throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e); @@ -194,88 +221,130 @@ public class ChannelApiController { } @PutMapping("/assets/{assetId}/transform") - public ResponseEntity transform(@PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @Valid @RequestBody TransformRequest request, - OAuth2AuthenticationToken oauthToken) { + public ResponseEntity transform( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @Valid @RequestBody TransformRequest request, + OAuth2AuthenticationToken oauthToken + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, sessionUsername); - return channelDirectoryService.updateTransform(broadcaster, assetId, request) - .map(ResponseEntity::ok) - .orElseThrow(() -> { - LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername); - return new ResponseStatusException(NOT_FOUND, "Asset not found"); - }); + return channelDirectoryService + .updateTransform(broadcaster, assetId, request) + .map(ResponseEntity::ok) + .orElseThrow(() -> { + LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername); + return new ResponseStatusException(NOT_FOUND, "Asset not found"); + }); } @PostMapping("/assets/{assetId}/play") - public ResponseEntity play(@PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @RequestBody(required = false) PlaybackRequest request, - OAuth2AuthenticationToken oauthToken) { + public ResponseEntity play( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @RequestBody(required = false) PlaybackRequest request, + OAuth2AuthenticationToken oauthToken + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); LOG.info("Triggering playback for asset {} on {} by {}", assetId, broadcaster, sessionUsername); - return channelDirectoryService.triggerPlayback(broadcaster, assetId, request) - .map(ResponseEntity::ok) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); + return channelDirectoryService + .triggerPlayback(broadcaster, assetId, request) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); } @PutMapping("/assets/{assetId}/visibility") - public ResponseEntity visibility(@PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - @RequestBody VisibilityRequest request, - OAuth2AuthenticationToken oauthToken) { + public ResponseEntity visibility( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @RequestBody VisibilityRequest request, + OAuth2AuthenticationToken oauthToken + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); - LOG.info("Updating visibility for asset {} on {} by {} to hidden={} ", assetId, broadcaster, sessionUsername , request.isHidden()); - return channelDirectoryService.updateVisibility(broadcaster, assetId, request) - .map(ResponseEntity::ok) - .orElseThrow(() -> { - LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername); - return new ResponseStatusException(NOT_FOUND, "Asset not found"); - }); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + LOG.info( + "Updating visibility for asset {} on {} by {} to hidden={} ", + assetId, + broadcaster, + sessionUsername, + request.isHidden() + ); + return channelDirectoryService + .updateVisibility(broadcaster, assetId, request) + .map(ResponseEntity::ok) + .orElseThrow(() -> { + LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername); + return new ResponseStatusException(NOT_FOUND, "Asset not found"); + }); } @GetMapping("/assets/{assetId}/content") - public ResponseEntity getAssetContent(@PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId) { + public ResponseEntity getAssetContent( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId + ) { LOG.debug("Serving asset {} for broadcaster {}", assetId, broadcaster); - return channelDirectoryService.getAssetContent(assetId) - .map(content -> ResponseEntity.ok() + return channelDirectoryService + .getAssetContent(assetId) + .map((content) -> + ResponseEntity.ok() .header("X-Content-Type-Options", "nosniff") .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType())) - .body(content.bytes())) + .body(content.bytes()) + ) .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); } @GetMapping("/assets/{assetId}/preview") - public ResponseEntity getAssetPreview(@PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId) { + public ResponseEntity getAssetPreview( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId + ) { LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster); - return channelDirectoryService.getAssetPreview(assetId, true) - .map(content -> ResponseEntity.ok() + return channelDirectoryService + .getAssetPreview(assetId, true) + .map((content) -> + ResponseEntity.ok() .header("X-Content-Type-Options", "nosniff") .contentType(MediaType.parseMediaType(content.mediaType())) - .body(content.bytes())) + .body(content.bytes()) + ) .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found")); } private String contentDispositionFor(String mediaType) { - if (mediaType != null && dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) { + if ( + mediaType != null && + dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType) + ) { return "inline"; } return "attachment"; } @DeleteMapping("/assets/{assetId}") - public ResponseEntity delete(@PathVariable("broadcaster") String broadcaster, - @PathVariable("assetId") String assetId, - OAuth2AuthenticationToken oauthToken) { + public ResponseEntity delete( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + OAuth2AuthenticationToken oauthToken + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); boolean removed = channelDirectoryService.deleteAsset(assetId); if (!removed) { LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, sessionUsername); @@ -285,9 +354,11 @@ public class ChannelApiController { return ResponseEntity.ok().build(); } - private OAuth2AuthorizedClient resolveAuthorizedClient(OAuth2AuthenticationToken oauthToken, - OAuth2AuthorizedClient authorizedClient, - HttpServletRequest request) { + private OAuth2AuthorizedClient resolveAuthorizedClient( + OAuth2AuthenticationToken oauthToken, + OAuth2AuthorizedClient authorizedClient, + HttpServletRequest request + ) { if (authorizedClient != null) { return authorizedClient; } @@ -295,12 +366,16 @@ public class ChannelApiController { return null; } OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient( - oauthToken.getAuthorizedClientRegistrationId(), - oauthToken, - request); + oauthToken.getAuthorizedClientRegistrationId(), + oauthToken, + request + ); if (sessionClient != null) { return sessionClient; } - return authorizedClientService.loadAuthorizedClient(oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName()); + return authorizedClientService.loadAuthorizedClient( + oauthToken.getAuthorizedClientRegistrationId(), + oauthToken.getName() + ); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelDirectoryApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelDirectoryApiController.java index db8c2da..3cccbb1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelDirectoryApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelDirectoryApiController.java @@ -1,13 +1,12 @@ package dev.kruhlmann.imgfloat.controller; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; +import java.util.List; 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 { diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java index 76bf7e1..1049177 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/SettingsApiController.java @@ -1,19 +1,27 @@ package dev.kruhlmann.imgfloat.controller; -import dev.kruhlmann.imgfloat.model.Settings; -import dev.kruhlmann.imgfloat.model.OauthSessionUser; -import dev.kruhlmann.imgfloat.service.SettingsService; -import dev.kruhlmann.imgfloat.service.AuthorizationService; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.model.Settings; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.SettingsService; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; +import java.io.IOException; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -24,20 +32,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.util.Collection; -import java.io.IOException; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; - -import static org.springframework.http.HttpStatus.FORBIDDEN; -import static org.springframework.http.HttpStatus.NOT_FOUND; -import static org.springframework.http.HttpStatus.BAD_REQUEST; - @RestController @RequestMapping("/api/settings") @SecurityRequirement(name = "administrator") public class SettingsApiController { + private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class); private final SettingsService settingsService; @@ -49,7 +48,10 @@ public class SettingsApiController { } @PutMapping("/set") - public ResponseEntity setSettings(@Valid @RequestBody Settings newSettings, OAuth2AuthenticationToken oauthToken) { + public ResponseEntity setSettings( + @Valid @RequestBody Settings newSettings, + OAuth2AuthenticationToken oauthToken + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java index ed794ba..e97d05a 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java @@ -1,14 +1,16 @@ package dev.kruhlmann.imgfloat.controller; -import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; -import dev.kruhlmann.imgfloat.service.VersionService; -import dev.kruhlmann.imgfloat.service.SettingsService; -import dev.kruhlmann.imgfloat.service.AuthorizationService; -import dev.kruhlmann.imgfloat.model.Settings; -import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.model.Settings; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; +import dev.kruhlmann.imgfloat.service.SettingsService; +import dev.kruhlmann.imgfloat.service.VersionService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -19,11 +21,9 @@ import org.springframework.ui.Model; import org.springframework.util.unit.DataSize; import org.springframework.web.server.ResponseStatusException; -import static org.springframework.http.HttpStatus.FORBIDDEN; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; - @Controller public class ViewController { + private static final Logger LOG = LoggerFactory.getLogger(ViewController.class); private final ChannelDirectoryService channelDirectoryService; private final VersionService versionService; @@ -85,11 +85,16 @@ public class ViewController { } @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin") - public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken oauthToken, - Model model) { + public String adminView( + @org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken oauthToken, + Model model + ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); - authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); LOG.info("Rendering admin console for {} (requested by {})", broadcaster, sessionUsername); Settings settings = settingsService.get(); model.addAttribute("broadcaster", broadcaster.toLowerCase()); @@ -106,8 +111,10 @@ public class ViewController { } @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast") - public String broadcastView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, - Model model) { + public String broadcastView( + @org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, + Model model + ) { LOG.debug("Rendering broadcast overlay for {}", broadcaster); model.addAttribute("broadcaster", broadcaster.toLowerCase()); return "broadcast"; diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AdminRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/AdminRequest.java index 5416be7..9053067 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AdminRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AdminRequest.java @@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.model; import jakarta.validation.constraints.NotBlank; public class AdminRequest { + @NotBlank private String username; diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java b/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java index 7c2b1a2..1c479ae 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java @@ -4,9 +4,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.PrePersist; -import jakarta.persistence.Table; import jakarta.persistence.PreUpdate; - +import jakarta.persistence.Table; import java.time.Instant; import java.util.Locale; import java.util.UUID; @@ -14,18 +13,25 @@ import java.util.UUID; @Entity @Table(name = "assets") public class Asset { + @Id private String id; + @Column(nullable = false) private String broadcaster; + @Column(nullable = false) private String name; + @Column(columnDefinition = "TEXT", nullable = false) private String url; + @Column(columnDefinition = "TEXT", nullable = false) private String preview; + @Column(nullable = false) private Instant createdAt; + private double x; private double y; private double width; @@ -43,8 +49,7 @@ public class Asset { private Double audioVolume; private boolean hidden; - public Asset() { - } + public Asset() {} public Asset(String broadcaster, String name, String url, double width, double height) { this.id = UUID.randomUUID().toString(); diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java index a91b29f..16701c6 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java @@ -4,12 +4,13 @@ import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_NULL) public class AssetEvent { + public enum Type { CREATED, UPDATED, VISIBILITY, PLAY, - DELETED + DELETED, } private Type type; diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetPatch.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetPatch.java index f97f04a..e6c36d0 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetPatch.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetPatch.java @@ -8,37 +8,37 @@ import com.fasterxml.jackson.annotation.JsonInclude; */ @JsonInclude(JsonInclude.Include.NON_NULL) public record AssetPatch( - String id, - Double x, - Double y, - Double width, - Double height, - Double rotation, - Double speed, - Boolean muted, - Integer zIndex, - Boolean hidden, - Boolean audioLoop, - Integer audioDelayMillis, - Double audioSpeed, - Double audioPitch, - Double audioVolume + String id, + Double x, + Double y, + Double width, + Double height, + Double rotation, + Double speed, + Boolean muted, + Integer zIndex, + Boolean hidden, + Boolean audioLoop, + Integer audioDelayMillis, + Double audioSpeed, + Double audioPitch, + Double audioVolume ) { public static TransformSnapshot capture(Asset asset) { return new TransformSnapshot( - asset.getX(), - asset.getY(), - asset.getWidth(), - asset.getHeight(), - asset.getRotation(), - asset.getSpeed(), - asset.isMuted(), - asset.getZIndex(), - asset.isAudioLoop(), - asset.getAudioDelayMillis(), - asset.getAudioSpeed(), - asset.getAudioPitch(), - asset.getAudioVolume() + asset.getX(), + asset.getY(), + asset.getWidth(), + asset.getHeight(), + asset.getRotation(), + asset.getSpeed(), + asset.isMuted(), + asset.getZIndex(), + asset.isAudioLoop(), + asset.getAudioDelayMillis(), + asset.getAudioSpeed(), + asset.getAudioPitch(), + asset.getAudioVolume() ); } @@ -48,41 +48,43 @@ public record AssetPatch( */ public static AssetPatch fromTransform(TransformSnapshot before, Asset asset, TransformRequest request) { return new AssetPatch( - asset.getId(), - changed(before.x(), asset.getX()), - changed(before.y(), asset.getY()), - changed(before.width(), asset.getWidth()), - changed(before.height(), asset.getHeight()), - changed(before.rotation(), asset.getRotation()), - request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null, - request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null, - request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null, - null, - request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null, - request.getAudioDelayMillis() != null ? changed(before.audioDelayMillis(), asset.getAudioDelayMillis()) : null, - request.getAudioSpeed() != null ? changed(before.audioSpeed(), asset.getAudioSpeed()) : null, - request.getAudioPitch() != null ? changed(before.audioPitch(), asset.getAudioPitch()) : null, - request.getAudioVolume() != null ? changed(before.audioVolume(), asset.getAudioVolume()) : null + asset.getId(), + changed(before.x(), asset.getX()), + changed(before.y(), asset.getY()), + changed(before.width(), asset.getWidth()), + changed(before.height(), asset.getHeight()), + changed(before.rotation(), asset.getRotation()), + request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null, + request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null, + request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null, + null, + request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null, + request.getAudioDelayMillis() != null + ? changed(before.audioDelayMillis(), asset.getAudioDelayMillis()) + : null, + request.getAudioSpeed() != null ? changed(before.audioSpeed(), asset.getAudioSpeed()) : null, + request.getAudioPitch() != null ? changed(before.audioPitch(), asset.getAudioPitch()) : null, + request.getAudioVolume() != null ? changed(before.audioVolume(), asset.getAudioVolume()) : null ); } public static AssetPatch fromVisibility(Asset asset) { return new AssetPatch( - asset.getId(), - null, - null, - null, - null, - null, - null, - null, - null, - asset.isHidden(), - null, - null, - null, - null, - null + asset.getId(), + null, + null, + null, + null, + null, + null, + null, + null, + asset.isHidden(), + null, + null, + null, + null, + null ); } @@ -99,18 +101,18 @@ public record AssetPatch( } public record TransformSnapshot( - double x, - double y, - double width, - double height, - double rotation, - double speed, - boolean muted, - int zIndex, - boolean audioLoop, - int audioDelayMillis, - double audioSpeed, - double audioPitch, - double audioVolume - ) { } + double x, + double y, + double width, + double height, + double rotation, + double speed, + boolean muted, + int zIndex, + boolean audioLoop, + int audioDelayMillis, + double audioSpeed, + double audioPitch, + double audioVolume + ) {} } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java index a277ddd..31af79e 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java @@ -3,57 +3,57 @@ package dev.kruhlmann.imgfloat.model; import java.time.Instant; public record AssetView( - String id, - String broadcaster, - String name, - String url, - String previewUrl, - double x, - double y, - double width, - double height, - double rotation, - Double speed, - Boolean muted, - String mediaType, - String originalMediaType, - Integer zIndex, - Boolean audioLoop, - Integer audioDelayMillis, - Double audioSpeed, - Double audioPitch, - Double audioVolume, - boolean hidden, - boolean hasPreview, - Instant createdAt + String id, + String broadcaster, + String name, + String url, + String previewUrl, + double x, + double y, + double width, + double height, + double rotation, + Double speed, + Boolean muted, + String mediaType, + String originalMediaType, + Integer zIndex, + Boolean audioLoop, + Integer audioDelayMillis, + Double audioSpeed, + Double audioPitch, + Double audioVolume, + boolean hidden, + boolean hasPreview, + Instant createdAt ) { public static AssetView from(String broadcaster, Asset asset) { return new AssetView( - asset.getId(), - asset.getBroadcaster(), - asset.getName(), - "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", - asset.getPreview() != null && !asset.getPreview().isBlank() - ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" - : null, - asset.getX(), - asset.getY(), - asset.getWidth(), - asset.getHeight(), - asset.getRotation(), - asset.getSpeed(), - asset.isMuted(), - asset.getMediaType(), - asset.getOriginalMediaType(), - asset.getZIndex(), - asset.isAudioLoop(), - asset.getAudioDelayMillis(), - asset.getAudioSpeed(), - asset.getAudioPitch(), - asset.getAudioVolume(), - asset.isHidden(), - asset.getPreview() != null && !asset.getPreview().isBlank(), - asset.getCreatedAt() + asset.getId(), + asset.getBroadcaster(), + asset.getName(), + "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", + asset.getPreview() != null && !asset.getPreview().isBlank() + ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" + : null, + asset.getX(), + asset.getY(), + asset.getWidth(), + asset.getHeight(), + asset.getRotation(), + asset.getSpeed(), + asset.isMuted(), + asset.getMediaType(), + asset.getOriginalMediaType(), + asset.getZIndex(), + asset.isAudioLoop(), + asset.getAudioDelayMillis(), + asset.getAudioSpeed(), + asset.getAudioPitch(), + asset.getAudioVolume(), + asset.isHidden(), + asset.getPreview() != null && !asset.getPreview().isBlank(), + asset.getCreatedAt() ); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/CanvasSettingsRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/CanvasSettingsRequest.java index 99e7fd7..e989fe0 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/CanvasSettingsRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/CanvasSettingsRequest.java @@ -3,14 +3,14 @@ package dev.kruhlmann.imgfloat.model; import jakarta.validation.constraints.Positive; public class CanvasSettingsRequest { + @Positive private double width; @Positive private double height; - public CanvasSettingsRequest() { - } + public CanvasSettingsRequest() {} public CanvasSettingsRequest(double width, double height) { this.width = width; diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java b/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java index 4818604..9c198a7 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/Channel.java @@ -10,7 +10,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; - import java.util.Collections; import java.util.HashSet; import java.util.Locale; @@ -20,6 +19,7 @@ import java.util.stream.Collectors; @Entity @Table(name = "channels") public class Channel { + @Id private String broadcaster; @@ -32,8 +32,7 @@ public class Channel { private double canvasHeight = 1080; - public Channel() { - } + public Channel() {} public Channel(String broadcaster) { this.broadcaster = normalize(broadcaster); @@ -77,9 +76,7 @@ public class Channel { @PreUpdate public void normalizeFields() { this.broadcaster = normalize(broadcaster); - this.admins = admins.stream() - .map(Channel::normalize) - .collect(Collectors.toSet()); + this.admins = admins.stream().map(Channel::normalize).collect(Collectors.toSet()); if (canvasWidth <= 0) { canvasWidth = 1920; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/PlaybackRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/PlaybackRequest.java index b3d4495..43824d9 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/PlaybackRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/PlaybackRequest.java @@ -1,6 +1,7 @@ package dev.kruhlmann.imgfloat.model; public class PlaybackRequest { + private Boolean play; public Boolean getPlay() { diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java b/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java index a53e8ea..2991c9f 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/Settings.java @@ -4,34 +4,42 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.PrePersist; -import jakarta.persistence.Table; import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; @Entity @Table(name = "settings") public class Settings { + @Id @Column(nullable = false) private int id = 1; + @Column(nullable = false) private double minAssetPlaybackSpeedFraction; + @Column(nullable = false) private double maxAssetPlaybackSpeedFraction; + @Column(nullable = false) private double minAssetAudioPitchFraction; + @Column(nullable = false) private double maxAssetAudioPitchFraction; + @Column(nullable = false) private double minAssetVolumeFraction; + @Column(nullable = false) private double maxAssetVolumeFraction; + @Column(nullable = false) private int maxCanvasSideLengthPixels; + @Column(nullable = false) private int canvasFramesPerSecond; - protected Settings() { - } + protected Settings() {} public static Settings defaults() { Settings s = new Settings(); @@ -117,5 +125,4 @@ public class Settings { public void setCanvasFramesPerSecond(int canvasFramesPerSecond) { this.canvasFramesPerSecond = canvasFramesPerSecond; } - } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/SystemAdministrator.java b/src/main/java/dev/kruhlmann/imgfloat/model/SystemAdministrator.java index ae992d3..2e70bed 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/SystemAdministrator.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/SystemAdministrator.java @@ -4,27 +4,24 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.PrePersist; -import jakarta.persistence.Table; import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; - import java.time.Instant; import java.util.Locale; import java.util.UUID; @Entity -@Table( - name = "system_administrators", - uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username") -) +@Table(name = "system_administrators", uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username")) public class SystemAdministrator { + @Id private String id; + @Column(name = "twitch_username", nullable = false) private String twitchUsername; - public SystemAdministrator() { - } + public SystemAdministrator() {} public SystemAdministrator(String twitchUsername) { this.twitchUsername = twitchUsername; @@ -43,7 +40,6 @@ public class SystemAdministrator { return id; } - public String getTwitchUsername() { return twitchUsername; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/TransformRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/TransformRequest.java index f66adc0..35eaf99 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/TransformRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/TransformRequest.java @@ -6,6 +6,7 @@ import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; public class TransformRequest { + private double x; private double y; @@ -25,6 +26,7 @@ public class TransformRequest { @Positive(message = "zIndex must be at least 1") private Integer zIndex; + private Boolean audioLoop; @PositiveOrZero(message = "Audio delay must be zero or greater") diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/VisibilityRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/VisibilityRequest.java index 2afac76..ba579b1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/VisibilityRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/VisibilityRequest.java @@ -1,6 +1,7 @@ package dev.kruhlmann.imgfloat.model; public class VisibilityRequest { + private boolean hidden; public boolean isHidden() { diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/AssetRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/AssetRepository.java index 65ee5dd..c65ebce 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/AssetRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/AssetRepository.java @@ -1,9 +1,8 @@ package dev.kruhlmann.imgfloat.repository; import dev.kruhlmann.imgfloat.model.Asset; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; public interface AssetRepository extends JpaRepository { List findByBroadcaster(String broadcaster); diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/ChannelRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/ChannelRepository.java index 17b7e11..283168a 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/ChannelRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/ChannelRepository.java @@ -1,9 +1,8 @@ package dev.kruhlmann.imgfloat.repository; import dev.kruhlmann.imgfloat.model.Channel; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; public interface ChannelRepository extends JpaRepository { List findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(String broadcasterFragment); diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/SettingsRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/SettingsRepository.java index 3c55518..d29f588 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/SettingsRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/SettingsRepository.java @@ -3,5 +3,4 @@ package dev.kruhlmann.imgfloat.repository; import dev.kruhlmann.imgfloat.model.Settings; import org.springframework.data.jpa.repository.JpaRepository; -public interface SettingsRepository extends JpaRepository { -} +public interface SettingsRepository extends JpaRepository {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java index 23af56a..50297b5 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java @@ -2,35 +2,29 @@ package dev.kruhlmann.imgfloat.service; import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.repository.AssetRepository; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.scheduling.annotation.Async; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service public class AssetCleanupService { - private static final Logger logger = - LoggerFactory.getLogger(AssetCleanupService.class); + private static final Logger logger = LoggerFactory.getLogger(AssetCleanupService.class); private final AssetRepository assetRepository; private final AssetStorageService assetStorageService; - public AssetCleanupService( - AssetRepository assetRepository, - AssetStorageService assetStorageService - ) { + public AssetCleanupService(AssetRepository assetRepository, AssetStorageService assetStorageService) { this.assetRepository = assetRepository; this.assetStorageService = assetStorageService; } @@ -41,10 +35,7 @@ public class AssetCleanupService { public void cleanup() { logger.info("Collecting referenced assets"); - Set referencedIds = assetRepository.findAll() - .stream() - .map(Asset::getId) - .collect(Collectors.toSet()); + Set referencedIds = assetRepository.findAll().stream().map(Asset::getId).collect(Collectors.toSet()); assetStorageService.deleteOrphanedAssets(referencedIds); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java index 07a5a01..ca94aba 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetStorageService.java @@ -1,12 +1,7 @@ package dev.kruhlmann.imgfloat.service; -import dev.kruhlmann.imgfloat.service.media.AssetContent; import dev.kruhlmann.imgfloat.model.Asset; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - +import dev.kruhlmann.imgfloat.service.media.AssetContent; import java.io.IOException; import java.nio.file.*; import java.util.Locale; @@ -14,9 +9,14 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; @Service public class AssetStorageService { + private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class); private static final Map EXTENSIONS = Map.ofEntries( Map.entry("image/png", ".png"), @@ -42,15 +42,15 @@ public class AssetStorageService { private final Path previewRoot; public AssetStorageService( - @Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot, - @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot + @Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot, + @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot ) { String assetsBase = assetRoot != null - ? assetRoot - : Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-assets").toString(); + ? assetRoot + : Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-assets").toString(); String previewsBase = previewRoot != null - ? previewRoot - : Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-previews").toString(); + ? previewRoot + : Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-previews").toString(); this.assetRoot = Paths.get(assetsBase).normalize().toAbsolutePath(); this.previewRoot = Paths.get(previewsBase).normalize().toAbsolutePath(); @@ -62,9 +62,7 @@ public class AssetStorageService { } } - public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) - throws IOException { - + public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) throws IOException { if (assetBytes == null || assetBytes.length == 0) { throw new IOException("Asset content is empty"); } @@ -72,35 +70,35 @@ public class AssetStorageService { Path file = assetPath(broadcaster, assetId, mediaType); Files.createDirectories(file.getParent()); - Files.write(file, assetBytes, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE); + Files.write( + file, + assetBytes, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE + ); logger.info("Wrote asset to {}", file.toString()); } - public void storePreview(String broadcaster, String assetId, byte[] previewBytes) - throws IOException { - + public void storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException { if (previewBytes == null || previewBytes.length == 0) return; Path file = previewPath(broadcaster, assetId); Files.createDirectories(file.getParent()); - Files.write(file, previewBytes, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE); + Files.write( + file, + previewBytes, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE + ); logger.info("Wrote asset to {}", file.toString()); } public Optional loadAssetFile(Asset asset) { try { - Path file = assetPath( - asset.getBroadcaster(), - asset.getId(), - asset.getMediaType() - ); + Path file = assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType()); if (!Files.exists(file)) return Optional.empty(); @@ -141,12 +139,8 @@ public class AssetStorageService { public void deleteAsset(Asset asset) { try { - Files.deleteIfExists( - assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType()) - ); - Files.deleteIfExists( - previewPath(asset.getBroadcaster(), asset.getId()) - ); + Files.deleteIfExists(assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType())); + Files.deleteIfExists(previewPath(asset.getBroadcaster(), asset.getId())); } catch (Exception e) { logger.warn("Failed to delete asset {}", asset.getId(), e); } @@ -162,16 +156,17 @@ public class AssetStorageService { return; } try (var paths = Files.walk(root)) { - paths.filter(Files::isRegularFile) - .filter(p -> isOrphan(p, referencedAssetIds)) - .forEach(p -> { - try { - Files.delete(p); - logger.warn("Deleted orphan file {}", p); - } catch (IOException e) { - logger.error("Failed to delete {}", p, e); - } - }); + paths + .filter(Files::isRegularFile) + .filter((p) -> isOrphan(p, referencedAssetIds)) + .forEach((p) -> { + try { + Files.delete(p); + logger.warn("Deleted orphan file {}", p); + } catch (IOException e) { + logger.error("Failed to delete {}", p, e); + } + }); } catch (IOException e) { logger.error("Failed to walk {}", root, e); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java index 02472e1..b6fe7e0 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AuthorizationService.java @@ -1,30 +1,33 @@ package dev.kruhlmann.imgfloat.service; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.SystemAdministratorService; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; -import static org.springframework.http.HttpStatus.UNAUTHORIZED; -import static org.springframework.http.HttpStatus.FORBIDDEN; -import static org.springframework.http.HttpStatus.NOT_FOUND; - @Service public class AuthorizationService { + private static final Logger LOG = LoggerFactory.getLogger(AuthorizationService.class); private final ChannelDirectoryService channelDirectoryService; private final SystemAdministratorService systemAdministratorService; - public AuthorizationService(ChannelDirectoryService channelDirectoryService, SystemAdministratorService systemAdministratorService) { + public AuthorizationService( + ChannelDirectoryService channelDirectoryService, + SystemAdministratorService systemAdministratorService + ) { this.channelDirectoryService = channelDirectoryService; this.systemAdministratorService = systemAdministratorService; } - + public void userMatchesSessionUsernameOrThrowHttpError(String submittedUsername, String sessionUsername) { if (sessionUsername == null) { LOG.warn("Access denied for broadcaster-only action by unauthenticated user"); @@ -35,14 +38,25 @@ public class AuthorizationService { throw new ResponseStatusException(NOT_FOUND, "You can only manage your own channel"); } if (!sessionUsername.equals(submittedUsername)) { - LOG.warn("User match with oauth token failed: session user {} does not match submitted user {}", sessionUsername, submittedUsername); + LOG.warn( + "User match with oauth token failed: session user {} does not match submitted user {}", + sessionUsername, + submittedUsername + ); throw new ResponseStatusException(FORBIDDEN, "You are not this user"); } } - public void userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(String broadcaster, String sessionUsername) { + public void userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + String broadcaster, + String sessionUsername + ) { if (!userIsBroadcasterOrChannelAdminForBroadcaster(broadcaster, sessionUsername)) { - LOG.warn("Access denied for broadcaster/admin-only action by user {} on broadcaster {}", sessionUsername, broadcaster); + LOG.warn( + "Access denied for broadcaster/admin-only action by user {} on broadcaster {}", + sessionUsername, + broadcaster + ); throw new ResponseStatusException(FORBIDDEN, "You do not have permission to manage this channel"); } } @@ -64,15 +78,20 @@ public class AuthorizationService { public boolean userIsChannelAdminForBroadcaster(String broadcaster, String sessionUsername) { if (sessionUsername == null || broadcaster == null) { - LOG.warn("Channel admin check failed: broadcaster or session username is null (broadcaster: {}, sessionUsername: {})", broadcaster, sessionUsername); + LOG.warn( + "Channel admin check failed: broadcaster or session username is null (broadcaster: {}, sessionUsername: {})", + broadcaster, + sessionUsername + ); return false; } return channelDirectoryService.isAdmin(broadcaster, sessionUsername); } public boolean userIsBroadcasterOrChannelAdminForBroadcaster(String broadcaster, String sessionUser) { - return userIsBroadcaster(sessionUser, broadcaster) || - userIsChannelAdminForBroadcaster(sessionUser, broadcaster); + return ( + userIsBroadcaster(sessionUser, broadcaster) || userIsChannelAdminForBroadcaster(sessionUser, broadcaster) + ); } public boolean userIsSystemAdministrator(String sessionUsername) { diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index d5f811f..c6c94f2 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -1,11 +1,14 @@ package dev.kruhlmann.imgfloat.service; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; + import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.AssetEvent; import dev.kruhlmann.imgfloat.model.AssetPatch; -import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; +import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.TransformRequest; @@ -17,7 +20,9 @@ import dev.kruhlmann.imgfloat.service.media.AssetContent; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; import dev.kruhlmann.imgfloat.service.media.OptimizedAsset; - +import java.io.IOException; +import java.util.*; +import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -27,15 +32,9 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import java.io.IOException; -import java.util.*; -import java.util.regex.Pattern; - -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; - @Service public class ChannelDirectoryService { + private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]"); @@ -68,21 +67,18 @@ public class ChannelDirectoryService { this.settingsService = settingsService; } - public Channel getOrCreateChannel(String broadcaster) { String normalized = normalize(broadcaster); - return channelRepository.findById(normalized) - .orElseGet(() -> channelRepository.save(new Channel(normalized))); + return channelRepository.findById(normalized).orElseGet(() -> channelRepository.save(new Channel(normalized))); } public List searchBroadcasters(String query) { String q = normalize(query); return channelRepository - .findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc( - q == null ? "" : q) - .stream() - .map(Channel::getBroadcaster) - .toList(); + .findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(q == null ? "" : q) + .stream() + .map(Channel::getBroadcaster) + .toList(); } public boolean addAdmin(String broadcaster, String username) { @@ -90,8 +86,7 @@ public class ChannelDirectoryService { boolean added = channel.addAdmin(username); if (added) { channelRepository.save(channel); - messagingTemplate.convertAndSend(topicFor(broadcaster), - "Admin added: " + username); + messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username); } return added; } @@ -101,22 +96,19 @@ public class ChannelDirectoryService { boolean removed = channel.removeAdmin(username); if (removed) { channelRepository.save(channel); - messagingTemplate.convertAndSend(topicFor(broadcaster), - "Admin removed: " + username); + messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username); } return removed; } public Collection getAssetsForAdmin(String broadcaster) { String normalized = normalize(broadcaster); - return sortAndMapAssets(normalized, - assetRepository.findByBroadcaster(normalized)); + return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized)); } public Collection getVisibleAssets(String broadcaster) { String normalized = normalize(broadcaster); - return sortAndMapAssets(normalized, - assetRepository.findByBroadcasterAndHiddenFalse(normalized)); + return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalized)); } public CanvasSettingsRequest getCanvasSettings(String broadcaster) { @@ -137,19 +129,15 @@ public class ChannelDirectoryService { long maxSize = uploadLimitBytes; if (fileSize > maxSize) { throw new ResponseStatusException( - PAYLOAD_TOO_LARGE, - String.format( - "Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.", - fileSize, - maxSize - ) + PAYLOAD_TOO_LARGE, + String.format("Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.", fileSize, maxSize) ); } Channel channel = getOrCreateChannel(broadcaster); byte[] bytes = file.getBytes(); - String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes) - .orElseThrow(() -> new ResponseStatusException( - BAD_REQUEST, "Unsupported media type")); + String mediaType = mediaDetectionService + .detectAllowedMediaType(file, bytes) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type")); OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); if (optimized == null) { @@ -157,32 +145,29 @@ public class ChannelDirectoryService { } String safeName = Optional.ofNullable(file.getOriginalFilename()) - .map(this::sanitizeFilename) - .filter(s -> !s.isBlank()) - .orElse("asset_" + System.currentTimeMillis()); + .map(this::sanitizeFilename) + .filter((s) -> !s.isBlank()) + .orElse("asset_" + System.currentTimeMillis()); - double width = optimized.width() > 0 ? optimized.width() : - (optimized.mediaType().startsWith("audio/") ? 400 : 640); - double height = optimized.height() > 0 ? optimized.height() : - (optimized.mediaType().startsWith("audio/") ? 80 : 360); + double width = optimized.width() > 0 + ? optimized.width() + : (optimized.mediaType().startsWith("audio/") ? 400 : 640); + double height = optimized.height() > 0 + ? optimized.height() + : (optimized.mediaType().startsWith("audio/") ? 80 : 360); - Asset asset = new Asset(channel.getBroadcaster(), safeName, "", - width, height); + Asset asset = new Asset(channel.getBroadcaster(), safeName, "", width, height); asset.setOriginalMediaType(mediaType); asset.setMediaType(optimized.mediaType()); assetStorageService.storeAsset( - channel.getBroadcaster(), - asset.getId(), - optimized.bytes(), - optimized.mediaType() + channel.getBroadcaster(), + asset.getId(), + optimized.bytes(), + optimized.mediaType() ); - assetStorageService.storePreview( - channel.getBroadcaster(), - asset.getId(), - optimized.previewBytes() - ); + assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes()); asset.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : ""); asset.setSpeed(1.0); @@ -197,8 +182,7 @@ public class ChannelDirectoryService { assetRepository.save(asset); AssetView view = AssetView.from(channel.getBroadcaster(), asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), - AssetEvent.created(broadcaster, view)); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); return Optional.of(view); } @@ -211,37 +195,37 @@ public class ChannelDirectoryService { public Optional updateTransform(String broadcaster, String assetId, TransformRequest req) { String normalized = normalize(broadcaster); - return assetRepository.findById(assetId) - .filter(asset -> normalized.equals(asset.getBroadcaster())) - .map(asset -> { - AssetPatch.TransformSnapshot before = AssetPatch.capture(asset); - validateTransform(req); + return assetRepository + .findById(assetId) + .filter((asset) -> normalized.equals(asset.getBroadcaster())) + .map((asset) -> { + AssetPatch.TransformSnapshot before = AssetPatch.capture(asset); + validateTransform(req); - asset.setX(req.getX()); - asset.setY(req.getY()); - asset.setWidth(req.getWidth()); - asset.setHeight(req.getHeight()); - asset.setRotation(req.getRotation()); + asset.setX(req.getX()); + asset.setY(req.getY()); + asset.setWidth(req.getWidth()); + asset.setHeight(req.getHeight()); + asset.setRotation(req.getRotation()); - if (req.getZIndex() != null) asset.setZIndex(req.getZIndex()); - if (req.getSpeed() != null) asset.setSpeed(req.getSpeed()); - if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted()); - if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop()); - if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis()); - if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed()); - if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch()); - if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume()); + if (req.getZIndex() != null) asset.setZIndex(req.getZIndex()); + if (req.getSpeed() != null) asset.setSpeed(req.getSpeed()); + if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted()); + if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop()); + if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis()); + if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed()); + if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch()); + if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume()); - assetRepository.save(asset); + assetRepository.save(asset); - AssetView view = AssetView.from(normalized, asset); - AssetPatch patch = AssetPatch.fromTransform(before, asset, req); - if (hasPatchChanges(patch)) { - messagingTemplate.convertAndSend(topicFor(broadcaster), - AssetEvent.updated(broadcaster, patch)); - } - return view; - }); + AssetView view = AssetView.from(normalized, asset); + AssetPatch patch = AssetPatch.fromTransform(before, asset, req); + if (hasPatchChanges(patch)) { + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); + } + return view; + }); } private void validateTransform(TransformRequest req) { @@ -254,68 +238,90 @@ public class ChannelDirectoryService { double maxVolume = settings.getMaxAssetVolumeFraction(); int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels(); - if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels) - throw new ResponseStatusException(BAD_REQUEST, "Canvas width out of range [0 to " + canvasMaxSizePixels + "]"); - if (req.getHeight() <= 0) - throw new ResponseStatusException(BAD_REQUEST, "Canvas height out of range [0 to " + canvasMaxSizePixels + "]"); - if (req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)) - throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]"); - if (req.getZIndex() != null && req.getZIndex() < 1) - throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1"); - if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0) - throw new ResponseStatusException(BAD_REQUEST, "Audio delay >= 0"); - if (req.getAudioSpeed() != null && (req.getAudioSpeed() < minSpeed || req.getAudioSpeed() > maxSpeed)) - throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range"); - if (req.getAudioPitch() != null && (req.getAudioPitch() < minPitch || req.getAudioPitch() > maxPitch)) - throw new ResponseStatusException(BAD_REQUEST, "Audio pitch out of range"); - if (req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)) - throw new ResponseStatusException(BAD_REQUEST, "Audio volume out of range [" + minVolume + " to " + maxVolume + "]"); + if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels) throw new ResponseStatusException( + BAD_REQUEST, + "Canvas width out of range [0 to " + canvasMaxSizePixels + "]" + ); + if (req.getHeight() <= 0) throw new ResponseStatusException( + BAD_REQUEST, + "Canvas height out of range [0 to " + canvasMaxSizePixels + "]" + ); + if ( + req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed) + ) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]"); + if (req.getZIndex() != null && req.getZIndex() < 1) throw new ResponseStatusException( + BAD_REQUEST, + "zIndex must be >= 1" + ); + if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0) throw new ResponseStatusException( + BAD_REQUEST, + "Audio delay >= 0" + ); + if ( + req.getAudioSpeed() != null && (req.getAudioSpeed() < minSpeed || req.getAudioSpeed() > maxSpeed) + ) throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range"); + if ( + req.getAudioPitch() != null && (req.getAudioPitch() < minPitch || req.getAudioPitch() > maxPitch) + ) throw new ResponseStatusException(BAD_REQUEST, "Audio pitch out of range"); + if ( + req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume) + ) throw new ResponseStatusException( + BAD_REQUEST, + "Audio volume out of range [" + minVolume + " to " + maxVolume + "]" + ); } public Optional triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) { String normalized = normalize(broadcaster); - return assetRepository.findById(assetId) - .filter(a -> normalized.equals(a.getBroadcaster())) - .map(asset -> { - AssetView view = AssetView.from(normalized, asset); - boolean play = req == null || req.getPlay(); - messagingTemplate.convertAndSend(topicFor(broadcaster), - AssetEvent.play(broadcaster, view, play)); - return view; - }); + return assetRepository + .findById(assetId) + .filter((a) -> normalized.equals(a.getBroadcaster())) + .map((asset) -> { + AssetView view = AssetView.from(normalized, asset); + boolean play = req == null || req.getPlay(); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, play)); + return view; + }); } public Optional updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { String normalized = normalize(broadcaster); - return assetRepository.findById(assetId) - .filter(a -> normalized.equals(a.getBroadcaster())) - .map(asset -> { - boolean wasHidden = asset.isHidden(); - boolean hidden = request.isHidden(); - if (wasHidden == hidden) { - return AssetView.from(normalized, asset); - } + return assetRepository + .findById(assetId) + .filter((a) -> normalized.equals(a.getBroadcaster())) + .map((asset) -> { + boolean wasHidden = asset.isHidden(); + boolean hidden = request.isHidden(); + if (wasHidden == hidden) { + return AssetView.from(normalized, asset); + } - asset.setHidden(hidden); - assetRepository.save(asset); - AssetView view = AssetView.from(normalized, asset); - AssetPatch patch = AssetPatch.fromVisibility(asset); - AssetView payload = hidden ? null : view; - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch, payload)); - return view; - }); + asset.setHidden(hidden); + assetRepository.save(asset); + AssetView view = AssetView.from(normalized, asset); + AssetPatch patch = AssetPatch.fromVisibility(asset); + AssetView payload = hidden ? null : view; + messagingTemplate.convertAndSend( + topicFor(broadcaster), + AssetEvent.visibility(broadcaster, patch, payload) + ); + return view; + }); } public boolean deleteAsset(String assetId) { - return assetRepository.findById(assetId) - .map(asset -> { - assetRepository.delete(asset); - assetStorageService.deleteAsset(asset); - messagingTemplate.convertAndSend(topicFor(asset.getBroadcaster()), - AssetEvent.deleted(asset.getBroadcaster(), assetId)); - return true; - }) - .orElse(false); + return assetRepository + .findById(assetId) + .map((asset) -> { + assetRepository.delete(asset); + assetStorageService.deleteAsset(asset); + messagingTemplate.convertAndSend( + topicFor(asset.getBroadcaster()), + AssetEvent.deleted(asset.getBroadcaster(), assetId) + ); + return true; + }) + .orElse(false); } public Optional getAssetContent(String assetId) { @@ -323,25 +329,29 @@ public class ChannelDirectoryService { } public Optional getAssetPreview(String assetId, boolean includeHidden) { - return assetRepository.findById(assetId) - .filter(a -> includeHidden || !a.isHidden()) - .flatMap(assetStorageService::loadPreviewSafely); + return assetRepository + .findById(assetId) + .filter((a) -> includeHidden || !a.isHidden()) + .flatMap(assetStorageService::loadPreviewSafely); } public boolean isAdmin(String broadcaster, String username) { - return channelRepository.findById(normalize(broadcaster)) - .map(Channel::getAdmins) - .map(admins -> admins.contains(normalize(username))) - .orElse(false); + return channelRepository + .findById(normalize(broadcaster)) + .map(Channel::getAdmins) + .map((admins) -> admins.contains(normalize(username))) + .orElse(false); } public Collection adminChannelsFor(String username) { if (username == null) return List.of(); String login = username.toLowerCase(); - return channelRepository.findAll().stream() - .filter(c -> c.getAdmins().contains(login)) - .map(Channel::getBroadcaster) - .toList(); + return channelRepository + .findAll() + .stream() + .filter((c) -> c.getAdmins().contains(login)) + .map(Channel::getBroadcaster) + .toList(); } private String normalize(String value) { @@ -353,35 +363,46 @@ public class ChannelDirectoryService { } private List sortAndMapAssets(String broadcaster, Collection assets) { - return assets.stream() - .sorted(Comparator.comparingInt(Asset::getZIndex) - .thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) - .map(a -> AssetView.from(broadcaster, a)) - .toList(); + return assets + .stream() + .sorted( + Comparator.comparingInt(Asset::getZIndex).thenComparing( + Asset::getCreatedAt, + Comparator.nullsFirst(Comparator.naturalOrder()) + ) + ) + .map((a) -> AssetView.from(broadcaster, a)) + .toList(); } private int nextZIndex(String broadcaster) { - return assetRepository.findByBroadcaster(normalize(broadcaster)) + return ( + assetRepository + .findByBroadcaster(normalize(broadcaster)) .stream() .mapToInt(Asset::getZIndex) .max() - .orElse(0) + 1; + .orElse(0) + + 1 + ); } private boolean hasPatchChanges(AssetPatch patch) { - return patch.x() != null - || patch.y() != null - || patch.width() != null - || patch.height() != null - || patch.rotation() != null - || patch.speed() != null - || patch.muted() != null - || patch.zIndex() != null - || patch.hidden() != null - || patch.audioLoop() != null - || patch.audioDelayMillis() != null - || patch.audioSpeed() != null - || patch.audioPitch() != null - || patch.audioVolume() != null; + return ( + patch.x() != null || + patch.y() != null || + patch.width() != null || + patch.height() != null || + patch.rotation() != null || + patch.speed() != null || + patch.muted() != null || + patch.zIndex() != null || + patch.hidden() != null || + patch.audioLoop() != null || + patch.audioDelayMillis() != null || + patch.audioSpeed() != null || + patch.audioPitch() != null || + patch.audioVolume() != null + ); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/SettingsService.java b/src/main/java/dev/kruhlmann/imgfloat/service/SettingsService.java index dca4ce9..a76f040 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/SettingsService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/SettingsService.java @@ -1,10 +1,9 @@ package dev.kruhlmann.imgfloat.service; -import dev.kruhlmann.imgfloat.model.Settings; -import dev.kruhlmann.imgfloat.repository.SettingsRepository; - import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import dev.kruhlmann.imgfloat.model.Settings; +import dev.kruhlmann.imgfloat.repository.SettingsRepository; import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,6 +11,7 @@ import org.springframework.stereotype.Service; @Service public class SettingsService { + private static final Logger logger = LoggerFactory.getLogger(SettingsService.class); private final SettingsRepository repo; @@ -44,12 +44,7 @@ public class SettingsService { public void logSettings(String msg, Settings settings) { try { - logger.info("{}:\n{}", - msg, - objectMapper - .writerWithDefaultPrettyPrinter() - .writeValueAsString(settings) - ); + logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings)); } catch (JsonProcessingException e) { logger.error("Failed to serialize settings", e); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java b/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java index 3ffc0de..74a934b 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/SystemAdministratorService.java @@ -3,29 +3,26 @@ package dev.kruhlmann.imgfloat.service; import dev.kruhlmann.imgfloat.model.SystemAdministrator; import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository; import jakarta.annotation.PostConstruct; +import java.util.Locale; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; import org.springframework.core.env.Environment; - -import java.util.Locale; +import org.springframework.stereotype.Service; @Service public class SystemAdministratorService { - private static final Logger logger = - LoggerFactory.getLogger(SystemAdministratorService.class); + private static final Logger logger = LoggerFactory.getLogger(SystemAdministratorService.class); private final SystemAdministratorRepository repo; private final String initialSysadmin; private final Environment environment; public SystemAdministratorService( - SystemAdministratorRepository repo, - @Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") - String initialSysadmin, - Environment environment + SystemAdministratorRepository repo, + @Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") String initialSysadmin, + Environment environment ) { this.repo = repo; this.initialSysadmin = initialSysadmin; @@ -38,7 +35,11 @@ public class SystemAdministratorService { return; } - if (Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper"))) { + if ( + Boolean.parseBoolean( + environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper") + ) + ) { logger.info("Skipping system administrator bootstrap in test context"); return; } @@ -65,17 +66,13 @@ public class SystemAdministratorService { public void removeSysadmin(String twitchUsername) { if (repo.count() <= 1) { - throw new IllegalStateException( - "Cannot remove the last system administrator" - ); + throw new IllegalStateException("Cannot remove the last system administrator"); } long deleted = repo.deleteByTwitchUsername(normalize(twitchUsername)); if (deleted == 0) { - throw new IllegalArgumentException( - "System administrator does not exist" - ); + throw new IllegalArgumentException("System administrator does not exist"); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/TwitchUserLookupService.java b/src/main/java/dev/kruhlmann/imgfloat/service/TwitchUserLookupService.java index 281b2a4..c9c33fc 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/TwitchUserLookupService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/TwitchUserLookupService.java @@ -1,8 +1,22 @@ package dev.kruhlmann.imgfloat.service; -import dev.kruhlmann.imgfloat.model.TwitchUserProfile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import dev.kruhlmann.imgfloat.model.TwitchUserProfile; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.web.client.RestTemplateBuilder; @@ -15,31 +29,17 @@ import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; -import java.time.Duration; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Set; -import java.util.Optional; - @Service public class TwitchUserLookupService { + private static final Logger LOG = LoggerFactory.getLogger(TwitchUserLookupService.class); private final RestTemplate restTemplate; public TwitchUserLookupService(RestTemplateBuilder builder) { this.restTemplate = builder - .setConnectTimeout(Duration.ofSeconds(15)) - .setReadTimeout(Duration.ofSeconds(15)) - .build(); + .setConnectTimeout(Duration.ofSeconds(15)) + .setReadTimeout(Duration.ofSeconds(15)) + .build(); } public List fetchProfiles(Collection logins, String accessToken, String clientId) { @@ -47,23 +47,27 @@ public class TwitchUserLookupService { return List.of(); } - List normalizedLogins = logins.stream() - .filter(Objects::nonNull) - .map(login -> login.toLowerCase(Locale.ROOT)) - .distinct() - .toList(); + List normalizedLogins = logins + .stream() + .filter(Objects::nonNull) + .map((login) -> login.toLowerCase(Locale.ROOT)) + .distinct() + .toList(); Map byLogin = fetchUsers(normalizedLogins, accessToken, clientId); - return normalizedLogins.stream() - .map(login -> toProfile(login, byLogin.get(login))) - .toList(); + return normalizedLogins + .stream() + .map((login) -> toProfile(login, byLogin.get(login))) + .toList(); } - public List fetchModerators(String broadcasterLogin, - Collection existingAdmins, - String accessToken, - String clientId) { + public List fetchModerators( + String broadcasterLogin, + Collection existingAdmins, + String accessToken, + String clientId + ) { if (broadcasterLogin == null || broadcasterLogin.isBlank()) { LOG.warn("Cannot fetch moderators without a broadcaster login"); return List.of(); @@ -77,8 +81,8 @@ public class TwitchUserLookupService { String normalizedBroadcaster = broadcasterLogin.toLowerCase(Locale.ROOT); Map broadcasterData = fetchUsers(List.of(normalizedBroadcaster), accessToken, clientId); String broadcasterId = Optional.ofNullable(broadcasterData.get(normalizedBroadcaster)) - .map(TwitchUserData::id) - .orElse(null); + .map(TwitchUserData::id) + .orElse(null); if (broadcasterId == null || broadcasterId.isBlank()) { LOG.warn("No broadcaster id found for {} when fetching moderators", broadcasterLogin); @@ -87,10 +91,11 @@ public class TwitchUserLookupService { Set skipLogins = new HashSet<>(); if (existingAdmins != null) { - existingAdmins.stream() - .filter(Objects::nonNull) - .map(login -> login.toLowerCase(Locale.ROOT)) - .forEach(skipLogins::add); + existingAdmins + .stream() + .filter(Objects::nonNull) + .map((login) -> login.toLowerCase(Locale.ROOT)) + .forEach(skipLogins::add); } skipLogins.add(normalizedBroadcaster); @@ -102,36 +107,43 @@ public class TwitchUserLookupService { String cursor = null; do { - UriComponentsBuilder builder = UriComponentsBuilder - .fromHttpUrl("https://api.twitch.tv/helix/moderation/moderators") - .queryParam("broadcaster_id", broadcasterId) - .queryParam("first", 100); + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl( + "https://api.twitch.tv/helix/moderation/moderators" + ) + .queryParam("broadcaster_id", broadcasterId) + .queryParam("first", 100); if (cursor != null && !cursor.isBlank()) { builder.queryParam("after", cursor); } try { ResponseEntity response = restTemplate.exchange( - builder.build(true).toUri(), - HttpMethod.GET, - new HttpEntity<>(headers), - TwitchModeratorsResponse.class); + builder.build(true).toUri(), + HttpMethod.GET, + new HttpEntity<>(headers), + TwitchModeratorsResponse.class + ); TwitchModeratorsResponse body = response.getBody(); - LOG.debug("Fetched {} moderator records for {} (cursor={})", body != null && body.data() != null ? body.data().size() : 0, broadcasterLogin, cursor); + LOG.debug( + "Fetched {} moderator records for {} (cursor={})", + body != null && body.data() != null ? body.data().size() : 0, + broadcasterLogin, + cursor + ); if (body != null && body.data() != null) { - body.data().stream() - .filter(Objects::nonNull) - .map(ModeratorData::userLogin) - .filter(Objects::nonNull) - .map(login -> login.toLowerCase(Locale.ROOT)) - .filter(login -> !skipLogins.contains(login)) - .forEach(moderatorLogins::add); + body + .data() + .stream() + .filter(Objects::nonNull) + .map(ModeratorData::userLogin) + .filter(Objects::nonNull) + .map((login) -> login.toLowerCase(Locale.ROOT)) + .filter((login) -> !skipLogins.contains(login)) + .forEach(moderatorLogins::add); } - cursor = body != null && body.pagination() != null - ? body.pagination().cursor() - : null; + cursor = body != null && body.pagination() != null ? body.pagination().cursor() : null; } catch (RestClientException ex) { LOG.warn("Unable to fetch Twitch moderators for {}", broadcasterLogin, ex); return List.of(); @@ -158,11 +170,12 @@ public class TwitchUserLookupService { return Collections.emptyMap(); } - List normalizedLogins = logins.stream() - .filter(Objects::nonNull) - .map(login -> login.toLowerCase(Locale.ROOT)) - .distinct() - .toList(); + List normalizedLogins = logins + .stream() + .filter(Objects::nonNull) + .map((login) -> login.toLowerCase(Locale.ROOT)) + .distinct() + .toList(); if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) { return Collections.emptyMap(); @@ -172,27 +185,33 @@ public class TwitchUserLookupService { headers.setBearerAuth(accessToken); headers.add("Client-ID", clientId); - UriComponentsBuilder uriBuilder = UriComponentsBuilder - .fromHttpUrl("https://api.twitch.tv/helix/users"); - normalizedLogins.forEach(login -> uriBuilder.queryParam("login", login)); + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl("https://api.twitch.tv/helix/users"); + normalizedLogins.forEach((login) -> uriBuilder.queryParam("login", login)); HttpEntity entity = new HttpEntity<>(headers); try { ResponseEntity response = restTemplate.exchange( - uriBuilder.build(true).toUri(), - HttpMethod.GET, - entity, - TwitchUsersResponse.class); + uriBuilder.build(true).toUri(), + HttpMethod.GET, + entity, + TwitchUsersResponse.class + ); return response.getBody() == null - ? Collections.emptyMap() - : response.getBody().data().stream() - .filter(Objects::nonNull) - .collect(Collectors.toMap( - user -> user.login().toLowerCase(Locale.ROOT), - Function.identity(), - (a, b) -> a, - HashMap::new)); + ? Collections.emptyMap() + : response + .getBody() + .data() + .stream() + .filter(Objects::nonNull) + .collect( + Collectors.toMap( + (user) -> user.login().toLowerCase(Locale.ROOT), + Function.identity(), + (a, b) -> a, + HashMap::new + ) + ); } catch (RestClientException ex) { LOG.warn("Unable to fetch Twitch user profiles", ex); return Collections.emptyMap(); @@ -200,31 +219,26 @@ public class TwitchUserLookupService { } @JsonIgnoreProperties(ignoreUnknown = true) - private record TwitchUsersResponse(List data) { - } + private record TwitchUsersResponse(List data) {} @JsonIgnoreProperties(ignoreUnknown = true) private record TwitchUserData( - String id, - String login, - @JsonProperty("display_name") String displayName, - @JsonProperty("profile_image_url") String profileImageUrl) { - } + String id, + String login, + @JsonProperty("display_name") String displayName, + @JsonProperty("profile_image_url") String profileImageUrl + ) {} @JsonIgnoreProperties(ignoreUnknown = true) - private record TwitchModeratorsResponse( - List data, - Pagination pagination) { - } + private record TwitchModeratorsResponse(List data, Pagination pagination) {} @JsonIgnoreProperties(ignoreUnknown = true) private record ModeratorData( - @JsonProperty("user_id") String userId, - @JsonProperty("user_login") String userLogin, - @JsonProperty("user_name") String userName) { - } + @JsonProperty("user_id") String userId, + @JsonProperty("user_login") String userLogin, + @JsonProperty("user_name") String userName + ) {} @JsonIgnoreProperties(ignoreUnknown = true) - private record Pagination(String cursor) { - } + private record Pagination(String cursor) {} } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/VersionService.java b/src/main/java/dev/kruhlmann/imgfloat/service/VersionService.java index 2da4f7c..c900ff8 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/VersionService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/VersionService.java @@ -1,17 +1,17 @@ package dev.kruhlmann.imgfloat.service; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; @Component public class VersionService { + private static final Logger LOG = LoggerFactory.getLogger(VersionService.class); private final String version; private final String releaseVersion; @@ -58,7 +58,9 @@ public class VersionService { } private String getPomVersion() { - try (var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")) { + try ( + var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties") + ) { if (inputStream == null) { return null; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/AssetContent.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/AssetContent.java index ab2180d..f75cfd7 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/AssetContent.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/AssetContent.java @@ -1,3 +1,3 @@ package dev.kruhlmann.imgfloat.service.media; -public record AssetContent(byte[] bytes, String mediaType) { } +public record AssetContent(byte[] bytes, String mediaType) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java index c24da84..8a5cf17 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionService.java @@ -1,53 +1,53 @@ package dev.kruhlmann.imgfloat.service.media; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.URLConnection; import java.util.Map; import java.util.Optional; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; @Service public class MediaDetectionService { + private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class); private static final Map EXTENSION_TYPES = Map.ofEntries( - Map.entry("png", "image/png"), - Map.entry("jpg", "image/jpeg"), - Map.entry("jpeg", "image/jpeg"), - Map.entry("gif", "image/gif"), - Map.entry("webp", "image/webp"), - Map.entry("mp4", "video/mp4"), - Map.entry("webm", "video/webm"), - Map.entry("mov", "video/quicktime"), - Map.entry("mp3", "audio/mpeg"), - Map.entry("wav", "audio/wav"), - Map.entry("ogg", "audio/ogg") + Map.entry("png", "image/png"), + Map.entry("jpg", "image/jpeg"), + Map.entry("jpeg", "image/jpeg"), + Map.entry("gif", "image/gif"), + Map.entry("webp", "image/webp"), + Map.entry("mp4", "video/mp4"), + Map.entry("webm", "video/webm"), + Map.entry("mov", "video/quicktime"), + Map.entry("mp3", "audio/mpeg"), + Map.entry("wav", "audio/wav"), + Map.entry("ogg", "audio/ogg") ); private static final Set ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values()); public Optional detectAllowedMediaType(MultipartFile file, byte[] bytes) { - Optional detected = detectMediaType(bytes) - .filter(MediaDetectionService::isAllowedMediaType); + Optional detected = detectMediaType(bytes).filter(MediaDetectionService::isAllowedMediaType); if (detected.isPresent()) { return detected; } - Optional declared = Optional.ofNullable(file.getContentType()) - .filter(MediaDetectionService::isAllowedMediaType); + Optional declared = Optional.ofNullable(file.getContentType()).filter( + MediaDetectionService::isAllowedMediaType + ); if (declared.isPresent()) { return declared; } return Optional.ofNullable(file.getOriginalFilename()) - .map(name -> name.replaceAll("^.*\\.", "").toLowerCase()) - .map(EXTENSION_TYPES::get) - .filter(MediaDetectionService::isAllowedMediaType); + .map((name) -> name.replaceAll("^.*\\.", "").toLowerCase()) + .map(EXTENSION_TYPES::get) + .filter(MediaDetectionService::isAllowedMediaType); } private Optional detectMediaType(byte[] bytes) { @@ -68,6 +68,9 @@ public class MediaDetectionService { } public static boolean isInlineDisplayType(String mediaType) { - return mediaType != null && (mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/")); + return ( + mediaType != null && + (mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/")) + ); } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java index 02351d9..e800ff5 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationService.java @@ -1,20 +1,5 @@ package dev.kruhlmann.imgfloat.service.media; -import org.jcodec.api.FrameGrab; -import org.jcodec.api.JCodecException; -import org.jcodec.common.io.ByteBufferSeekableByteChannel; -import org.jcodec.common.model.Picture; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -import javax.imageio.IIOImage; -import javax.imageio.ImageIO; -import javax.imageio.ImageWriteParam; -import javax.imageio.ImageWriter; -import javax.imageio.metadata.IIOMetadata; -import javax.imageio.stream.ImageInputStream; -import javax.imageio.stream.ImageOutputStream; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -24,9 +9,24 @@ import java.nio.ByteBuffer; import java.nio.file.Files; import java.util.List; import java.util.Optional; +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.stream.ImageInputStream; +import javax.imageio.stream.ImageOutputStream; +import org.jcodec.api.FrameGrab; +import org.jcodec.api.JCodecException; +import org.jcodec.common.io.ByteBufferSeekableByteChannel; +import org.jcodec.common.model.Picture; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; @Service public class MediaOptimizationService { + private static final int MIN_GIF_DELAY_MS = 20; private static final Logger logger = LoggerFactory.getLogger(MediaOptimizationService.class); private final MediaPreviewService previewService; @@ -86,10 +86,11 @@ public class MediaOptimizationService { if (frames.isEmpty()) { return null; } - int baseDelay = frames.stream() - .mapToInt(frame -> normalizeDelay(frame.delayMs())) - .reduce(this::greatestCommonDivisor) - .orElse(100); + int baseDelay = frames + .stream() + .mapToInt((frame) -> normalizeDelay(frame.delayMs())) + .reduce(this::greatestCommonDivisor) + .orElse(100); int fps = Math.max(1, (int) Math.round(1000.0 / baseDelay)); File temp = File.createTempFile("gif-convert", ".mp4"); temp.deleteOnExit(); @@ -104,7 +105,13 @@ public class MediaOptimizationService { encoder.finish(); BufferedImage cover = frames.get(0).image(); byte[] video = Files.readAllBytes(temp.toPath()); - return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight(), previewService.encodePreview(cover)); + return new OptimizedAsset( + video, + "video/mp4", + cover.getWidth(), + cover.getHeight(), + previewService.encodePreview(cover) + ); } finally { Files.deleteIfExists(temp.toPath()); } @@ -183,8 +190,10 @@ public class MediaOptimizationService { } } ImageWriter writer = writers.next(); - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) { + try ( + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageOutputStream ios = ImageIO.createImageOutputStream(baos) + ) { writer.setOutput(ios); ImageWriteParam param = writer.getDefaultWriteParam(); if (param.canWriteCompressed()) { @@ -211,7 +220,7 @@ public class MediaOptimizationService { return new Dimension(640, 360); } - private record GifFrame(BufferedImage image, int delayMs) { } + private record GifFrame(BufferedImage image, int delayMs) {} - private record Dimension(int width, int height) { } + private record Dimension(int width, int height) {} } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaPreviewService.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaPreviewService.java index a83acac..d9af534 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaPreviewService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/MediaPreviewService.java @@ -1,5 +1,10 @@ package dev.kruhlmann.imgfloat.service.media; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import javax.imageio.ImageIO; import org.jcodec.api.FrameGrab; import org.jcodec.api.JCodecException; import org.jcodec.common.io.ByteBufferSeekableByteChannel; @@ -9,14 +14,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; - @Service public class MediaPreviewService { + private static final Logger logger = LoggerFactory.getLogger(MediaPreviewService.class); public byte[] encodePreview(BufferedImage image) { diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/media/OptimizedAsset.java b/src/main/java/dev/kruhlmann/imgfloat/service/media/OptimizedAsset.java index 21a4a6b..c208ece 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/media/OptimizedAsset.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/media/OptimizedAsset.java @@ -1,3 +1,3 @@ package dev.kruhlmann.imgfloat.service.media; -public record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) { } +public record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) {} diff --git a/src/main/node/app.js b/src/main/node/app.js index 0b11c67..adfb490 100644 --- a/src/main/node/app.js +++ b/src/main/node/app.js @@ -2,35 +2,35 @@ const { app, BrowserWindow } = require("electron"); const path = require("path"); function createWindow() { - const url = "https://imgfloat.kruhlmann.dev/channels"; - const initialWindowWidthPx = 960; - const initialWindowHeightPx = 640; - const applicationWindow = new BrowserWindow({ - width: initialWindowWidthPx, - height: initialWindowHeightPx, - transparent: true, - frame: true, - backgroundColor: "#00000000", - alwaysOnTop: false, - icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"), - webPreferences: { backgroundThrottling: false }, - }); - applicationWindow.setMenu(null); + const url = "https://imgfloat.kruhlmann.dev/channels"; + const initialWindowWidthPx = 960; + const initialWindowHeightPx = 640; + const applicationWindow = new BrowserWindow({ + width: initialWindowWidthPx, + height: initialWindowHeightPx, + transparent: true, + frame: true, + backgroundColor: "#00000000", + alwaysOnTop: false, + icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"), + webPreferences: { backgroundThrottling: false }, + }); + applicationWindow.setMenu(null); - let canvasSizeInterval; - const clearCanvasSizeInterval = () => { - if (canvasSizeInterval) { - clearInterval(canvasSizeInterval); - canvasSizeInterval = undefined; - } - }; + let canvasSizeInterval; + const clearCanvasSizeInterval = () => { + if (canvasSizeInterval) { + clearInterval(canvasSizeInterval); + canvasSizeInterval = undefined; + } + }; - const lockWindowToCanvas = async () => { - if (applicationWindow.isDestroyed()) { - return false; - } - try { - const size = await applicationWindow.webContents.executeJavaScript(`(() => { + const lockWindowToCanvas = async () => { + if (applicationWindow.isDestroyed()) { + return false; + } + try { + const size = await applicationWindow.webContents.executeJavaScript(`(() => { const canvas = document.getElementById('broadcast-canvas'); if (!canvas || !canvas.width || !canvas.height) { return null; @@ -38,52 +38,54 @@ function createWindow() { return { width: Math.round(canvas.width), height: Math.round(canvas.height) }; })();`); - if (size?.width && size?.height) { - const [currentWidth, currentHeight] = applicationWindow.getSize(); - if (currentWidth !== size.width || currentHeight !== size.height) { - applicationWindow.setSize(size.width, size.height, false); + if (size?.width && size?.height) { + const [currentWidth, currentHeight] = applicationWindow.getSize(); + if (currentWidth !== size.width || currentHeight !== size.height) { + applicationWindow.setSize(size.width, size.height, false); + } + applicationWindow.setMinimumSize(size.width, size.height); + applicationWindow.setMaximumSize(size.width, size.height); + applicationWindow.setResizable(false); + return true; + } + } catch (error) { + // Best-effort sizing; ignore errors from early navigation states. } - applicationWindow.setMinimumSize(size.width, size.height); - applicationWindow.setMaximumSize(size.width, size.height); - applicationWindow.setResizable(false); - return true; - } - } catch (error) { - // Best-effort sizing; ignore errors from early navigation states. - } - return false; - }; + return false; + }; - const handleNavigation = (navigationUrl) => { - try { - const { pathname } = new URL(navigationUrl); - const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname); + const handleNavigation = (navigationUrl) => { + try { + const { pathname } = new URL(navigationUrl); + const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname); - if (isBroadcast) { - clearCanvasSizeInterval(); - canvasSizeInterval = setInterval(lockWindowToCanvas, 750); - lockWindowToCanvas(); - } else { - clearCanvasSizeInterval(); - applicationWindow.setResizable(true); - applicationWindow.setMinimumSize(320, 240); - applicationWindow.setMaximumSize(10000, 10000); - applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false); - } - } catch { - // Ignore malformed URLs while navigating. - } - }; + if (isBroadcast) { + clearCanvasSizeInterval(); + canvasSizeInterval = setInterval(lockWindowToCanvas, 750); + lockWindowToCanvas(); + } else { + clearCanvasSizeInterval(); + applicationWindow.setResizable(true); + applicationWindow.setMinimumSize(320, 240); + applicationWindow.setMaximumSize(10000, 10000); + applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false); + } + } catch { + // Ignore malformed URLs while navigating. + } + }; - applicationWindow.loadURL(url); + applicationWindow.loadURL(url); - applicationWindow.webContents.on("did-finish-load", () => { - handleNavigation(applicationWindow.webContents.getURL()); - }); + applicationWindow.webContents.on("did-finish-load", () => { + handleNavigation(applicationWindow.webContents.getURL()); + }); - applicationWindow.webContents.on("did-navigate", (_event, navigationUrl) => handleNavigation(navigationUrl)); - applicationWindow.webContents.on("did-navigate-in-page", (_event, navigationUrl) => handleNavigation(navigationUrl)); - applicationWindow.on("closed", clearCanvasSizeInterval); + applicationWindow.webContents.on("did-navigate", (_event, navigationUrl) => handleNavigation(navigationUrl)); + applicationWindow.webContents.on("did-navigate-in-page", (_event, navigationUrl) => + handleNavigation(navigationUrl), + ); + applicationWindow.on("closed", clearCanvasSizeInterval); } app.whenReady().then(createWindow); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f73831e..8f46bb2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,65 +1,65 @@ server: - port: ${SERVER_PORT:8080} - tomcat: - max-swallow-size: 0 - ssl: - enabled: ${SSL_ENABLED:false} - key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12} - key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit} - key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12} - error: - include-message: never - include-stacktrace: never + port: ${SERVER_PORT:8080} + tomcat: + max-swallow-size: 0 + ssl: + enabled: ${SSL_ENABLED:false} + key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12} + key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit} + key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12} + error: + include-message: never + include-stacktrace: never spring: - config: - import: optional:file:.env[.properties] - application: - name: imgfloat - devtools: - restart: - enabled: true - livereload: - enabled: true - thymeleaf: - cache: false - datasource: - url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL - driver-class-name: org.sqlite.JDBC - hikari: - connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;" - maximum-pool-size: 1 - minimum-idle: 1 - jpa: - open-in-view: false - hibernate: - ddl-auto: update - database-platform: org.hibernate.community.dialect.SQLiteDialect - session: - store-type: jdbc - jdbc: - initialize-schema: always - platform: sqlite - security: - oauth2: - client: - registration: - twitch: - client-id: ${TWITCH_CLIENT_ID} - client-secret: ${TWITCH_CLIENT_SECRET} - client-authentication-method: client_secret_post - redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}" - authorization-grant-type: authorization_code - scope: ["user:read:email", "moderation:read"] - provider: - twitch: - authorization-uri: https://id.twitch.tv/oauth2/authorize - token-uri: https://id.twitch.tv/oauth2/token - user-info-uri: https://api.twitch.tv/helix/users - user-name-attribute: login + config: + import: optional:file:.env[.properties] + application: + name: imgfloat + devtools: + restart: + enabled: true + livereload: + enabled: true + thymeleaf: + cache: false + datasource: + url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL + driver-class-name: org.sqlite.JDBC + hikari: + connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;" + maximum-pool-size: 1 + minimum-idle: 1 + jpa: + open-in-view: false + hibernate: + ddl-auto: update + database-platform: org.hibernate.community.dialect.SQLiteDialect + session: + store-type: jdbc + jdbc: + initialize-schema: always + platform: sqlite + security: + oauth2: + client: + registration: + twitch: + client-id: ${TWITCH_CLIENT_ID} + client-secret: ${TWITCH_CLIENT_SECRET} + client-authentication-method: client_secret_post + redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}" + authorization-grant-type: authorization_code + scope: ["user:read:email", "moderation:read"] + provider: + twitch: + authorization-uri: https://id.twitch.tv/oauth2/authorize + token-uri: https://id.twitch.tv/oauth2/token + user-info-uri: https://api.twitch.tv/helix/users + user-name-attribute: login management: - endpoints: - web: - exposure: - include: health,info + endpoints: + web: + exposure: + include: health,info diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index f2fbdee..57ed61e 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1,2150 +1,2150 @@ * { - box-sizing: border-box; + box-sizing: border-box; } p { - margin: 0; + margin: 0; } .hidden { - display: none !important; + display: none !important; } body { - font-family: Arial, sans-serif; - background: #0f172a; - color: #e2e8f0; - margin: 0; - padding: 0; + font-family: Arial, sans-serif; + background: #0f172a; + color: #e2e8f0; + margin: 0; + padding: 0; } .landing-body { - min-height: 100vh; - background: - radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.18), transparent 30%), - radial-gradient(circle at 80% 0%, rgba(59, 130, 246, 0.16), transparent 25%), #0f172a; + min-height: 100vh; + background: + radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.18), transparent 30%), + radial-gradient(circle at 80% 0%, rgba(59, 130, 246, 0.16), transparent 25%), #0f172a; } .landing { - max-width: 1100px; - margin: 0 auto; - padding: 40px 20px 64px; + max-width: 1100px; + margin: 0 auto; + padding: 40px 20px 64px; } .landing-meta { - display: flex; - justify-content: flex-end; - margin-top: 18px; + display: flex; + justify-content: flex-end; + margin-top: 18px; } .build-chip { - display: inline-flex; - align-items: center; - gap: 10px; - padding: 6px 10px; - background: rgba(15, 23, 42, 0.7); - border: 1px solid rgba(148, 163, 184, 0.24); - border-radius: 12px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24); + display: inline-flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + background: rgba(15, 23, 42, 0.7); + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24); } .version-badge { - padding: 4px 10px; - border-radius: 999px; - background: linear-gradient(135deg, rgba(124, 58, 237, 0.12), rgba(59, 130, 246, 0.12)); - border: 1px solid rgba(124, 58, 237, 0.35); - color: #cbd5e1; - font-weight: 700; - letter-spacing: 0.2px; - font-size: 13px; + padding: 4px 10px; + border-radius: 999px; + background: linear-gradient(135deg, rgba(124, 58, 237, 0.12), rgba(59, 130, 246, 0.12)); + border: 1px solid rgba(124, 58, 237, 0.35); + color: #cbd5e1; + font-weight: 700; + letter-spacing: 0.2px; + font-size: 13px; } .channels-body, .settings-body { - min-height: 100vh; - background: - radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.16), transparent 30%), - radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 28%), #0f172a; - display: flex; - align-items: center; - justify-content: center; - padding: clamp(24px, 4vw, 48px); + min-height: 100vh; + background: + radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.16), transparent 30%), + radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 28%), #0f172a; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(24px, 4vw, 48px); } .channels-shell, .settings-shell { - width: min(760px, 100%); - display: flex; - flex-direction: column; - gap: 20px; + width: min(760px, 100%); + display: flex; + flex-direction: column; + gap: 20px; } .channels-header, .settings-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } .cta-row.compact { - margin: 0; + margin: 0; } .settings-main { - display: flex; - justify-content: center; + display: flex; + justify-content: center; } .settings-card { - width: 100%; - background: rgba(11, 18, 32, 0.95); - border: 1px solid #1f2937; - border-radius: 16px; - padding: clamp(20px, 3vw, 32px); - box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); - display: flex; - flex-direction: column; - gap: 10px; + width: 100%; + background: rgba(11, 18, 32, 0.95); + border: 1px solid #1f2937; + border-radius: 16px; + padding: clamp(20px, 3vw, 32px); + box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 10px; } .channels-main { - display: flex; - justify-content: center; + display: flex; + justify-content: center; } .channel-card { - width: 100%; - background: rgba(11, 18, 32, 0.95); - border: 1px solid #1f2937; - border-radius: 16px; - padding: clamp(20px, 3vw, 32px); - box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); - display: flex; - flex-direction: column; - gap: 10px; + width: 100%; + background: rgba(11, 18, 32, 0.95); + border: 1px solid #1f2937; + border-radius: 16px; + padding: clamp(20px, 3vw, 32px); + box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 10px; } .channel-card h1 { - margin: 6px 0 4px; + margin: 6px 0 4px; } .channel-form { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 6px; + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 6px; } .settings-form { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 6px; + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 6px; } .settings-hero { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 20px; - align-items: center; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + align-items: center; } .settings-layout { - display: grid; - grid-template-columns: 2fr minmax(260px, 1fr); - gap: 18px; - align-items: start; + display: grid; + grid-template-columns: 2fr minmax(260px, 1fr); + gap: 18px; + align-items: start; } @media (max-width: 900px) { - .settings-layout { - grid-template-columns: 1fr; - } + .settings-layout { + grid-template-columns: 1fr; + } } .settings-panel { - display: flex; - flex-direction: column; - gap: 18px; + display: flex; + flex-direction: column; + gap: 18px; } .settings-sidebar { - display: flex; - flex-direction: column; - gap: 14px; + display: flex; + flex-direction: column; + gap: 14px; } .hero-copy h1 { - margin: 8px 0 6px; + margin: 8px 0 6px; } .stat-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; } .stat-grid.compact { - gap: 10px; + gap: 10px; } .stat { - border: 1px solid #1f2937; - border-radius: 12px; - padding: 14px; - background: rgba(255, 255, 255, 0.02); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); + border: 1px solid #1f2937; + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.02); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); } .stat-label { - color: #cbd5e1; - font-size: 13px; - margin: 0 0 4px; + color: #cbd5e1; + font-size: 13px; + margin: 0 0 4px; } .stat-value { - font-size: 22px; - font-weight: 700; - margin: 0; + font-size: 22px; + font-weight: 700; + margin: 0; } .stat-subtitle { - margin: 6px 0 0; - color: #94a3b8; - font-size: 12px; + margin: 6px 0 0; + color: #94a3b8; + font-size: 12px; } .field-hint { - color: #94a3b8; - font-size: 13px; - margin: 6px 0 0; + color: #94a3b8; + font-size: 13px; + margin: 6px 0 0; } .form-section { - border: 1px solid #1f2937; - border-radius: 12px; - padding: 14px; - background: rgba(255, 255, 255, 0.01); - display: flex; - flex-direction: column; - gap: 12px; + border: 1px solid #1f2937; + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.01); + display: flex; + flex-direction: column; + gap: 12px; } .form-heading h3 { - margin: 4px 0 0; + margin: 4px 0 0; } .form-heading .muted { - margin-top: 2px; + margin-top: 2px; } .form-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-top: 4px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 4px; } .status-chip { - padding: 8px 12px; - border-radius: 999px; - background: rgba(148, 163, 184, 0.1); - border: 1px solid rgba(148, 163, 184, 0.3); - color: #e2e8f0; - font-size: 14px; - margin: 0; + padding: 8px 12px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.1); + border: 1px solid rgba(148, 163, 184, 0.3); + color: #e2e8f0; + font-size: 14px; + margin: 0; } .status-chip.status-success { - background: rgba(34, 197, 94, 0.12); - border-color: rgba(34, 197, 94, 0.35); - color: #bbf7d0; + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.35); + color: #bbf7d0; } .status-chip.status-warning { - background: rgba(251, 191, 36, 0.12); - border-color: rgba(251, 191, 36, 0.4); - color: #fef3c7; + background: rgba(251, 191, 36, 0.12); + border-color: rgba(251, 191, 36, 0.4); + color: #fef3c7; } .info-card { - display: flex; - flex-direction: column; - gap: 8px; + display: flex; + flex-direction: column; + gap: 8px; } .info-card.subtle { - background: rgba(15, 23, 42, 0.75); + background: rgba(15, 23, 42, 0.75); } .hint-list { - margin: 0; - padding-left: 16px; - color: #cbd5e1; - display: flex; - flex-direction: column; - gap: 6px; + margin: 0; + padding-left: 16px; + color: #cbd5e1; + display: flex; + flex-direction: column; + gap: 6px; } .channels-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 10px; - border-radius: 12px; - border: 1px solid #1f2937; - background: rgba(11, 18, 32, 0.9); + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 10px; + border-radius: 12px; + border: 1px solid #1f2937; + background: rgba(11, 18, 32, 0.9); } .landing-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 32px; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 32px; } .brand { - display: flex; - align-items: center; - gap: 12px; + display: flex; + align-items: center; + gap: 12px; } .brand-mark { - width: 40px; - height: 40px; - display: grid; - place-items: center; - font-weight: 700; - letter-spacing: 0.5px; + width: 40px; + height: 40px; + display: grid; + place-items: center; + font-weight: 700; + letter-spacing: 0.5px; } .brand-title { - font-weight: 700; - font-size: 18px; + font-weight: 700; + font-size: 18px; } .brand-subtitle { - color: #94a3b8; - font-size: 13px; + color: #94a3b8; + font-size: 13px; } .hero { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 28px; - align-items: center; - background: rgba(15, 23, 42, 0.85); - border: 1px solid #1f2937; - padding: 28px; - border-radius: 16px; - box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 28px; + align-items: center; + background: rgba(15, 23, 42, 0.85); + border: 1px solid #1f2937; + padding: 28px; + border-radius: 16px; + box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); } .hero-compact { - grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); - padding: 24px; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + padding: 24px; } .hero-text h1 { - font-size: 32px; - line-height: 1.2; - margin: 8px 0 12px; + font-size: 32px; + line-height: 1.2; + margin: 8px 0 12px; } .lead { - color: #cbd5e1; - line-height: 1.6; + color: #cbd5e1; + line-height: 1.6; } .pill-list { - list-style: none; - padding: 0; - margin: 16px 0 0; - display: flex; - gap: 10px; - flex-wrap: wrap; + list-style: none; + padding: 0; + margin: 16px 0 0; + display: flex; + gap: 10px; + flex-wrap: wrap; } .pill-list.minimal { - margin-top: 12px; + margin-top: 12px; } .pill-list li { - background: rgba(124, 58, 237, 0.12); - border: 1px solid rgba(124, 58, 237, 0.2); - color: #e9d5ff; - padding: 8px 12px; - border-radius: 999px; - font-weight: 600; - font-size: 14px; + background: rgba(124, 58, 237, 0.12); + border: 1px solid rgba(124, 58, 237, 0.2); + color: #e9d5ff; + padding: 8px 12px; + border-radius: 999px; + font-weight: 600; + font-size: 14px; } .pill-list.minimal li { - background: rgba(124, 58, 237, 0.08); - border-color: rgba(124, 58, 237, 0.18); + background: rgba(124, 58, 237, 0.08); + border-color: rgba(124, 58, 237, 0.18); } .download-section { - margin-top: 26px; - padding: 20px; - background: rgba(11, 18, 32, 0.92); - border-radius: 14px; - border: 1px solid #1f2937; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45); - display: flex; - flex-direction: column; - gap: 14px; + margin-top: 26px; + padding: 20px; + background: rgba(11, 18, 32, 0.92); + border-radius: 14px; + border: 1px solid #1f2937; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 14px; } .download-header h2, .download-header h3 { - margin: 6px 0 8px; + margin: 6px 0 8px; } .version-inline { - font-weight: 700; - color: #e2e8f0; + font-weight: 700; + color: #e2e8f0; } .download-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; } .download-card { - border: 1px solid #1f2937; - border-radius: 12px; - padding: 14px; - background: rgba(15, 23, 42, 0.82); - display: flex; - flex-direction: column; - gap: 10px; - transition: - border-color 0.2s ease, - box-shadow 0.2s ease, - transform 0.2s ease; + border: 1px solid #1f2937; + border-radius: 12px; + padding: 14px; + background: rgba(15, 23, 42, 0.82); + display: flex; + flex-direction: column; + gap: 10px; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; } .download-card-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } .download-card--active { - border-color: rgba(124, 58, 237, 0.55); - box-shadow: 0 16px 40px rgba(124, 58, 237, 0.22); - transform: translateY(-2px); + border-color: rgba(124, 58, 237, 0.55); + box-shadow: 0 16px 40px rgba(124, 58, 237, 0.22); + transform: translateY(-2px); } .download-card .button { - margin-top: 4px; + margin-top: 4px; } .download-card-block { - display: flex; - flex-direction: column; - gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; } .eyebrow { - text-transform: uppercase; - letter-spacing: 1px; - color: #a5b4fc; - font-size: 12px; - margin: 0; + text-transform: uppercase; + letter-spacing: 1px; + color: #a5b4fc; + font-size: 12px; + margin: 0; } .eyebrow.subtle { - color: #cbd5e1; + color: #cbd5e1; } .cta-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; - margin: 16px 0 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin: 16px 0 10px; } .button, button { - background: #7c3aed; - color: white; - padding: 10px 16px; - border: none; - border-radius: 8px; - cursor: pointer; - text-decoration: none; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - font-weight: 600; - box-shadow: 0 10px 30px rgba(124, 58, 237, 0.25); + background: #7c3aed; + color: white; + padding: 10px 16px; + border: none; + border-radius: 8px; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 600; + box-shadow: 0 10px 30px rgba(124, 58, 237, 0.25); } .button:disabled, button:disabled, .button[aria-disabled="true"] { - background: #a78bfa; - color: #e5e7eb; - cursor: not-allowed; - box-shadow: none; - opacity: 0.7; + background: #a78bfa; + color: #e5e7eb; + cursor: not-allowed; + box-shadow: none; + opacity: 0.7; } .button:disabled:hover, button:disabled:hover { - transform: none; + transform: none; } .button.block { - width: 100%; + width: 100%; } .broadcaster-button { - background: linear-gradient(115deg, #7c3aed, #2563eb); - border: 1px solid rgba(124, 58, 237, 0.35); - box-shadow: 0 14px 35px rgba(37, 99, 235, 0.3); + background: linear-gradient(115deg, #7c3aed, #2563eb); + border: 1px solid rgba(124, 58, 237, 0.35); + box-shadow: 0 14px 35px rgba(37, 99, 235, 0.3); } .broadcaster-button:hover { - filter: brightness(1.05); - box-shadow: 0 16px 38px rgba(37, 99, 235, 0.38); + filter: brightness(1.05); + box-shadow: 0 16px 38px rgba(37, 99, 235, 0.38); } .block { - width: 100%; - display: flex; - align-items: center; - justify-content: center; + width: 100%; + display: flex; + align-items: center; + justify-content: center; } .button.ghost { - background: transparent; - border: 1px solid #2d3a57; - box-shadow: none; + background: transparent; + border: 1px solid #2d3a57; + box-shadow: none; } .ghost { - background: transparent; - border: 1px solid #2d3a57; - box-shadow: none; + background: transparent; + border: 1px solid #2d3a57; + box-shadow: none; } .secondary { - background: #475569; + background: #475569; } .secondary.danger { - background: #7f1d1d; - border: 1px solid rgba(248, 113, 113, 0.35); - color: #fecdd3; + background: #7f1d1d; + border: 1px solid rgba(248, 113, 113, 0.35); + color: #fecdd3; } .secondary.danger:hover:not(:disabled) { - border-color: rgba(248, 113, 113, 0.6); - background: #991b1b; - color: #fee2e2; + border-color: rgba(248, 113, 113, 0.6); + background: #991b1b; + color: #fee2e2; } .hero-panel { - background: #0b1220; - border: 1px solid #1f2937; - border-radius: 14px; - padding: 18px; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 14px; + padding: 18px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); } .search-panel { - display: flex; - flex-direction: column; - gap: 12px; + 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; + 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); + outline: none; + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); } .text-input:disabled, .text-input[aria-disabled="true"] { - background: #020617; - border-color: #334155; - color: #64748b; - cursor: not-allowed; - box-shadow: none; + background: #020617; + border-color: #334155; + color: #64748b; + cursor: not-allowed; + box-shadow: none; } .text-input:disabled::placeholder { - color: #475569; + color: #475569; } .search-form { - display: flex; - flex-direction: column; - gap: 10px; + display: flex; + flex-direction: column; + gap: 10px; } .search-row { - display: flex; - gap: 10px; - align-items: stretch; + display: flex; + gap: 10px; + align-items: stretch; } .search-row .text-input { - flex: 1; + flex: 1; } .search-row .button { - padding: 0 16px; - height: 46px; + padding: 0 16px; + height: 46px; } .badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: 999px; - background: rgba(124, 58, 237, 0.1); - color: #c4b5fd; - font-weight: 600; - font-size: 12px; - border: 1px solid rgba(124, 58, 237, 0.2); + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(124, 58, 237, 0.1); + color: #c4b5fd; + font-weight: 600; + font-size: 12px; + border: 1px solid rgba(124, 58, 237, 0.2); } .badge.subtle { - background: rgba(148, 163, 184, 0.1); - color: #cbd5e1; - border-color: rgba(148, 163, 184, 0.2); + background: rgba(148, 163, 184, 0.1); + color: #cbd5e1; + border-color: rgba(148, 163, 184, 0.2); } .badge.soft { - background: rgba(59, 130, 246, 0.12); - color: #bfdbfe; - border-color: rgba(59, 130, 246, 0.26); + background: rgba(59, 130, 246, 0.12); + color: #bfdbfe; + border-color: rgba(59, 130, 246, 0.26); } .badge.danger { - background: rgba(248, 113, 113, 0.12); - color: #fecdd3; - border-color: rgba(248, 113, 113, 0.45); + background: rgba(248, 113, 113, 0.12); + color: #fecdd3; + border-color: rgba(248, 113, 113, 0.45); } .badge-row { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; - margin-top: 6px; + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + margin-top: 6px; } .badge-row.stacked { - flex-direction: column; - align-items: flex-start; - gap: 6px; + flex-direction: column; + align-items: flex-start; + gap: 6px; } .preview-summary { - margin: 10px 0 18px; + margin: 10px 0 18px; } .panel-actions { - display: flex; - flex-direction: row; - gap: 20px; - padding: 20px; + display: flex; + flex-direction: row; + gap: 20px; + padding: 20px; } .preview-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; } .preview-card { - background: rgba(255, 255, 255, 0.02); - border: 1px solid #1f2937; - border-radius: 12px; - padding: 12px; - color: #cbd5e1; + background: rgba(255, 255, 255, 0.02); + border: 1px solid #1f2937; + border-radius: 12px; + padding: 12px; + color: #cbd5e1; } .preview-card p { - margin: 8px 0 0; - color: #cbd5e1; - line-height: 1.4; + margin: 8px 0 0; + color: #cbd5e1; + line-height: 1.4; } .landing-footer { - display: flex; - align-items: center; - justify-content: space-between; - background: #0b1220; - padding: 16px; - border: 1px solid #1f2937; - border-radius: 12px; + display: flex; + align-items: center; + justify-content: space-between; + background: #0b1220; + padding: 16px; + border: 1px solid #1f2937; + border-radius: 12px; } .landing-footer.compact { - margin-top: 18px; + margin-top: 18px; } .admin-layout { - min-height: 100vh; - display: flex; - flex-direction: column; + min-height: 100vh; + display: flex; + flex-direction: column; } .admin-body { - background: - radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.12), transparent 32%), - radial-gradient(circle at 90% 5%, rgba(124, 58, 237, 0.1), transparent 30%), #0f172a; + background: + radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.12), transparent 32%), + radial-gradient(circle at 90% 5%, rgba(124, 58, 237, 0.1), transparent 30%), #0f172a; } .admin-frame { - margin: 0 auto; - display: flex; - flex-direction: column; - width: 100%; - height: 100vh; + margin: 0 auto; + display: flex; + flex-direction: column; + width: 100%; + height: 100vh; } .admin-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - padding: 10px 12px; - background: linear-gradient(90deg, rgba(15, 23, 42, 0.95), rgba(12, 20, 34, 0.9)); - border-bottom: 1px solid #1f2937; - border-radius: 0; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 10px 12px; + background: linear-gradient(90deg, rgba(15, 23, 42, 0.95), rgba(12, 20, 34, 0.9)); + border-bottom: 1px solid #1f2937; + border-radius: 0; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); } .topbar-left { - display: flex; - align-items: center; - gap: 10px; + display: flex; + align-items: center; + gap: 10px; } .admin-identity { - display: flex; - gap: 4px; - align-items: flex-start; - flex-direction: column; + display: flex; + gap: 4px; + align-items: flex-start; + flex-direction: column; } .admin-identity h1 { - margin: 2px 0 4px; + margin: 2px 0 4px; } .admin-workspace { - --sidebar-width: 300px; - display: grid; - height: 100%; - grid-template-columns: var(--sidebar-width) 1fr; - gap: 12px; - align-items: stretch; - position: relative; + --sidebar-width: 300px; + display: grid; + height: 100%; + grid-template-columns: var(--sidebar-width) 1fr; + gap: 12px; + align-items: stretch; + position: relative; } .admin-rail { - background: rgba(11, 18, 32, 0.92); - border-right: 1px solid #1f2937; - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.02), - 0 18px 45px rgba(0, 0, 0, 0.4); - display: flex; - flex-direction: column; - height: calc(100vh - 81px); + background: rgba(11, 18, 32, 0.92); + border-right: 1px solid #1f2937; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.02), + 0 18px 45px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + height: calc(100vh - 81px); } .rail-header { - display: flex; - align-items: center; - justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; } .rail-body { - display: flex; - flex-direction: column; - gap: 8px; - flex: 1; - min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; } .rail-scroll { - overflow-y: auto; - padding-right: 2px; - flex: 1; - min-height: 0; + overflow-y: auto; + padding-right: 2px; + flex: 1; + min-height: 0; } .canvas-stack { - background: linear-gradient(180deg, rgba(10, 15, 26, 0.95), rgba(12, 17, 30, 0.96)); - border-radius: 16px; - padding: 12px 12px 14px; - box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); - display: flex; - flex-direction: column; - gap: 10px; - min-height: 72vh; + background: linear-gradient(180deg, rgba(10, 15, 26, 0.95), rgba(12, 17, 30, 0.96)); + border-radius: 16px; + padding: 12px 12px 14px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 10px; + min-height: 72vh; } .canvas-surface { - display: flex; - flex-direction: column; - gap: 12px; - height: 100%; + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; } .header-actions.tight { - gap: 8px; + gap: 8px; } .header-actions.horizontal { - flex-direction: row; - align-items: center; - gap: 10px; + flex-direction: row; + align-items: center; + gap: 10px; } .header-actions.horizontal form { - margin: 0; + margin: 0; } .dashboard-body { - min-height: 100vh; - background: - radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.14), transparent 30%), - radial-gradient(circle at 90% 10%, rgba(124, 58, 237, 0.12), transparent 28%), #0f172a; - padding: 36px 18px 64px; + min-height: 100vh; + background: + radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.14), transparent 30%), + radial-gradient(circle at 90% 10%, rgba(124, 58, 237, 0.12), transparent 28%), #0f172a; + padding: 36px 18px 64px; } .dashboard-shell { - max-width: 1100px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: 20px; + max-width: 1100px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 20px; } .dashboard-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 14px 18px; - background: rgba(15, 23, 42, 0.85); - border: 1px solid #1f2937; - border-radius: 14px; - box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 18px; + background: rgba(15, 23, 42, 0.85); + border: 1px solid #1f2937; + border-radius: 14px; + box-shadow: 0 12px 35px rgba(0, 0, 0, 0.35); } .user-pill { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; - padding: 10px 12px; - border-radius: 12px; - background: rgba(124, 58, 237, 0.1); - border: 1px solid rgba(124, 58, 237, 0.2); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(124, 58, 237, 0.1); + border: 1px solid rgba(124, 58, 237, 0.2); } .user-display { - font-weight: 700; - color: #e2e8f0; - font-size: 16px; + font-weight: 700; + color: #e2e8f0; + font-size: 16px; } .dashboard-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 20px; - padding: 22px; - background: rgba(15, 23, 42, 0.8); - border: 1px solid #1f2937; - border-radius: 16px; - box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 22px; + background: rgba(15, 23, 42, 0.8); + border: 1px solid #1f2937; + border-radius: 16px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35); } .dashboard-header h1 { - margin: 6px 0 10px; + margin: 6px 0 10px; } .header-actions { - display: flex; - flex-direction: column; - gap: 10px; - align-items: flex-end; + display: flex; + flex-direction: column; + gap: 10px; + align-items: flex-end; } .chip-row { - display: flex; - gap: 10px; - flex-wrap: wrap; - margin-top: 10px; + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; } .chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border-radius: 999px; - background: rgba(124, 58, 237, 0.15); - color: #c4b5fd; - border: 1px solid rgba(124, 58, 237, 0.25); - font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(124, 58, 237, 0.15); + color: #c4b5fd; + border: 1px solid rgba(124, 58, 237, 0.25); + font-weight: 600; } .chip.subtle { - background: rgba(148, 163, 184, 0.12); - color: #e2e8f0; - border-color: rgba(148, 163, 184, 0.3); + background: rgba(148, 163, 184, 0.12); + color: #e2e8f0; + border-color: rgba(148, 163, 184, 0.3); } .card-grid { - display: grid; - gap: 16px; + display: grid; + gap: 16px; } .card-grid.two-col { - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } .card { - background: #0b1220; - border: 1px solid #1f2937; - border-radius: 14px; - padding: 18px; - box-shadow: 0 14px 40px rgba(0, 0, 0, 0.35); + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 14px; + padding: 18px; + box-shadow: 0 14px 40px rgba(0, 0, 0, 0.35); } .card-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; } .pill { - padding: 6px 10px; - border-radius: 999px; - background: rgba(59, 130, 246, 0.15); - color: #bfdbfe; - border: 1px solid rgba(59, 130, 246, 0.25); - font-weight: 600; - font-size: 13px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(59, 130, 246, 0.15); + color: #bfdbfe; + border: 1px solid rgba(59, 130, 246, 0.25); + font-weight: 600; + font-size: 13px; } .inline-form { - display: flex; - gap: 10px; - flex-wrap: wrap; + display: flex; + gap: 10px; + flex-wrap: wrap; } .inline-form input { - flex: 1; - min-width: 220px; - padding: 10px; - border-radius: 10px; - border: 1px solid #1f2937; - background: #111827; - color: #e2e8f0; + flex: 1; + min-width: 220px; + padding: 10px; + border-radius: 10px; + border: 1px solid #1f2937; + background: #111827; + color: #e2e8f0; } .card-section { - margin-top: 16px; - display: flex; - flex-direction: column; - gap: 8px; + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; } .container { - max-width: 960px; - margin: 40px auto; - background: #111827; - padding: 24px; - border-radius: 12px; - box-shadow: 0 5px 16px rgba(0, 0, 0, 0.3); + max-width: 960px; + margin: 40px auto; + background: #111827; + padding: 24px; + border-radius: 12px; + box-shadow: 0 5px 16px rgba(0, 0, 0, 0.3); } .controls { - display: flex; - gap: 24px; - padding: 16px; - background: #0b1220; - border-radius: 12px; + display: flex; + gap: 24px; + padding: 16px; + background: #0b1220; + border-radius: 12px; } .controls-full { - width: 100%; + width: 100%; } .assets-panel { - background: #0b1220; - padding: 18px; + background: #0b1220; + padding: 18px; } .asset-management { - display: grid; - grid-template-columns: 2fr 356px; - gap: 16px; - align-items: start; + display: grid; + grid-template-columns: 2fr 356px; + gap: 16px; + align-items: start; } .asset-column { - display: flex; - flex-direction: column; - gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; } .asset-column.inspector { - position: sticky; - top: 12px; + position: sticky; + top: 12px; } @media (max-width: 1024px) { - .asset-management { - grid-template-columns: 1fr; - } + .asset-management { + grid-template-columns: 1fr; + } - .asset-column.inspector { - position: static; - } + .asset-column.inspector { + position: static; + } } @media (max-width: 1200px) { - .admin-workspace { - grid-template-columns: 1fr; - } + .admin-workspace { + grid-template-columns: 1fr; + } - .admin-rail { - max-width: 520px; - } + .admin-rail { + max-width: 520px; + } - .rail-inspector { - position: static; - width: 100%; - max-height: none; - } + .rail-inspector { + position: static; + width: 100%; + max-height: none; + } - .overlay { - min-height: 520px; - height: 60vh; - } + .overlay { + min-height: 520px; + height: 60vh; + } } .controls ul { - list-style: none; - padding: 0; - margin-top: 12px; + list-style: none; + padding: 0; + margin-top: 12px; } .controls li { - margin: 6px 0; + margin: 6px 0; } .overlay { - position: relative; - flex: 1; - min-height: clamp(520px, 72vh, 980px); - height: 100%; - background: - radial-gradient(circle at 18% 20%, rgba(59, 130, 246, 0.08), transparent 38%), - radial-gradient(circle at 80% 0%, rgba(124, 58, 237, 0.08), transparent 40%), #0b1220; - overflow: hidden; - border: 1px solid #1f2937; - border-radius: 16px; - box-shadow: 0 16px 45px rgba(0, 0, 0, 0.45); - display: grid; - place-items: center; + position: relative; + flex: 1; + min-height: clamp(520px, 72vh, 980px); + height: 100%; + background: + radial-gradient(circle at 18% 20%, rgba(59, 130, 246, 0.08), transparent 38%), + radial-gradient(circle at 80% 0%, rgba(124, 58, 237, 0.08), transparent 40%), #0b1220; + overflow: hidden; + border: 1px solid #1f2937; + border-radius: 16px; + box-shadow: 0 16px 45px rgba(0, 0, 0, 0.45); + display: grid; + place-items: center; } .overlay canvas { - position: absolute; - top: 0; - left: 0; - pointer-events: auto; - z-index: 2; + position: absolute; + top: 0; + left: 0; + pointer-events: auto; + z-index: 2; } #admin-canvas { - border: 1px solid rgba(148, 163, 184, 0.35); - border-radius: 10px; - box-shadow: - 0 0 0 1px rgba(15, 23, 42, 0.5), - 0 16px 45px rgba(0, 0, 0, 0.55); - background-color: rgba(255, 0, 255, 0.1); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 10px; + box-shadow: + 0 0 0 1px rgba(15, 23, 42, 0.5), + 0 16px 45px rgba(0, 0, 0, 0.55); + background-color: rgba(255, 0, 255, 0.1); } .broadcast-body canvas { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; } .broadcast-body { - margin: 0; - overflow: hidden; - background: transparent; + margin: 0; + overflow: hidden; + background: transparent; } .panel { - margin-top: 24px; - padding: 16px; - background: #0b1220; - border-radius: 10px; - border: 1px solid #1f2937; + margin-top: 24px; + padding: 16px; + background: #0b1220; + border-radius: 10px; + border: 1px solid #1f2937; } .panel.hidden { - display: none; + display: none; } .panel-title { - margin: 4px 0 0; - font-size: 18px; - color: #e5e7eb; + margin: 4px 0 0; + font-size: 18px; + color: #e5e7eb; } .panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; } .canvas-panel { - padding: 18px 18px 20px; - margin-top: 0; + padding: 18px 18px 20px; + margin-top: 0; } .canvas-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; } .canvas-meta { - display: flex; - gap: 8px; - align-items: center; + display: flex; + gap: 8px; + align-items: center; } .canvas-stage { - margin-top: 14px; - display: flex; - flex-direction: column; - gap: 10px; + margin-top: 14px; + display: flex; + flex-direction: column; + gap: 10px; } .canvas-boundary { - background-image: - linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px), - linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px); - background-size: 24px 24px; - position: relative; - isolation: isolate; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + background-size: 24px 24px; + position: relative; + isolation: isolate; } .canvas-boundary::after { - content: ""; - position: absolute; - border-radius: 12px; - pointer-events: none; - box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.02), - 0 16px 40px rgba(0, 0, 0, 0.35); + content: ""; + position: absolute; + border-radius: 12px; + pointer-events: none; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.02), + 0 16px 40px rgba(0, 0, 0, 0.35); } .canvas-guides { - position: absolute; - inset: 0; - pointer-events: none; + position: absolute; + inset: 0; + pointer-events: none; } .canvas-footnote { - color: #94a3b8; - font-size: 13px; - margin: 0 4px; + color: #94a3b8; + font-size: 13px; + margin: 0 4px; } .rail-inspector { - margin-top: 0; - display: flex; - flex-direction: column; - gap: 12px; - max-height: none; - overflow: visible; - border-top: 1px solid #1f2937; - padding: 14px 16px 0; - width: 100%; + margin-top: 0; + display: flex; + flex-direction: column; + gap: 12px; + max-height: none; + overflow: visible; + border-top: 1px solid #1f2937; + padding: 14px 16px 0; + width: 100%; } .rail-inspector .panel-section { - margin-top: 0; - overflow: visible; - padding-right: 0; - max-height: none; + margin-top: 0; + overflow: visible; + padding-right: 0; + max-height: none; } .control-panel { - margin-top: 0; + margin-top: 0; } .asset-settings { - background: transparent; - box-shadow: none; - padding: 0; - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; + background: transparent; + box-shadow: none; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; } .panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; } .panel-header h4 { - margin: 0; + margin: 0; } .panel-summary { - background: rgba(124, 58, 237, 0.06); - border: 1px solid rgba(124, 58, 237, 0.22); - border-radius: 12px; - padding: 12px 14px; - max-width: 320px; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + background: rgba(124, 58, 237, 0.06); + border: 1px solid rgba(124, 58, 237, 0.22); + border-radius: 12px; + padding: 12px 14px; + max-width: 320px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); } .panel-summary p { - margin: 0; + margin: 0; } .asset-inspector { - background: transparent; - border: none; - border-radius: 0; - padding: 0; - width: 100%; + background: transparent; + border: none; + border-radius: 0; + padding: 0; + width: 100%; } .asset-controls-placeholder { - margin-top: 0; + margin-top: 0; } .selected-asset-banner { - display: grid; - grid-template-columns: 1fr; - gap: 10px; - padding: 4px 0 10px; - border-radius: 0; - background: transparent; - border: none; - box-shadow: none; + display: grid; + grid-template-columns: 1fr; + gap: 10px; + padding: 4px 0 10px; + border-radius: 0; + background: transparent; + border: none; + box-shadow: none; } .selected-asset-main { - display: flex; - flex-direction: column; - gap: 6px; - width: 100%; - min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + min-width: 0; } .selected-asset-actions { - display: flex; - align-items: center; - gap: 8px; + display: flex; + align-items: center; + gap: 8px; } .panel ul { - list-style: none; - padding: 0; - margin: 8px 0 0 0; + list-style: none; + padding: 0; + margin: 8px 0 0 0; } .panel li { - margin: 6px 0; + margin: 6px 0; } .upload-row { - display: flex; - align-items: center; + display: flex; + align-items: center; } .file-input-field { - display: none; + display: none; } .file-input-trigger { - flex: 1; - display: inline-flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - background: rgba(124, 58, 237, 0.08); - cursor: pointer; - transition: - background 120ms ease, - background 120ms ease; + flex: 1; + display: inline-flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: rgba(124, 58, 237, 0.08); + cursor: pointer; + transition: + background 120ms ease, + background 120ms ease; } .file-input-trigger:hover { - background: rgba(124, 58, 237, 0.14); + background: rgba(124, 58, 237, 0.14); } .file-input-icon { - width: 38px; - height: 38px; - border-radius: 8px; - display: grid; - place-items: center; - background: rgba(124, 58, 237, 0.16); - color: #c4b5fd; + width: 38px; + height: 38px; + border-radius: 8px; + display: grid; + place-items: center; + background: rgba(124, 58, 237, 0.16); + color: #c4b5fd; } .file-input-copy { - display: flex; - flex-direction: column; - gap: 4px; + display: flex; + flex-direction: column; + gap: 4px; } .file-input-copy strong { - font-size: 14px; + font-size: 14px; } .file-input-copy small { - color: #cbd5e1; + color: #cbd5e1; } .title-row { - display: flex; - align-items: baseline; - gap: 8px; - flex-wrap: nowrap; - justify-content: space-between; + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: nowrap; + justify-content: space-between; } .title-row strong { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .asset-resolution { - margin-left: auto; - white-space: nowrap; - font-size: 12px; - color: #cbd5e1; - text-align: right; + margin-left: auto; + white-space: nowrap; + font-size: 12px; + color: #cbd5e1; + text-align: right; } .property-list { - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; } .property-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; } .property-label { - color: #e2e8f0; - font-weight: 600; - font-size: 14px; + color: #e2e8f0; + font-weight: 600; + font-size: 14px; } .property-control { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; - min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; } .property-row .number-input { - max-width: 140px; - text-align: right; + max-width: 140px; + text-align: right; } .property-row .range-input { - min-width: 200px; - flex: 1; + min-width: 200px; + flex: 1; } .property-row .badge-row { - justify-content: flex-end; + justify-content: flex-end; } .inline-toggle { - padding-top: 0; + padding-top: 0; } .meta-text { - margin: 6px 0 0; + margin: 6px 0 0; } .subtle-text { - color: #94a3b8; - font-size: 12px; + color: #94a3b8; + font-size: 12px; } .panel-section { - margin-top: 12px; - padding: 14px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(148, 163, 184, 0.15); + margin-top: 12px; + padding: 14px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(148, 163, 184, 0.15); } .panel-section.two-col { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 14px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; } .section-header { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 10px; + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; } .section-header h5 { - margin: 0; + margin: 0; } .field-note { - margin: 0; - color: #94a3b8; - font-size: 12px; + margin: 0; + color: #94a3b8; + font-size: 12px; } .stacked-field { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 10px; + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; } .label-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } .value-hint { - color: #cbd5e1; - font-size: 12px; + color: #cbd5e1; + font-size: 12px; } .asset-list { - display: flex; - flex-direction: column; - padding: 0; - margin: 0; + display: flex; + flex-direction: column; + padding: 0; + margin: 0; } .asset-item { - display: flex; - flex-direction: column; - align-items: stretch; - padding: 8px 10px; - background: #111827; - border-top: 1px solid #1f2937; - cursor: pointer; - gap: 8px; - font-size: 13px; - line-height: 1.35; - height: 60px; + display: flex; + flex-direction: column; + align-items: stretch; + padding: 8px 10px; + background: #111827; + border-top: 1px solid #1f2937; + cursor: pointer; + gap: 8px; + font-size: 13px; + line-height: 1.35; + height: 60px; } .asset-item:last-child { - border-bottom: 1px solid #1f2937; + border-bottom: 1px solid #1f2937; } .asset-item strong { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .asset-item.selected { - background: #235; + background: #235; } .asset-item.pending { - cursor: default; - opacity: 0.92; + cursor: default; + opacity: 0.92; } .asset-item.is-hidden { - opacity: 0.72; - border-style: dashed; + opacity: 0.72; + border-style: dashed; } .asset-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; } .asset-row .meta { - flex: 1; + flex: 1; } .asset-item .meta { - display: flex; - flex-direction: column; - gap: 4px; + display: flex; + flex-direction: column; + gap: 4px; } .asset-item small { - color: #94a3b8; + color: #94a3b8; } .asset-item .actions { - display: flex; - gap: 6px; + display: flex; + gap: 6px; } .asset-inspector .selected-asset-actions .icon-button, .asset-inspector .selected-asset-actions .icon-button:disabled { - background: #0f172a; + background: #0f172a; } .asset-inspector .panel-section { - margin-top: 0; - padding: 0; - border-radius: 0; - background: transparent; - border: none; + margin-top: 0; + padding: 0; + border-radius: 0; + background: transparent; + border: none; } .asset-meta-badges { - margin-top: 4px; + margin-top: 4px; } .asset-detail { - margin-top: 4px; + margin-top: 4px; } .icon-button { - display: inline-flex; - align-items: center; - text-decoration: none; - text-align: center; - justify-content: center; - gap: 6px; - padding: 8px 10px; - width: 34px; - border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.25); - background: rgba(255, 255, 255, 0.04); - color: #e2e8f0; - transition: all 0.15s ease; + display: inline-flex; + align-items: center; + text-decoration: none; + text-align: center; + justify-content: center; + gap: 6px; + padding: 8px 10px; + width: 34px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(255, 255, 255, 0.04); + color: #e2e8f0; + transition: all 0.15s ease; } .icon-button .icon { - font-size: 16px; - line-height: 1; + font-size: 16px; + line-height: 1; } .icon-button:hover { - border-color: rgba(124, 58, 237, 0.4); - box-shadow: 0 5px 18px rgba(0, 0, 0, 0.25); + border-color: rgba(124, 58, 237, 0.4); + box-shadow: 0 5px 18px rgba(0, 0, 0, 0.25); } .icon-button.danger { - border-color: rgba(248, 113, 113, 0.35); - color: #fecdd3; + border-color: rgba(248, 113, 113, 0.35); + color: #fecdd3; } .icon-button.danger:hover { - border-color: rgba(248, 113, 113, 0.6); - background: rgba(248, 113, 113, 0.08); + border-color: rgba(248, 113, 113, 0.6); + background: rgba(248, 113, 113, 0.08); } .asset-item.hidden { - opacity: 0.6; + opacity: 0.6; } .asset-preview { - width: 38px; - height: 38px; - object-fit: contain; - background: #0b1220; - border: 1px solid #1f2937; - border-radius: 8px; - flex-shrink: 0; + width: 38px; + height: 38px; + object-fit: contain; + background: #0b1220; + border: 1px solid #1f2937; + border-radius: 8px; + flex-shrink: 0; } .asset-preview.still { - position: relative; - overflow: hidden; - background-size: cover; - background-position: center; - background-repeat: no-repeat; - object-fit: cover; + position: relative; + overflow: hidden; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + object-fit: cover; } .asset-preview.still:not(.has-image) { - display: grid; - place-items: center; - color: #cbd5e1; - background: #111827; + display: grid; + place-items: center; + color: #cbd5e1; + background: #111827; } .preview-overlay { - position: absolute; - inset: 0; - display: grid; - place-items: center; - background: linear-gradient(180deg, rgba(15, 23, 42, 0.4), rgba(15, 23, 42, 0.4)); - color: #e5e7eb; - pointer-events: none; - font-size: 18px; + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.4), rgba(15, 23, 42, 0.4)); + color: #e5e7eb; + pointer-events: none; + font-size: 18px; } .pending-preview { - display: grid; - place-items: center; - color: #cbd5e1; - background: rgba(124, 58, 237, 0.08); - border-style: dashed; + display: grid; + place-items: center; + color: #cbd5e1; + background: rgba(124, 58, 237, 0.08); + border-style: dashed; } .upload-progress { - margin-top: 10px; - width: 100%; - height: 6px; - background: rgba(148, 163, 184, 0.12); - border: 1px solid rgba(148, 163, 184, 0.25); - border-radius: 999px; - overflow: hidden; + margin-top: 10px; + width: 100%; + height: 6px; + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 999px; + overflow: hidden; } .upload-progress-bar { - width: 100%; - height: 100%; - background: linear-gradient(90deg, rgba(124, 58, 237, 0.8), rgba(99, 102, 241, 0.8), rgba(124, 58, 237, 0.8)); - background-size: 200% 100%; - animation: upload-progress 1.2s linear infinite; + width: 100%; + height: 100%; + background: linear-gradient(90deg, rgba(124, 58, 237, 0.8), rgba(99, 102, 241, 0.8), rgba(124, 58, 237, 0.8)); + background-size: 200% 100%; + animation: upload-progress 1.2s linear infinite; } .upload-progress-bar.is-processing { - background: linear-gradient(90deg, rgba(34, 197, 94, 0.85), rgba(52, 211, 153, 0.8), rgba(34, 197, 94, 0.85)); - animation-duration: 1.8s; + background: linear-gradient(90deg, rgba(34, 197, 94, 0.85), rgba(52, 211, 153, 0.8), rgba(34, 197, 94, 0.85)); + animation-duration: 1.8s; } @keyframes upload-progress { - from { - background-position: 200% 0; - } - to { - background-position: -200% 0; - } + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } } .audio-icon { - display: flex; - align-items: center; - justify-content: center; - font-size: 28px; - color: #fbbf24; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + color: #fbbf24; } .sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; } .control-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; - margin-top: 12px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-top: 12px; } .control-grid.condensed { - gap: 10px; - margin-top: 8px; + gap: 10px; + margin-top: 8px; } .control-grid.split-row { - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - margin-top: 6px; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + margin-top: 6px; } .control-grid.three-col { - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } .control-grid label { - display: flex; - flex-direction: column; - gap: 6px; - color: #cbd5e1; + display: flex; + flex-direction: column; + gap: 6px; + color: #cbd5e1; } .control-grid .inline-toggle { - align-items: center; - justify-content: space-between; + align-items: center; + justify-content: space-between; } .control-grid input[type="number"], .control-grid input[type="range"] { - padding: 8px; - border-radius: 6px; - border: 1px solid #1f2937; - background: #0f172a; - color: #e2e8f0; + padding: 8px; + border-radius: 6px; + border: 1px solid #1f2937; + background: #0f172a; + color: #e2e8f0; } .range-meta { - display: flex; - justify-content: space-between; - color: #94a3b8; - font-size: 12px; - margin-top: -6px; - padding: 0 2px; + display: flex; + justify-content: space-between; + color: #94a3b8; + font-size: 12px; + margin-top: -6px; + padding: 0 2px; } .number-input { - position: relative; - padding-right: 48px !important; - font-variant-numeric: tabular-nums; + position: relative; + padding-right: 48px !important; + font-variant-numeric: tabular-nums; } .number-input::-webkit-outer-spin-button, .number-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; + -webkit-appearance: none; + margin: 0; } .number-input { - -moz-appearance: textfield; + -moz-appearance: textfield; } .number-input:focus { - border-color: #7c3aed; - box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); + border-color: #7c3aed; + box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); } .control-actions { - display: flex; - gap: 8px; - margin-top: 12px; - flex-wrap: wrap; - width: 100%; + display: flex; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; + width: 100%; } .control-actions.compact button { - padding: 10px 12px; + padding: 10px 12px; } .control-actions.filled { - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(124, 58, 237, 0.22); - padding: 12px; - border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(124, 58, 237, 0.22); + padding: 12px; + border-radius: 12px; } .unified-actions { - padding: 0; - margin: 14px 0; + padding: 0; + margin: 14px 0; } .unified-actions button { - flex: 1 1 48px; + flex: 1 1 48px; } .checkbox-inline { - display: flex; - align-items: center; - gap: 8px; - padding-top: 6px; - position: relative; + display: flex; + align-items: center; + gap: 8px; + padding-top: 6px; + position: relative; } .checkbox-inline.toggle { - gap: 12px; - cursor: pointer; - user-select: none; + gap: 12px; + cursor: pointer; + user-select: none; } .checkbox-inline input[type="checkbox"] { - position: absolute; - opacity: 0; - width: 1px; - height: 1px; + position: absolute; + opacity: 0; + width: 1px; + height: 1px; } .toggle-track { - position: relative; - align-self: flex-start; - width: 52px; - height: 25px; - display: inline-flex; - align-items: center; - padding: 4px; - border-radius: 999px; - background: linear-gradient(180deg, #e2e8f0, #cbd5e1); - border: 1px solid #cbd5e1; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); - transition: - background 160ms ease, - border-color 160ms ease, - box-shadow 160ms ease; + position: relative; + align-self: flex-start; + width: 52px; + height: 25px; + display: inline-flex; + align-items: center; + padding: 4px; + border-radius: 999px; + background: linear-gradient(180deg, #e2e8f0, #cbd5e1); + border: 1px solid #cbd5e1; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18); + transition: + background 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; } .toggle-thumb { - width: 17px; - height: 17px; - border-radius: 999px; - background: #ffffff; - box-shadow: - 0 2px 4px rgba(0, 0, 0, 0.18), - 0 1px 0 rgba(255, 255, 255, 0.6) inset; - transform: translateX(0); - transition: - transform 160ms ease, - box-shadow 160ms ease, - background 160ms ease; + width: 17px; + height: 17px; + border-radius: 999px; + background: #ffffff; + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.18), + 0 1px 0 rgba(255, 255, 255, 0.6) inset; + transform: translateX(0); + transition: + transform 160ms ease, + box-shadow 160ms ease, + background 160ms ease; } .checkbox-inline input[type="checkbox"]:checked + .toggle-track { - background: linear-gradient(180deg, #7c3aed, #342366); - border-color: #7c3aed; - box-shadow: - inset 0 1px 2px rgba(0, 0, 0, 0.12), - 0 0 0 1px rgba(52, 199, 89, 0.35); + background: linear-gradient(180deg, #7c3aed, #342366); + border-color: #7c3aed; + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.12), + 0 0 0 1px rgba(52, 199, 89, 0.35); } .checkbox-inline input[type="checkbox"]:checked + .toggle-track .toggle-thumb { - transform: translateX(25px); - box-shadow: - 0 2px 6px rgba(40, 183, 75, 0.35), - 0 1px 0 rgba(255, 255, 255, 0.6) inset; + transform: translateX(25px); + box-shadow: + 0 2px 6px rgba(40, 183, 75, 0.35), + 0 1px 0 rgba(255, 255, 255, 0.6) inset; } .checkbox-inline input[type="checkbox"]:focus-visible + .toggle-track { - box-shadow: - 0 0 0 3px rgba(124, 58, 237, 0.35), - inset 0 1px 2px rgba(0, 0, 0, 0.18); + box-shadow: + 0 0 0 3px rgba(124, 58, 237, 0.35), + inset 0 1px 2px rgba(0, 0, 0, 0.18); } .toggle-label { - color: #e2e8f0; - font-weight: 600; + color: #e2e8f0; + font-weight: 600; } .muted { - color: #94a3b8; - font-size: 0.9em; + color: #94a3b8; + font-size: 0.9em; } .tiny { - font-size: 13px; + font-size: 13px; } .range-value { - color: #a5b4fc; - font-size: 12px; - margin-top: -4px; + color: #a5b4fc; + font-size: 12px; + margin-top: -4px; } .landing-footer .muted { - font-size: 12px; + font-size: 12px; } .stacked-list { - list-style: none; - padding: 0; - margin: 12px 0 0; - display: flex; - flex-direction: column; - gap: 10px; + list-style: none; + padding: 0; + margin: 12px 0 0; + display: flex; + flex-direction: column; + gap: 10px; } #admin-suggestions { - max-height: 240px; - overflow-y: auto; - padding-right: 6px; + max-height: 240px; + overflow-y: auto; + padding-right: 6px; } .stacked-list-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 14px; - border-radius: 12px; - background: #111827; - border: 1px solid #1f2937; + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 14px; + border-radius: 12px; + background: #111827; + border: 1px solid #1f2937; } .stacked-list-item .list-title { - margin: 0; - font-weight: 700; + margin: 0; + font-weight: 700; } .identity-row { - display: flex; - align-items: center; - gap: 12px; + display: flex; + align-items: center; + gap: 12px; } .identity-text { - display: flex; - flex-direction: column; - gap: 2px; + display: flex; + flex-direction: column; + gap: 2px; } .avatar { - width: 40px; - height: 40px; - border-radius: 50%; - object-fit: cover; - background: linear-gradient(135deg, #7c3aed, #4f46e5); - display: grid; - place-items: center; - font-weight: 700; - color: #e0e7ff; - text-transform: uppercase; + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + background: linear-gradient(135deg, #7c3aed, #4f46e5); + display: grid; + place-items: center; + font-weight: 700; + color: #e0e7ff; + text-transform: uppercase; } .avatar-fallback { - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.08); } .toast-container { - position: fixed; - bottom: 16px; - right: 16px; - display: flex; - flex-direction: column; - gap: 12px; - z-index: 10000; - max-width: 360px; + position: fixed; + bottom: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 12px; + z-index: 10000; + max-width: 360px; } .toast { - display: grid; - grid-template-columns: auto 1fr; - gap: 12px; - align-items: center; - padding: 12px 14px; - border-radius: 12px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); - border: 1px solid rgba(255, 255, 255, 0.08); - background: #0b1221; - color: #e5e7eb; - cursor: pointer; - transition: - transform 120ms ease, - opacity 120ms ease; + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.08); + background: #0b1221; + color: #e5e7eb; + cursor: pointer; + transition: + transform 120ms ease, + opacity 120ms ease; } .toast:hover { - transform: translateY(-2px); + transform: translateY(-2px); } .toast-exit { - opacity: 0; - transform: translateY(-6px); + opacity: 0; + transform: translateY(-6px); } .toast-indicator { - width: 12px; - height: 12px; - border-radius: 50%; - background: #a5b4fc; - box-shadow: 0 0 0 4px rgba(165, 180, 252, 0.16); + width: 12px; + height: 12px; + border-radius: 50%; + background: #a5b4fc; + box-shadow: 0 0 0 4px rgba(165, 180, 252, 0.16); } .toast-message { - margin: 0; - font-size: 14px; - line-height: 1.4; + margin: 0; + font-size: 14px; + line-height: 1.4; } .toast-success { - border-color: rgba(34, 197, 94, 0.35); - background: rgba(16, 185, 129, 0.42); + border-color: rgba(34, 197, 94, 0.35); + background: rgba(16, 185, 129, 0.42); } .toast-success .toast-indicator { - background: #34d399; - box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.2); + background: #34d399; + box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.2); } .toast-error { - border-color: rgba(239, 68, 68, 0.35); - background: rgba(248, 113, 113, 0.42); + border-color: rgba(239, 68, 68, 0.35); + background: rgba(248, 113, 113, 0.42); } .toast-error .toast-indicator { - background: #f87171; - box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.2); + background: #f87171; + box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.2); } .toast-warning { - border-color: rgba(251, 191, 36, 0.35); - background: rgba(251, 191, 36, 0.42); + border-color: rgba(251, 191, 36, 0.35); + background: rgba(251, 191, 36, 0.42); } .toast-warning .toast-indicator { - background: #facc15; - box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.2); + background: #facc15; + box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.2); } .toast-info { - border-color: rgba(96, 165, 250, 0.35); - background: rgba(96, 165, 250, 0.12); + border-color: rgba(96, 165, 250, 0.35); + background: rgba(96, 165, 250, 0.12); } .toast-info .toast-indicator { - background: #60a5fa; - box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); + background: #60a5fa; + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); } .cookie-consent { - position: fixed; - bottom: 16px; - left: 16px; - right: 16px; - max-width: 520px; - margin-left: auto; - padding: 16px 52px 14px 18px; - display: grid; - grid-template-columns: 1fr auto; - gap: 12px; - background: linear-gradient(135deg, rgba(124, 58, 237, 0.25), rgba(59, 130, 246, 0.18)); - border: 1px solid rgba(148, 163, 184, 0.35); - border-radius: 14px; - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45); - color: #e2e8f0; - z-index: 11000; - backdrop-filter: blur(4px); - transition: - opacity 150ms ease, - transform 150ms ease; + position: fixed; + bottom: 16px; + left: 16px; + right: 16px; + max-width: 520px; + margin-left: auto; + padding: 16px 52px 14px 18px; + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + background: linear-gradient(135deg, rgba(124, 58, 237, 0.25), rgba(59, 130, 246, 0.18)); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 14px; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45); + color: #e2e8f0; + z-index: 11000; + backdrop-filter: blur(4px); + transition: + opacity 150ms ease, + transform 150ms ease; } .cookie-consent-exit { - opacity: 0; - transform: translateY(6px); + opacity: 0; + transform: translateY(6px); } .cookie-consent-body { - display: flex; - flex-direction: column; - gap: 6px; - padding-right: 18px; + display: flex; + flex-direction: column; + gap: 6px; + padding-right: 18px; } .cookie-consent-copy { - margin: 0; - line-height: 1.45; - color: #cbd5e1; + margin: 0; + line-height: 1.45; + color: #cbd5e1; } .cookie-consent-copy a { - color: #a5b4fc; - text-decoration: underline; + color: #a5b4fc; + text-decoration: underline; } .cookie-consent-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; } .cookie-consent-close { - position: absolute; - top: 10px; - right: 10px; - background: transparent; - border: 1px solid rgba(226, 232, 240, 0.3); - color: #e2e8f0; - border-radius: 50%; - width: 32px; - height: 32px; - font-size: 18px; - font-weight: 700; - line-height: 1; - cursor: pointer; - transition: all 120ms ease; + position: absolute; + top: 10px; + right: 10px; + background: transparent; + border: 1px solid rgba(226, 232, 240, 0.3); + color: #e2e8f0; + border-radius: 50%; + width: 32px; + height: 32px; + font-size: 18px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: all 120ms ease; } .cookie-consent-close:hover { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(226, 232, 240, 0.45); + background: rgba(255, 255, 255, 0.08); + border-color: rgba(226, 232, 240, 0.45); } @media (max-width: 640px) { - .cookie-consent { - grid-template-columns: 1fr; - padding: 16px; - } + .cookie-consent { + grid-template-columns: 1fr; + padding: 16px; + } - .cookie-consent-actions { - justify-content: flex-start; - } + .cookie-consent-actions { + justify-content: flex-start; + } } diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 048ab12..27e22e3 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -67,280 +67,283 @@ let lastSizeInputChanged = null; let stompClient; audioUnlockEvents.forEach((eventName) => { - window.addEventListener(eventName, () => { - if (!pendingAudioUnlock.size) return; - pendingAudioUnlock.forEach((controller) => { - safePlay(controller); + window.addEventListener(eventName, () => { + if (!pendingAudioUnlock.size) return; + pendingAudioUnlock.forEach((controller) => { + safePlay(controller); + }); + pendingAudioUnlock.clear(); }); - pendingAudioUnlock.clear(); - }); }); function debounce(fn, wait = 150) { - let timeout; - return (...args) => { - clearTimeout(timeout); - timeout = setTimeout(() => fn(...args), wait); - }; + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), wait); + }; } function isFormInputElement(element) { - if (!element) return false; - if (element.isContentEditable) return true; - const tag = element.tagName ? element.tagName.toLowerCase() : ""; - return ["input", "textarea", "select", "button", "option"].includes(tag); + if (!element) return false; + if (element.isContentEditable) return true; + const tag = element.tagName ? element.tagName.toLowerCase() : ""; + return ["input", "textarea", "select", "button", "option"].includes(tag); } function schedulePersistTransform(asset, silent = false, delay = 200) { - if (!asset?.id) return; - cancelPendingTransform(asset.id); - const timeout = setTimeout(() => { - pendingTransformSaves.delete(asset.id); - persistTransform(asset, silent); - }, delay); - pendingTransformSaves.set(asset.id, timeout); + if (!asset?.id) return; + cancelPendingTransform(asset.id); + const timeout = setTimeout(() => { + pendingTransformSaves.delete(asset.id); + persistTransform(asset, silent); + }, delay); + pendingTransformSaves.set(asset.id, timeout); } function cancelPendingTransform(assetId) { - const pending = pendingTransformSaves.get(assetId); - if (pending) { - clearTimeout(pending); - pendingTransformSaves.delete(assetId); - } + const pending = pendingTransformSaves.get(assetId); + if (pending) { + clearTimeout(pending); + pendingTransformSaves.delete(assetId); + } } function ensureLayerPosition(assetId, placement = "keep") { - const asset = assets.get(assetId); - if (asset && isAudioAsset(asset)) { - return; - } - const existingIndex = layerOrder.indexOf(assetId); - if (existingIndex !== -1 && placement === "keep") { - return; - } - if (existingIndex !== -1) { - layerOrder.splice(existingIndex, 1); - } - if (placement === "append") { - layerOrder.push(assetId); - } else { - layerOrder.unshift(assetId); - } - layerOrder = layerOrder.filter((id) => assets.has(id)); + const asset = assets.get(assetId); + if (asset && isAudioAsset(asset)) { + return; + } + const existingIndex = layerOrder.indexOf(assetId); + if (existingIndex !== -1 && placement === "keep") { + return; + } + if (existingIndex !== -1) { + layerOrder.splice(existingIndex, 1); + } + if (placement === "append") { + layerOrder.push(assetId); + } else { + layerOrder.unshift(assetId); + } + layerOrder = layerOrder.filter((id) => assets.has(id)); } function getLayerOrder() { - layerOrder = layerOrder.filter((id) => { - const asset = assets.get(id); - return asset && !isAudioAsset(asset); - }); - assets.forEach((asset, id) => { - if (isAudioAsset(asset)) { - return; - } - if (!layerOrder.includes(id)) { - layerOrder.unshift(id); - } - }); - return layerOrder; + layerOrder = layerOrder.filter((id) => { + const asset = assets.get(id); + return asset && !isAudioAsset(asset); + }); + assets.forEach((asset, id) => { + if (isAudioAsset(asset)) { + return; + } + if (!layerOrder.includes(id)) { + layerOrder.unshift(id); + } + }); + return layerOrder; } function getAssetsByLayer() { - return getLayerOrder() - .map((id) => assets.get(id)) - .filter(Boolean); + return getLayerOrder() + .map((id) => assets.get(id)) + .filter(Boolean); } function getAudioAssets() { - return Array.from(assets.values()) - .filter((asset) => isAudioAsset(asset)) - .sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0)); + return Array.from(assets.values()) + .filter((asset) => isAudioAsset(asset)) + .sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0)); } function getRenderOrder() { - return [...getLayerOrder()] - .reverse() - .map((id) => assets.get(id)) - .filter(Boolean); + return [...getLayerOrder()] + .reverse() + .map((id) => assets.get(id)) + .filter(Boolean); } function getLayerValue(assetId) { - const asset = assets.get(assetId); - if (asset && isAudioAsset(asset)) { - return 0; - } - const order = getLayerOrder(); - const index = order.indexOf(assetId); - if (index === -1) return 1; - return order.length - index; + const asset = assets.get(assetId); + if (asset && isAudioAsset(asset)) { + return 0; + } + const order = getLayerOrder(); + const index = order.indexOf(assetId); + if (index === -1) return 1; + return order.length - index; } function addPendingUpload(name) { - const pending = { - id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`, - name, - status: "uploading", - createdAtMs: Date.now(), - }; - pendingUploads.push(pending); - renderAssetList(); - return pending.id; + const pending = { + id: `pending-${Date.now()}-${Math.round(Math.random() * 100000)}`, + name, + status: "uploading", + createdAtMs: Date.now(), + }; + pendingUploads.push(pending); + renderAssetList(); + return pending.id; } function updatePendingUpload(id, updates = {}) { - const pending = pendingUploads.find((item) => item.id === id); - if (!pending) return; - Object.assign(pending, updates); - renderAssetList(); + const pending = pendingUploads.find((item) => item.id === id); + if (!pending) return; + Object.assign(pending, updates); + renderAssetList(); } function removePendingUpload(id) { - const index = pendingUploads.findIndex((item) => item.id === id); - if (index === -1) return; - pendingUploads.splice(index, 1); - renderAssetList(); + const index = pendingUploads.findIndex((item) => item.id === id); + if (index === -1) return; + pendingUploads.splice(index, 1); + renderAssetList(); } function resolvePendingUploadByName(name) { - if (!name) return; - const index = pendingUploads.findIndex((item) => item.name === name); - if (index === -1) return; - pendingUploads.splice(index, 1); - renderAssetList(); + if (!name) return; + const index = pendingUploads.findIndex((item) => item.name === name); + if (index === -1) return; + pendingUploads.splice(index, 1); + renderAssetList(); } function formatDurationLabel(durationMs) { - const totalSeconds = Math.max(0, Math.round(durationMs / 1000)); - const seconds = totalSeconds % 60; - const minutes = Math.floor(totalSeconds / 60) % 60; - const hours = Math.floor(totalSeconds / 3600); - const parts = []; - if (hours > 0) { - parts.push(`${hours}h`); - } - if (minutes > 0 || hours > 0) { - parts.push(`${minutes}m`); - } - if (seconds > 0 || parts.length === 0) { - parts.push(`${seconds}s`); - } - return parts.join(" "); + const totalSeconds = Math.max(0, Math.round(durationMs / 1000)); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + const parts = []; + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0 || hours > 0) { + parts.push(`${minutes}m`); + } + if (seconds > 0 || parts.length === 0) { + parts.push(`${seconds}s`); + } + return parts.join(" "); } function recordDuration(assetId, seconds) { - if (!Number.isFinite(seconds) || seconds <= 0) { - return; - } - const asset = assets.get(assetId); - if (!asset) { - return; - } - const nextMs = Math.round(seconds * 1000); - if (asset.durationMs === nextMs) { - return; - } - asset.durationMs = nextMs; - if (asset.id === selectedAssetId) { - updateSelectedAssetSummary(asset); - } - drawAndList(); + if (!Number.isFinite(seconds) || seconds <= 0) { + return; + } + const asset = assets.get(assetId); + if (!asset) { + return; + } + const nextMs = Math.round(seconds * 1000); + if (asset.durationMs === nextMs) { + return; + } + asset.durationMs = nextMs; + if (asset.id === selectedAssetId) { + updateSelectedAssetSummary(asset); + } + drawAndList(); } function hasDuration(asset) { - return ( - asset && Number.isFinite(asset.durationMs) && asset.durationMs > 0 && (isAudioAsset(asset) || isVideoAsset(asset)) - ); + return ( + asset && + Number.isFinite(asset.durationMs) && + asset.durationMs > 0 && + (isAudioAsset(asset) || isVideoAsset(asset)) + ); } function getDurationBadge(asset) { - if (!hasDuration(asset)) { - return null; - } - return formatDurationLabel(asset.durationMs); + if (!hasDuration(asset)) { + return null; + } + return formatDurationLabel(asset.durationMs); } function setSpeedLabel(percent) { - if (!speedLabel) return; - speedLabel.textContent = `${Math.round(percent)}%`; + if (!speedLabel) return; + speedLabel.textContent = `${Math.round(percent)}%`; } function setAudioSpeedLabel(percentValue) { - if (!audioSpeedLabel) return; - const multiplier = Math.max(0, percentValue) / 100; - const formatted = multiplier >= 10 ? multiplier.toFixed(0) : multiplier.toFixed(2); - audioSpeedLabel.textContent = `${formatted}x`; + if (!audioSpeedLabel) return; + const multiplier = Math.max(0, percentValue) / 100; + const formatted = multiplier >= 10 ? multiplier.toFixed(0) : multiplier.toFixed(2); + audioSpeedLabel.textContent = `${formatted}x`; } function formatDelayLabel(ms) { - const numeric = Math.max(0, parseInt(ms, 10) || 0); - if (numeric >= 1000) { - const seconds = numeric / 1000; - const decimals = Number.isInteger(seconds) ? 0 : 1; - return `${seconds.toFixed(decimals)}s`; - } - return `${numeric}ms`; + const numeric = Math.max(0, parseInt(ms, 10) || 0); + if (numeric >= 1000) { + const seconds = numeric / 1000; + const decimals = Number.isInteger(seconds) ? 0 : 1; + return `${seconds.toFixed(decimals)}s`; + } + return `${numeric}ms`; } function setAudioDelayLabel(value) { - if (!audioDelayLabel) return; - audioDelayLabel.textContent = formatDelayLabel(value); + if (!audioDelayLabel) return; + audioDelayLabel.textContent = formatDelayLabel(value); } function setAudioPitchLabel(percentValue) { - if (!audioPitchLabel) return; - const numeric = Math.round(Math.max(0, percentValue)); - audioPitchLabel.textContent = `${numeric}%`; + if (!audioPitchLabel) return; + const numeric = Math.round(Math.max(0, percentValue)); + audioPitchLabel.textContent = `${numeric}%`; } function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); + return Math.min(max, Math.max(min, value)); } function sliderToVolume(sliderValue) { - const normalized = clamp(sliderValue, 0, VOLUME_SLIDER_MAX) / VOLUME_SLIDER_MAX; - const curved = normalized + VOLUME_CURVE_STRENGTH * normalized * (1 - normalized) * (1 - 2 * normalized); - return clamp( - curved * SETTINGS.maxAssetVolumeFraction, - SETTINGS.minAssetVolumeFraction, - SETTINGS.maxAssetVolumeFraction, - ); + const normalized = clamp(sliderValue, 0, VOLUME_SLIDER_MAX) / VOLUME_SLIDER_MAX; + const curved = normalized + VOLUME_CURVE_STRENGTH * normalized * (1 - normalized) * (1 - 2 * normalized); + return clamp( + curved * SETTINGS.maxAssetVolumeFraction, + SETTINGS.minAssetVolumeFraction, + SETTINGS.maxAssetVolumeFraction, + ); } function volumeToSlider(volumeValue) { - const target = - clamp(volumeValue ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction) / - SETTINGS.maxAssetVolumeFraction; - let low = 0; - let high = VOLUME_SLIDER_MAX; - for (let i = 0; i < 24; i += 1) { - const mid = (low + high) / 2; - const midNormalized = sliderToVolume(mid) / SETTINGS.maxAssetVolumeFraction; - if (midNormalized < target) { - low = mid; - } else { - high = mid; + const target = + clamp(volumeValue ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction) / + SETTINGS.maxAssetVolumeFraction; + let low = 0; + let high = VOLUME_SLIDER_MAX; + for (let i = 0; i < 24; i += 1) { + const mid = (low + high) / 2; + const midNormalized = sliderToVolume(mid) / SETTINGS.maxAssetVolumeFraction; + if (midNormalized < target) { + low = mid; + } else { + high = mid; + } } - } - return Math.round(high); + return Math.round(high); } function setVolumeLabel(sliderValue) { - if (!volumeLabel) return; - const volumePercent = Math.round(sliderToVolume(sliderValue) * 100); - volumeLabel.textContent = `${volumePercent}%`; + if (!volumeLabel) return; + const volumePercent = Math.round(sliderToVolume(sliderValue) * 100); + volumeLabel.textContent = `${volumePercent}%`; } function queueAudioForUnlock(controller) { - if (!controller) return; - pendingAudioUnlock.add(controller); + if (!controller) return; + pendingAudioUnlock.add(controller); } function safePlay(controller) { - if (!controller?.element) return; - const playPromise = controller.element.play(); - if (playPromise?.catch) { - playPromise.catch(() => queueAudioForUnlock(controller)); - } + if (!controller?.element) return; + const playPromise = controller.element.play(); + if (playPromise?.catch) { + playPromise.catch(() => queueAudioForUnlock(controller)); + } } if (widthInput) widthInput.addEventListener("input", () => handleSizeInputChange("width")); @@ -351,1916 +354,1925 @@ if (speedInput) speedInput.addEventListener("input", updatePlaybackFromInputs); if (volumeInput) volumeInput.addEventListener("input", updateVolumeFromInput); if (audioLoopInput) audioLoopInput.addEventListener("change", updateAudioSettingsFromInputs); if (audioDelayInput) - audioDelayInput.addEventListener("input", () => { - setAudioDelayLabel(audioDelayInput.value); - updateAudioSettingsFromInputs(); - }); + audioDelayInput.addEventListener("input", () => { + setAudioDelayLabel(audioDelayInput.value); + updateAudioSettingsFromInputs(); + }); if (audioSpeedInput) - audioSpeedInput.addEventListener("input", () => { - setAudioSpeedLabel(audioSpeedInput.value); - updateAudioSettingsFromInputs(); - }); + audioSpeedInput.addEventListener("input", () => { + setAudioSpeedLabel(audioSpeedInput.value); + updateAudioSettingsFromInputs(); + }); if (audioPitchInput) - audioPitchInput.addEventListener("input", () => { - setAudioPitchLabel(audioPitchInput.value); - updateAudioSettingsFromInputs(); - }); + audioPitchInput.addEventListener("input", () => { + setAudioPitchLabel(audioPitchInput.value); + updateAudioSettingsFromInputs(); + }); if (selectedDeleteBtn) { - selectedDeleteBtn.addEventListener("click", () => { - const asset = getSelectedAsset(); - if (!asset) return; - deleteAsset(asset); - }); + selectedDeleteBtn.addEventListener("click", () => { + const asset = getSelectedAsset(); + if (!asset) return; + deleteAsset(asset); + }); } window.addEventListener("keydown", (event) => { - if (isFormInputElement(event.target)) { - return; - } + if (isFormInputElement(event.target)) { + return; + } - const asset = getSelectedAsset(); + const asset = getSelectedAsset(); - if ((event.key === "Delete" || event.key === "Backspace") && asset) { - event.preventDefault(); - deleteAsset(asset); - return; - } + if ((event.key === "Delete" || event.key === "Backspace") && asset) { + event.preventDefault(); + deleteAsset(asset); + return; + } - if (!asset || isAudioAsset(asset)) { - return; - } + if (!asset || isAudioAsset(asset)) { + return; + } - const step = event.shiftKey ? KEYBOARD_NUDGE_FAST_STEP : KEYBOARD_NUDGE_STEP; - let moved = false; + const step = event.shiftKey ? KEYBOARD_NUDGE_FAST_STEP : KEYBOARD_NUDGE_STEP; + let moved = false; - switch (event.key) { - case "ArrowUp": - asset.y -= step; - moved = true; - break; - case "ArrowDown": - asset.y += step; - moved = true; - break; - case "ArrowLeft": - asset.x -= step; - moved = true; - break; - case "ArrowRight": - asset.x += step; - moved = true; - break; - default: - break; - } + switch (event.key) { + case "ArrowUp": + asset.y -= step; + moved = true; + break; + case "ArrowDown": + asset.y += step; + moved = true; + break; + case "ArrowLeft": + asset.x -= step; + moved = true; + break; + case "ArrowRight": + asset.x += step; + moved = true; + break; + default: + break; + } - if (moved) { - event.preventDefault(); - updateRenderState(asset); - schedulePersistTransform(asset); - drawAndList(); - } + if (moved) { + event.preventDefault(); + updateRenderState(asset); + schedulePersistTransform(asset); + drawAndList(); + } }); function connect() { - const socket = new SockJS("/ws"); - stompClient = Stomp.over(socket); - stompClient.connect( - {}, - () => { - stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => { - const body = JSON.parse(payload.body); - handleEvent(body); - }); - fetchAssets(); - }, - (error) => { - console.warn("WebSocket connection issue", error); - setTimeout(() => showToast("Live updates connection interrupted. Retrying may be necessary.", "warning"), 1000); - }, - ); + const socket = new SockJS("/ws"); + stompClient = Stomp.over(socket); + stompClient.connect( + {}, + () => { + stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => { + const body = JSON.parse(payload.body); + handleEvent(body); + }); + fetchAssets(); + }, + (error) => { + console.warn("WebSocket connection issue", error); + setTimeout( + () => showToast("Live updates connection interrupted. Retrying may be necessary.", "warning"), + 1000, + ); + }, + ); } function fetchAssets() { - fetch(`/api/channels/${broadcaster}/assets`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load assets"); - } - return r.json(); - }) - .then(renderAssets) - .catch(() => showToast("Unable to load assets. Please refresh.", "error")); + fetch(`/api/channels/${broadcaster}/assets`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load assets"); + } + return r.json(); + }) + .then(renderAssets) + .catch(() => showToast("Unable to load assets. Please refresh.", "error")); } function fetchCanvasSettings() { - return fetch(`/api/channels/${broadcaster}/canvas`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load canvas"); - } - return r.json(); - }) - .then((settings) => { - canvasSettings = settings; - resizeCanvas(); - }) - .catch(() => { - resizeCanvas(); - showToast("Using default canvas size. Unable to load saved settings.", "warning"); - }); + return fetch(`/api/channels/${broadcaster}/canvas`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load canvas"); + } + return r.json(); + }) + .then((settings) => { + canvasSettings = settings; + resizeCanvas(); + }) + .catch(() => { + resizeCanvas(); + showToast("Using default canvas size. Unable to load saved settings.", "warning"); + }); } function resizeCanvas() { - if (!overlay) { - return; - } - const bounds = overlay.getBoundingClientRect(); - const scale = Math.min(bounds.width / canvasSettings.width, bounds.height / canvasSettings.height); - const displayWidth = canvasSettings.width * scale; - const displayHeight = canvasSettings.height * scale; - canvas.width = canvasSettings.width; - canvas.height = canvasSettings.height; - canvas.style.width = `${displayWidth}px`; - canvas.style.height = `${displayHeight}px`; - canvas.style.left = `${(bounds.width - displayWidth) / 2}px`; - canvas.style.top = `${(bounds.height - displayHeight) / 2}px`; - if (canvasResolutionLabel) { - canvasResolutionLabel.textContent = `${canvasSettings.width} x ${canvasSettings.height}`; - } - if (canvasScaleLabel) { - canvasScaleLabel.textContent = `${Math.round(scale * 100)}%`; - } - requestDraw(); + if (!overlay) { + return; + } + const bounds = overlay.getBoundingClientRect(); + const scale = Math.min(bounds.width / canvasSettings.width, bounds.height / canvasSettings.height); + const displayWidth = canvasSettings.width * scale; + const displayHeight = canvasSettings.height * scale; + canvas.width = canvasSettings.width; + canvas.height = canvasSettings.height; + canvas.style.width = `${displayWidth}px`; + canvas.style.height = `${displayHeight}px`; + canvas.style.left = `${(bounds.width - displayWidth) / 2}px`; + canvas.style.top = `${(bounds.height - displayHeight) / 2}px`; + if (canvasResolutionLabel) { + canvasResolutionLabel.textContent = `${canvasSettings.width} x ${canvasSettings.height}`; + } + if (canvasScaleLabel) { + canvasScaleLabel.textContent = `${Math.round(scale * 100)}%`; + } + requestDraw(); } function renderAssets(list) { - layerOrder = []; - list.forEach((item) => storeAsset(item, { placement: "append" })); - drawAndList(); + layerOrder = []; + list.forEach((item) => storeAsset(item, { placement: "append" })); + drawAndList(); } function storeAsset(asset, options = {}) { - if (!asset) return; - const placement = options.placement || "keep"; - const existing = assets.get(asset.id); - const merged = existing ? { ...existing, ...asset } : { ...asset }; - const mediaChanged = existing && existing.url !== merged.url; - const previewChanged = existing && existing.previewUrl !== merged.previewUrl; - if (mediaChanged || previewChanged) { - clearMedia(asset.id); - } - const parsedCreatedAt = merged.createdAt ? new Date(merged.createdAt).getTime() : NaN; - const hasCreatedAtMs = typeof merged.createdAtMs === "number" && Number.isFinite(merged.createdAtMs); - if (!hasCreatedAtMs) { - merged.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now(); - } - assets.set(asset.id, merged); - ensureLayerPosition(asset.id, existing ? "keep" : placement); - if (!renderStates.has(asset.id)) { - renderStates.set(asset.id, { ...merged }); - } - resolvePendingUploadByName(asset.name); + if (!asset) return; + const placement = options.placement || "keep"; + const existing = assets.get(asset.id); + const merged = existing ? { ...existing, ...asset } : { ...asset }; + const mediaChanged = existing && existing.url !== merged.url; + const previewChanged = existing && existing.previewUrl !== merged.previewUrl; + if (mediaChanged || previewChanged) { + clearMedia(asset.id); + } + const parsedCreatedAt = merged.createdAt ? new Date(merged.createdAt).getTime() : NaN; + const hasCreatedAtMs = typeof merged.createdAtMs === "number" && Number.isFinite(merged.createdAtMs); + if (!hasCreatedAtMs) { + merged.createdAtMs = Number.isFinite(parsedCreatedAt) ? parsedCreatedAt : Date.now(); + } + assets.set(asset.id, merged); + ensureLayerPosition(asset.id, existing ? "keep" : placement); + if (!renderStates.has(asset.id)) { + renderStates.set(asset.id, { ...merged }); + } + resolvePendingUploadByName(asset.name); } function updateRenderState(asset) { - if (!asset) return; - const state = renderStates.get(asset.id) || {}; - state.x = asset.x; - state.y = asset.y; - state.width = asset.width; - state.height = asset.height; - state.rotation = asset.rotation; - renderStates.set(asset.id, state); + if (!asset) return; + const state = renderStates.get(asset.id) || {}; + state.x = asset.x; + state.y = asset.y; + state.width = asset.width; + state.height = asset.height; + state.rotation = asset.rotation; + renderStates.set(asset.id, state); } function handleEvent(event) { - const assetId = event.assetId || event?.patch?.id || event?.payload?.id; - if (event.type === "DELETED") { - assets.delete(assetId); - layerOrder = layerOrder.filter((id) => id !== assetId); - clearMedia(assetId); - renderStates.delete(assetId); - loopPlaybackState.delete(assetId); - cancelPendingTransform(assetId); - if (selectedAssetId === assetId) { - selectedAssetId = null; + const assetId = event.assetId || event?.patch?.id || event?.payload?.id; + if (event.type === "DELETED") { + assets.delete(assetId); + layerOrder = layerOrder.filter((id) => id !== assetId); + clearMedia(assetId); + renderStates.delete(assetId); + loopPlaybackState.delete(assetId); + cancelPendingTransform(assetId); + if (selectedAssetId === assetId) { + selectedAssetId = null; + } + } else if (event.patch) { + applyPatch(assetId, event.patch); + } else if (event.payload) { + storeAsset(event.payload); + if (!event.payload.hidden && !isVideoAsset(event.payload)) { + ensureMedia(event.payload); + if (isAudioAsset(event.payload) && !loopPlaybackState.has(event.payload.id)) { + loopPlaybackState.set(event.payload.id, true); + } + } else { + clearMedia(event.payload.id); + loopPlaybackState.delete(event.payload.id); + } } - } else if (event.patch) { - applyPatch(assetId, event.patch); - } else if (event.payload) { - storeAsset(event.payload); - if (!event.payload.hidden && !isVideoAsset(event.payload)) { - ensureMedia(event.payload); - if (isAudioAsset(event.payload) && !loopPlaybackState.has(event.payload.id)) { - loopPlaybackState.set(event.payload.id, true); - } - } else { - clearMedia(event.payload.id); - loopPlaybackState.delete(event.payload.id); - } - } - drawAndList(); + drawAndList(); } function applyPatch(assetId, patch) { - if (!assetId || !patch) { - return; - } - const existing = assets.get(assetId); - if (!existing) { - return; - } - const merged = { ...existing, ...patch }; - const isAudio = isAudioAsset(merged); - if (patch.hidden) { - clearMedia(assetId); - loopPlaybackState.delete(assetId); - } - const targetLayer = Number.isFinite(patch.layer) ? patch.layer : Number.isFinite(patch.zIndex) ? patch.zIndex : null; - if (!isAudio && Number.isFinite(targetLayer)) { - const currentOrder = getLayerOrder().filter((id) => id !== assetId); - const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); - currentOrder.splice(insertIndex, 0, assetId); - layerOrder = currentOrder; - } - storeAsset(merged); - if (!isAudio) { - updateRenderState(merged); - } + if (!assetId || !patch) { + return; + } + const existing = assets.get(assetId); + if (!existing) { + return; + } + const merged = { ...existing, ...patch }; + const isAudio = isAudioAsset(merged); + if (patch.hidden) { + clearMedia(assetId); + loopPlaybackState.delete(assetId); + } + const targetLayer = Number.isFinite(patch.layer) + ? patch.layer + : Number.isFinite(patch.zIndex) + ? patch.zIndex + : null; + if (!isAudio && Number.isFinite(targetLayer)) { + const currentOrder = getLayerOrder().filter((id) => id !== assetId); + const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); + currentOrder.splice(insertIndex, 0, assetId); + layerOrder = currentOrder; + } + storeAsset(merged); + if (!isAudio) { + updateRenderState(merged); + } } function drawAndList() { - requestDraw(); - renderAssetList(); + requestDraw(); + renderAssetList(); } function requestDraw() { - if (drawPending) { - return; - } - drawPending = true; - requestAnimationFrame(() => { - drawPending = false; - draw(); - }); + if (drawPending) { + return; + } + drawPending = true; + requestAnimationFrame(() => { + drawPending = false; + draw(); + }); } function draw() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - getRenderOrder().forEach((asset) => drawAsset(asset)); + ctx.clearRect(0, 0, canvas.width, canvas.height); + getRenderOrder().forEach((asset) => drawAsset(asset)); } function drawAsset(asset) { - const renderState = smoothState(asset); - const halfWidth = renderState.width / 2; - const halfHeight = renderState.height / 2; - ctx.save(); - ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); - ctx.rotate((renderState.rotation * Math.PI) / 180); + const renderState = smoothState(asset); + const halfWidth = renderState.width / 2; + const halfHeight = renderState.height / 2; + ctx.save(); + ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); + ctx.rotate((renderState.rotation * Math.PI) / 180); - if (isAudioAsset(asset)) { - autoStartAudio(asset); + if (isAudioAsset(asset)) { + autoStartAudio(asset); + ctx.restore(); + return; + } + + let drawSource = null; + let ready = false; + let showPlayOverlay = false; + if (isVideoAsset(asset) || isGifAsset(asset)) { + drawSource = ensureCanvasPreview(asset); + ready = isDrawable(drawSource); + showPlayOverlay = true; + } else { + const media = ensureMedia(asset); + drawSource = media?.isAnimated ? media.bitmap : media; + ready = isDrawable(media); + } + if (ready && drawSource) { + ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; + ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); + } else { + ctx.globalAlpha = asset.hidden ? 0.2 : 0.4; + ctx.fillStyle = "rgba(124, 58, 237, 0.35)"; + ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height); + } + + if (asset.hidden) { + ctx.fillStyle = "rgba(15, 23, 42, 0.35)"; + ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height); + } + + ctx.globalAlpha = 1; + ctx.strokeStyle = asset.id === selectedAssetId ? "rgba(124, 58, 237, 0.9)" : "rgba(255, 255, 255, 0.4)"; + ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1; + ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []); + ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height); + if (showPlayOverlay) { + drawPlayOverlay(renderState); + } + if (asset.id === selectedAssetId) { + drawSelectionOverlay(renderState); + } ctx.restore(); - return; - } - - let drawSource = null; - let ready = false; - let showPlayOverlay = false; - if (isVideoAsset(asset) || isGifAsset(asset)) { - drawSource = ensureCanvasPreview(asset); - ready = isDrawable(drawSource); - showPlayOverlay = true; - } else { - const media = ensureMedia(asset); - drawSource = media?.isAnimated ? media.bitmap : media; - ready = isDrawable(media); - } - if (ready && drawSource) { - ctx.globalAlpha = asset.hidden ? 0.35 : 0.9; - ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); - } else { - ctx.globalAlpha = asset.hidden ? 0.2 : 0.4; - ctx.fillStyle = "rgba(124, 58, 237, 0.35)"; - ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height); - } - - if (asset.hidden) { - ctx.fillStyle = "rgba(15, 23, 42, 0.35)"; - ctx.fillRect(-halfWidth, -halfHeight, renderState.width, renderState.height); - } - - ctx.globalAlpha = 1; - ctx.strokeStyle = asset.id === selectedAssetId ? "rgba(124, 58, 237, 0.9)" : "rgba(255, 255, 255, 0.4)"; - ctx.lineWidth = asset.id === selectedAssetId ? 2 : 1; - ctx.setLineDash(asset.id === selectedAssetId ? [6, 4] : []); - ctx.strokeRect(-halfWidth, -halfHeight, renderState.width, renderState.height); - if (showPlayOverlay) { - drawPlayOverlay(renderState); - } - if (asset.id === selectedAssetId) { - drawSelectionOverlay(renderState); - } - ctx.restore(); } function smoothState(asset) { - const previous = renderStates.get(asset.id) || { ...asset }; - const factor = interactionState && interactionState.assetId === asset.id ? 0.45 : 0.18; - previous.x = lerp(previous.x, asset.x, factor); - previous.y = lerp(previous.y, asset.y, factor); - previous.width = lerp(previous.width, asset.width, factor); - previous.height = lerp(previous.height, asset.height, factor); - previous.rotation = smoothAngle(previous.rotation, asset.rotation, factor); - renderStates.set(asset.id, previous); - return previous; + const previous = renderStates.get(asset.id) || { ...asset }; + const factor = interactionState && interactionState.assetId === asset.id ? 0.45 : 0.18; + previous.x = lerp(previous.x, asset.x, factor); + previous.y = lerp(previous.y, asset.y, factor); + previous.width = lerp(previous.width, asset.width, factor); + previous.height = lerp(previous.height, asset.height, factor); + previous.rotation = smoothAngle(previous.rotation, asset.rotation, factor); + renderStates.set(asset.id, previous); + return previous; } function smoothAngle(current, target, factor) { - let delta = ((target - current + 180) % 360) - 180; - return current + delta * factor; + let delta = ((target - current + 180) % 360) - 180; + return current + delta * factor; } function lerp(a, b, t) { - return a + (b - a) * t; + return a + (b - a) * t; } function drawPlayOverlay(asset) { - const size = Math.max(24, Math.min(asset.width, asset.height) * 0.2); - ctx.save(); - ctx.fillStyle = "rgba(15, 23, 42, 0.35)"; - ctx.beginPath(); - ctx.arc(0, 0, size * 0.75, 0, Math.PI * 2); - ctx.fill(); + const size = Math.max(24, Math.min(asset.width, asset.height) * 0.2); + ctx.save(); + ctx.fillStyle = "rgba(15, 23, 42, 0.35)"; + ctx.beginPath(); + ctx.arc(0, 0, size * 0.75, 0, Math.PI * 2); + ctx.fill(); - ctx.fillStyle = "#ffffff"; - ctx.beginPath(); - ctx.moveTo(-size * 0.3, -size * 0.45); - ctx.lineTo(size * 0.55, 0); - ctx.lineTo(-size * 0.3, size * 0.45); - ctx.closePath(); - ctx.fill(); - ctx.restore(); + ctx.fillStyle = "#ffffff"; + ctx.beginPath(); + ctx.moveTo(-size * 0.3, -size * 0.45); + ctx.lineTo(size * 0.55, 0); + ctx.lineTo(-size * 0.3, size * 0.45); + ctx.closePath(); + ctx.fill(); + ctx.restore(); } function drawSelectionOverlay(asset) { - const halfWidth = asset.width / 2; - const halfHeight = asset.height / 2; - ctx.save(); - ctx.setLineDash([6, 4]); - ctx.strokeStyle = "rgba(124, 58, 237, 0.9)"; - ctx.lineWidth = 1.5; - ctx.strokeRect(-halfWidth, -halfHeight, asset.width, asset.height); + const halfWidth = asset.width / 2; + const halfHeight = asset.height / 2; + ctx.save(); + ctx.setLineDash([6, 4]); + ctx.strokeStyle = "rgba(124, 58, 237, 0.9)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(-halfWidth, -halfHeight, asset.width, asset.height); - const handles = getHandlePositions(asset); - handles.forEach((handle) => { - drawHandle(handle.x - halfWidth, handle.y - halfHeight, false); - }); + const handles = getHandlePositions(asset); + handles.forEach((handle) => { + drawHandle(handle.x - halfWidth, handle.y - halfHeight, false); + }); - drawHandle(0, -halfHeight - ROTATE_HANDLE_OFFSET, true); - ctx.restore(); + drawHandle(0, -halfHeight - ROTATE_HANDLE_OFFSET, true); + ctx.restore(); } function drawHandle(x, y, isRotation) { - ctx.save(); - ctx.setLineDash([]); - ctx.fillStyle = isRotation ? "rgba(96, 165, 250, 0.9)" : "rgba(124, 58, 237, 0.9)"; - ctx.strokeStyle = "#0f172a"; - ctx.lineWidth = 1; - if (isRotation) { - ctx.beginPath(); - ctx.arc(x, y, HANDLE_SIZE * 0.65, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - } else { - ctx.fillRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); - ctx.strokeRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); - } - ctx.restore(); + ctx.save(); + ctx.setLineDash([]); + ctx.fillStyle = isRotation ? "rgba(96, 165, 250, 0.9)" : "rgba(124, 58, 237, 0.9)"; + ctx.strokeStyle = "#0f172a"; + ctx.lineWidth = 1; + if (isRotation) { + ctx.beginPath(); + ctx.arc(x, y, HANDLE_SIZE * 0.65, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } else { + ctx.fillRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); + ctx.strokeRect(x - HANDLE_SIZE / 2, y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); + } + ctx.restore(); } function getHandlePositions(asset) { - return [ - { x: 0, y: 0, type: "nw" }, - { x: asset.width / 2, y: 0, type: "n" }, - { x: asset.width, y: 0, type: "ne" }, - { x: asset.width, y: asset.height / 2, type: "e" }, - { x: asset.width, y: asset.height, type: "se" }, - { x: asset.width / 2, y: asset.height, type: "s" }, - { x: 0, y: asset.height, type: "sw" }, - { x: 0, y: asset.height / 2, type: "w" }, - ]; + return [ + { x: 0, y: 0, type: "nw" }, + { x: asset.width / 2, y: 0, type: "n" }, + { x: asset.width, y: 0, type: "ne" }, + { x: asset.width, y: asset.height / 2, type: "e" }, + { x: asset.width, y: asset.height, type: "se" }, + { x: asset.width / 2, y: asset.height, type: "s" }, + { x: 0, y: asset.height, type: "sw" }, + { x: 0, y: asset.height / 2, type: "w" }, + ]; } function rotatePoint(x, y, degrees) { - const radians = (degrees * Math.PI) / 180; - const cos = Math.cos(radians); - const sin = Math.sin(radians); - return { - x: x * cos - y * sin, - y: x * sin + y * cos, - }; + const radians = (degrees * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + return { + x: x * cos - y * sin, + y: x * sin + y * cos, + }; } function pointerToLocal(asset, point) { - const centerX = asset.x + asset.width / 2; - const centerY = asset.y + asset.height / 2; - const dx = point.x - centerX; - const dy = point.y - centerY; - const rotated = rotatePoint(dx, dy, -asset.rotation); - return { - x: rotated.x + asset.width / 2, - y: rotated.y + asset.height / 2, - }; + const centerX = asset.x + asset.width / 2; + const centerY = asset.y + asset.height / 2; + const dx = point.x - centerX; + const dy = point.y - centerY; + const rotated = rotatePoint(dx, dy, -asset.rotation); + return { + x: rotated.x + asset.width / 2, + y: rotated.y + asset.height / 2, + }; } function angleFromCenter(asset, point) { - const centerX = asset.x + asset.width / 2; - const centerY = asset.y + asset.height / 2; - return (Math.atan2(point.y - centerY, point.x - centerX) * 180) / Math.PI; + const centerX = asset.x + asset.width / 2; + const centerY = asset.y + asset.height / 2; + return (Math.atan2(point.y - centerY, point.x - centerX) * 180) / Math.PI; } function hitHandle(asset, point) { - const local = pointerToLocal(asset, point); - const tolerance = HANDLE_SIZE * 1.2; - const rotationDistance = Math.hypot(local.x - asset.width / 2, local.y + ROTATE_HANDLE_OFFSET); - if (Math.abs(local.y + ROTATE_HANDLE_OFFSET) <= tolerance && rotationDistance <= tolerance * 1.5) { - return "rotate"; - } - for (const handle of getHandlePositions(asset)) { - if (Math.abs(local.x - handle.x) <= tolerance && Math.abs(local.y - handle.y) <= tolerance) { - return handle.type; + const local = pointerToLocal(asset, point); + const tolerance = HANDLE_SIZE * 1.2; + const rotationDistance = Math.hypot(local.x - asset.width / 2, local.y + ROTATE_HANDLE_OFFSET); + if (Math.abs(local.y + ROTATE_HANDLE_OFFSET) <= tolerance && rotationDistance <= tolerance * 1.5) { + return "rotate"; } - } - return null; + for (const handle of getHandlePositions(asset)) { + if (Math.abs(local.x - handle.x) <= tolerance && Math.abs(local.y - handle.y) <= tolerance) { + return handle.type; + } + } + return null; } function cursorForHandle(handle) { - switch (handle) { - case "nw": - case "se": - return "nwse-resize"; - case "ne": - case "sw": - return "nesw-resize"; - case "n": - case "s": - return "ns-resize"; - case "e": - case "w": - return "ew-resize"; - case "rotate": - return "grab"; - default: - return "default"; - } + switch (handle) { + case "nw": + case "se": + return "nwse-resize"; + case "ne": + case "sw": + return "nesw-resize"; + case "n": + case "s": + return "ns-resize"; + case "e": + case "w": + return "ew-resize"; + case "rotate": + return "grab"; + default: + return "default"; + } } function resizeFromHandle(state, point) { - const asset = assets.get(state.assetId); - if (!asset) return; - const basis = state.original; - const local = pointerToLocal(basis, point); - const handle = state.handle; - const minSize = 10; + const asset = assets.get(state.assetId); + if (!asset) return; + const basis = state.original; + const local = pointerToLocal(basis, point); + const handle = state.handle; + const minSize = 10; - let nextWidth = basis.width; - let nextHeight = basis.height; - let offsetX = 0; - let offsetY = 0; + let nextWidth = basis.width; + let nextHeight = basis.height; + let offsetX = 0; + let offsetY = 0; - if (handle.includes("e")) { - nextWidth = basis.width + (local.x - state.startLocal.x); - } - if (handle.includes("s")) { - nextHeight = basis.height + (local.y - state.startLocal.y); - } - if (handle.includes("w")) { - nextWidth = basis.width - (local.x - state.startLocal.x); - } - if (handle.includes("n")) { - nextHeight = basis.height - (local.y - state.startLocal.y); - } - - const ratio = isAspectLocked(asset.id) ? getAssetAspectRatio(asset) || basis.width / Math.max(basis.height, 1) : null; - if (ratio) { - const widthChanged = handle.includes("e") || handle.includes("w"); - const heightChanged = handle.includes("n") || handle.includes("s"); - if (widthChanged && !heightChanged) { - nextHeight = nextWidth / ratio; - } else if (!widthChanged && heightChanged) { - nextWidth = nextHeight * ratio; - } else { - if (Math.abs(nextWidth - basis.width) > Math.abs(nextHeight - basis.height)) { - nextHeight = nextWidth / ratio; - } else { - nextWidth = nextHeight * ratio; - } + if (handle.includes("e")) { + nextWidth = basis.width + (local.x - state.startLocal.x); + } + if (handle.includes("s")) { + nextHeight = basis.height + (local.y - state.startLocal.y); + } + if (handle.includes("w")) { + nextWidth = basis.width - (local.x - state.startLocal.x); + } + if (handle.includes("n")) { + nextHeight = basis.height - (local.y - state.startLocal.y); } - } - nextWidth = Math.max(minSize, nextWidth); - nextHeight = Math.max(minSize, nextHeight); + const ratio = isAspectLocked(asset.id) + ? getAssetAspectRatio(asset) || basis.width / Math.max(basis.height, 1) + : null; + if (ratio) { + const widthChanged = handle.includes("e") || handle.includes("w"); + const heightChanged = handle.includes("n") || handle.includes("s"); + if (widthChanged && !heightChanged) { + nextHeight = nextWidth / ratio; + } else if (!widthChanged && heightChanged) { + nextWidth = nextHeight * ratio; + } else { + if (Math.abs(nextWidth - basis.width) > Math.abs(nextHeight - basis.height)) { + nextHeight = nextWidth / ratio; + } else { + nextWidth = nextHeight * ratio; + } + } + } - if (handle.includes("w")) { - offsetX = basis.width - nextWidth; - } - if (handle.includes("n")) { - offsetY = basis.height - nextHeight; - } + nextWidth = Math.max(minSize, nextWidth); + nextHeight = Math.max(minSize, nextHeight); - const shift = rotatePoint(offsetX, offsetY, basis.rotation); - asset.x = basis.x + shift.x; - asset.y = basis.y + shift.y; - asset.width = nextWidth; - asset.height = nextHeight; - updateRenderState(asset); - requestDraw(); + if (handle.includes("w")) { + offsetX = basis.width - nextWidth; + } + if (handle.includes("n")) { + offsetY = basis.height - nextHeight; + } + + const shift = rotatePoint(offsetX, offsetY, basis.rotation); + asset.x = basis.x + shift.x; + asset.y = basis.y + shift.y; + asset.width = nextWidth; + asset.height = nextHeight; + updateRenderState(asset); + requestDraw(); } function updateHoverCursor(point) { - const asset = getSelectedAsset(); - if (asset) { - const handle = hitHandle(asset, point); - if (handle) { - canvas.style.cursor = cursorForHandle(handle); - return; + const asset = getSelectedAsset(); + if (asset) { + const handle = hitHandle(asset, point); + if (handle) { + canvas.style.cursor = cursorForHandle(handle); + return; + } } - } - const hit = findAssetAtPoint(point.x, point.y); - canvas.style.cursor = hit ? "move" : "default"; + const hit = findAssetAtPoint(point.x, point.y); + canvas.style.cursor = hit ? "move" : "default"; } function isVideoAsset(asset) { - const type = asset?.mediaType || asset?.originalMediaType || ""; - return type.startsWith("video/"); + const type = asset?.mediaType || asset?.originalMediaType || ""; + return type.startsWith("video/"); } function isAudioAsset(asset) { - const type = asset?.mediaType || asset?.originalMediaType || ""; - return type.startsWith("audio/"); + const type = asset?.mediaType || asset?.originalMediaType || ""; + return type.startsWith("audio/"); } function isVideoElement(element) { - return element && element.tagName === "VIDEO"; + return element && element.tagName === "VIDEO"; } function getDisplayMediaType(asset) { - const raw = asset.originalMediaType || asset.mediaType || ""; - if (!raw) { - return "Unknown"; - } - const parts = raw.split("/"); - return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase(); + const raw = asset.originalMediaType || asset.mediaType || ""; + if (!raw) { + return "Unknown"; + } + const parts = raw.split("/"); + return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase(); } function isGifAsset(asset) { - return asset?.mediaType?.toLowerCase() === "image/gif"; + return asset?.mediaType?.toLowerCase() === "image/gif"; } function isDrawable(element) { - if (!element) { - return false; - } - if (element.isAnimated) { - return !!element.bitmap; - } - if (isVideoElement(element)) { - return element.readyState >= 2; - } - if (typeof ImageBitmap !== "undefined" && element instanceof ImageBitmap) { - return true; - } - return !!element.complete; + if (!element) { + return false; + } + if (element.isAnimated) { + return !!element.bitmap; + } + if (isVideoElement(element)) { + return element.readyState >= 2; + } + if (typeof ImageBitmap !== "undefined" && element instanceof ImageBitmap) { + return true; + } + return !!element.complete; } function clearMedia(assetId) { - mediaCache.delete(assetId); - const cachedPreview = previewCache.get(assetId); - if (cachedPreview && cachedPreview.startsWith("blob:")) { - URL.revokeObjectURL(cachedPreview); - } - previewCache.delete(assetId); - previewImageCache.delete(assetId); - const animated = animatedCache.get(assetId); - if (animated) { - animated.cancelled = true; - clearTimeout(animated.timeout); - animated.bitmap?.close?.(); - animated.decoder?.close?.(); - animatedCache.delete(assetId); - } - const audio = audioControllers.get(assetId); - if (audio) { - if (audio.delayTimeout) { - clearTimeout(audio.delayTimeout); + mediaCache.delete(assetId); + const cachedPreview = previewCache.get(assetId); + if (cachedPreview && cachedPreview.startsWith("blob:")) { + URL.revokeObjectURL(cachedPreview); + } + previewCache.delete(assetId); + previewImageCache.delete(assetId); + const animated = animatedCache.get(assetId); + if (animated) { + animated.cancelled = true; + clearTimeout(animated.timeout); + animated.bitmap?.close?.(); + animated.decoder?.close?.(); + animatedCache.delete(assetId); + } + const audio = audioControllers.get(assetId); + if (audio) { + if (audio.delayTimeout) { + clearTimeout(audio.delayTimeout); + } + audio.element.pause(); + audio.element.currentTime = 0; + audioControllers.delete(assetId); } - audio.element.pause(); - audio.element.currentTime = 0; - audioControllers.delete(assetId); - } } function ensureAudioController(asset) { - const cached = audioControllers.get(asset.id); - if (cached && cached.src === asset.url) { - applyAudioSettings(cached, asset); - return cached; - } + const cached = audioControllers.get(asset.id); + if (cached && cached.src === asset.url) { + applyAudioSettings(cached, asset); + return cached; + } - if (cached) { - clearMedia(asset.id); - } + if (cached) { + clearMedia(asset.id); + } - const element = new Audio(asset.url); - element.autoplay = true; - element.controls = true; - element.preload = "auto"; - element.addEventListener("loadedmetadata", () => recordDuration(asset.id, element.duration)); - const controller = { - id: asset.id, - src: asset.url, - element, - delayTimeout: null, - loopEnabled: false, - delayMs: 0, - baseDelayMs: 0, - }; - element.onended = () => handleAudioEnded(asset.id); - audioControllers.set(asset.id, controller); - applyAudioSettings(controller, asset, true); - return controller; + const element = new Audio(asset.url); + element.autoplay = true; + element.controls = true; + element.preload = "auto"; + element.addEventListener("loadedmetadata", () => recordDuration(asset.id, element.duration)); + const controller = { + id: asset.id, + src: asset.url, + element, + delayTimeout: null, + loopEnabled: false, + delayMs: 0, + baseDelayMs: 0, + }; + element.onended = () => handleAudioEnded(asset.id); + audioControllers.set(asset.id, controller); + applyAudioSettings(controller, asset, true); + return controller; } function applyAudioSettings(controller, asset, resetPosition = false) { - controller.loopEnabled = !!asset.audioLoop; - controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0); - controller.delayMs = controller.baseDelayMs; - const speed = Math.max(0.25, asset.audioSpeed || 1); - const pitch = Math.max(0.5, asset.audioPitch || 1); - controller.element.playbackRate = speed * pitch; - const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); - controller.element.volume = volume; - if (resetPosition) { - controller.element.currentTime = 0; - controller.element.pause(); - } + controller.loopEnabled = !!asset.audioLoop; + controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0); + controller.delayMs = controller.baseDelayMs; + const speed = Math.max(0.25, asset.audioSpeed || 1); + const pitch = Math.max(0.5, asset.audioPitch || 1); + controller.element.playbackRate = speed * pitch; + const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); + controller.element.volume = volume; + if (resetPosition) { + controller.element.currentTime = 0; + controller.element.pause(); + } } function handleAudioEnded(assetId) { - const controller = audioControllers.get(assetId); - if (!controller) return; - controller.element.currentTime = 0; - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - } - if (controller.loopEnabled) { - controller.delayTimeout = setTimeout(() => { - safePlay(controller); - }, controller.delayMs); - } else { - controller.element.pause(); - } + const controller = audioControllers.get(assetId); + if (!controller) return; + controller.element.currentTime = 0; + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + } + if (controller.loopEnabled) { + controller.delayTimeout = setTimeout(() => { + safePlay(controller); + }, controller.delayMs); + } else { + controller.element.pause(); + } } function stopAudio(assetId) { - const controller = audioControllers.get(assetId); - if (!controller) return; - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - } - controller.element.pause(); - controller.element.currentTime = 0; - controller.delayTimeout = null; - controller.delayMs = controller.baseDelayMs; + const controller = audioControllers.get(assetId); + if (!controller) return; + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + } + controller.element.pause(); + controller.element.currentTime = 0; + controller.delayTimeout = null; + controller.delayMs = controller.baseDelayMs; } function autoStartAudio(asset) { - if (!isAudioAsset(asset) || asset.hidden) { - return; - } - ensureAudioController(asset); + if (!isAudioAsset(asset) || asset.hidden) { + return; + } + ensureAudioController(asset); } function ensureMedia(asset) { - const cached = mediaCache.get(asset.id); - if (cached && cached.src !== asset.url) { - clearMedia(asset.id); - } - if (cached && cached.src === asset.url) { - applyMediaSettings(cached, asset); - return cached; - } - - if (isAudioAsset(asset)) { - ensureAudioController(asset); - mediaCache.delete(asset.id); - return null; - } - - if (isVideoAsset(asset)) { - return null; - } - - if (isGifAsset(asset) && "ImageDecoder" in window) { - const animated = ensureAnimatedImage(asset); - if (animated) { - mediaCache.set(asset.id, animated); - return animated; + const cached = mediaCache.get(asset.id); + if (cached && cached.src !== asset.url) { + clearMedia(asset.id); + } + if (cached && cached.src === asset.url) { + applyMediaSettings(cached, asset); + return cached; } - } - const element = isVideoAsset(asset) ? document.createElement("video") : new Image(); - element.crossOrigin = "anonymous"; - if (isVideoElement(element)) { - element.loop = true; - const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); - element.muted = volume === 0; - element.volume = Math.min(volume, 1); - element.playsInline = true; - element.autoplay = false; - element.preload = "metadata"; - element.onloadeddata = requestDraw; - element.onloadedmetadata = () => recordDuration(asset.id, element.duration); - element.src = asset.url; - const playback = asset.speed ?? 1; - element.playbackRate = Math.max(playback, 0.01); - element.pause(); - } else { - element.onload = requestDraw; - element.src = asset.url; - } - mediaCache.set(asset.id, element); - return element; + if (isAudioAsset(asset)) { + ensureAudioController(asset); + mediaCache.delete(asset.id); + return null; + } + + if (isVideoAsset(asset)) { + return null; + } + + if (isGifAsset(asset) && "ImageDecoder" in window) { + const animated = ensureAnimatedImage(asset); + if (animated) { + mediaCache.set(asset.id, animated); + return animated; + } + } + + const element = isVideoAsset(asset) ? document.createElement("video") : new Image(); + element.crossOrigin = "anonymous"; + if (isVideoElement(element)) { + element.loop = true; + const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); + element.muted = volume === 0; + element.volume = Math.min(volume, 1); + element.playsInline = true; + element.autoplay = false; + element.preload = "metadata"; + element.onloadeddata = requestDraw; + element.onloadedmetadata = () => recordDuration(asset.id, element.duration); + element.src = asset.url; + const playback = asset.speed ?? 1; + element.playbackRate = Math.max(playback, 0.01); + element.pause(); + } else { + element.onload = requestDraw; + element.src = asset.url; + } + mediaCache.set(asset.id, element); + return element; } function ensureAnimatedImage(asset) { - const cached = animatedCache.get(asset.id); - if (cached && cached.url === asset.url) { - return cached; - } + const cached = animatedCache.get(asset.id); + if (cached && cached.url === asset.url) { + return cached; + } - if (cached) { - clearMedia(asset.id); - } + if (cached) { + clearMedia(asset.id); + } - const controller = { - id: asset.id, - url: asset.url, - src: asset.url, - decoder: null, - bitmap: null, - timeout: null, - cancelled: false, - isAnimated: true, - }; + const controller = { + id: asset.id, + url: asset.url, + src: asset.url, + decoder: null, + bitmap: null, + timeout: null, + cancelled: false, + isAnimated: true, + }; - fetch(asset.url) - .then((r) => r.blob()) - .then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" })) - .then((decoder) => { - if (controller.cancelled) { - decoder.close?.(); - return null; - } - controller.decoder = decoder; - scheduleNextFrame(controller); - return controller; - }) - .catch(() => { - animatedCache.delete(asset.id); - }); + fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" })) + .then((decoder) => { + if (controller.cancelled) { + decoder.close?.(); + return null; + } + controller.decoder = decoder; + scheduleNextFrame(controller); + return controller; + }) + .catch(() => { + animatedCache.delete(asset.id); + }); - animatedCache.set(asset.id, controller); - return controller; + animatedCache.set(asset.id, controller); + return controller; } function scheduleNextFrame(controller) { - if (controller.cancelled || !controller.decoder) { - return; - } - controller.decoder - .decode() - .then(({ image, complete }) => { - if (controller.cancelled) { - image.close?.(); + if (controller.cancelled || !controller.decoder) { return; - } - controller.bitmap?.close?.(); - createImageBitmap(image) - .then((bitmap) => { - controller.bitmap = bitmap; - requestDraw(); - }) - .finally(() => image.close?.()); + } + controller.decoder + .decode() + .then(({ image, complete }) => { + if (controller.cancelled) { + image.close?.(); + return; + } + controller.bitmap?.close?.(); + createImageBitmap(image) + .then((bitmap) => { + controller.bitmap = bitmap; + requestDraw(); + }) + .finally(() => image.close?.()); - const durationMicros = image.duration || 0; - const delay = durationMicros > 0 ? durationMicros / 1000 : 100; - const hasMore = !complete; - controller.timeout = setTimeout(() => { - if (controller.cancelled) { - return; - } - if (hasMore) { - scheduleNextFrame(controller); - } else { - controller.decoder.reset(); - scheduleNextFrame(controller); - } - }, delay); - }) - .catch(() => { - animatedCache.delete(controller.id); - }); + const durationMicros = image.duration || 0; + const delay = durationMicros > 0 ? durationMicros / 1000 : 100; + const hasMore = !complete; + controller.timeout = setTimeout(() => { + if (controller.cancelled) { + return; + } + if (hasMore) { + scheduleNextFrame(controller); + } else { + controller.decoder.reset(); + scheduleNextFrame(controller); + } + }, delay); + }) + .catch(() => { + animatedCache.delete(controller.id); + }); } function applyMediaSettings(element, asset) { - if (!isVideoElement(element)) { - return; - } - const nextSpeed = asset.speed ?? 1; - const effectiveSpeed = Math.max(nextSpeed, 0.01); - if (element.playbackRate !== effectiveSpeed) { - element.playbackRate = effectiveSpeed; - } - const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); - element.muted = volume === 0; - element.volume = Math.min(volume, 1); - if (nextSpeed === 0) { - element.pause(); - return; - } - const playPromise = element.play(); - if (playPromise?.catch) { - playPromise.catch(() => {}); - } + if (!isVideoElement(element)) { + return; + } + const nextSpeed = asset.speed ?? 1; + const effectiveSpeed = Math.max(nextSpeed, 0.01); + if (element.playbackRate !== effectiveSpeed) { + element.playbackRate = effectiveSpeed; + } + const volume = clamp(asset.audioVolume ?? 1, SETTINGS.minAssetVolumeFraction, SETTINGS.maxAssetVolumeFraction); + element.muted = volume === 0; + element.volume = Math.min(volume, 1); + if (nextSpeed === 0) { + element.pause(); + return; + } + const playPromise = element.play(); + if (playPromise?.catch) { + playPromise.catch(() => {}); + } } function renderAssetList() { - const list = document.getElementById("asset-list"); - if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { - controlsPlaceholder.appendChild(controlsPanel); - } - if (controlsPanel) { - controlsPanel.classList.add("hidden"); - } - list.innerHTML = ""; + const list = document.getElementById("asset-list"); + if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { + controlsPlaceholder.appendChild(controlsPanel); + } + if (controlsPanel) { + controlsPanel.classList.add("hidden"); + } + list.innerHTML = ""; - const hasAssets = assets.size > 0; - const hasPending = pendingUploads.length > 0; + const hasAssets = assets.size > 0; + const hasPending = pendingUploads.length > 0; + + if (!hasAssets && !hasPending) { + selectedAssetId = null; + if (assetInspector) { + assetInspector.classList.add("hidden"); + } + const empty = document.createElement("li"); + empty.textContent = ""; + list.appendChild(empty); + updateSelectedAssetControls(); + return; + } - if (!hasAssets && !hasPending) { - selectedAssetId = null; if (assetInspector) { - assetInspector.classList.add("hidden"); + assetInspector.classList.toggle("hidden", !hasAssets); } - const empty = document.createElement("li"); - empty.textContent = ""; - list.appendChild(empty); + + const pendingItems = [...pendingUploads].sort((a, b) => (a.createdAtMs || 0) - (b.createdAtMs || 0)); + pendingItems.forEach((pending) => { + list.appendChild(createPendingListItem(pending)); + }); + + const audioAssets = getAudioAssets(); + const sortedAssets = [...audioAssets, ...getAssetsByLayer()]; + sortedAssets.forEach((asset) => { + const li = document.createElement("li"); + li.className = "asset-item"; + if (asset.id === selectedAssetId) { + li.classList.add("selected"); + } + li.classList.toggle("is-hidden", !!asset.hidden); + + const row = document.createElement("div"); + row.className = "asset-row"; + + const preview = createPreviewElement(asset); + + const meta = document.createElement("div"); + meta.className = "meta"; + const name = document.createElement("strong"); + name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; + const details = document.createElement("small"); + details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)}`; + meta.appendChild(name); + meta.appendChild(details); + + const actions = document.createElement("div"); + actions.className = "actions"; + + if (isAudioAsset(asset)) { + const playBtn = document.createElement("button"); + playBtn.type = "button"; + playBtn.className = "ghost icon-button"; + const isLooping = !!asset.audioLoop; + const isPlayingLoop = getLoopPlaybackState(asset); + updatePlayButtonIcon(playBtn, isLooping, isPlayingLoop); + playBtn.title = isLooping ? (isPlayingLoop ? "Pause looping audio" : "Play looping audio") : "Play audio"; + playBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const nextPlay = isLooping ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) : true; + if (isLooping) { + loopPlaybackState.set(asset.id, nextPlay); + updatePlayButtonIcon(playBtn, true, nextPlay); + playBtn.title = nextPlay ? "Pause looping audio" : "Play looping audio"; + } + triggerAudioPlayback(asset, nextPlay); + }); + actions.appendChild(playBtn); + } + + if (!isAudioAsset(asset)) { + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = "ghost icon-button"; + toggleBtn.innerHTML = ``; + toggleBtn.title = asset.hidden ? "Show asset" : "Hide asset"; + toggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + selectedAssetId = asset.id; + updateVisibility(asset, !asset.hidden); + }); + actions.appendChild(toggleBtn); + } + + row.appendChild(preview); + row.appendChild(meta); + row.appendChild(actions); + + li.addEventListener("click", () => { + selectedAssetId = asset.id; + updateRenderState(asset); + drawAndList(); + }); + + li.appendChild(row); + list.appendChild(li); + }); + updateSelectedAssetControls(); - return; - } +} - if (assetInspector) { - assetInspector.classList.toggle("hidden", !hasAssets); - } - - const pendingItems = [...pendingUploads].sort((a, b) => (a.createdAtMs || 0) - (b.createdAtMs || 0)); - pendingItems.forEach((pending) => { - list.appendChild(createPendingListItem(pending)); - }); - - const audioAssets = getAudioAssets(); - const sortedAssets = [...audioAssets, ...getAssetsByLayer()]; - sortedAssets.forEach((asset) => { +function createPendingListItem(pending) { const li = document.createElement("li"); - li.className = "asset-item"; - if (asset.id === selectedAssetId) { - li.classList.add("selected"); - } - li.classList.toggle("is-hidden", !!asset.hidden); + li.className = "asset-item pending"; const row = document.createElement("div"); row.className = "asset-row"; - const preview = createPreviewElement(asset); + const preview = document.createElement("div"); + preview.className = "asset-preview pending-preview"; + preview.innerHTML = ''; const meta = document.createElement("div"); meta.className = "meta"; const name = document.createElement("strong"); - name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; + name.textContent = pending?.name || "Uploading asset"; const details = document.createElement("small"); - details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)}`; + details.textContent = pending.status === "processing" ? "Processing upload…" : "Uploading…"; meta.appendChild(name); meta.appendChild(details); - const actions = document.createElement("div"); - actions.className = "actions"; - - if (isAudioAsset(asset)) { - const playBtn = document.createElement("button"); - playBtn.type = "button"; - playBtn.className = "ghost icon-button"; - const isLooping = !!asset.audioLoop; - const isPlayingLoop = getLoopPlaybackState(asset); - updatePlayButtonIcon(playBtn, isLooping, isPlayingLoop); - playBtn.title = isLooping ? (isPlayingLoop ? "Pause looping audio" : "Play looping audio") : "Play audio"; - playBtn.addEventListener("click", (e) => { - e.stopPropagation(); - const nextPlay = isLooping ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) : true; - if (isLooping) { - loopPlaybackState.set(asset.id, nextPlay); - updatePlayButtonIcon(playBtn, true, nextPlay); - playBtn.title = nextPlay ? "Pause looping audio" : "Play looping audio"; - } - triggerAudioPlayback(asset, nextPlay); - }); - actions.appendChild(playBtn); - } - - if (!isAudioAsset(asset)) { - const toggleBtn = document.createElement("button"); - toggleBtn.type = "button"; - toggleBtn.className = "ghost icon-button"; - toggleBtn.innerHTML = ``; - toggleBtn.title = asset.hidden ? "Show asset" : "Hide asset"; - toggleBtn.addEventListener("click", (e) => { - e.stopPropagation(); - selectedAssetId = asset.id; - updateVisibility(asset, !asset.hidden); - }); - actions.appendChild(toggleBtn); + const progress = document.createElement("div"); + progress.className = "upload-progress"; + const bar = document.createElement("div"); + bar.className = "upload-progress-bar"; + if (pending.status === "processing") { + bar.classList.add("is-processing"); } + progress.appendChild(bar); + meta.appendChild(progress); row.appendChild(preview); row.appendChild(meta); - row.appendChild(actions); - - li.addEventListener("click", () => { - selectedAssetId = asset.id; - updateRenderState(asset); - drawAndList(); - }); - li.appendChild(row); - list.appendChild(li); - }); - updateSelectedAssetControls(); -} - -function createPendingListItem(pending) { - const li = document.createElement("li"); - li.className = "asset-item pending"; - - const row = document.createElement("div"); - row.className = "asset-row"; - - const preview = document.createElement("div"); - preview.className = "asset-preview pending-preview"; - preview.innerHTML = ''; - - const meta = document.createElement("div"); - meta.className = "meta"; - const name = document.createElement("strong"); - name.textContent = pending?.name || "Uploading asset"; - const details = document.createElement("small"); - details.textContent = pending.status === "processing" ? "Processing upload…" : "Uploading…"; - meta.appendChild(name); - meta.appendChild(details); - - const progress = document.createElement("div"); - progress.className = "upload-progress"; - const bar = document.createElement("div"); - bar.className = "upload-progress-bar"; - if (pending.status === "processing") { - bar.classList.add("is-processing"); - } - progress.appendChild(bar); - meta.appendChild(progress); - - row.appendChild(preview); - row.appendChild(meta); - li.appendChild(row); - - return li; + return li; } function createBadge(label, extraClass = "") { - const badge = document.createElement("span"); - badge.className = `badge ${extraClass}`.trim(); - badge.textContent = label; - return badge; + const badge = document.createElement("span"); + badge.className = `badge ${extraClass}`.trim(); + badge.textContent = label; + return badge; } function getLoopPlaybackState(asset) { - if (!isAudioAsset(asset) || !asset.audioLoop) { - return false; - } - if (loopPlaybackState.has(asset.id)) { - return loopPlaybackState.get(asset.id); - } - const isVisible = asset.hidden === false || asset.hidden === undefined; - loopPlaybackState.set(asset.id, isVisible); - return isVisible; + if (!isAudioAsset(asset) || !asset.audioLoop) { + return false; + } + if (loopPlaybackState.has(asset.id)) { + return loopPlaybackState.get(asset.id); + } + const isVisible = asset.hidden === false || asset.hidden === undefined; + loopPlaybackState.set(asset.id, isVisible); + return isVisible; } function updatePlayButtonIcon(button, isLooping, isPlayingLoop) { - const icon = isLooping ? (isPlayingLoop ? "fa-pause" : "fa-play") : "fa-play"; - button.innerHTML = ``; + const icon = isLooping ? (isPlayingLoop ? "fa-pause" : "fa-play") : "fa-play"; + button.innerHTML = ``; } function createPreviewElement(asset) { - if (isAudioAsset(asset)) { - const icon = document.createElement("div"); - icon.className = "asset-preview audio-icon"; - icon.innerHTML = ''; - return icon; - } - if (isVideoAsset(asset) || isGifAsset(asset)) { - const still = document.createElement("div"); - still.className = "asset-preview still"; - still.setAttribute("aria-label", asset.name || "Asset preview"); + if (isAudioAsset(asset)) { + const icon = document.createElement("div"); + icon.className = "asset-preview audio-icon"; + icon.innerHTML = ''; + return icon; + } + if (isVideoAsset(asset) || isGifAsset(asset)) { + const still = document.createElement("div"); + still.className = "asset-preview still"; + still.setAttribute("aria-label", asset.name || "Asset preview"); - const overlay = document.createElement("div"); - overlay.className = "preview-overlay"; - overlay.innerHTML = ''; - still.appendChild(overlay); + const overlay = document.createElement("div"); + overlay.className = "preview-overlay"; + overlay.innerHTML = ''; + still.appendChild(overlay); - loadPreviewFrame(asset, still); - return still; - } + loadPreviewFrame(asset, still); + return still; + } - const img = document.createElement("img"); - img.className = "asset-preview"; - img.src = asset.url; - img.alt = asset.name || "Asset preview"; - img.loading = "lazy"; - return img; + const img = document.createElement("img"); + img.className = "asset-preview"; + img.src = asset.url; + img.alt = asset.name || "Asset preview"; + img.loading = "lazy"; + return img; } function fetchPreviewData(asset) { - if (!asset) return Promise.resolve(null); - const cached = previewCache.get(asset.id); - if (cached) { - return Promise.resolve(cached); - } + if (!asset) return Promise.resolve(null); + const cached = previewCache.get(asset.id); + if (cached) { + return Promise.resolve(cached); + } - const fallback = () => { - const fallbackPromise = isVideoAsset(asset) - ? captureVideoFrame(asset) - : isGifAsset(asset) - ? captureGifFrame(asset) - : Promise.resolve(null); - return fallbackPromise.then((result) => { - if (!result) { - return null; - } - previewCache.set(asset.id, result); - return result; - }); - }; - - if (!asset.previewUrl) { - return fallback(); - } - - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - previewCache.set(asset.id, asset.previewUrl); - resolve(asset.previewUrl); + const fallback = () => { + const fallbackPromise = isVideoAsset(asset) + ? captureVideoFrame(asset) + : isGifAsset(asset) + ? captureGifFrame(asset) + : Promise.resolve(null); + return fallbackPromise.then((result) => { + if (!result) { + return null; + } + previewCache.set(asset.id, result); + return result; + }); }; - img.onerror = () => fallback().then(resolve); - img.src = asset.previewUrl; - }).catch(() => null); + + if (!asset.previewUrl) { + return fallback(); + } + + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + previewCache.set(asset.id, asset.previewUrl); + resolve(asset.previewUrl); + }; + img.onerror = () => fallback().then(resolve); + img.src = asset.previewUrl; + }).catch(() => null); } function loadPreviewFrame(asset, element) { - if (!asset || !element) return; - fetchPreviewData(asset) - .then((dataUrl) => { - if (!dataUrl) return; - applyPreviewFrame(element, dataUrl); - }) - .catch(() => {}); + if (!asset || !element) return; + fetchPreviewData(asset) + .then((dataUrl) => { + if (!dataUrl) return; + applyPreviewFrame(element, dataUrl); + }) + .catch(() => {}); } function applyPreviewFrame(element, dataUrl) { - if (!element || !dataUrl) return; - element.style.backgroundImage = `url(${dataUrl})`; - element.classList.add("has-image"); + if (!element || !dataUrl) return; + element.style.backgroundImage = `url(${dataUrl})`; + element.classList.add("has-image"); } function ensureCanvasPreview(asset) { - const cachedData = previewCache.get(asset.id); - const cachedImage = previewImageCache.get(asset.id); - if (cachedData && cachedImage?.src === cachedData) { - return cachedImage.image; - } + const cachedData = previewCache.get(asset.id); + const cachedImage = previewImageCache.get(asset.id); + if (cachedData && cachedImage?.src === cachedData) { + return cachedImage.image; + } - if (cachedData) { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.onload = requestDraw; - img.src = cachedData; - previewImageCache.set(asset.id, { src: cachedData, image: img }); - return img; - } + if (cachedData) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = requestDraw; + img.src = cachedData; + previewImageCache.set(asset.id, { src: cachedData, image: img }); + return img; + } - fetchPreviewData(asset) - .then((dataUrl) => { - if (!dataUrl) return; - const img = new Image(); - img.crossOrigin = "anonymous"; - img.onload = requestDraw; - img.src = dataUrl; - previewImageCache.set(asset.id, { src: dataUrl, image: img }); - }) - .catch(() => {}); + fetchPreviewData(asset) + .then((dataUrl) => { + if (!dataUrl) return; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = requestDraw; + img.src = dataUrl; + previewImageCache.set(asset.id, { src: dataUrl, image: img }); + }) + .catch(() => {}); - return null; + return null; } function captureVideoFrame(asset) { - return new Promise((resolve) => { - const video = document.createElement("video"); - video.crossOrigin = "anonymous"; - video.preload = "auto"; - video.muted = true; - video.playsInline = true; - video.src = asset.url; + return new Promise((resolve) => { + const video = document.createElement("video"); + video.crossOrigin = "anonymous"; + video.preload = "auto"; + video.muted = true; + video.playsInline = true; + video.src = asset.url; - video.addEventListener("loadedmetadata", () => recordDuration(asset.id, video.duration), { once: true }); + video.addEventListener("loadedmetadata", () => recordDuration(asset.id, video.duration), { once: true }); - const cleanup = () => { - video.pause(); - video.removeAttribute("src"); - video.load(); - }; + const cleanup = () => { + video.pause(); + video.removeAttribute("src"); + video.load(); + }; - video.addEventListener( - "loadeddata", - () => { - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth || asset.width || 0; - canvas.height = video.videoHeight || asset.height || 0; - if (!canvas.width || !canvas.height) { - cleanup(); - resolve(null); - return; - } - const context = canvas.getContext("2d"); - context.drawImage(video, 0, 0, canvas.width, canvas.height); - try { - const dataUrl = canvas.toDataURL("image/png"); - resolve(dataUrl); - } catch (err) { - resolve(null); - } - cleanup(); - }, - { once: true }, - ); + video.addEventListener( + "loadeddata", + () => { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth || asset.width || 0; + canvas.height = video.videoHeight || asset.height || 0; + if (!canvas.width || !canvas.height) { + cleanup(); + resolve(null); + return; + } + const context = canvas.getContext("2d"); + context.drawImage(video, 0, 0, canvas.width, canvas.height); + try { + const dataUrl = canvas.toDataURL("image/png"); + resolve(dataUrl); + } catch (err) { + resolve(null); + } + cleanup(); + }, + { once: true }, + ); - video.addEventListener( - "error", - () => { - cleanup(); - resolve(null); - }, - { once: true }, - ); - }); + video.addEventListener( + "error", + () => { + cleanup(); + resolve(null); + }, + { once: true }, + ); + }); } function captureGifFrame(asset) { - if (!("ImageDecoder" in window)) { - return Promise.resolve(null); - } - return fetch(asset.url) - .then((r) => r.blob()) - .then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" })) - .then((decoder) => decoder.decode({ frameIndex: 0 })) - .then(({ image }) => { - const canvas = document.createElement("canvas"); - canvas.width = image.displayWidth || asset.width || 0; - canvas.height = image.displayHeight || asset.height || 0; - const ctx2d = canvas.getContext("2d"); - ctx2d.drawImage(image, 0, 0, canvas.width, canvas.height); - image.close?.(); - try { - return canvas.toDataURL("image/png"); - } catch (err) { - return null; - } - }) - .catch(() => null); + if (!("ImageDecoder" in window)) { + return Promise.resolve(null); + } + return fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" })) + .then((decoder) => decoder.decode({ frameIndex: 0 })) + .then(({ image }) => { + const canvas = document.createElement("canvas"); + canvas.width = image.displayWidth || asset.width || 0; + canvas.height = image.displayHeight || asset.height || 0; + const ctx2d = canvas.getContext("2d"); + ctx2d.drawImage(image, 0, 0, canvas.width, canvas.height); + image.close?.(); + try { + return canvas.toDataURL("image/png"); + } catch (err) { + return null; + } + }) + .catch(() => null); } function getSelectedAsset() { - return selectedAssetId ? assets.get(selectedAssetId) : null; + return selectedAssetId ? assets.get(selectedAssetId) : null; } function updateSelectedAssetControls(asset = getSelectedAsset()) { - if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { - controlsPlaceholder.appendChild(controlsPanel); - } - - updateSelectedAssetSummary(asset); - - if (!controlsPanel || !asset) { - if (controlsPanel) controlsPanel.classList.add("hidden"); - return; - } - - controlsPanel.classList.remove("hidden"); - lastSizeInputChanged = null; - if (selectedZLabel) { - selectedZLabel.textContent = getLayerValue(asset.id); - } - - if (widthInput) widthInput.value = Math.round(asset.width); - if (heightInput) heightInput.value = Math.round(asset.height); - if (aspectLockInput) { - aspectLockInput.checked = isAspectLocked(asset.id); - aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); - } - const hideLayout = isAudioAsset(asset); - if (layoutSection) { - layoutSection.classList.toggle("hidden", hideLayout); - const layoutControls = layoutSection.querySelectorAll("input, button"); - layoutControls.forEach((control) => { - control.disabled = hideLayout; - control.classList.toggle("disabled", hideLayout); - }); - } - if (assetActionButtons.length) { - assetActionButtons.forEach((button) => { - const allowForAudio = button.dataset.audioEnabled === "true"; - const disableButton = hideLayout && !allowForAudio; - button.disabled = disableButton; - button.classList.toggle("disabled", disableButton); - }); - } - if (speedInput) { - const percent = Math.round((asset.speed ?? 1) * 100); - speedInput.value = Math.min(1000, Math.max(0, percent)); - setSpeedLabel(speedInput.value); - } - if (playbackSection) { - const shouldShowPlayback = isVideoAsset(asset); - playbackSection.classList.toggle("hidden", !shouldShowPlayback); - speedInput?.classList?.toggle("disabled", !shouldShowPlayback); - } - if (volumeSection) { - const showVolume = isAudioAsset(asset) || isVideoAsset(asset); - volumeSection.classList.toggle("hidden", !showVolume); - const volumeControls = volumeSection.querySelectorAll("input"); - volumeControls.forEach((control) => { - control.disabled = !showVolume; - control.classList.toggle("disabled", !showVolume); - }); - if (showVolume && volumeInput) { - const sliderValue = volumeToSlider(asset.audioVolume ?? 1); - volumeInput.value = sliderValue; - setVolumeLabel(sliderValue); + if (controlsPlaceholder && controlsPanel && controlsPanel.parentElement !== controlsPlaceholder) { + controlsPlaceholder.appendChild(controlsPanel); } - } - if (audioSection) { - const showAudio = isAudioAsset(asset); - audioSection.classList.toggle("hidden", !showAudio); - const audioInputs = [audioLoopInput, audioDelayInput, audioSpeedInput, audioPitchInput]; - audioInputs.forEach((input) => { - if (!input) return; - input.disabled = !showAudio; - input.parentElement?.classList?.toggle("disabled", !showAudio); - }); - if (showAudio) { - audioLoopInput.checked = !!asset.audioLoop; - const delayMs = clamp(Math.max(0, asset.audioDelayMillis ?? 0), 0, 30000); - audioDelayInput.value = delayMs; - setAudioDelayLabel(delayMs); - const audioSpeedPercent = clamp(Math.round(Math.max(0.25, asset.audioSpeed ?? 1) * 100), 25, 400); - audioSpeedInput.value = audioSpeedPercent; - setAudioSpeedLabel(audioSpeedPercent); - const pitchPercent = clamp(Math.round(Math.max(0.5, asset.audioPitch ?? 1) * 100), 50, 200); - audioPitchInput.value = pitchPercent; - setAudioPitchLabel(pitchPercent); + + updateSelectedAssetSummary(asset); + + if (!controlsPanel || !asset) { + if (controlsPanel) controlsPanel.classList.add("hidden"); + return; + } + + controlsPanel.classList.remove("hidden"); + lastSizeInputChanged = null; + if (selectedZLabel) { + selectedZLabel.textContent = getLayerValue(asset.id); + } + + if (widthInput) widthInput.value = Math.round(asset.width); + if (heightInput) heightInput.value = Math.round(asset.height); + if (aspectLockInput) { + aspectLockInput.checked = isAspectLocked(asset.id); + aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); + } + const hideLayout = isAudioAsset(asset); + if (layoutSection) { + layoutSection.classList.toggle("hidden", hideLayout); + const layoutControls = layoutSection.querySelectorAll("input, button"); + layoutControls.forEach((control) => { + control.disabled = hideLayout; + control.classList.toggle("disabled", hideLayout); + }); + } + if (assetActionButtons.length) { + assetActionButtons.forEach((button) => { + const allowForAudio = button.dataset.audioEnabled === "true"; + const disableButton = hideLayout && !allowForAudio; + button.disabled = disableButton; + button.classList.toggle("disabled", disableButton); + }); + } + if (speedInput) { + const percent = Math.round((asset.speed ?? 1) * 100); + speedInput.value = Math.min(1000, Math.max(0, percent)); + setSpeedLabel(speedInput.value); + } + if (playbackSection) { + const shouldShowPlayback = isVideoAsset(asset); + playbackSection.classList.toggle("hidden", !shouldShowPlayback); + speedInput?.classList?.toggle("disabled", !shouldShowPlayback); + } + if (volumeSection) { + const showVolume = isAudioAsset(asset) || isVideoAsset(asset); + volumeSection.classList.toggle("hidden", !showVolume); + const volumeControls = volumeSection.querySelectorAll("input"); + volumeControls.forEach((control) => { + control.disabled = !showVolume; + control.classList.toggle("disabled", !showVolume); + }); + if (showVolume && volumeInput) { + const sliderValue = volumeToSlider(asset.audioVolume ?? 1); + volumeInput.value = sliderValue; + setVolumeLabel(sliderValue); + } + } + if (audioSection) { + const showAudio = isAudioAsset(asset); + audioSection.classList.toggle("hidden", !showAudio); + const audioInputs = [audioLoopInput, audioDelayInput, audioSpeedInput, audioPitchInput]; + audioInputs.forEach((input) => { + if (!input) return; + input.disabled = !showAudio; + input.parentElement?.classList?.toggle("disabled", !showAudio); + }); + if (showAudio) { + audioLoopInput.checked = !!asset.audioLoop; + const delayMs = clamp(Math.max(0, asset.audioDelayMillis ?? 0), 0, 30000); + audioDelayInput.value = delayMs; + setAudioDelayLabel(delayMs); + const audioSpeedPercent = clamp(Math.round(Math.max(0.25, asset.audioSpeed ?? 1) * 100), 25, 400); + audioSpeedInput.value = audioSpeedPercent; + setAudioSpeedLabel(audioSpeedPercent); + const pitchPercent = clamp(Math.round(Math.max(0.5, asset.audioPitch ?? 1) * 100), 50, 200); + audioPitchInput.value = pitchPercent; + setAudioPitchLabel(pitchPercent); + } } - } } function updateSelectedAssetSummary(asset) { - if (assetInspector) { - assetInspector.classList.toggle("hidden", !asset && !assets.size); - } + if (assetInspector) { + assetInspector.classList.toggle("hidden", !asset && !assets.size); + } - ensureDurationMetadata(asset); + ensureDurationMetadata(asset); - if (selectedAssetName) { - selectedAssetName.textContent = asset ? asset.name || `Asset ${asset.id.slice(0, 6)}` : "Choose an asset"; - } - if (selectedAssetMeta) { - selectedAssetMeta.textContent = asset - ? getDisplayMediaType(asset) - : "Pick an asset in the list to adjust its placement and playback."; - } - if (selectedAssetResolution) { - if (asset) { - selectedAssetResolution.textContent = `${Math.round(asset.width)}×${Math.round(asset.height)}`; - selectedAssetResolution.classList.remove("hidden"); - } else { - selectedAssetResolution.textContent = ""; - selectedAssetResolution.classList.add("hidden"); + if (selectedAssetName) { + selectedAssetName.textContent = asset ? asset.name || `Asset ${asset.id.slice(0, 6)}` : "Choose an asset"; } - } - if (selectedAssetIdLabel) { - if (asset) { - selectedAssetIdLabel.textContent = `ID: ${asset.id}`; - selectedAssetIdLabel.classList.remove("hidden"); - } else { - selectedAssetIdLabel.classList.add("hidden"); - selectedAssetIdLabel.textContent = ""; + if (selectedAssetMeta) { + selectedAssetMeta.textContent = asset + ? getDisplayMediaType(asset) + : "Pick an asset in the list to adjust its placement and playback."; } - } - if (selectedAssetBadges) { - selectedAssetBadges.innerHTML = ""; - if (asset) { - selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset))); - const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : ""; - if (aspectLabel) { - selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle")); - } - const durationLabel = getDurationBadge(asset); - if (durationLabel) { - selectedAssetBadges.appendChild(createBadge(durationLabel, "subtle")); - } - } - } - if (selectedVisibilityBtn) { - selectedVisibilityBtn.disabled = !asset; - selectedVisibilityBtn.onclick = null; - if (asset && isAudioAsset(asset)) { - const isLooping = !!asset.audioLoop; - const isPlayingLoop = getLoopPlaybackState(asset); - updatePlayButtonIcon(selectedVisibilityBtn, isLooping, isPlayingLoop); - selectedVisibilityBtn.title = isLooping - ? isPlayingLoop - ? "Pause looping audio" - : "Play looping audio" - : "Play audio"; - selectedVisibilityBtn.onclick = () => { - const nextPlay = isLooping ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) : true; - if (isLooping) { - loopPlaybackState.set(asset.id, nextPlay); - updatePlayButtonIcon(selectedVisibilityBtn, true, nextPlay); - selectedVisibilityBtn.title = nextPlay ? "Pause looping audio" : "Play looping audio"; + if (selectedAssetResolution) { + if (asset) { + selectedAssetResolution.textContent = `${Math.round(asset.width)}×${Math.round(asset.height)}`; + selectedAssetResolution.classList.remove("hidden"); + } else { + selectedAssetResolution.textContent = ""; + selectedAssetResolution.classList.add("hidden"); } - triggerAudioPlayback(asset, nextPlay); - }; - } else if (asset) { - selectedVisibilityBtn.title = asset.hidden ? "Show asset" : "Hide asset"; - selectedVisibilityBtn.innerHTML = ``; - selectedVisibilityBtn.onclick = () => updateVisibility(asset, !asset.hidden); - } else { - selectedVisibilityBtn.title = "Toggle visibility"; - selectedVisibilityBtn.innerHTML = ''; } - } - if (selectedDeleteBtn) { - selectedDeleteBtn.disabled = !asset; - selectedDeleteBtn.title = asset ? "Delete asset" : "Delete asset"; - } + if (selectedAssetIdLabel) { + if (asset) { + selectedAssetIdLabel.textContent = `ID: ${asset.id}`; + selectedAssetIdLabel.classList.remove("hidden"); + } else { + selectedAssetIdLabel.classList.add("hidden"); + selectedAssetIdLabel.textContent = ""; + } + } + if (selectedAssetBadges) { + selectedAssetBadges.innerHTML = ""; + if (asset) { + selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset))); + const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : ""; + if (aspectLabel) { + selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle")); + } + const durationLabel = getDurationBadge(asset); + if (durationLabel) { + selectedAssetBadges.appendChild(createBadge(durationLabel, "subtle")); + } + } + } + if (selectedVisibilityBtn) { + selectedVisibilityBtn.disabled = !asset; + selectedVisibilityBtn.onclick = null; + if (asset && isAudioAsset(asset)) { + const isLooping = !!asset.audioLoop; + const isPlayingLoop = getLoopPlaybackState(asset); + updatePlayButtonIcon(selectedVisibilityBtn, isLooping, isPlayingLoop); + selectedVisibilityBtn.title = isLooping + ? isPlayingLoop + ? "Pause looping audio" + : "Play looping audio" + : "Play audio"; + selectedVisibilityBtn.onclick = () => { + const nextPlay = isLooping ? !(loopPlaybackState.get(asset.id) ?? getLoopPlaybackState(asset)) : true; + if (isLooping) { + loopPlaybackState.set(asset.id, nextPlay); + updatePlayButtonIcon(selectedVisibilityBtn, true, nextPlay); + selectedVisibilityBtn.title = nextPlay ? "Pause looping audio" : "Play looping audio"; + } + triggerAudioPlayback(asset, nextPlay); + }; + } else if (asset) { + selectedVisibilityBtn.title = asset.hidden ? "Show asset" : "Hide asset"; + selectedVisibilityBtn.innerHTML = ``; + selectedVisibilityBtn.onclick = () => updateVisibility(asset, !asset.hidden); + } else { + selectedVisibilityBtn.title = "Toggle visibility"; + selectedVisibilityBtn.innerHTML = ''; + } + } + if (selectedDeleteBtn) { + selectedDeleteBtn.disabled = !asset; + selectedDeleteBtn.title = asset ? "Delete asset" : "Delete asset"; + } } function ensureDurationMetadata(asset) { - if (!asset || hasDuration(asset) || (!isVideoAsset(asset) && !isAudioAsset(asset))) { - return; - } + if (!asset || hasDuration(asset) || (!isVideoAsset(asset) && !isAudioAsset(asset))) { + return; + } - const element = document.createElement(isVideoAsset(asset) ? "video" : "audio"); - element.preload = "metadata"; - element.muted = true; - element.playsInline = true; - element.src = asset.url; + const element = document.createElement(isVideoAsset(asset) ? "video" : "audio"); + element.preload = "metadata"; + element.muted = true; + element.playsInline = true; + element.src = asset.url; - const cleanup = () => { - element.removeAttribute("src"); - element.load(); - }; + const cleanup = () => { + element.removeAttribute("src"); + element.load(); + }; - element.addEventListener( - "loadedmetadata", - () => { - recordDuration(asset.id, element.duration); - cleanup(); - }, - { once: true }, - ); + element.addEventListener( + "loadedmetadata", + () => { + recordDuration(asset.id, element.duration); + cleanup(); + }, + { once: true }, + ); - element.addEventListener("error", cleanup, { once: true }); + element.addEventListener("error", cleanup, { once: true }); } function applyTransformFromInputs() { - const asset = getSelectedAsset(); - if (!asset) return; - const locked = isAspectLocked(asset.id); - const ratio = getAssetAspectRatio(asset); - let nextWidth = parseFloat(widthInput?.value) || asset.width; - let nextHeight = parseFloat(heightInput?.value) || asset.height; + const asset = getSelectedAsset(); + if (!asset) return; + const locked = isAspectLocked(asset.id); + const ratio = getAssetAspectRatio(asset); + let nextWidth = parseFloat(widthInput?.value) || asset.width; + let nextHeight = parseFloat(heightInput?.value) || asset.height; - if (locked && ratio) { - if (lastSizeInputChanged === "height") { - nextWidth = nextHeight * ratio; - if (widthInput) widthInput.value = Math.round(nextWidth); - } else { - nextHeight = nextWidth / ratio; - if (heightInput) heightInput.value = Math.round(nextHeight); + if (locked && ratio) { + if (lastSizeInputChanged === "height") { + nextWidth = nextHeight * ratio; + if (widthInput) widthInput.value = Math.round(nextWidth); + } else { + nextHeight = nextWidth / ratio; + if (heightInput) heightInput.value = Math.round(nextHeight); + } } - } - asset.width = Math.max(10, nextWidth); - asset.height = Math.max(10, nextHeight); - updateRenderState(asset); - persistTransform(asset); - drawAndList(); + asset.width = Math.max(10, nextWidth); + asset.height = Math.max(10, nextHeight); + updateRenderState(asset); + persistTransform(asset); + drawAndList(); } function updatePlaybackFromInputs() { - const asset = getSelectedAsset(); - if (!asset || !isVideoAsset(asset)) return; - const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100)); - setSpeedLabel(percent); - asset.speed = percent / 100; - updateRenderState(asset); - schedulePersistTransform(asset); - const media = mediaCache.get(asset.id); - if (media) { - applyMediaSettings(media, asset); - } - drawAndList(); + const asset = getSelectedAsset(); + if (!asset || !isVideoAsset(asset)) return; + const percent = Math.max(0, Math.min(1000, parseFloat(speedInput?.value) || 100)); + setSpeedLabel(percent); + asset.speed = percent / 100; + updateRenderState(asset); + schedulePersistTransform(asset); + const media = mediaCache.get(asset.id); + if (media) { + applyMediaSettings(media, asset); + } + drawAndList(); } function updateVolumeFromInput() { - const asset = getSelectedAsset(); - if (!asset || !(isVideoAsset(asset) || isAudioAsset(asset))) return; - const sliderValue = Math.max(0, Math.min(VOLUME_SLIDER_MAX, parseFloat(volumeInput?.value) || 100)); - const volumeValue = sliderToVolume(sliderValue); - setVolumeLabel(sliderValue); - asset.audioVolume = volumeValue; - const media = mediaCache.get(asset.id); - if (media) { - applyMediaSettings(media, asset); - } - if (isAudioAsset(asset)) { - const controller = ensureAudioController(asset); - applyAudioSettings(controller, asset); - } - schedulePersistTransform(asset); - drawAndList(); + const asset = getSelectedAsset(); + if (!asset || !(isVideoAsset(asset) || isAudioAsset(asset))) return; + const sliderValue = Math.max(0, Math.min(VOLUME_SLIDER_MAX, parseFloat(volumeInput?.value) || 100)); + const volumeValue = sliderToVolume(sliderValue); + setVolumeLabel(sliderValue); + asset.audioVolume = volumeValue; + const media = mediaCache.get(asset.id); + if (media) { + applyMediaSettings(media, asset); + } + if (isAudioAsset(asset)) { + const controller = ensureAudioController(asset); + applyAudioSettings(controller, asset); + } + schedulePersistTransform(asset); + drawAndList(); } function updateAudioSettingsFromInputs() { - const asset = getSelectedAsset(); - if (!asset || !isAudioAsset(asset)) return; - asset.audioLoop = !!audioLoopInput?.checked; - const delayMs = clamp(Math.max(0, parseInt(audioDelayInput?.value || "0", 10)), 0, 30000); - asset.audioDelayMillis = delayMs; - setAudioDelayLabel(delayMs); - if (audioDelayInput) audioDelayInput.value = delayMs; - const nextAudioSpeedPercent = clamp(Math.max(25, parseInt(audioSpeedInput?.value || "100", 10)), 25, 400); - setAudioSpeedLabel(nextAudioSpeedPercent); - if (audioSpeedInput) audioSpeedInput.value = nextAudioSpeedPercent; - asset.audioSpeed = Math.max(0.25, nextAudioSpeedPercent / 100); - const nextAudioPitchPercent = clamp(Math.max(50, parseInt(audioPitchInput?.value || "100", 10)), 50, 200); - setAudioPitchLabel(nextAudioPitchPercent); - if (audioPitchInput) audioPitchInput.value = nextAudioPitchPercent; - asset.audioPitch = Math.max(0.5, nextAudioPitchPercent / 100); - const controller = ensureAudioController(asset); - applyAudioSettings(controller, asset); - schedulePersistTransform(asset); - drawAndList(); + const asset = getSelectedAsset(); + if (!asset || !isAudioAsset(asset)) return; + asset.audioLoop = !!audioLoopInput?.checked; + const delayMs = clamp(Math.max(0, parseInt(audioDelayInput?.value || "0", 10)), 0, 30000); + asset.audioDelayMillis = delayMs; + setAudioDelayLabel(delayMs); + if (audioDelayInput) audioDelayInput.value = delayMs; + const nextAudioSpeedPercent = clamp(Math.max(25, parseInt(audioSpeedInput?.value || "100", 10)), 25, 400); + setAudioSpeedLabel(nextAudioSpeedPercent); + if (audioSpeedInput) audioSpeedInput.value = nextAudioSpeedPercent; + asset.audioSpeed = Math.max(0.25, nextAudioSpeedPercent / 100); + const nextAudioPitchPercent = clamp(Math.max(50, parseInt(audioPitchInput?.value || "100", 10)), 50, 200); + setAudioPitchLabel(nextAudioPitchPercent); + if (audioPitchInput) audioPitchInput.value = nextAudioPitchPercent; + asset.audioPitch = Math.max(0.5, nextAudioPitchPercent / 100); + const controller = ensureAudioController(asset); + applyAudioSettings(controller, asset); + schedulePersistTransform(asset); + drawAndList(); } function nudgeRotation(delta) { - const asset = getSelectedAsset(); - if (!asset) return; - const next = (asset.rotation || 0) + delta; - asset.rotation = next; - updateRenderState(asset); - persistTransform(asset); - drawAndList(); + const asset = getSelectedAsset(); + if (!asset) return; + const next = (asset.rotation || 0) + delta; + asset.rotation = next; + updateRenderState(asset); + persistTransform(asset); + drawAndList(); } function recenterSelectedAsset() { - const asset = getSelectedAsset(); - if (!asset) return; - const centerX = (canvas.width - asset.width) / 2; - const centerY = (canvas.height - asset.height) / 2; - asset.x = centerX; - asset.y = centerY; - updateRenderState(asset); - persistTransform(asset); - drawAndList(); + const asset = getSelectedAsset(); + if (!asset) return; + const centerX = (canvas.width - asset.width) / 2; + const centerY = (canvas.height - asset.height) / 2; + asset.x = centerX; + asset.y = centerY; + updateRenderState(asset); + persistTransform(asset); + drawAndList(); } function bringForward() { - const asset = getSelectedAsset(); - if (!asset) return; - const ordered = getAssetsByLayer(); - const index = ordered.findIndex((item) => item.id === asset.id); - if (index <= 0) return; - [ordered[index], ordered[index - 1]] = [ordered[index - 1], ordered[index]]; - applyLayerOrder(ordered); + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getAssetsByLayer(); + const index = ordered.findIndex((item) => item.id === asset.id); + if (index <= 0) return; + [ordered[index], ordered[index - 1]] = [ordered[index - 1], ordered[index]]; + applyLayerOrder(ordered); } function bringBackward() { - const asset = getSelectedAsset(); - if (!asset) return; - const ordered = getAssetsByLayer(); - const index = ordered.findIndex((item) => item.id === asset.id); - if (index === -1 || index === ordered.length - 1) return; - [ordered[index], ordered[index + 1]] = [ordered[index + 1], ordered[index]]; - applyLayerOrder(ordered); + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getAssetsByLayer(); + const index = ordered.findIndex((item) => item.id === asset.id); + if (index === -1 || index === ordered.length - 1) return; + [ordered[index], ordered[index + 1]] = [ordered[index + 1], ordered[index]]; + applyLayerOrder(ordered); } function bringToFront() { - const asset = getSelectedAsset(); - if (!asset) return; - const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); - ordered.unshift(asset); - applyLayerOrder(ordered); + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); + ordered.unshift(asset); + applyLayerOrder(ordered); } function sendToBack() { - const asset = getSelectedAsset(); - if (!asset) return; - const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); - ordered.push(asset); - applyLayerOrder(ordered); + const asset = getSelectedAsset(); + if (!asset) return; + const ordered = getAssetsByLayer().filter((item) => item.id !== asset.id); + ordered.push(asset); + applyLayerOrder(ordered); } function applyLayerOrder(ordered) { - const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id)); - layerOrder = newOrder; - const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean); - changed.forEach((item) => updateRenderState(item)); - changed.forEach((item) => schedulePersistTransform(item, true)); - drawAndList(); + const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id)); + layerOrder = newOrder; + const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean); + changed.forEach((item) => updateRenderState(item)); + changed.forEach((item) => schedulePersistTransform(item, true)); + drawAndList(); } function getAssetAspectRatio(asset) { - const media = ensureMedia(asset); - if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) { - return media.videoWidth / media.videoHeight; - } - if (!isVideoElement(media) && media?.naturalWidth && media?.naturalHeight) { - return media.naturalWidth / media.naturalHeight; - } - if (asset.width && asset.height) { - return asset.width / asset.height; - } - return null; + const media = ensureMedia(asset); + if (isVideoElement(media) && media?.videoWidth && media?.videoHeight) { + return media.videoWidth / media.videoHeight; + } + if (!isVideoElement(media) && media?.naturalWidth && media?.naturalHeight) { + return media.naturalWidth / media.naturalHeight; + } + if (asset.width && asset.height) { + return asset.width / asset.height; + } + return null; } function formatAspectRatioLabel(asset) { - if (isAudioAsset(asset)) { - return ""; - } - const ratio = getAssetAspectRatio(asset); - if (!ratio) { - return ""; - } - const normalized = ratio >= 1 ? `${ratio.toFixed(2)}:1` : `1:${(1 / ratio).toFixed(2)}`; - return `AR ${normalized}`; + if (isAudioAsset(asset)) { + return ""; + } + const ratio = getAssetAspectRatio(asset); + if (!ratio) { + return ""; + } + const normalized = ratio >= 1 ? `${ratio.toFixed(2)}:1` : `1:${(1 / ratio).toFixed(2)}`; + return `AR ${normalized}`; } function setAspectLock(assetId, locked) { - aspectLockState.set(assetId, locked); + aspectLockState.set(assetId, locked); } function isAspectLocked(assetId) { - return aspectLockState.has(assetId) ? aspectLockState.get(assetId) : true; + return aspectLockState.has(assetId) ? aspectLockState.get(assetId) : true; } function handleSizeInputChange(type) { - lastSizeInputChanged = type; - const asset = getSelectedAsset(); - if (!asset) { - return; - } - if (!isAspectLocked(asset.id)) { + lastSizeInputChanged = type; + const asset = getSelectedAsset(); + if (!asset) { + return; + } + if (!isAspectLocked(asset.id)) { + commitSizeChange(); + return; + } + const ratio = getAssetAspectRatio(asset); + if (!ratio) { + return; + } + if (type === "width" && widthInput && heightInput) { + const width = parseFloat(widthInput.value); + if (width > 0) { + heightInput.value = Math.round(width / ratio); + } + } else if (type === "height" && widthInput && heightInput) { + const height = parseFloat(heightInput.value); + if (height > 0) { + widthInput.value = Math.round(height * ratio); + } + } commitSizeChange(); - return; - } - const ratio = getAssetAspectRatio(asset); - if (!ratio) { - return; - } - if (type === "width" && widthInput && heightInput) { - const width = parseFloat(widthInput.value); - if (width > 0) { - heightInput.value = Math.round(width / ratio); - } - } else if (type === "height" && widthInput && heightInput) { - const height = parseFloat(heightInput.value); - if (height > 0) { - widthInput.value = Math.round(height * ratio); - } - } - commitSizeChange(); } function updateVisibility(asset, hidden) { - fetch(`/api/channels/${broadcaster}/assets/${asset.id}/visibility`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hidden }), - }) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to update visibility"); - } - return r.json(); + fetch(`/api/channels/${broadcaster}/assets/${asset.id}/visibility`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hidden }), }) - .then((updated) => { - storeAsset(updated); - let visibilityMessage = null; - if (updated.hidden) { - loopPlaybackState.set(updated.id, false); - stopAudio(updated.id); - showToast("Asset hidden from broadcast.", "info"); - } else if (isAudioAsset(updated)) { - playAudioFromCanvas(updated, true); - visibilityMessage = "Asset is now visible and active."; - } else { - visibilityMessage = "Asset is now visible."; - } - if (visibilityMessage) { - showToast(visibilityMessage, "success"); - } - updateRenderState(updated); - drawAndList(); - }) - .catch(() => showToast("Unable to change visibility right now.", "error")); + .then((r) => { + if (!r.ok) { + throw new Error("Failed to update visibility"); + } + return r.json(); + }) + .then((updated) => { + storeAsset(updated); + let visibilityMessage = null; + if (updated.hidden) { + loopPlaybackState.set(updated.id, false); + stopAudio(updated.id); + showToast("Asset hidden from broadcast.", "info"); + } else if (isAudioAsset(updated)) { + playAudioFromCanvas(updated, true); + visibilityMessage = "Asset is now visible and active."; + } else { + visibilityMessage = "Asset is now visible."; + } + if (visibilityMessage) { + showToast(visibilityMessage, "success"); + } + updateRenderState(updated); + drawAndList(); + }) + .catch(() => showToast("Unable to change visibility right now.", "error")); } function triggerAudioPlayback(asset, shouldPlay = true) { - if (!asset) return Promise.resolve(); - return fetch(`/api/channels/${broadcaster}/assets/${asset.id}/play`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ play: shouldPlay }), - }) - .then((r) => r.json()) - .then((updated) => { - storeAsset(updated); - updateRenderState(updated); - return updated; - }); + if (!asset) return Promise.resolve(); + return fetch(`/api/channels/${broadcaster}/assets/${asset.id}/play`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ play: shouldPlay }), + }) + .then((r) => r.json()) + .then((updated) => { + storeAsset(updated); + updateRenderState(updated); + return updated; + }); } function deleteAsset(asset) { - fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: "DELETE" }) - .then((response) => { - if (!response.ok) { - throw new Error("Failed to delete asset"); - } - clearMedia(asset.id); - assets.delete(asset.id); - renderStates.delete(asset.id); - layerOrder = layerOrder.filter((id) => id !== asset.id); - cancelPendingTransform(asset.id); - if (selectedAssetId === asset.id) { - selectedAssetId = null; - } - drawAndList(); - showToast("Asset deleted.", "info"); - }) - .catch(() => showToast("Unable to delete asset. Please try again.", "error")); + fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: "DELETE" }) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to delete asset"); + } + clearMedia(asset.id); + assets.delete(asset.id); + renderStates.delete(asset.id); + layerOrder = layerOrder.filter((id) => id !== asset.id); + cancelPendingTransform(asset.id); + if (selectedAssetId === asset.id) { + selectedAssetId = null; + } + drawAndList(); + showToast("Asset deleted.", "info"); + }) + .catch(() => showToast("Unable to delete asset. Please try again.", "error")); } function handleFileSelection(input) { - if (!input) return; - const hasFile = input.files && input.files.length; - const name = hasFile ? input.files[0].name : ""; - if (fileNameLabel) { - fileNameLabel.textContent = name || "No file chosen"; - } - if (hasFile) { - uploadAsset(input.files[0]); - } + if (!input) return; + const hasFile = input.files && input.files.length; + const name = hasFile ? input.files[0].name : ""; + if (fileNameLabel) { + fileNameLabel.textContent = name || "No file chosen"; + } + if (hasFile) { + uploadAsset(input.files[0]); + } } function uploadAsset(file = null) { - const fileInput = document.getElementById("asset-file"); - const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null); - if (!selectedFile) { - showToast("Choose an image, GIF, video, or audio file to upload.", "info"); - return; - } - if (selectedFile.size > UPLOAD_LIMIT_BYTES) { - showToast(`File is too large. Maximum upload size is ${UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, "error"); - return; - } + const fileInput = document.getElementById("asset-file"); + const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null); + if (!selectedFile) { + showToast("Choose an image, GIF, video, or audio file to upload.", "info"); + return; + } + if (selectedFile.size > UPLOAD_LIMIT_BYTES) { + showToast(`File is too large. Maximum upload size is ${UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, "error"); + return; + } - const pendingId = addPendingUpload(selectedFile.name); - const data = new FormData(); - data.append("file", selectedFile); - if (fileNameLabel) { - fileNameLabel.textContent = "Uploading..."; - } - fetch(`/api/channels/${broadcaster}/assets`, { - method: "POST", - body: data, - }) - .then((response) => { - if (!response.ok) { - throw new Error("Upload failed"); - } - if (fileInput) { - fileInput.value = ""; - handleFileSelection(fileInput); - } - showToast("Upload received. Processing asset...", "success"); - updatePendingUpload(pendingId, { status: "processing" }); + const pendingId = addPendingUpload(selectedFile.name); + const data = new FormData(); + data.append("file", selectedFile); + if (fileNameLabel) { + fileNameLabel.textContent = "Uploading..."; + } + fetch(`/api/channels/${broadcaster}/assets`, { + method: "POST", + body: data, }) - .catch(() => { - if (fileNameLabel) { - fileNameLabel.textContent = "Upload failed"; - } - removePendingUpload(pendingId); - showToast("Upload failed. Please try again with a supported file.", "error"); - }); + .then((response) => { + if (!response.ok) { + throw new Error("Upload failed"); + } + if (fileInput) { + fileInput.value = ""; + handleFileSelection(fileInput); + } + showToast("Upload received. Processing asset...", "success"); + updatePendingUpload(pendingId, { status: "processing" }); + }) + .catch(() => { + if (fileNameLabel) { + fileNameLabel.textContent = "Upload failed"; + } + removePendingUpload(pendingId); + showToast("Upload failed. Please try again with a supported file.", "error"); + }); } function getCanvasPoint(event) { - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - return { - x: (event.clientX - rect.left) * scaleX, - y: (event.clientY - rect.top) * scaleY, - }; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY, + }; } function isPointOnAsset(asset, x, y) { - ctx.save(); - const halfWidth = asset.width / 2; - const halfHeight = asset.height / 2; - ctx.translate(asset.x + halfWidth, asset.y + halfHeight); - ctx.rotate((asset.rotation * Math.PI) / 180); - const path = new Path2D(); - path.rect(-halfWidth, -halfHeight, asset.width, asset.height); - const hit = ctx.isPointInPath(path, x, y); - ctx.restore(); - return hit; + ctx.save(); + const halfWidth = asset.width / 2; + const halfHeight = asset.height / 2; + ctx.translate(asset.x + halfWidth, asset.y + halfHeight); + ctx.rotate((asset.rotation * Math.PI) / 180); + const path = new Path2D(); + path.rect(-halfWidth, -halfHeight, asset.width, asset.height); + const hit = ctx.isPointInPath(path, x, y); + ctx.restore(); + return hit; } function findAssetAtPoint(x, y) { - const ordered = getAssetsByLayer(); - return ordered.find((asset) => !isAudioAsset(asset) && isPointOnAsset(asset, x, y)) || null; + const ordered = getAssetsByLayer(); + return ordered.find((asset) => !isAudioAsset(asset) && isPointOnAsset(asset, x, y)) || null; } function persistTransform(asset, silent = false) { - cancelPendingTransform(asset.id); - const layer = getLayerValue(asset.id); - fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - x: asset.x, - y: asset.y, - width: asset.width, - height: asset.height, - rotation: asset.rotation, - speed: asset.speed, - layer, - zIndex: layer, - audioLoop: asset.audioLoop, - audioDelayMillis: asset.audioDelayMillis, - audioSpeed: asset.audioSpeed, - audioPitch: asset.audioPitch, - audioVolume: asset.audioVolume, - }), - }) - .then((r) => { - if (!r.ok) { - throw new Error("Transform failed"); - } - return r.json(); + cancelPendingTransform(asset.id); + const layer = getLayerValue(asset.id); + fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + x: asset.x, + y: asset.y, + width: asset.width, + height: asset.height, + rotation: asset.rotation, + speed: asset.speed, + layer, + zIndex: layer, + audioLoop: asset.audioLoop, + audioDelayMillis: asset.audioDelayMillis, + audioSpeed: asset.audioSpeed, + audioPitch: asset.audioPitch, + audioVolume: asset.audioVolume, + }), }) - .then((updated) => { - storeAsset(updated); - updateRenderState(updated); - if (!silent) { - drawAndList(); - } - }) - .catch(() => { - if (!silent) { - showToast("Unable to save changes. Please retry.", "error"); - } - }); + .then((r) => { + if (!r.ok) { + throw new Error("Transform failed"); + } + return r.json(); + }) + .then((updated) => { + storeAsset(updated); + updateRenderState(updated); + if (!silent) { + drawAndList(); + } + }) + .catch(() => { + if (!silent) { + showToast("Unable to save changes. Please retry.", "error"); + } + }); } canvas.addEventListener("mousedown", (event) => { - const point = getCanvasPoint(event); - const current = getSelectedAsset(); - const handle = current ? hitHandle(current, point) : null; - if (current && handle) { - interactionState = - handle === "rotate" - ? { - mode: "rotate", - assetId: current.id, - startAngle: angleFromCenter(current, point), - startRotation: current.rotation || 0, - } - : { - mode: "resize", - assetId: current.id, - handle, - startLocal: pointerToLocal(current, point), - original: { ...current }, - }; - canvas.style.cursor = cursorForHandle(handle); - drawAndList(); - return; - } + const point = getCanvasPoint(event); + const current = getSelectedAsset(); + const handle = current ? hitHandle(current, point) : null; + if (current && handle) { + interactionState = + handle === "rotate" + ? { + mode: "rotate", + assetId: current.id, + startAngle: angleFromCenter(current, point), + startRotation: current.rotation || 0, + } + : { + mode: "resize", + assetId: current.id, + handle, + startLocal: pointerToLocal(current, point), + original: { ...current }, + }; + canvas.style.cursor = cursorForHandle(handle); + drawAndList(); + return; + } - const hit = findAssetAtPoint(point.x, point.y); - if (hit) { - selectedAssetId = hit.id; - updateRenderState(hit); - interactionState = { - mode: "move", - assetId: hit.id, - offsetX: point.x - hit.x, - offsetY: point.y - hit.y, - }; - canvas.style.cursor = "grabbing"; - } else { - selectedAssetId = null; - interactionState = null; - canvas.style.cursor = "default"; - } - drawAndList(); + const hit = findAssetAtPoint(point.x, point.y); + if (hit) { + selectedAssetId = hit.id; + updateRenderState(hit); + interactionState = { + mode: "move", + assetId: hit.id, + offsetX: point.x - hit.x, + offsetY: point.y - hit.y, + }; + canvas.style.cursor = "grabbing"; + } else { + selectedAssetId = null; + interactionState = null; + canvas.style.cursor = "default"; + } + drawAndList(); }); canvas.addEventListener("mousemove", (event) => { - const point = getCanvasPoint(event); - if (!interactionState) { - updateHoverCursor(point); - return; - } - const asset = assets.get(interactionState.assetId); - if (!asset) { - interactionState = null; - updateHoverCursor(point); - return; - } + const point = getCanvasPoint(event); + if (!interactionState) { + updateHoverCursor(point); + return; + } + const asset = assets.get(interactionState.assetId); + if (!asset) { + interactionState = null; + updateHoverCursor(point); + return; + } - if (interactionState.mode === "move") { - asset.x = point.x - interactionState.offsetX; - asset.y = point.y - interactionState.offsetY; - updateRenderState(asset); - canvas.style.cursor = "grabbing"; - requestDraw(); - } else if (interactionState.mode === "resize") { - resizeFromHandle(interactionState, point); - canvas.style.cursor = cursorForHandle(interactionState.handle); - } else if (interactionState.mode === "rotate") { - const angle = angleFromCenter(asset, point); - asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle); - updateRenderState(asset); - canvas.style.cursor = "grabbing"; - requestDraw(); - } + if (interactionState.mode === "move") { + asset.x = point.x - interactionState.offsetX; + asset.y = point.y - interactionState.offsetY; + updateRenderState(asset); + canvas.style.cursor = "grabbing"; + requestDraw(); + } else if (interactionState.mode === "resize") { + resizeFromHandle(interactionState, point); + canvas.style.cursor = cursorForHandle(interactionState.handle); + } else if (interactionState.mode === "rotate") { + const angle = angleFromCenter(asset, point); + asset.rotation = (interactionState.startRotation || 0) + (angle - interactionState.startAngle); + updateRenderState(asset); + canvas.style.cursor = "grabbing"; + requestDraw(); + } }); function endInteraction() { - if (!interactionState) { - return; - } - const asset = assets.get(interactionState.assetId); - interactionState = null; - canvas.style.cursor = "default"; - drawAndList(); - if (asset) { - persistTransform(asset); - } + if (!interactionState) { + return; + } + const asset = assets.get(interactionState.assetId); + interactionState = null; + canvas.style.cursor = "default"; + drawAndList(); + if (asset) { + persistTransform(asset); + } } canvas.addEventListener("mouseup", endInteraction); canvas.addEventListener("mouseleave", endInteraction); window.addEventListener("resize", () => { - resizeCanvas(); + resizeCanvas(); }); fetchCanvasSettings().finally(() => { - resizeCanvas(); - connect(); + resizeCanvas(); + connect(); }); diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index edc0d33..0340c13 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -1,7 +1,7 @@ const canvas = document.getElementById("broadcast-canvas"); const obsBrowser = !!window.obsstudio; const supportsAnimatedDecode = - typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !obsBrowser; + typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !obsBrowser; const canPlayProbe = document.createElement("video"); const ctx = canvas.getContext("2d"); let canvasSettings = { width: 1920, height: 1080 }; @@ -28,866 +28,870 @@ const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"]; let layerOrder = []; audioUnlockEvents.forEach((eventName) => { - window.addEventListener(eventName, () => { - if (!pendingAudioUnlock.size) return; - pendingAudioUnlock.forEach((controller) => safePlay(controller)); - pendingAudioUnlock.clear(); - }); + window.addEventListener(eventName, () => { + if (!pendingAudioUnlock.size) return; + pendingAudioUnlock.forEach((controller) => safePlay(controller)); + pendingAudioUnlock.clear(); + }); }); function ensureLayerPosition(assetId, placement = "keep") { - const asset = assets.get(assetId); - if (asset && isAudioAsset(asset)) { - return; - } - const existingIndex = layerOrder.indexOf(assetId); - if (existingIndex !== -1 && placement === "keep") { - return; - } - if (existingIndex !== -1) { - layerOrder.splice(existingIndex, 1); - } - if (placement === "append") { - layerOrder.push(assetId); - } else { - layerOrder.unshift(assetId); - } - layerOrder = layerOrder.filter((id) => assets.has(id)); + const asset = assets.get(assetId); + if (asset && isAudioAsset(asset)) { + return; + } + const existingIndex = layerOrder.indexOf(assetId); + if (existingIndex !== -1 && placement === "keep") { + return; + } + if (existingIndex !== -1) { + layerOrder.splice(existingIndex, 1); + } + if (placement === "append") { + layerOrder.push(assetId); + } else { + layerOrder.unshift(assetId); + } + layerOrder = layerOrder.filter((id) => assets.has(id)); } function getLayerOrder() { - layerOrder = layerOrder.filter((id) => { - const asset = assets.get(id); - return asset && !isAudioAsset(asset); - }); - assets.forEach((asset, id) => { - if (isAudioAsset(asset)) { - return; - } - if (!layerOrder.includes(id)) { - layerOrder.unshift(id); - } - }); - return layerOrder; + layerOrder = layerOrder.filter((id) => { + const asset = assets.get(id); + return asset && !isAudioAsset(asset); + }); + assets.forEach((asset, id) => { + if (isAudioAsset(asset)) { + return; + } + if (!layerOrder.includes(id)) { + layerOrder.unshift(id); + } + }); + return layerOrder; } function getRenderOrder() { - return [...getLayerOrder()] - .reverse() - .map((id) => assets.get(id)) - .filter(Boolean); + return [...getLayerOrder()] + .reverse() + .map((id) => assets.get(id)) + .filter(Boolean); } function queueRemoval(assetId) { - if (assetId) { - pendingRemovals.add(assetId); - } + if (assetId) { + pendingRemovals.add(assetId); + } } function removeAsset(assetId) { - assets.delete(assetId); - layerOrder = layerOrder.filter((id) => id !== assetId); - clearMedia(assetId); - renderStates.delete(assetId); - visibilityStates.delete(assetId); + assets.delete(assetId); + layerOrder = layerOrder.filter((id) => id !== assetId); + clearMedia(assetId); + renderStates.delete(assetId); + visibilityStates.delete(assetId); } function flushPendingRemovals() { - if (!pendingRemovals.size) return; - pendingRemovals.forEach((id) => removeAsset(id)); - pendingRemovals.clear(); + if (!pendingRemovals.size) return; + pendingRemovals.forEach((id) => removeAsset(id)); + pendingRemovals.clear(); } function connect() { - const socket = new SockJS("/ws"); - const stompClient = Stomp.over(socket); - stompClient.connect({}, () => { - stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => { - const body = JSON.parse(payload.body); - handleEvent(body); + const socket = new SockJS("/ws"); + const stompClient = Stomp.over(socket); + stompClient.connect({}, () => { + stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => { + const body = JSON.parse(payload.body); + handleEvent(body); + }); + fetch(`/api/channels/${broadcaster}/assets/visible`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load assets"); + } + return r.json(); + }) + .then(renderAssets) + .catch(() => showToast("Unable to load overlay assets. Retrying may help.", "error")); }); - fetch(`/api/channels/${broadcaster}/assets/visible`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load assets"); - } - return r.json(); - }) - .then(renderAssets) - .catch(() => showToast("Unable to load overlay assets. Retrying may help.", "error")); - }); } function renderAssets(list) { - layerOrder = []; - list.forEach((asset) => storeAsset(asset, "append")); - draw(); + layerOrder = []; + list.forEach((asset) => storeAsset(asset, "append")); + draw(); } function storeAsset(asset, placement = "keep") { - if (!asset) return; - const wasExisting = assets.has(asset.id); - assets.set(asset.id, asset); - ensureLayerPosition(asset.id, placement); - if (!wasExisting && !visibilityStates.has(asset.id)) { - const initialAlpha = 0; // Fade in newly discovered assets - visibilityStates.set(asset.id, { alpha: initialAlpha, targetHidden: !!asset.hidden }); - } + if (!asset) return; + const wasExisting = assets.has(asset.id); + assets.set(asset.id, asset); + ensureLayerPosition(asset.id, placement); + if (!wasExisting && !visibilityStates.has(asset.id)) { + const initialAlpha = 0; // Fade in newly discovered assets + visibilityStates.set(asset.id, { alpha: initialAlpha, targetHidden: !!asset.hidden }); + } } function fetchCanvasSettings() { - return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/canvas`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load canvas"); - } - return r.json(); - }) - .then((settings) => { - canvasSettings = settings; - resizeCanvas(); - }) - .catch(() => { - resizeCanvas(); - showToast("Using default canvas size. Unable to load saved settings.", "warning"); - }); + return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/canvas`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load canvas"); + } + return r.json(); + }) + .then((settings) => { + canvasSettings = settings; + resizeCanvas(); + }) + .catch(() => { + resizeCanvas(); + showToast("Using default canvas size. Unable to load saved settings.", "warning"); + }); } function resizeCanvas() { - const scale = Math.min(window.innerWidth / canvasSettings.width, window.innerHeight / canvasSettings.height); - const displayWidth = canvasSettings.width * scale; - const displayHeight = canvasSettings.height * scale; - canvas.width = canvasSettings.width; - canvas.height = canvasSettings.height; - canvas.style.width = `${displayWidth}px`; - canvas.style.height = `${displayHeight}px`; - canvas.style.left = `${(window.innerWidth - displayWidth) / 2}px`; - canvas.style.top = `${(window.innerHeight - displayHeight) / 2}px`; - draw(); + const scale = Math.min(window.innerWidth / canvasSettings.width, window.innerHeight / canvasSettings.height); + const displayWidth = canvasSettings.width * scale; + const displayHeight = canvasSettings.height * scale; + canvas.width = canvasSettings.width; + canvas.height = canvasSettings.height; + canvas.style.width = `${displayWidth}px`; + canvas.style.height = `${displayHeight}px`; + canvas.style.left = `${(window.innerWidth - displayWidth) / 2}px`; + canvas.style.top = `${(window.innerHeight - displayHeight) / 2}px`; + draw(); } function handleEvent(event) { - const assetId = event.assetId || event?.patch?.id || event?.payload?.id; - if (event.type === "VISIBILITY") { - handleVisibilityEvent(event); - return; - } - if (event.type === "DELETED") { - removeAsset(assetId); - } else if (event.patch) { - applyPatch(assetId, event.patch); - if (event.payload) { - const payload = normalizePayload(event.payload); - if (payload.hidden) { - hideAssetWithTransition(payload); - } else if (!assets.has(payload.id)) { - upsertVisibleAsset(payload, "append"); - } + const assetId = event.assetId || event?.patch?.id || event?.payload?.id; + if (event.type === "VISIBILITY") { + handleVisibilityEvent(event); + return; } - } else if (event.type === "PLAY" && event.payload) { - const payload = normalizePayload(event.payload); - storeAsset(payload); - if (isAudioAsset(payload)) { - handleAudioPlay(payload, event.play !== false); + if (event.type === "DELETED") { + removeAsset(assetId); + } else if (event.patch) { + applyPatch(assetId, event.patch); + if (event.payload) { + const payload = normalizePayload(event.payload); + if (payload.hidden) { + hideAssetWithTransition(payload); + } else if (!assets.has(payload.id)) { + upsertVisibleAsset(payload, "append"); + } + } + } else if (event.type === "PLAY" && event.payload) { + const payload = normalizePayload(event.payload); + storeAsset(payload); + if (isAudioAsset(payload)) { + handleAudioPlay(payload, event.play !== false); + } + } else if (event.payload && !event.payload.hidden) { + const payload = normalizePayload(event.payload); + upsertVisibleAsset(payload); + } else if (event.payload && event.payload.hidden) { + hideAssetWithTransition(event.payload); } - } else if (event.payload && !event.payload.hidden) { - const payload = normalizePayload(event.payload); - upsertVisibleAsset(payload); - } else if (event.payload && event.payload.hidden) { - hideAssetWithTransition(event.payload); - } - draw(); + draw(); } function normalizePayload(payload) { - return { ...payload }; + return { ...payload }; } function hideAssetWithTransition(asset) { - const payload = asset ? normalizePayload(asset) : null; - if (!payload?.id) { - return; - } - const existing = assets.get(payload.id); - if ( - !existing && - (!Number.isFinite(payload.x) || - !Number.isFinite(payload.y) || - !Number.isFinite(payload.width) || - !Number.isFinite(payload.height)) - ) { - return; - } - const merged = normalizePayload({ ...(existing || {}), ...payload, hidden: true }); - storeAsset(merged); - stopAudio(payload.id); + const payload = asset ? normalizePayload(asset) : null; + if (!payload?.id) { + return; + } + const existing = assets.get(payload.id); + if ( + !existing && + (!Number.isFinite(payload.x) || + !Number.isFinite(payload.y) || + !Number.isFinite(payload.width) || + !Number.isFinite(payload.height)) + ) { + return; + } + const merged = normalizePayload({ ...(existing || {}), ...payload, hidden: true }); + storeAsset(merged); + stopAudio(payload.id); } function upsertVisibleAsset(asset, placement = "keep") { - const payload = asset ? normalizePayload(asset) : null; - if (!payload?.id) { - return; - } - const placementMode = assets.has(payload.id) ? "keep" : placement; - storeAsset(payload, placementMode); - ensureMedia(payload); - if (isAudioAsset(payload)) { - playAudioImmediately(payload); - } + const payload = asset ? normalizePayload(asset) : null; + if (!payload?.id) { + return; + } + const placementMode = assets.has(payload.id) ? "keep" : placement; + storeAsset(payload, placementMode); + ensureMedia(payload); + if (isAudioAsset(payload)) { + playAudioImmediately(payload); + } } function handleVisibilityEvent(event) { - const payload = event.payload ? normalizePayload(event.payload) : null; - const patch = event.patch; - const id = payload?.id || patch?.id || event.assetId; + const payload = event.payload ? normalizePayload(event.payload) : null; + const patch = event.patch; + const id = payload?.id || patch?.id || event.assetId; + + if (payload?.hidden || patch?.hidden) { + hideAssetWithTransition({ id, ...payload, ...patch }); + draw(); + return; + } + + if (payload) { + const placement = assets.has(payload.id) ? "keep" : "append"; + upsertVisibleAsset(payload, placement); + } + + if (patch && id) { + applyPatch(id, patch); + } - if (payload?.hidden || patch?.hidden) { - hideAssetWithTransition({ id, ...payload, ...patch }); draw(); - return; - } - - if (payload) { - const placement = assets.has(payload.id) ? "keep" : "append"; - upsertVisibleAsset(payload, placement); - } - - if (patch && id) { - applyPatch(id, patch); - } - - draw(); } function applyPatch(assetId, patch) { - if (!assetId || !patch) { - return; - } - const sanitizedPatch = Object.fromEntries( - Object.entries(patch).filter(([, value]) => value !== null && value !== undefined), - ); - const existing = assets.get(assetId); - if (!existing) { - return; - } - const merged = normalizePayload({ ...existing, ...sanitizedPatch }); - const isAudio = isAudioAsset(merged); - if (sanitizedPatch.hidden) { - hideAssetWithTransition(merged); - return; - } - const targetLayer = Number.isFinite(patch.layer) ? patch.layer : Number.isFinite(patch.zIndex) ? patch.zIndex : null; - if (!isAudio && Number.isFinite(targetLayer)) { - const currentOrder = getLayerOrder().filter((id) => id !== assetId); - const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); - currentOrder.splice(insertIndex, 0, assetId); - layerOrder = currentOrder; - } - storeAsset(merged); - ensureMedia(merged); + if (!assetId || !patch) { + return; + } + const sanitizedPatch = Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== null && value !== undefined), + ); + const existing = assets.get(assetId); + if (!existing) { + return; + } + const merged = normalizePayload({ ...existing, ...sanitizedPatch }); + const isAudio = isAudioAsset(merged); + if (sanitizedPatch.hidden) { + hideAssetWithTransition(merged); + return; + } + const targetLayer = Number.isFinite(patch.layer) + ? patch.layer + : Number.isFinite(patch.zIndex) + ? patch.zIndex + : null; + if (!isAudio && Number.isFinite(targetLayer)) { + const currentOrder = getLayerOrder().filter((id) => id !== assetId); + const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer)); + currentOrder.splice(insertIndex, 0, assetId); + layerOrder = currentOrder; + } + storeAsset(merged); + ensureMedia(merged); } function draw() { - if (frameScheduled) { - pendingDraw = true; - return; - } - frameScheduled = true; - requestAnimationFrame((timestamp) => { - const elapsed = timestamp - lastRenderTime; - const delay = MIN_FRAME_TIME - elapsed; - const shouldRender = elapsed >= MIN_FRAME_TIME; - - if (shouldRender) { - lastRenderTime = timestamp; - renderFrame(); + if (frameScheduled) { + pendingDraw = true; + return; } + frameScheduled = true; + requestAnimationFrame((timestamp) => { + const elapsed = timestamp - lastRenderTime; + const delay = MIN_FRAME_TIME - elapsed; + const shouldRender = elapsed >= MIN_FRAME_TIME; - frameScheduled = false; - if (pendingDraw || !shouldRender) { - pendingDraw = false; - setTimeout(draw, Math.max(0, delay)); - } - }); + if (shouldRender) { + lastRenderTime = timestamp; + renderFrame(); + } + + frameScheduled = false; + if (pendingDraw || !shouldRender) { + pendingDraw = false; + setTimeout(draw, Math.max(0, delay)); + } + }); } function renderFrame() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - getRenderOrder().forEach(drawAsset); - flushPendingRemovals(); + ctx.clearRect(0, 0, canvas.width, canvas.height); + getRenderOrder().forEach(drawAsset); + flushPendingRemovals(); } function drawAsset(asset) { - const visibility = getVisibilityState(asset); - if (visibility.alpha <= VISIBILITY_THRESHOLD && asset.hidden) { - queueRemoval(asset.id); - return; - } - const renderState = smoothState(asset); - const halfWidth = renderState.width / 2; - const halfHeight = renderState.height / 2; - ctx.save(); - ctx.globalAlpha = Math.max(0, Math.min(1, visibility.alpha)); - ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); - ctx.rotate((renderState.rotation * Math.PI) / 180); - - if (isAudioAsset(asset)) { - if (!asset.hidden) { - autoStartAudio(asset); + const visibility = getVisibilityState(asset); + if (visibility.alpha <= VISIBILITY_THRESHOLD && asset.hidden) { + queueRemoval(asset.id); + return; } + const renderState = smoothState(asset); + const halfWidth = renderState.width / 2; + const halfHeight = renderState.height / 2; + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(1, visibility.alpha)); + ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); + ctx.rotate((renderState.rotation * Math.PI) / 180); + + if (isAudioAsset(asset)) { + if (!asset.hidden) { + autoStartAudio(asset); + } + ctx.restore(); + return; + } + + const media = ensureMedia(asset); + const drawSource = media?.isAnimated ? media.bitmap : media; + const ready = isDrawable(media); + if (ready && drawSource) { + ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); + } + ctx.restore(); - return; - } - - const media = ensureMedia(asset); - const drawSource = media?.isAnimated ? media.bitmap : media; - const ready = isDrawable(media); - if (ready && drawSource) { - ctx.drawImage(drawSource, -halfWidth, -halfHeight, renderState.width, renderState.height); - } - - ctx.restore(); } function getVisibilityState(asset) { - const current = visibilityStates.get(asset.id) || {}; - const targetAlpha = asset.hidden ? 0 : 1; - const startingAlpha = Number.isFinite(current.alpha) ? current.alpha : 0; - const factor = asset.hidden ? 0.18 : 0.2; - const nextAlpha = lerp(startingAlpha, targetAlpha, factor); - const state = { alpha: nextAlpha, targetHidden: !!asset.hidden }; - visibilityStates.set(asset.id, state); - return state; + const current = visibilityStates.get(asset.id) || {}; + const targetAlpha = asset.hidden ? 0 : 1; + const startingAlpha = Number.isFinite(current.alpha) ? current.alpha : 0; + const factor = asset.hidden ? 0.18 : 0.2; + const nextAlpha = lerp(startingAlpha, targetAlpha, factor); + const state = { alpha: nextAlpha, targetHidden: !!asset.hidden }; + visibilityStates.set(asset.id, state); + return state; } function smoothState(asset) { - const previous = renderStates.get(asset.id) || { ...asset }; - const factor = 0.15; - const next = { - x: lerp(previous.x, asset.x, factor), - y: lerp(previous.y, asset.y, factor), - width: lerp(previous.width, asset.width, factor), - height: lerp(previous.height, asset.height, factor), - rotation: smoothAngle(previous.rotation, asset.rotation, factor), - }; - renderStates.set(asset.id, next); - return next; + const previous = renderStates.get(asset.id) || { ...asset }; + const factor = 0.15; + const next = { + x: lerp(previous.x, asset.x, factor), + y: lerp(previous.y, asset.y, factor), + width: lerp(previous.width, asset.width, factor), + height: lerp(previous.height, asset.height, factor), + rotation: smoothAngle(previous.rotation, asset.rotation, factor), + }; + renderStates.set(asset.id, next); + return next; } function smoothAngle(current, target, factor) { - const delta = ((target - current + 180) % 360) - 180; - return current + delta * factor; + const delta = ((target - current + 180) % 360) - 180; + return current + delta * factor; } function lerp(a, b, t) { - return a + (b - a) * t; + return a + (b - a) * t; } function queueAudioForUnlock(controller) { - if (!controller) return; - pendingAudioUnlock.add(controller); + if (!controller) return; + pendingAudioUnlock.add(controller); } function safePlay(controller) { - if (!controller?.element) return; - const playPromise = controller.element.play(); - if (playPromise?.catch) { - playPromise.catch(() => queueAudioForUnlock(controller)); - } + if (!controller?.element) return; + const playPromise = controller.element.play(); + if (playPromise?.catch) { + playPromise.catch(() => queueAudioForUnlock(controller)); + } } function recordDuration(assetId, seconds) { - if (!Number.isFinite(seconds) || seconds <= 0) { - return; - } - const asset = assets.get(assetId); - if (!asset) { - return; - } - const nextMs = Math.round(seconds * 1000); - if (asset.durationMs === nextMs) { - return; - } - asset.durationMs = nextMs; + if (!Number.isFinite(seconds) || seconds <= 0) { + return; + } + const asset = assets.get(assetId); + if (!asset) { + return; + } + const nextMs = Math.round(seconds * 1000); + if (asset.durationMs === nextMs) { + return; + } + asset.durationMs = nextMs; } function isVideoAsset(asset) { - return asset?.mediaType?.startsWith("video/"); + return asset?.mediaType?.startsWith("video/"); } function isAudioAsset(asset) { - return asset?.mediaType?.startsWith("audio/"); + return asset?.mediaType?.startsWith("audio/"); } function isVideoElement(element) { - return element && element.tagName === "VIDEO"; + return element && element.tagName === "VIDEO"; } function isGifAsset(asset) { - return asset?.mediaType?.toLowerCase() === "image/gif"; + return asset?.mediaType?.toLowerCase() === "image/gif"; } function isDrawable(element) { - if (!element) { - return false; - } - if (element.isAnimated) { - return !!element.bitmap; - } - if (isVideoElement(element)) { - return element.readyState >= 2; - } - if (typeof ImageBitmap !== "undefined" && element instanceof ImageBitmap) { - return true; - } - return !!element.complete; + if (!element) { + return false; + } + if (element.isAnimated) { + return !!element.bitmap; + } + if (isVideoElement(element)) { + return element.readyState >= 2; + } + if (typeof ImageBitmap !== "undefined" && element instanceof ImageBitmap) { + return true; + } + return !!element.complete; } function clearMedia(assetId) { - const element = mediaCache.get(assetId); - if (isVideoElement(element)) { - element.src = ""; - element.remove(); - } - mediaCache.delete(assetId); - const animated = animatedCache.get(assetId); - if (animated) { - animated.cancelled = true; - clearTimeout(animated.timeout); - animated.bitmap?.close?.(); - animated.decoder?.close?.(); - animatedCache.delete(assetId); - } - animationFailures.delete(assetId); - const cachedBlob = blobCache.get(assetId); - if (cachedBlob?.objectUrl) { - URL.revokeObjectURL(cachedBlob.objectUrl); - } - blobCache.delete(assetId); - const audio = audioControllers.get(assetId); - if (audio) { - if (audio.delayTimeout) { - clearTimeout(audio.delayTimeout); + const element = mediaCache.get(assetId); + if (isVideoElement(element)) { + element.src = ""; + element.remove(); + } + mediaCache.delete(assetId); + const animated = animatedCache.get(assetId); + if (animated) { + animated.cancelled = true; + clearTimeout(animated.timeout); + animated.bitmap?.close?.(); + animated.decoder?.close?.(); + animatedCache.delete(assetId); + } + animationFailures.delete(assetId); + const cachedBlob = blobCache.get(assetId); + if (cachedBlob?.objectUrl) { + URL.revokeObjectURL(cachedBlob.objectUrl); + } + blobCache.delete(assetId); + const audio = audioControllers.get(assetId); + if (audio) { + if (audio.delayTimeout) { + clearTimeout(audio.delayTimeout); + } + audio.element.pause(); + audio.element.currentTime = 0; + audio.element.src = ""; + audio.element.remove(); + audioControllers.delete(assetId); } - audio.element.pause(); - audio.element.currentTime = 0; - audio.element.src = ""; - audio.element.remove(); - audioControllers.delete(assetId); - } } function ensureAudioController(asset) { - const cached = audioControllers.get(asset.id); - if (cached && cached.src === asset.url) { - applyAudioSettings(cached, asset); - return cached; - } + const cached = audioControllers.get(asset.id); + if (cached && cached.src === asset.url) { + applyAudioSettings(cached, asset); + return cached; + } - if (cached) { - clearMedia(asset.id); - } + if (cached) { + clearMedia(asset.id); + } - const element = new Audio(asset.url); - element.autoplay = true; - element.preload = "auto"; - element.controls = false; - element.addEventListener("loadedmetadata", () => recordDuration(asset.id, element.duration)); - const controller = { - id: asset.id, - src: asset.url, - element, - delayTimeout: null, - loopEnabled: false, - loopActive: true, - delayMs: 0, - baseDelayMs: 0, - }; - element.onended = () => handleAudioEnded(asset.id); - audioControllers.set(asset.id, controller); - applyAudioSettings(controller, asset, true); - return controller; + const element = new Audio(asset.url); + element.autoplay = true; + element.preload = "auto"; + element.controls = false; + element.addEventListener("loadedmetadata", () => recordDuration(asset.id, element.duration)); + const controller = { + id: asset.id, + src: asset.url, + element, + delayTimeout: null, + loopEnabled: false, + loopActive: true, + delayMs: 0, + baseDelayMs: 0, + }; + element.onended = () => handleAudioEnded(asset.id); + audioControllers.set(asset.id, controller); + applyAudioSettings(controller, asset, true); + return controller; } function applyAudioSettings(controller, asset, resetPosition = false) { - controller.loopEnabled = !!asset.audioLoop; - controller.loopActive = controller.loopEnabled && controller.loopActive !== false; - controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0); - controller.delayMs = controller.baseDelayMs; - applyAudioElementSettings(controller.element, asset); - if (resetPosition) { - controller.element.currentTime = 0; - controller.element.pause(); - } + controller.loopEnabled = !!asset.audioLoop; + controller.loopActive = controller.loopEnabled && controller.loopActive !== false; + controller.baseDelayMs = Math.max(0, asset.audioDelayMillis || 0); + controller.delayMs = controller.baseDelayMs; + applyAudioElementSettings(controller.element, asset); + if (resetPosition) { + controller.element.currentTime = 0; + controller.element.pause(); + } } function applyAudioElementSettings(element, asset) { - const speed = Math.max(0.25, asset.audioSpeed || 1); - const pitch = Math.max(0.5, asset.audioPitch || 1); - element.playbackRate = speed * pitch; - const volume = Math.max(0, Math.min(2, asset.audioVolume ?? 1)); - element.volume = Math.min(volume, 1); + const speed = Math.max(0.25, asset.audioSpeed || 1); + const pitch = Math.max(0.5, asset.audioPitch || 1); + element.playbackRate = speed * pitch; + const volume = Math.max(0, Math.min(2, asset.audioVolume ?? 1)); + element.volume = Math.min(volume, 1); } function getAssetVolume(asset) { - return Math.max(0, Math.min(2, asset?.audioVolume ?? 1)); + return Math.max(0, Math.min(2, asset?.audioVolume ?? 1)); } function applyMediaVolume(element, asset) { - if (!element) return 1; - const volume = getAssetVolume(asset); - element.volume = Math.min(volume, 1); - return volume; + if (!element) return 1; + const volume = getAssetVolume(asset); + element.volume = Math.min(volume, 1); + return volume; } function handleAudioEnded(assetId) { - const controller = audioControllers.get(assetId); - if (!controller) return; - controller.element.currentTime = 0; - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - } - if (controller.loopEnabled && controller.loopActive) { - controller.delayTimeout = setTimeout(() => { - safePlay(controller); - }, controller.delayMs); - } else { - controller.element.pause(); - } + const controller = audioControllers.get(assetId); + if (!controller) return; + controller.element.currentTime = 0; + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + } + if (controller.loopEnabled && controller.loopActive) { + controller.delayTimeout = setTimeout(() => { + safePlay(controller); + }, controller.delayMs); + } else { + controller.element.pause(); + } } function stopAudio(assetId) { - const controller = audioControllers.get(assetId); - if (!controller) return; - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - } - controller.element.pause(); - controller.element.currentTime = 0; - controller.delayTimeout = null; - controller.delayMs = controller.baseDelayMs; - controller.loopActive = false; + const controller = audioControllers.get(assetId); + if (!controller) return; + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + } + controller.element.pause(); + controller.element.currentTime = 0; + controller.delayTimeout = null; + controller.delayMs = controller.baseDelayMs; + controller.loopActive = false; } function playAudioImmediately(asset) { - const controller = ensureAudioController(asset); - if (controller.delayTimeout) { - clearTimeout(controller.delayTimeout); - controller.delayTimeout = null; - } - controller.element.currentTime = 0; - const originalDelay = controller.delayMs; - controller.delayMs = 0; - safePlay(controller); - controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; + const controller = ensureAudioController(asset); + if (controller.delayTimeout) { + clearTimeout(controller.delayTimeout); + controller.delayTimeout = null; + } + controller.element.currentTime = 0; + const originalDelay = controller.delayMs; + controller.delayMs = 0; + safePlay(controller); + controller.delayMs = controller.baseDelayMs ?? originalDelay ?? 0; } function playOverlappingAudio(asset) { - const temp = new Audio(asset.url); - temp.autoplay = true; - temp.preload = "auto"; - temp.controls = false; - applyAudioElementSettings(temp, asset); - const controller = { element: temp }; - temp.onended = () => { - temp.remove(); - }; - safePlay(controller); + const temp = new Audio(asset.url); + temp.autoplay = true; + temp.preload = "auto"; + temp.controls = false; + applyAudioElementSettings(temp, asset); + const controller = { element: temp }; + temp.onended = () => { + temp.remove(); + }; + safePlay(controller); } function handleAudioPlay(asset, shouldPlay) { - const controller = ensureAudioController(asset); - controller.loopActive = !!shouldPlay; - if (!shouldPlay) { - stopAudio(asset.id); - return; - } - if (asset.audioLoop) { - controller.delayMs = controller.baseDelayMs; - safePlay(controller); - } else { - playOverlappingAudio(asset); - } + const controller = ensureAudioController(asset); + controller.loopActive = !!shouldPlay; + if (!shouldPlay) { + stopAudio(asset.id); + return; + } + if (asset.audioLoop) { + controller.delayMs = controller.baseDelayMs; + safePlay(controller); + } else { + playOverlappingAudio(asset); + } } function autoStartAudio(asset) { - if (!isAudioAsset(asset) || asset.hidden) { - return; - } - const controller = ensureAudioController(asset); - if (!controller.loopEnabled || !controller.loopActive) { - return; - } - if (!controller.element.paused && !controller.element.ended) { - return; - } - if (controller.delayTimeout) { - return; - } - controller.delayTimeout = setTimeout(() => { - safePlay(controller); - }, controller.delayMs); + if (!isAudioAsset(asset) || asset.hidden) { + return; + } + const controller = ensureAudioController(asset); + if (!controller.loopEnabled || !controller.loopActive) { + return; + } + if (!controller.element.paused && !controller.element.ended) { + return; + } + if (controller.delayTimeout) { + return; + } + controller.delayTimeout = setTimeout(() => { + safePlay(controller); + }, controller.delayMs); } function ensureMedia(asset) { - const cached = mediaCache.get(asset.id); - const cachedSource = getCachedSource(cached); - if (cached && cachedSource !== asset.url) { - clearMedia(asset.id); - } - if (cached && cachedSource === asset.url) { - applyMediaSettings(cached, asset); - return cached; - } - - if (isAudioAsset(asset)) { - ensureAudioController(asset); - mediaCache.delete(asset.id); - return null; - } - - if (isGifAsset(asset) && supportsAnimatedDecode) { - const animated = ensureAnimatedImage(asset); - if (animated) { - mediaCache.set(asset.id, animated); - return animated; + const cached = mediaCache.get(asset.id); + const cachedSource = getCachedSource(cached); + if (cached && cachedSource !== asset.url) { + clearMedia(asset.id); } - } - - const element = isVideoAsset(asset) ? document.createElement("video") : new Image(); - element.dataset.sourceUrl = asset.url; - element.crossOrigin = "anonymous"; - if (isVideoElement(element)) { - if (!canPlayVideoType(asset.mediaType)) { - return null; + if (cached && cachedSource === asset.url) { + applyMediaSettings(cached, asset); + return cached; } - element.loop = true; - element.playsInline = true; - element.autoplay = true; - element.controls = false; - element.onloadeddata = draw; - element.onloadedmetadata = () => recordDuration(asset.id, element.duration); - element.preload = "auto"; - element.addEventListener("error", () => clearMedia(asset.id)); - applyMediaVolume(element, asset); - element.muted = true; - setVideoSource(element, asset); - } else { - element.onload = draw; - element.src = asset.url; - } - mediaCache.set(asset.id, element); - return element; + + if (isAudioAsset(asset)) { + ensureAudioController(asset); + mediaCache.delete(asset.id); + return null; + } + + if (isGifAsset(asset) && supportsAnimatedDecode) { + const animated = ensureAnimatedImage(asset); + if (animated) { + mediaCache.set(asset.id, animated); + return animated; + } + } + + const element = isVideoAsset(asset) ? document.createElement("video") : new Image(); + element.dataset.sourceUrl = asset.url; + element.crossOrigin = "anonymous"; + if (isVideoElement(element)) { + if (!canPlayVideoType(asset.mediaType)) { + return null; + } + element.loop = true; + element.playsInline = true; + element.autoplay = true; + element.controls = false; + element.onloadeddata = draw; + element.onloadedmetadata = () => recordDuration(asset.id, element.duration); + element.preload = "auto"; + element.addEventListener("error", () => clearMedia(asset.id)); + applyMediaVolume(element, asset); + element.muted = true; + setVideoSource(element, asset); + } else { + element.onload = draw; + element.src = asset.url; + } + mediaCache.set(asset.id, element); + return element; } function ensureAnimatedImage(asset) { - const failedAt = animationFailures.get(asset.id); - if (failedAt && Date.now() - failedAt < 15000) { - return null; - } - const cached = animatedCache.get(asset.id); - if (cached && cached.url === asset.url) { - return cached; - } - - animationFailures.delete(asset.id); - - if (cached) { - clearMedia(asset.id); - } - - const controller = { - id: asset.id, - url: asset.url, - src: asset.url, - decoder: null, - bitmap: null, - timeout: null, - cancelled: false, - isAnimated: true, - }; - - fetchAssetBlob(asset) - .then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" })) - .then((decoder) => { - if (controller.cancelled) { - decoder.close?.(); + const failedAt = animationFailures.get(asset.id); + if (failedAt && Date.now() - failedAt < 15000) { return null; - } - controller.decoder = decoder; - scheduleNextFrame(controller); - return controller; - }) - .catch(() => { - animatedCache.delete(asset.id); - animationFailures.set(asset.id, Date.now()); - }); + } + const cached = animatedCache.get(asset.id); + if (cached && cached.url === asset.url) { + return cached; + } - animatedCache.set(asset.id, controller); - return controller; + animationFailures.delete(asset.id); + + if (cached) { + clearMedia(asset.id); + } + + const controller = { + id: asset.id, + url: asset.url, + src: asset.url, + decoder: null, + bitmap: null, + timeout: null, + cancelled: false, + isAnimated: true, + }; + + fetchAssetBlob(asset) + .then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" })) + .then((decoder) => { + if (controller.cancelled) { + decoder.close?.(); + return null; + } + controller.decoder = decoder; + scheduleNextFrame(controller); + return controller; + }) + .catch(() => { + animatedCache.delete(asset.id); + animationFailures.set(asset.id, Date.now()); + }); + + animatedCache.set(asset.id, controller); + return controller; } function fetchAssetBlob(asset) { - const cached = blobCache.get(asset.id); - if (cached && cached.url === asset.url && cached.blob) { - return Promise.resolve(cached.blob); - } - if (cached && cached.url === asset.url && cached.pending) { - return cached.pending; - } + const cached = blobCache.get(asset.id); + if (cached && cached.url === asset.url && cached.blob) { + return Promise.resolve(cached.blob); + } + if (cached && cached.url === asset.url && cached.pending) { + return cached.pending; + } - const pending = fetch(asset.url) - .then((r) => r.blob()) - .then((blob) => { - const previous = blobCache.get(asset.id); - const existingUrl = previous?.url === asset.url ? previous.objectUrl : null; - const objectUrl = existingUrl || URL.createObjectURL(blob); - blobCache.set(asset.id, { url: asset.url, blob, objectUrl }); - return blob; - }); - blobCache.set(asset.id, { url: asset.url, pending }); - return pending; + const pending = fetch(asset.url) + .then((r) => r.blob()) + .then((blob) => { + const previous = blobCache.get(asset.id); + const existingUrl = previous?.url === asset.url ? previous.objectUrl : null; + const objectUrl = existingUrl || URL.createObjectURL(blob); + blobCache.set(asset.id, { url: asset.url, blob, objectUrl }); + return blob; + }); + blobCache.set(asset.id, { url: asset.url, pending }); + return pending; } function setVideoSource(element, asset) { - if (!shouldUseBlobUrl(asset)) { - applyVideoSource(element, asset.url, asset); - return; - } - - const cached = blobCache.get(asset.id); - if (cached?.url === asset.url && cached.objectUrl) { - applyVideoSource(element, cached.objectUrl, asset); - return; - } - - fetchAssetBlob(asset) - .then(() => { - const next = blobCache.get(asset.id); - if (next?.url !== asset.url || !next.objectUrl) { + if (!shouldUseBlobUrl(asset)) { + applyVideoSource(element, asset.url, asset); return; - } - applyVideoSource(element, next.objectUrl, asset); - }) - .catch(() => {}); + } + + const cached = blobCache.get(asset.id); + if (cached?.url === asset.url && cached.objectUrl) { + applyVideoSource(element, cached.objectUrl, asset); + return; + } + + fetchAssetBlob(asset) + .then(() => { + const next = blobCache.get(asset.id); + if (next?.url !== asset.url || !next.objectUrl) { + return; + } + applyVideoSource(element, next.objectUrl, asset); + }) + .catch(() => {}); } function applyVideoSource(element, objectUrl, asset) { - element.src = objectUrl; - startVideoPlayback(element, asset); + element.src = objectUrl; + startVideoPlayback(element, asset); } function shouldUseBlobUrl(asset) { - return !obsBrowser && asset?.mediaType && canPlayVideoType(asset.mediaType); + return !obsBrowser && asset?.mediaType && canPlayVideoType(asset.mediaType); } function canPlayVideoType(mediaType) { - if (!mediaType) { - return true; - } - const support = canPlayProbe.canPlayType(mediaType); - return support === "probably" || support === "maybe"; + if (!mediaType) { + return true; + } + const support = canPlayProbe.canPlayType(mediaType); + return support === "probably" || support === "maybe"; } function getCachedSource(element) { - return element?.dataset?.sourceUrl || element?.src; + return element?.dataset?.sourceUrl || element?.src; } function scheduleNextFrame(controller) { - if (controller.cancelled || !controller.decoder) { - return; - } - controller.decoder - .decode() - .then(({ image, complete }) => { - if (controller.cancelled) { - image.close?.(); + if (controller.cancelled || !controller.decoder) { return; - } - controller.bitmap?.close?.(); - createImageBitmap(image) - .then((bitmap) => { - controller.bitmap = bitmap; - draw(); - }) - .finally(() => image.close?.()); + } + controller.decoder + .decode() + .then(({ image, complete }) => { + if (controller.cancelled) { + image.close?.(); + return; + } + controller.bitmap?.close?.(); + createImageBitmap(image) + .then((bitmap) => { + controller.bitmap = bitmap; + draw(); + }) + .finally(() => image.close?.()); - const durationMicros = image.duration || 0; - const delay = durationMicros > 0 ? durationMicros / 1000 : 100; - const hasMore = !complete; - controller.timeout = setTimeout(() => { - if (controller.cancelled) { - return; - } - if (hasMore) { - scheduleNextFrame(controller); - } else { - controller.decoder.reset(); - scheduleNextFrame(controller); - } - }, delay); - }) - .catch(() => { - // If decoding fails, clear animated cache so static fallback is used next render - animatedCache.delete(controller.id); - animationFailures.set(controller.id, Date.now()); - }); + const durationMicros = image.duration || 0; + const delay = durationMicros > 0 ? durationMicros / 1000 : 100; + const hasMore = !complete; + controller.timeout = setTimeout(() => { + if (controller.cancelled) { + return; + } + if (hasMore) { + scheduleNextFrame(controller); + } else { + controller.decoder.reset(); + scheduleNextFrame(controller); + } + }, delay); + }) + .catch(() => { + // If decoding fails, clear animated cache so static fallback is used next render + animatedCache.delete(controller.id); + animationFailures.set(controller.id, Date.now()); + }); } function applyMediaSettings(element, asset) { - if (!isVideoElement(element)) { - return; - } - startVideoPlayback(element, asset); + if (!isVideoElement(element)) { + return; + } + startVideoPlayback(element, asset); } function startVideoPlayback(element, asset) { - const nextSpeed = asset.speed ?? 1; - const effectiveSpeed = Math.max(nextSpeed, 0.01); - if (element.playbackRate !== effectiveSpeed) { - element.playbackRate = effectiveSpeed; - } - const volume = applyMediaVolume(element, asset); - const shouldUnmute = volume > 0; - element.muted = true; - - if (effectiveSpeed === 0) { - element.pause(); - return; - } - - element.play(); - - if (shouldUnmute) { - if (!element.paused && element.readyState >= 2) { - element.muted = false; - } else { - element.addEventListener( - "playing", - () => { - element.muted = false; - }, - { once: true }, - ); + const nextSpeed = asset.speed ?? 1; + const effectiveSpeed = Math.max(nextSpeed, 0.01); + if (element.playbackRate !== effectiveSpeed) { + element.playbackRate = effectiveSpeed; + } + const volume = applyMediaVolume(element, asset); + const shouldUnmute = volume > 0; + element.muted = true; + + if (effectiveSpeed === 0) { + element.pause(); + return; + } + + element.play(); + + if (shouldUnmute) { + if (!element.paused && element.readyState >= 2) { + element.muted = false; + } else { + element.addEventListener( + "playing", + () => { + element.muted = false; + }, + { once: true }, + ); + } } - } } function startRenderLoop() { - if (renderIntervalId) { - return; - } - renderIntervalId = setInterval(() => { - draw(); - }, MIN_FRAME_TIME); + if (renderIntervalId) { + return; + } + renderIntervalId = setInterval(() => { + draw(); + }, MIN_FRAME_TIME); } window.addEventListener("resize", () => { - resizeCanvas(); + resizeCanvas(); }); fetchCanvasSettings().finally(() => { - resizeCanvas(); - startRenderLoop(); - connect(); + resizeCanvas(); + startRenderLoop(); + connect(); }); diff --git a/src/main/resources/static/js/cookie-consent.js b/src/main/resources/static/js/cookie-consent.js index e5b95b9..6aa5fc4 100644 --- a/src/main/resources/static/js/cookie-consent.js +++ b/src/main/resources/static/js/cookie-consent.js @@ -12,7 +12,7 @@ const persistDismissal = () => { try { window.localStorage.setItem(CONSENT_STORAGE_KEY, "true"); - } catch { } + } catch {} document.cookie = `${CONSENT_STORAGE_KEY}=true; max-age=${COOKIE_MAX_AGE_SECONDS}; path=/; SameSite=Lax`; }; @@ -21,7 +21,7 @@ if (window.localStorage.getItem(CONSENT_STORAGE_KEY) === "true") { return true; } - } catch { } + } catch {} return readConsentCookie() === "true"; }; diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index 82857e4..bf559ff 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -1,228 +1,228 @@ function buildIdentity(admin) { - const identity = document.createElement("div"); - identity.className = "identity-row"; + const identity = document.createElement("div"); + identity.className = "identity-row"; - const avatar = document.createElement(admin.avatarUrl ? "img" : "div"); - avatar.className = "avatar"; - if (admin.avatarUrl) { - avatar.src = admin.avatarUrl; - avatar.alt = `${admin.displayName || admin.login} avatar`; - } else { - avatar.classList.add("avatar-fallback"); - avatar.textContent = (admin.displayName || admin.login || "?").charAt(0).toUpperCase(); - } + const avatar = document.createElement(admin.avatarUrl ? "img" : "div"); + avatar.className = "avatar"; + if (admin.avatarUrl) { + avatar.src = admin.avatarUrl; + avatar.alt = `${admin.displayName || admin.login} avatar`; + } else { + avatar.classList.add("avatar-fallback"); + avatar.textContent = (admin.displayName || admin.login || "?").charAt(0).toUpperCase(); + } - const details = document.createElement("div"); - details.className = "identity-text"; - const title = document.createElement("p"); - title.className = "list-title"; - title.textContent = admin.displayName || admin.login; - const subtitle = document.createElement("p"); - subtitle.className = "muted"; - subtitle.textContent = `@${admin.login}`; + const details = document.createElement("div"); + details.className = "identity-text"; + const title = document.createElement("p"); + title.className = "list-title"; + title.textContent = admin.displayName || admin.login; + const subtitle = document.createElement("p"); + subtitle.className = "muted"; + subtitle.textContent = `@${admin.login}`; - details.appendChild(title); - details.appendChild(subtitle); - identity.appendChild(avatar); - identity.appendChild(details); - return identity; + details.appendChild(title); + details.appendChild(subtitle); + identity.appendChild(avatar); + identity.appendChild(details); + return identity; } function renderAdmins(list) { - const adminList = document.getElementById("admin-list"); - if (!adminList) return; - adminList.innerHTML = ""; - if (!list || list.length === 0) { - const empty = document.createElement("li"); - empty.textContent = "No channel admins yet"; - adminList.appendChild(empty); - return; - } + const adminList = document.getElementById("admin-list"); + if (!adminList) return; + adminList.innerHTML = ""; + if (!list || list.length === 0) { + const empty = document.createElement("li"); + empty.textContent = "No channel admins yet"; + adminList.appendChild(empty); + return; + } - list.forEach((admin) => { - const li = document.createElement("li"); - li.className = "stacked-list-item"; + list.forEach((admin) => { + const li = document.createElement("li"); + li.className = "stacked-list-item"; - li.appendChild(buildIdentity(admin)); + li.appendChild(buildIdentity(admin)); - const actions = document.createElement("div"); - actions.className = "actions"; + const actions = document.createElement("div"); + actions.className = "actions"; - const removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.className = "secondary"; - removeBtn.textContent = "Remove"; - removeBtn.addEventListener("click", () => removeAdmin(admin.login)); + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "secondary"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", () => removeAdmin(admin.login)); - actions.appendChild(removeBtn); - li.appendChild(actions); - adminList.appendChild(li); - }); + actions.appendChild(removeBtn); + li.appendChild(actions); + adminList.appendChild(li); + }); } function renderSuggestedAdmins(list) { - const suggestionList = document.getElementById("admin-suggestions"); - if (!suggestionList) return; + const suggestionList = document.getElementById("admin-suggestions"); + if (!suggestionList) return; - suggestionList.innerHTML = ""; - if (!list || list.length === 0) { - const empty = document.createElement("li"); - empty.className = "stacked-list-item"; - empty.textContent = "No moderator suggestions right now"; - suggestionList.appendChild(empty); - return; - } + suggestionList.innerHTML = ""; + if (!list || list.length === 0) { + const empty = document.createElement("li"); + empty.className = "stacked-list-item"; + empty.textContent = "No moderator suggestions right now"; + suggestionList.appendChild(empty); + return; + } - list.forEach((admin) => { - const li = document.createElement("li"); - li.className = "stacked-list-item"; + list.forEach((admin) => { + const li = document.createElement("li"); + li.className = "stacked-list-item"; - li.appendChild(buildIdentity(admin)); + li.appendChild(buildIdentity(admin)); - const actions = document.createElement("div"); - actions.className = "actions"; + const actions = document.createElement("div"); + actions.className = "actions"; - const addBtn = document.createElement("button"); - addBtn.type = "button"; - addBtn.className = "ghost"; - addBtn.textContent = "Add as admin"; - addBtn.addEventListener("click", () => addAdmin(admin.login)); + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "ghost"; + addBtn.textContent = "Add as admin"; + addBtn.addEventListener("click", () => addAdmin(admin.login)); - actions.appendChild(addBtn); - li.appendChild(actions); - suggestionList.appendChild(li); - }); + actions.appendChild(addBtn); + li.appendChild(actions); + suggestionList.appendChild(li); + }); } function fetchSuggestedAdmins() { - fetch(`/api/channels/${broadcaster}/admins/suggestions`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load admin suggestions"); - } - return r.json(); - }) - .then(renderSuggestedAdmins) - .catch(() => { - renderSuggestedAdmins([]); - }); + fetch(`/api/channels/${broadcaster}/admins/suggestions`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load admin suggestions"); + } + return r.json(); + }) + .then(renderSuggestedAdmins) + .catch(() => { + renderSuggestedAdmins([]); + }); } function fetchAdmins() { - fetch(`/api/channels/${broadcaster}/admins`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load admins"); - } - return r.json(); - }) - .then(renderAdmins) - .catch(() => { - renderAdmins([]); - showToast("Unable to load admins right now. Please try again.", "error"); - }); + fetch(`/api/channels/${broadcaster}/admins`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load admins"); + } + return r.json(); + }) + .then(renderAdmins) + .catch(() => { + renderAdmins([]); + showToast("Unable to load admins right now. Please try again.", "error"); + }); } function removeAdmin(username) { - if (!username) return; - fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, { - method: "DELETE", - }) - .then((response) => { - if (!response.ok) { - throw new Error(); - } - fetchAdmins(); - fetchSuggestedAdmins(); + if (!username) return; + fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, { + method: "DELETE", }) - .catch(() => { - showToast("Failed to remove admin. Please retry.", "error"); - }); + .then((response) => { + if (!response.ok) { + throw new Error(); + } + fetchAdmins(); + fetchSuggestedAdmins(); + }) + .catch(() => { + showToast("Failed to remove admin. Please retry.", "error"); + }); } function addAdmin(usernameFromAction) { - const input = document.getElementById("new-admin"); - const username = (usernameFromAction || input?.value || "").trim(); - if (!username) { - showToast("Enter a Twitch username to add as an admin.", "info"); - return; - } + const input = document.getElementById("new-admin"); + const username = (usernameFromAction || input?.value || "").trim(); + if (!username) { + showToast("Enter a Twitch username to add as an admin.", "info"); + return; + } - fetch(`/api/channels/${broadcaster}/admins`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username }), - }) - .then((response) => { - if (!response.ok) { - throw new Error("Add admin failed"); - } - if (input) { - input.value = ""; - } - showToast(`Added @${username} as an admin.`, "success"); - fetchAdmins(); - fetchSuggestedAdmins(); + fetch(`/api/channels/${broadcaster}/admins`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username }), }) - .catch(() => showToast("Unable to add admin right now. Please try again.", "error")); + .then((response) => { + if (!response.ok) { + throw new Error("Add admin failed"); + } + if (input) { + input.value = ""; + } + showToast(`Added @${username} as an admin.`, "success"); + fetchAdmins(); + fetchSuggestedAdmins(); + }) + .catch(() => showToast("Unable to add admin right now. Please try again.", "error")); } function renderCanvasSettings(settings) { - const widthInput = document.getElementById("canvas-width"); - const heightInput = document.getElementById("canvas-height"); - if (widthInput) widthInput.value = Math.round(settings.width); - if (heightInput) heightInput.value = Math.round(settings.height); + const widthInput = document.getElementById("canvas-width"); + const heightInput = document.getElementById("canvas-height"); + if (widthInput) widthInput.value = Math.round(settings.width); + if (heightInput) heightInput.value = Math.round(settings.height); } function fetchCanvasSettings() { - fetch(`/api/channels/${broadcaster}/canvas`) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load canvas settings"); - } - return r.json(); - }) - .then(renderCanvasSettings) - .catch(() => { - renderCanvasSettings({ width: 1920, height: 1080 }); - showToast("Using default canvas size. Unable to load saved settings.", "warning"); - }); + fetch(`/api/channels/${broadcaster}/canvas`) + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load canvas settings"); + } + return r.json(); + }) + .then(renderCanvasSettings) + .catch(() => { + renderCanvasSettings({ width: 1920, height: 1080 }); + showToast("Using default canvas size. Unable to load saved settings.", "warning"); + }); } function saveCanvasSettings() { - const widthInput = document.getElementById("canvas-width"); - const heightInput = document.getElementById("canvas-height"); - const status = document.getElementById("canvas-status"); - const width = parseFloat(widthInput?.value) || 0; - const height = parseFloat(heightInput?.value) || 0; - if (width <= 0 || height <= 0) { - showToast("Please enter a valid width and height.", "info"); - return; - } - if (status) status.textContent = "Saving..."; - fetch(`/api/channels/${broadcaster}/canvas`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ width, height }), - }) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to save canvas"); - } - return r.json(); + const widthInput = document.getElementById("canvas-width"); + const heightInput = document.getElementById("canvas-height"); + const status = document.getElementById("canvas-status"); + const width = parseFloat(widthInput?.value) || 0; + const height = parseFloat(heightInput?.value) || 0; + if (width <= 0 || height <= 0) { + showToast("Please enter a valid width and height.", "info"); + return; + } + if (status) status.textContent = "Saving..."; + fetch(`/api/channels/${broadcaster}/canvas`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ width, height }), }) - .then((settings) => { - renderCanvasSettings(settings); - if (status) status.textContent = "Saved."; - showToast("Canvas size saved successfully.", "success"); - setTimeout(() => { - if (status) status.textContent = ""; - }, 2000); - }) - .catch(() => { - if (status) status.textContent = "Unable to save right now."; - showToast("Unable to save canvas size. Please retry.", "error"); - }); + .then((r) => { + if (!r.ok) { + throw new Error("Failed to save canvas"); + } + return r.json(); + }) + .then((settings) => { + renderCanvasSettings(settings); + if (status) status.textContent = "Saved."; + showToast("Canvas size saved successfully.", "success"); + setTimeout(() => { + if (status) status.textContent = ""; + }, 2000); + }) + .catch(() => { + if (status) status.textContent = "Unable to save right now."; + showToast("Unable to save canvas size. Please retry.", "error"); + }); } fetchAdmins(); diff --git a/src/main/resources/static/js/downloads.js b/src/main/resources/static/js/downloads.js index 13c1185..4a0b9ed 100644 --- a/src/main/resources/static/js/downloads.js +++ b/src/main/resources/static/js/downloads.js @@ -1,40 +1,40 @@ function detectPlatform() { - const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || "").toLowerCase(); - const userAgent = (navigator.userAgent || "").toLowerCase(); - const platformString = `${navigatorPlatform} ${userAgent}`; + const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || "").toLowerCase(); + const userAgent = (navigator.userAgent || "").toLowerCase(); + const platformString = `${navigatorPlatform} ${userAgent}`; - if (platformString.includes("mac") || platformString.includes("darwin")) { - return "mac"; - } - if (platformString.includes("win")) { - return "windows"; - } - if (platformString.includes("linux")) { - return "linux"; - } - return null; + if (platformString.includes("mac") || platformString.includes("darwin")) { + return "mac"; + } + if (platformString.includes("win")) { + return "windows"; + } + if (platformString.includes("linux")) { + return "linux"; + } + return null; } function markRecommendedDownload(section) { - const cards = Array.from(section.querySelectorAll(".download-card")); - if (!cards.length) { - return; - } - - const platform = detectPlatform(); - const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0]; - - cards.forEach((card) => { - const isPreferred = card === preferredCard; - card.classList.toggle("download-card--active", isPreferred); - const badge = card.querySelector(".recommended-badge"); - if (badge) { - badge.classList.toggle("hidden", !isPreferred); + const cards = Array.from(section.querySelectorAll(".download-card")); + if (!cards.length) { + return; } - }); + + const platform = detectPlatform(); + const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0]; + + cards.forEach((card) => { + const isPreferred = card === preferredCard; + card.classList.toggle("download-card--active", isPreferred); + const badge = card.querySelector(".recommended-badge"); + if (badge) { + badge.classList.toggle("hidden", !isPreferred); + } + }); } document.addEventListener("DOMContentLoaded", () => { - const downloadSections = document.querySelectorAll(".download-section, .download-card-block"); - downloadSections.forEach(markRecommendedDownload); + const downloadSections = document.querySelectorAll(".download-section, .download-card-block"); + downloadSections.forEach(markRecommendedDownload); }); diff --git a/src/main/resources/static/js/landing.js b/src/main/resources/static/js/landing.js index 3c41e29..886da85 100644 --- a/src/main/resources/static/js/landing.js +++ b/src/main/resources/static/js/landing.js @@ -1,54 +1,54 @@ document.addEventListener("DOMContentLoaded", () => { - const searchForm = document.getElementById("channel-search-form"); - const searchInput = document.getElementById("channel-search"); - const suggestions = document.getElementById("channel-suggestions"); + const searchForm = document.getElementById("channel-search-form"); + const searchInput = document.getElementById("channel-search"); + const suggestions = document.getElementById("channel-suggestions"); - if (!searchForm || !searchInput || !suggestions) { - console.error("Required elements not found in the DOM"); - return; - } + if (!searchForm || !searchInput || !suggestions) { + console.error("Required elements not found in the DOM"); + return; + } - let channels = []; + let channels = []; - function updateSuggestions(term) { - const normalizedTerm = term.trim().toLowerCase(); - const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20); + 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); + 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.focus({ preventScroll: true }); + searchInput.select(); + + 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`; }); - } - 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.focus({ preventScroll: true }); - searchInput.select(); - - 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(); + loadChannels(); }); diff --git a/src/main/resources/static/js/settings.js b/src/main/resources/static/js/settings.js index 9b17cee..361bd67 100644 --- a/src/main/resources/static/js/settings.js +++ b/src/main/resources/static/js/settings.js @@ -19,130 +19,130 @@ const currentSettings = JSON.parse(serverRenderedSettings); let userSettings = { ...currentSettings }; function jsonEquals(a, b) { - if (a === b) return true; + if (a === b) return true; - if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { - return false; - } + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { + return false; + } - const keysA = Object.keys(a); - const keysB = Object.keys(b); + const keysA = Object.keys(a); + const keysB = Object.keys(b); - if (keysA.length !== keysB.length) return false; + if (keysA.length !== keysB.length) return false; - for (const key of keysA) { - if (!keysB.includes(key)) return false; - if (!jsonEquals(a[key], b[key])) return false; - } + for (const key of keysA) { + if (!keysB.includes(key)) return false; + if (!jsonEquals(a[key], b[key])) return false; + } - return true; + return true; } function setFormSettings(s) { - canvasFpsElement.value = s.canvasFramesPerSecond; - canvasSizeElement.value = s.maxCanvasSideLengthPixels; + canvasFpsElement.value = s.canvasFramesPerSecond; + canvasSizeElement.value = s.maxCanvasSideLengthPixels; - minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction; - maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction; - minPitchElement.value = s.minAssetAudioPitchFraction; - maxPitchElement.value = s.maxAssetAudioPitchFraction; - minVolumeElement.value = s.minAssetVolumeFraction; - maxVolumeElement.value = s.maxAssetVolumeFraction; + minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction; + maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction; + minPitchElement.value = s.minAssetAudioPitchFraction; + maxPitchElement.value = s.maxAssetAudioPitchFraction; + minVolumeElement.value = s.minAssetVolumeFraction; + maxVolumeElement.value = s.maxAssetVolumeFraction; } function updateStatCards(settings) { - if (!settings) return; - statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`; - statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`; - statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`; - statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`; - statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`; + if (!settings) return; + statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`; + statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`; + statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`; + statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`; + statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`; } function readInt(input) { - return input.checkValidity() ? Number(input.value) : null; + return input.checkValidity() ? Number(input.value) : null; } function readFloat(input) { - return input.checkValidity() ? Number(input.value) : null; + return input.checkValidity() ? Number(input.value) : null; } function loadUserSettingsFromDom() { - userSettings.canvasFramesPerSecond = readInt(canvasFpsElement); - userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement); - userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement); - userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement); - userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement); - userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement); - userSettings.minAssetVolumeFraction = readFloat(minVolumeElement); - userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement); + userSettings.canvasFramesPerSecond = readInt(canvasFpsElement); + userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement); + userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement); + userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement); + userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement); + userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement); + userSettings.minAssetVolumeFraction = readFloat(minVolumeElement); + userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement); } function updateSubmitButtonDisabledState() { - if (jsonEquals(currentSettings, userSettings)) { - submitButtonElement.disabled = "disabled"; - statusElement.textContent = "No changes yet."; - statusElement.classList.remove("status-success", "status-warning"); - return; - } - if (!formElement.checkValidity()) { - submitButtonElement.disabled = "disabled"; - statusElement.textContent = "Fix highlighted fields."; - statusElement.classList.add("status-warning"); - statusElement.classList.remove("status-success"); - return; - } - submitButtonElement.disabled = null; - statusElement.textContent = "Ready to save."; - statusElement.classList.remove("status-warning"); + if (jsonEquals(currentSettings, userSettings)) { + submitButtonElement.disabled = "disabled"; + statusElement.textContent = "No changes yet."; + statusElement.classList.remove("status-success", "status-warning"); + return; + } + if (!formElement.checkValidity()) { + submitButtonElement.disabled = "disabled"; + statusElement.textContent = "Fix highlighted fields."; + statusElement.classList.add("status-warning"); + statusElement.classList.remove("status-success"); + return; + } + submitButtonElement.disabled = null; + statusElement.textContent = "Ready to save."; + statusElement.classList.remove("status-warning"); } function submitSettingsForm() { - if (submitButtonElement.getAttribute("disabled") != null) { - console.warn("Attempted to submit invalid form"); - showToast("Settings not valid", "warning"); - return; - } - statusElement.textContent = "Saving…"; - statusElement.classList.remove("status-success", "status-warning"); - fetch("/api/settings/set", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(userSettings), - }) - .then((r) => { - if (!r.ok) { - throw new Error("Failed to load canvas"); - } - return r.json(); + if (submitButtonElement.getAttribute("disabled") != null) { + console.warn("Attempted to submit invalid form"); + showToast("Settings not valid", "warning"); + return; + } + statusElement.textContent = "Saving…"; + statusElement.classList.remove("status-success", "status-warning"); + fetch("/api/settings/set", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(userSettings), }) - .then((newSettings) => { - currentSettings = { ...newSettings }; - userSettings = { ...newSettings }; - updateStatCards(newSettings); - showToast("Settings saved", "success"); - statusElement.textContent = "Saved."; - statusElement.classList.add("status-success"); - updateSubmitButtonDisabledState(); - }) - .catch((error) => { - showToast("Unable to save settings", "error"); - console.error(error); - statusElement.textContent = "Save failed. Try again."; - statusElement.classList.add("status-warning"); - }); + .then((r) => { + if (!r.ok) { + throw new Error("Failed to load canvas"); + } + return r.json(); + }) + .then((newSettings) => { + currentSettings = { ...newSettings }; + userSettings = { ...newSettings }; + updateStatCards(newSettings); + showToast("Settings saved", "success"); + statusElement.textContent = "Saved."; + statusElement.classList.add("status-success"); + updateSubmitButtonDisabledState(); + }) + .catch((error) => { + showToast("Unable to save settings", "error"); + console.error(error); + statusElement.textContent = "Save failed. Try again."; + statusElement.classList.add("status-warning"); + }); } formElement.querySelectorAll("input").forEach((input) => { - input.addEventListener("input", () => { - loadUserSettingsFromDom(); - updateSubmitButtonDisabledState(); - }); + input.addEventListener("input", () => { + loadUserSettingsFromDom(); + updateSubmitButtonDisabledState(); + }); }); formElement.addEventListener("submit", (event) => { - event.preventDefault(); - submitSettingsForm(); + event.preventDefault(); + submitSettingsForm(); }); setFormSettings(currentSettings); diff --git a/src/main/resources/static/js/toast.js b/src/main/resources/static/js/toast.js index a4146ae..80515ba 100644 --- a/src/main/resources/static/js/toast.js +++ b/src/main/resources/static/js/toast.js @@ -1,51 +1,51 @@ (function () { - const CONTAINER_ID = "toast-container"; - const DEFAULT_DURATION = 4200; + const CONTAINER_ID = "toast-container"; + const DEFAULT_DURATION = 4200; - function ensureContainer() { - let container = document.getElementById(CONTAINER_ID); - if (!container) { - container = document.createElement("div"); - container.id = CONTAINER_ID; - container.className = "toast-container"; - container.setAttribute("aria-live", "polite"); - container.setAttribute("aria-atomic", "true"); - document.body.appendChild(container); + function ensureContainer() { + let container = document.getElementById(CONTAINER_ID); + if (!container) { + container = document.createElement("div"); + container.id = CONTAINER_ID; + container.className = "toast-container"; + container.setAttribute("aria-live", "polite"); + container.setAttribute("aria-atomic", "true"); + document.body.appendChild(container); + } + return container; } - return container; - } - function buildToast(message, type) { - const toast = document.createElement("div"); - toast.className = `toast toast-${type}`; + function buildToast(message, type) { + const toast = document.createElement("div"); + toast.className = `toast toast-${type}`; - const indicator = document.createElement("span"); - indicator.className = "toast-indicator"; - indicator.setAttribute("aria-hidden", "true"); + const indicator = document.createElement("span"); + indicator.className = "toast-indicator"; + indicator.setAttribute("aria-hidden", "true"); - const content = document.createElement("div"); - content.className = "toast-message"; - content.textContent = message; + const content = document.createElement("div"); + content.className = "toast-message"; + content.textContent = message; - toast.appendChild(indicator); - toast.appendChild(content); - return toast; - } + toast.appendChild(indicator); + toast.appendChild(content); + return toast; + } - function removeToast(toast) { - if (!toast) return; - toast.classList.add("toast-exit"); - setTimeout(() => toast.remove(), 250); - } + function removeToast(toast) { + if (!toast) return; + toast.classList.add("toast-exit"); + setTimeout(() => toast.remove(), 250); + } - window.showToast = function showToast(message, type = "info", options = {}) { - if (!message) return; - const normalized = ["success", "error", "warning", "info"].includes(type) ? type : "info"; - const duration = typeof options.duration === "number" ? options.duration : DEFAULT_DURATION; - const container = ensureContainer(); - const toast = buildToast(message, normalized); - container.appendChild(toast); - setTimeout(() => removeToast(toast), Math.max(1200, duration)); - toast.addEventListener("click", () => removeToast(toast)); - }; + window.showToast = function showToast(message, type = "info", options = {}) { + if (!message) return; + const normalized = ["success", "error", "warning", "info"].includes(type) ? type : "info"; + const duration = typeof options.duration === "number" ? options.duration : DEFAULT_DURATION; + const container = ensureContainer(); + const toast = buildToast(message, normalized); + container.appendChild(toast); + setTimeout(() => removeToast(toast), Math.max(1200, duration)); + toast.addEventListener("click", () => removeToast(toast)); + }; })(); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index ca58f16..3fad909 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -1,308 +1,358 @@ - - - Imgfloat Admin - - - - - - - -
-
-
-
-

CHANNEL ADMIN

-

-
-
- -
- -
- +
+
+
Volume
+
+
+
+ Playback volume + 100% +
+ +
0%200%
+
+
-
-
-
-

Canvas

-

Live composition

+ +
+ + + + + + + + + +
+
+
+
+
+ + +
+
+
+

Canvas

+

Live composition

+
+
+ 1920 x 1080 + 100% +
+
+
+
+
+ +
+
+

Edges of the canvas are outlined to match the aspect ratio of the stream.

+
+
+
-
- 1920 x 1080 - 100% -
- -
-
-
- -
-
-

Edges of the canvas are outlined to match the aspect ratio of the stream.

-
-
- - - - - - - - + + + + + + diff --git a/src/main/resources/templates/broadcast.html b/src/main/resources/templates/broadcast.html index 761cb63..bc39f74 100644 --- a/src/main/resources/templates/broadcast.html +++ b/src/main/resources/templates/broadcast.html @@ -1,20 +1,20 @@ - - - Imgfloat Broadcast - - - - - - - - - - - - + + + Imgfloat Broadcast + + + + + + + + + + + + diff --git a/src/main/resources/templates/channels.html b/src/main/resources/templates/channels.html index b81ec3a..9ab3439 100644 --- a/src/main/resources/templates/channels.html +++ b/src/main/resources/templates/channels.html @@ -1,46 +1,46 @@ - - - Browse channels - Imgfloat - - - - -
-
-
-
IF
-
-
Imgfloat
-
Twitch overlay manager
-
-
-
+ + + Browse channels - Imgfloat + + + + +
+
+
+
IF
+
+
Imgfloat
+
Twitch overlay manager
+
+
+
-
-
-

Broadcast overlay

-

Open a channel

-

Type the channel name to jump straight to their overlay.

-
- - - - -
-
-
-
- - - +
+
+

Broadcast overlay

+

Open a channel

+

Type the channel name to jump straight to their overlay.

+
+ + + + +
+
+
+
+ + + diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 9d98303..6a8fb71 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -1,119 +1,119 @@ - - - Imgfloat Dashboard - - - - -
-
-
- -
-
Imgfloat
-
Twitch overlay manager
-
-
-
- Signed in as - user -
-
+ + + Imgfloat Dashboard + + + + +
+
+
+ +
+
Imgfloat
+
Twitch overlay manager
+
+
+
+ Signed in as + user +
+
-
-

Navigation

-

Shortcuts

-

Jump into your overlay

- -
+
+

Navigation

+

Shortcuts

+

Jump into your overlay

+ +
-
-

Settings

-

Overlay dimensions

-

Match these with your OBS resolution.

-
- - -
-
- - -
-
+
+

Settings

+

Overlay dimensions

+

Match these with your OBS resolution.

+
+ + +
+
+ + +
+
-
-
-
-
-

Collaboration

-

Channel admins

-

Invite moderators to help manage assets.

-
-
-
- - -
-
-
-

Channel Admins

-

Users who can currently modify your overlay.

-
-
    -
    -
    -
    -

    Your Twitch moderators

    -

    Add moderators who already help run your channel.

    -
    -
      -
      -
      -
      +
      +
      +
      +
      +

      Collaboration

      +

      Channel admins

      +

      Invite moderators to help manage assets.

      +
      +
      +
      + + +
      +
      +
      +

      Channel Admins

      +

      Users who can currently modify your overlay.

      +
      +
        +
        +
        +
        +

        Your Twitch moderators

        +

        Add moderators who already help run your channel.

        +
        +
          +
          +
          +
          -
          -
          -
          -

          Your access

          -

          Channels you administer

          -

          Jump into a teammate's overlay console.

          -
          -
          -

          No admin invitations yet.

          -
            -
          • -
            -

            channel

            -

            Channel admin access

            -
            - Open -
          • -
          -
          +
          +
          +
          +

          Your access

          +

          Channels you administer

          +

          Jump into a teammate's overlay console.

          +
          +
          +

          No admin invitations yet.

          +
            +
          • +
            +

            channel

            +

            Channel admin access

            +
            + Open +
          • +
          +
          -
          -
          - - - - - - +
          +
          + + + + + + diff --git a/src/main/resources/templates/fragments/downloads.html b/src/main/resources/templates/fragments/downloads.html index b4504cd..153fbb6 100644 --- a/src/main/resources/templates/fragments/downloads.html +++ b/src/main/resources/templates/fragments/downloads.html @@ -1,44 +1,44 @@ -
          -

          Desktop app

          -

          Download Imgfloat

          -
          -
          -
          -
          -

          macOS

          - -
          -

          Apple Silicon build (ARM64)

          - Download DMG +
          +

          Desktop app

          +

          Download Imgfloat

          -
          -
          -

          Windows

          - -
          -

          Installer for Windows 10 and 11

          - Download EXE +
          +
          +
          +

          macOS

          + +
          +

          Apple Silicon build (ARM64)

          + Download DMG +
          +
          +
          +

          Windows

          + +
          +

          Installer for Windows 10 and 11

          + Download EXE +
          +
          +
          +

          Linux

          + +
          +

          AppImage for most distributions

          + Download AppImage +
          -
          -
          -

          Linux

          - -
          -

          AppImage for most distributions

          - Download AppImage -
          -
          diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 34b3a53..ebbf23d 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -1,48 +1,50 @@ - - - Imgfloat - Twitch overlay - - - - -
          -
          -
          - -
          -
          Imgfloat
          -
          Twitch overlay manager
          -
          -
          -
          + + + Imgfloat - Twitch overlay + + + + +
          +
          +
          + +
          +
          Imgfloat
          +
          Twitch overlay manager
          +
          +
          +
          -
          -
          -

          Overlay toolkit

          -

          Collaborative real-time Twitch overlay

          -

          Customize your Twitch stream with audio, video and images updated by your mods in real-time

          - -
          -
          +
          +
          +

          Overlay toolkit

          +

          Collaborative real-time Twitch overlay

          +

          + Customize your Twitch stream with audio, video and images updated by your mods in real-time +

          + +
          +
          -
          +
          -
          -
          - License - MIT +
          +
          + License + MIT +
          +
          + Build + unknown +
          +
          -
          - Build - unknown -
          -
          -
          - - - + + + diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 0ab209e..1702a2a 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -1,256 +1,269 @@ - - - Imgfloat Admin - - - - - - - -
          -
          -
          -
          IF
          -
          -
          Imgfloat
          -
          Twitch overlay manager
          -
          + + + Imgfloat Admin + + + + + + + +
          +
          +
          +
          IF
          +
          +
          Imgfloat
          +
          Twitch overlay manager
          +
          +
          +
          + +
          +
          +
          +

          System administrator settings

          +

          Application defaults

          +

          + Configure overlay performance and audio guardrails for every channel using Imgfloat. These + settings are applied globally. +

          +
          + Performance tuned + Server-wide + Admin only +
          +
          +
          +
          +

          Canvas FPS

          +

          --

          +

          Longest side --

          +
          +
          +

          Playback speed

          +

          --

          +

          Applies to all animations

          +
          +
          +

          Audio pitch

          +

          --

          +

          Fraction of original clip

          +
          +
          +

          Volume limits

          +

          --

          +

          Keeps alerts comfortable

          +
          +
          +
          + +
          +
          +
          +
          +

          Overlay defaults

          +

          Performance & audio budget

          +

          + Tune the canvas and audio guardrails to keep overlays smooth and balanced. +

          +
          +
          + +
          +
          +
          +

          Canvas

          +

          Rendering budget

          +

          + Match FPS and max dimensions to your streaming canvas for consistent overlays. +

          +
          +
          + + + +
          +

          + Use the longest edge of your OBS browser source to prevent stretching. +

          +
          + +
          +
          +

          Playback

          +

          Animation speed limits

          +

          + Bound default speeds between 0 and 1 so clips run predictably. +

          +
          +
          + + + +
          +

          + Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate. +

          +
          + +
          +
          +

          Audio

          +

          Pitch & volume guardrails

          +

          + Prevent harsh audio by bounding pitch and volume as fractions of the source. +

          +
          +
          + + + +
          +
          + + + +
          +

          + Volume and pitch values are percentages of the original clip between 0 and 1. +

          +
          + + +
          +
          + + +
          +
          -
          - -
          -
          -
          -

          System administrator settings

          -

          Application defaults

          -

          - Configure overlay performance and audio guardrails for every channel using Imgfloat. These settings are - applied globally. -

          -
          - Performance tuned - Server-wide - Admin only -
          -
          -
          -
          -

          Canvas FPS

          -

          --

          -

          Longest side --

          -
          -
          -

          Playback speed

          -

          --

          -

          Applies to all animations

          -
          -
          -

          Audio pitch

          -

          --

          -

          Fraction of original clip

          -
          -
          -

          Volume limits

          -

          --

          -

          Keeps alerts comfortable

          -
          -
          -
          - -
          -
          -
          -
          -

          Overlay defaults

          -

          Performance & audio budget

          -

          Tune the canvas and audio guardrails to keep overlays smooth and balanced.

          -
          -
          - -
          -
          -
          -

          Canvas

          -

          Rendering budget

          -

          - Match FPS and max dimensions to your streaming canvas for consistent overlays. -

          -
          -
          - - - -
          -

          Use the longest edge of your OBS browser source to prevent stretching.

          -
          - -
          -
          -

          Playback

          -

          Animation speed limits

          -

          Bound default speeds between 0 and 1 so clips run predictably.

          -
          -
          - - - -
          -

          - Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate. -

          -
          - -
          -
          -

          Audio

          -

          Pitch & volume guardrails

          -

          Prevent harsh audio by bounding pitch and volume as fractions of the source.

          -
          -
          - - - -
          -
          - - - -
          -

          Volume and pitch values are percentages of the original clip between 0 and 1.

          -
          - - -
          -
          - - -
          -
          -
          - - - - - + + + + + diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelApiIntegrationTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelApiIntegrationTest.java index 9166ec3..ee7f33e 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelApiIntegrationTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelApiIntegrationTest.java @@ -1,35 +1,35 @@ package dev.kruhlmann.imgfloat; -import com.fasterxml.jackson.databind.ObjectMapper; -import dev.kruhlmann.imgfloat.model.VisibilityRequest; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.mock.web.MockMultipartFile; - -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.IOException; - -import javax.imageio.ImageIO; - import static org.hamcrest.Matchers.hasSize; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@SpringBootTest(properties = { +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.kruhlmann.imgfloat.model.VisibilityRequest; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest( + properties = { "spring.security.oauth2.client.registration.twitch.client-id=test-client-id", - "spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret" -}) + "spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret", + } +) @AutoConfigureMockMvc class ChannelApiIntegrationTest { @@ -42,57 +42,92 @@ class ChannelApiIntegrationTest { @Test void broadcasterManagesAdminsAndAssets() throws Exception { String broadcaster = "caster"; - mockMvc.perform(post("/api/channels/{broadcaster}/admins", broadcaster) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"helper\"}") - .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) - .andExpect(status().isOk()); + mockMvc + .perform( + post("/api/channels/{broadcaster}/admins", broadcaster) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"helper\"}") + .with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))) + ) + .andExpect(status().isOk()); - mockMvc.perform(get("/api/channels/{broadcaster}/admins", broadcaster) - .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].login").value("helper")) - .andExpect(jsonPath("$[0].displayName").value("helper")); + mockMvc + .perform( + get("/api/channels/{broadcaster}/admins", broadcaster).with( + oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)) + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].login").value("helper")) + .andExpect(jsonPath("$[0].displayName").value("helper")); MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng()); - String assetId = objectMapper.readTree(mockMvc.perform(multipart("/api/channels/{broadcaster}/assets", broadcaster) - .file(file) - .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) - .andExpect(status().isOk()) - .andReturn().getResponse().getContentAsString()).get("id").asText(); + String assetId = objectMapper + .readTree( + mockMvc + .perform( + multipart("/api/channels/{broadcaster}/assets", broadcaster) + .file(file) + .with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))) + ) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString() + ) + .get("id") + .asText(); - mockMvc.perform(get("/api/channels/{broadcaster}/assets", broadcaster) - .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(1))); + mockMvc + .perform( + get("/api/channels/{broadcaster}/assets", broadcaster).with( + oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)) + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))); VisibilityRequest visibilityRequest = new VisibilityRequest(); visibilityRequest.setHidden(false); - mockMvc.perform(put("/api/channels/{broadcaster}/assets/{id}/visibility", broadcaster, assetId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(visibilityRequest)) - .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.hidden").value(false)); + mockMvc + .perform( + put("/api/channels/{broadcaster}/assets/{id}/visibility", broadcaster, assetId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(visibilityRequest)) + .with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hidden").value(false)); - mockMvc.perform(get("/api/channels/{broadcaster}/assets/visible", broadcaster) - .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(1))); + mockMvc + .perform( + get("/api/channels/{broadcaster}/assets/visible", broadcaster).with( + oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)) + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))); - mockMvc.perform(delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId) - .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) - .andExpect(status().isOk()); + mockMvc + .perform( + delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId).with( + oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)) + ) + ) + .andExpect(status().isOk()); } @Test void rejectsAdminChangesFromNonBroadcaster() throws Exception { - mockMvc.perform(post("/api/channels/{broadcaster}/admins", "caster") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"helper\"}") - .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", "intruder")))) - .andExpect(status().isForbidden()); + mockMvc + .perform( + post("/api/channels/{broadcaster}/admins", "caster") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"helper\"}") + .with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", "intruder"))) + ) + .andExpect(status().isForbidden()); } private byte[] samplePng() throws IOException { diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryApiIntegrationTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryApiIntegrationTest.java index 39e478c..95fb4d6 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryApiIntegrationTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryApiIntegrationTest.java @@ -1,5 +1,10 @@ package dev.kruhlmann.imgfloat; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.repository.ChannelRepository; import org.junit.jupiter.api.BeforeEach; @@ -9,15 +14,12 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; -import static org.hamcrest.Matchers.hasSize; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest(properties = { +@SpringBootTest( + properties = { "spring.security.oauth2.client.registration.twitch.client-id=test-client-id", - "spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret" -}) + "spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret", + } +) @AutoConfigureMockMvc class ChannelDirectoryApiIntegrationTest { @@ -38,10 +40,11 @@ class ChannelDirectoryApiIntegrationTest { channelRepository.save(new Channel("alpha")); channelRepository.save(new Channel("ALPINE")); - mockMvc.perform(get("/api/channels").param("q", "Al")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$", hasSize(2))) - .andExpect(jsonPath("$[0]").value("alpha")) - .andExpect(jsonPath("$[1]").value("alpine")); + mockMvc + .perform(get("/api/channels").param("q", "Al")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0]").value("alpha")) + .andExpect(jsonPath("$[1]").value("alpine")); } } diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index 0ccb8ad..54471d5 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -1,27 +1,28 @@ package dev.kruhlmann.imgfloat; -import dev.kruhlmann.imgfloat.model.TransformRequest; -import dev.kruhlmann.imgfloat.model.VisibilityRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.Channel; +import dev.kruhlmann.imgfloat.model.Settings; +import dev.kruhlmann.imgfloat.model.TransformRequest; +import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository; -import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.AssetStorageService; +import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; +import dev.kruhlmann.imgfloat.service.SettingsService; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; import dev.kruhlmann.imgfloat.service.media.MediaPreviewService; -import dev.kruhlmann.imgfloat.service.SettingsService; -import dev.kruhlmann.imgfloat.model.Settings; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; - import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -33,19 +34,17 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; - import javax.imageio.ImageIO; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.mockito.Mockito.verify; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; class ChannelDirectoryServiceTest { + private ChannelDirectoryService service; private SimpMessagingTemplate messagingTemplate; private ChannelRepository channelRepository; @@ -66,8 +65,15 @@ class ChannelDirectoryServiceTest { MediaPreviewService mediaPreviewService = new MediaPreviewService(); MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService); MediaDetectionService mediaDetectionService = new MediaDetectionService(); - service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate, - assetStorageService, mediaDetectionService, mediaOptimizationService, settingsService); + service = new ChannelDirectoryService( + channelRepository, + assetRepository, + messagingTemplate, + assetStorageService, + mediaDetectionService, + mediaOptimizationService, + settingsService + ); ReflectionTestUtils.setField(service, "uploadLimitBytes", 5_000_000L); } @@ -78,7 +84,10 @@ class ChannelDirectoryServiceTest { Optional created = service.createAsset("caster", file); assertThat(created).isPresent(); ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); - verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture()); + verify(messagingTemplate).convertAndSend( + org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), + captor.capture() + ); } @Test @@ -105,8 +114,8 @@ class ChannelDirectoryServiceTest { transform.setWidth(0); assertThatThrownBy(() -> service.updateTransform(channel, id, transform)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("Canvas width out of range"); + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Canvas width out of range"); } @Test @@ -118,15 +127,15 @@ class ChannelDirectoryServiceTest { speedTransform.setSpeed(5.0); assertThatThrownBy(() -> service.updateTransform(channel, id, speedTransform)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("Speed out of range"); + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Speed out of range"); TransformRequest volumeTransform = validTransform(); volumeTransform.setAudioVolume(6.5); assertThatThrownBy(() -> service.updateTransform(channel, id, volumeTransform)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("Audio volume out of range"); + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Audio volume out of range"); } @Test @@ -178,44 +187,56 @@ class ChannelDirectoryServiceTest { Map channels = new ConcurrentHashMap<>(); Map assets = new ConcurrentHashMap<>(); - when(channelRepository.findById(anyString())) - .thenAnswer(invocation -> Optional.ofNullable(channels.get(invocation.getArgument(0)))); - when(channelRepository.save(any(Channel.class))) - .thenAnswer(invocation -> { - Channel channel = invocation.getArgument(0); - channels.put(channel.getBroadcaster(), channel); - return channel; - }); - when(channelRepository.findAll()) - .thenAnswer(invocation -> List.copyOf(channels.values())); - when(channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(anyString())) - .thenAnswer(invocation -> channels.values().stream() - .filter(channel -> Optional.ofNullable(channel.getBroadcaster()).orElse("") - .contains(Optional.ofNullable(invocation.getArgument(0, String.class)).orElse("").toLowerCase())) - .sorted(Comparator.comparing(Channel::getBroadcaster)) - .limit(50) - .toList()); + when(channelRepository.findById(anyString())).thenAnswer((invocation) -> + Optional.ofNullable(channels.get(invocation.getArgument(0))) + ); + when(channelRepository.save(any(Channel.class))).thenAnswer((invocation) -> { + Channel channel = invocation.getArgument(0); + channels.put(channel.getBroadcaster(), channel); + return channel; + }); + when(channelRepository.findAll()).thenAnswer((invocation) -> List.copyOf(channels.values())); + when(channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(anyString())).thenAnswer( + (invocation) -> + channels + .values() + .stream() + .filter((channel) -> + Optional.ofNullable(channel.getBroadcaster()) + .orElse("") + .contains( + Optional.ofNullable(invocation.getArgument(0, String.class)).orElse("").toLowerCase() + ) + ) + .sorted(Comparator.comparing(Channel::getBroadcaster)) + .limit(50) + .toList() + ); - when(assetRepository.save(any(Asset.class))) - .thenAnswer(invocation -> { - Asset asset = invocation.getArgument(0); - assets.put(asset.getId(), asset); - return asset; - }); - when(assetRepository.findById(anyString())) - .thenAnswer(invocation -> Optional.ofNullable(assets.get(invocation.getArgument(0)))); - when(assetRepository.findByBroadcaster(anyString())) - .thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), false)); - when(assetRepository.findByBroadcasterAndHiddenFalse(anyString())) - .thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), true)); - doAnswer(invocation -> assets.remove(invocation.getArgument(0, Asset.class).getId())) - .when(assetRepository).delete(any(Asset.class)); + when(assetRepository.save(any(Asset.class))).thenAnswer((invocation) -> { + Asset asset = invocation.getArgument(0); + assets.put(asset.getId(), asset); + return asset; + }); + when(assetRepository.findById(anyString())).thenAnswer((invocation) -> + Optional.ofNullable(assets.get(invocation.getArgument(0))) + ); + when(assetRepository.findByBroadcaster(anyString())).thenAnswer((invocation) -> + filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), false) + ); + when(assetRepository.findByBroadcasterAndHiddenFalse(anyString())).thenAnswer((invocation) -> + filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), true) + ); + doAnswer((invocation) -> assets.remove(invocation.getArgument(0, Asset.class).getId())) + .when(assetRepository) + .delete(any(Asset.class)); } private List filterAssetsByBroadcaster(Collection assets, String broadcaster, boolean onlyVisible) { - return assets.stream() - .filter(asset -> asset.getBroadcaster().equalsIgnoreCase(broadcaster)) - .filter(asset -> !onlyVisible || !asset.isHidden()) - .toList(); + return assets + .stream() + .filter((asset) -> asset.getBroadcaster().equalsIgnoreCase(broadcaster)) + .filter((asset) -> !onlyVisible || !asset.isHidden()) + .toList(); } } diff --git a/src/test/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverterTest.java b/src/test/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverterTest.java index db6706e..aeb3b1c 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverterTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverterTest.java @@ -1,5 +1,7 @@ package dev.kruhlmann.imgfloat.config; +import static org.assertj.core.api.Assertions.assertThat; + import org.junit.jupiter.api.Test; import org.springframework.http.RequestEntity; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; @@ -12,39 +14,43 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResp import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.util.MultiValueMap; -import static org.assertj.core.api.Assertions.assertThat; - class TwitchAuthorizationCodeGrantRequestEntityConverterTest { @Test void addsClientIdAndSecretToTokenRequestBody() { ClientRegistration registration = ClientRegistration.withRegistrationId("twitch") - .clientId("twitch-id") - .clientSecret("twitch-secret") - .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .redirectUri("https://example.com/redirect") - .scope("user:read:email") - .authorizationUri("https://id.twitch.tv/oauth2/authorize") - .tokenUri("https://id.twitch.tv/oauth2/token") - .userInfoUri("https://api.twitch.tv/helix/users") - .userNameAttributeName("preferred_username") - .build(); + .clientId("twitch-id") + .clientSecret("twitch-secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("https://example.com/redirect") + .scope("user:read:email") + .authorizationUri("https://id.twitch.tv/oauth2/authorize") + .tokenUri("https://id.twitch.tv/oauth2/token") + .userInfoUri("https://api.twitch.tv/helix/users") + .userNameAttributeName("preferred_username") + .build(); OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() - .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) - .clientId(registration.getClientId()) - .redirectUri(registration.getRedirectUri()) - .state("state") - .build(); + .authorizationUri(registration.getProviderDetails().getAuthorizationUri()) + .clientId(registration.getClientId()) + .redirectUri(registration.getRedirectUri()) + .state("state") + .build(); OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code") - .redirectUri(registration.getRedirectUri()) - .state("state") - .build(); + .redirectUri(registration.getRedirectUri()) + .state("state") + .build(); - OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse); - OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(registration, exchange); + OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange( + authorizationRequest, + authorizationResponse + ); + OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest( + registration, + exchange + ); var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter(); RequestEntity requestEntity = converter.convert(grantRequest); diff --git a/src/test/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2ErrorResponseErrorHandlerTest.java b/src/test/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2ErrorResponseErrorHandlerTest.java index 400e3f9..1052da6 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2ErrorResponseErrorHandlerTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2ErrorResponseErrorHandlerTest.java @@ -1,7 +1,11 @@ package dev.kruhlmann.imgfloat.config; -import java.net.URI; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import java.net.URI; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -13,11 +17,6 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenRespon import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestTemplate; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; -import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; - class TwitchOAuth2ErrorResponseErrorHandlerTest { private final TwitchOAuth2ErrorResponseErrorHandler handler = new TwitchOAuth2ErrorResponseErrorHandler(); @@ -27,12 +26,12 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest { MockClientHttpResponse response = new MockClientHttpResponse(new byte[0], HttpStatus.BAD_REQUEST); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); - OAuth2AuthorizationException exception = assertThrows(OAuth2AuthorizationException.class, - () -> handler.handleError(response)); + OAuth2AuthorizationException exception = assertThrows(OAuth2AuthorizationException.class, () -> + handler.handleError(response) + ); assertThat(exception.getError().getErrorCode()).isEqualTo("invalid_token_response"); - assertThat(exception.getError().getDescription()) - .contains("Failed to parse Twitch OAuth error response"); + assertThat(exception.getError().getDescription()).contains("Failed to parse Twitch OAuth error response"); } @Test @@ -41,13 +40,20 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest { restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler()); MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); - server.expect(requestTo("https://id.twitch.tv/oauth2/token")) - .andRespond(withSuccess( - "{\"access_token\":\"abc\",\"token_type\":\"bearer\",\"expires_in\":3600,\"scope\":[]}", - MediaType.APPLICATION_JSON)); + server + .expect(requestTo("https://id.twitch.tv/oauth2/token")) + .andRespond( + withSuccess( + "{\"access_token\":\"abc\",\"token_type\":\"bearer\",\"expires_in\":3600,\"scope\":[]}", + MediaType.APPLICATION_JSON + ) + ); RequestEntity request = RequestEntity.post(URI.create("https://id.twitch.tv/oauth2/token")).build(); - ResponseEntity response = restTemplate.exchange(request, OAuth2AccessTokenResponse.class); + ResponseEntity response = restTemplate.exchange( + request, + OAuth2AccessTokenResponse.class + ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isNotNull(); diff --git a/src/test/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2UserServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2UserServiceTest.java index bd1dd06..3de72cf 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2UserServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/config/TwitchOAuth2UserServiceTest.java @@ -8,7 +8,6 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat import java.time.Instant; import java.util.Set; - import org.junit.jupiter.api.Test; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; @@ -26,51 +25,54 @@ class TwitchOAuth2UserServiceTest { @Test void unwrapsTwitchUserAndAddsClientIdHeaderToUserInfoRequest() { ClientRegistration registration = twitchRegistrationBuilder() - .clientId("client-123") - .clientSecret("secret") - .build(); + .clientId("client-123") + .clientSecret("secret") + .build(); OAuth2UserRequest userRequest = userRequest(registration); RestTemplate restTemplate = TwitchOAuth2UserService.createRestTemplate(userRequest); MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); - TwitchOAuth2UserService service = new TwitchOAuth2UserService(ignored -> restTemplate); + TwitchOAuth2UserService service = new TwitchOAuth2UserService((ignored) -> restTemplate); - server.expect(requestTo("https://api.twitch.tv/helix/users")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header("Client-ID", "client-123")) - .andRespond(withSuccess( - "{\"data\":[{\"id\":\"42\",\"login\":\"demo\",\"display_name\":\"Demo\"}]}", - MediaType.APPLICATION_JSON)); + server + .expect(requestTo("https://api.twitch.tv/helix/users")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("Client-ID", "client-123")) + .andRespond( + withSuccess( + "{\"data\":[{\"id\":\"42\",\"login\":\"demo\",\"display_name\":\"Demo\"}]}", + MediaType.APPLICATION_JSON + ) + ); OAuth2User user = service.loadUser(userRequest); assertThat(user.getName()).isEqualTo("demo"); - assertThat(user.getAttributes()) - .containsEntry("id", "42") - .containsEntry("display_name", "Demo"); + assertThat(user.getAttributes()).containsEntry("id", "42").containsEntry("display_name", "Demo"); server.verify(); } private OAuth2UserRequest userRequest(ClientRegistration registration) { OAuth2AccessToken accessToken = new OAuth2AccessToken( - OAuth2AccessToken.TokenType.BEARER, - "token", - Instant.now(), - Instant.now().plusSeconds(60), - Set.of("user:read:email")); + OAuth2AccessToken.TokenType.BEARER, + "token", + Instant.now(), + Instant.now().plusSeconds(60), + Set.of("user:read:email") + ); return new OAuth2UserRequest(registration, accessToken); } private ClientRegistration.Builder twitchRegistrationBuilder() { return ClientRegistration.withRegistrationId("twitch") - .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) - .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) - .clientName("Twitch") - .redirectUri("https://example.com/login/oauth2/code/twitch") - .authorizationUri("https://id.twitch.tv/oauth2/authorize") - .tokenUri("https://id.twitch.tv/oauth2/token") - .userInfoUri("https://api.twitch.tv/helix/users") - .userNameAttributeName("login"); + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) + .clientName("Twitch") + .redirectUri("https://example.com/login/oauth2/code/twitch") + .authorizationUri("https://id.twitch.tv/oauth2/authorize") + .tokenUri("https://id.twitch.tv/oauth2/token") + .userInfoUri("https://api.twitch.tv/helix/users") + .userNameAttributeName("login"); } } diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java index 351e601..960f1f5 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/service/AssetStorageServiceTest.java @@ -1,18 +1,18 @@ package dev.kruhlmann.imgfloat.service; -import dev.kruhlmann.imgfloat.service.media.AssetContent; -import dev.kruhlmann.imgfloat.model.Asset; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dev.kruhlmann.imgfloat.model.Asset; +import dev.kruhlmann.imgfloat.service.media.AssetContent; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + class AssetStorageServiceTest { + private AssetStorageService service; private Path assets; private Path previews; @@ -27,13 +27,13 @@ class AssetStorageServiceTest { @Test void refusesToStoreEmptyAsset() { assertThatThrownBy(() -> service.storeAsset("caster", "id", new byte[0], "image/png")) - .isInstanceOf(IOException.class) - .hasMessageContaining("empty"); + .isInstanceOf(IOException.class) + .hasMessageContaining("empty"); } @Test void storesAndLoadsAssets() throws IOException { - byte[] bytes = new byte[]{1, 2, 3}; + byte[] bytes = new byte[] { 1, 2, 3 }; Asset asset = new Asset("caster", "asset", "http://example.com", 10, 10); asset.setMediaType("image/png"); @@ -53,7 +53,7 @@ class AssetStorageServiceTest { @Test void storesAndLoadsPreviews() throws IOException { - byte[] preview = new byte[]{9, 8, 7}; + byte[] preview = new byte[] { 9, 8, 7 }; Asset asset = new Asset("caster", "asset", "http://example.com", 10, 10); asset.setMediaType("image/png"); diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java index e15bcc4..b93af0d 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaDetectionServiceTest.java @@ -1,18 +1,18 @@ package dev.kruhlmann.imgfloat.service.media; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockMultipartFile; - -import java.io.IOException; - import static org.assertj.core.api.Assertions.assertThat; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + class MediaDetectionServiceTest { + private final MediaDetectionService service = new MediaDetectionService(); @Test void acceptsMagicBytesOverDeclaredType() throws IOException { - byte[] png = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}; + byte[] png = new byte[] { (byte) 0x89, 0x50, 0x4E, 0x47 }; MockMultipartFile file = new MockMultipartFile("file", "image.png", "text/plain", png); assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png"); @@ -20,14 +20,14 @@ class MediaDetectionServiceTest { @Test void fallsBackToFilenameAllowlist() throws IOException { - MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, new byte[]{1, 2, 3}); + MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, new byte[] { 1, 2, 3 }); assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png"); } @Test void rejectsUnknownTypes() throws IOException { - MockMultipartFile file = new MockMultipartFile("file", "unknown.bin", null, new byte[]{1, 2, 3}); + MockMultipartFile file = new MockMultipartFile("file", "unknown.bin", null, new byte[] { 1, 2, 3 }); assertThat(service.detectAllowedMediaType(file, file.getBytes())).isEmpty(); } diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationServiceTest.java index 25e7a33..ef3bdf1 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/service/media/MediaOptimizationServiceTest.java @@ -1,16 +1,16 @@ package dev.kruhlmann.imgfloat.service.media; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.IOException; - import static org.assertj.core.api.Assertions.assertThat; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + class MediaOptimizationServiceTest { + private MediaOptimizationService service; @BeforeEach @@ -38,7 +38,7 @@ class MediaOptimizationServiceTest { @Test void returnsNullForUnsupportedBytes() throws IOException { - OptimizedAsset optimized = service.optimizeAsset(new byte[]{1, 2, 3}, "application/octet-stream"); + OptimizedAsset optimized = service.optimizeAsset(new byte[] { 1, 2, 3 }, "application/octet-stream"); assertThat(optimized).isNull(); }