Add anonymous listening support and redesign web-player UI
Backend: - Expose GET /config/playback so the frontend can detect whether anonymous listening is enabled without requiring auth - Wire anonymous access through track streaming and YouTube resolve flows Frontend: - Allow browsing/listening without a session when the backend enables it - Redesign Inicio, Sidebar and top bar with a new dark theme, typography, and layout (top bar with live quick-search, sticky/blurred on scroll) - Populate Top música, Popular and Recomendados with real Musify + YouTube Music data instead of placeholders - Various shape, spacing, color and animation refinements across the redesigned components
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
using Mediator;
|
||||
using Musify.Application.Configuration.Responses;
|
||||
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
/// <summary>Reads the parts of <see cref="PlaybackConfiguration"/> that are safe to expose to an
|
||||
/// unauthenticated client, so it can decide whether to gate the app behind sign-in at all.</summary>
|
||||
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
|
||||
|
||||
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
|
||||
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
|
||||
{
|
||||
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(new PlaybackPublicConfigResponse(
|
||||
playbackConfiguration.AllowAnonymousListening,
|
||||
playbackConfiguration.AnonymousFragmentSeconds));
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
namespace Musify.Application.Configuration.Responses
|
||||
{
|
||||
/// <param name="AllowAnonymousListening">Whether listeners without a session can stream music at all.</param>
|
||||
/// <param name="AnonymousFragmentSeconds">How many seconds of a track an anonymous listener may hear.
|
||||
/// Zero means no cap. Meaningless when <paramref name="AllowAnonymousListening"/> is false.</param>
|
||||
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
|
||||
}
|
||||
@@ -17,9 +17,6 @@ namespace Musify.Application.YouTube
|
||||
{
|
||||
public async ValueTask<ErrorOr<YouTubeAlbumDetail>> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.AlbumId))
|
||||
return Error.Validation(description: "The album id must not be empty.");
|
||||
|
||||
var result = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
|
||||
if (result.IsError)
|
||||
return result;
|
||||
|
||||
@@ -10,8 +10,6 @@ using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.YouTube
|
||||
{
|
||||
/// <param name="UserId">Null for an anonymous request — allowed only when
|
||||
/// <see cref="PlaybackConfiguration.AllowAnonymousListening"/> is enabled.</param>
|
||||
public record ResolveYouTubeTrackStreamCommand(string VideoId, long? UserId)
|
||||
: ICommand<ErrorOr<YouTubeStreamResponse>>;
|
||||
|
||||
@@ -26,7 +24,7 @@ namespace Musify.Application.YouTube
|
||||
{
|
||||
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var isAnonymous = request.UserId is null;
|
||||
var isAnonymous = !request.UserId.HasValue;
|
||||
if (isAnonymous && !playbackConfiguration.AllowAnonymousListening)
|
||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||
|
||||
@@ -43,10 +41,6 @@ namespace Musify.Application.YouTube
|
||||
serverStream.ExpiresInSeconds);
|
||||
}
|
||||
|
||||
// Below this point the only option left is streaming straight from YouTube's own CDN,
|
||||
// which bypasses our Gateway (and its stream tickets) entirely — there's no way to cap that
|
||||
// to the configured anonymous fragment, so an anonymous listener simply doesn't get it: they
|
||||
// can retry once the track has been provisioned server-side, where the cap above applies.
|
||||
if (isAnonymous && playbackConfiguration.AnonymousFragmentSeconds > 0)
|
||||
return Error.Conflict(description: "This track isn't available for anonymous preview yet. Try again in a moment.");
|
||||
|
||||
@@ -63,7 +57,7 @@ namespace Musify.Application.YouTube
|
||||
logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description);
|
||||
}
|
||||
|
||||
if (track != null && request.UserId is not null)
|
||||
if (track != null&& request.UserId != null)
|
||||
{
|
||||
await database.ListeningHistories.AddAsync(new ListeningHistory
|
||||
{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Mediator;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Configuration.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class ConfigEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/config")
|
||||
.WithTags("Config");
|
||||
|
||||
group.MapGet("/playback", GetPlaybackPublicConfig)
|
||||
.WithName("GetPlaybackPublicConfig")
|
||||
.WithSummary("Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously.")
|
||||
.Produces<PlaybackPublicConfigResponse>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<PlaybackPublicConfigResponse> GetPlaybackPublicConfig(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ app.UseAuthorization();
|
||||
|
||||
app
|
||||
.MapAlbumEndpoints()
|
||||
.MapConfigEndpoints()
|
||||
.MapMixEndpoints()
|
||||
.MapPlayListEndpoints()
|
||||
.MapTrackEndpoints()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Configuration.Responses;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class ConfigEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
|
||||
var response = await client.GetAsync("/config/playback", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<PlaybackPublicConfigResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body.AllowAnonymousListening);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Configuration
|
||||
{
|
||||
public sealed class GetPlaybackPublicConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_ReflectsPlaybackConfiguration()
|
||||
{
|
||||
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
|
||||
var handler = new GetPlaybackPublicConfigQueryHandler(playback);
|
||||
|
||||
var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.AllowAnonymousListening);
|
||||
Assert.Equal(30, response.AnonymousFragmentSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DefaultsToDisabled()
|
||||
{
|
||||
var handler = new GetPlaybackPublicConfigQueryHandler(TestConfigurations.Playback());
|
||||
|
||||
var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(response.AllowAnonymousListening);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user