From c7fb97c93f36f9ecce8b1e871a1ad34cc3f5ee98 Mon Sep 17 00:00:00 2001 From: Razvan Iacob Date: Thu, 5 Nov 2026 11:17:21 -0500 Subject: [PATCH] feat: play Audius playlists in a Winamp player Opens a Winamp player (Webamp) from search, with a short default playlist from Audius, and keeps it playing across pages. Members can share a public Audius playlist or album in profile settings, and their profile gets a button that plays it in Winamp. Desktop only. --- KNOWLEDGE_BASE.md | 10 + PRODUCT.md | 1 + app/Actions/CreateExport.php | 1 + app/Actions/DeleteUserPlaylist.php | 21 ++ app/Actions/UpdateUserPlaylist.php | 21 ++ app/Contracts/Services/PlaylistProvider.php | 20 ++ app/Http/Controllers/UserController.php | 5 +- .../Controllers/UserPlaylistController.php | 29 +++ .../Controllers/UserProfileController.php | 1 + .../Requests/UpdateUserPlaylistRequest.php | 32 +++ app/Models/User.php | 4 + app/Providers/AppServiceProvider.php | 7 + app/Queries/PlaylistQuery.php | 47 +++++ app/Rules/PlaylistUrl.php | 28 +++ .../AudiusPlaylistProvider.php | 45 ++++ .../NullPlaylistProvider.php | 15 ++ bun.lock | 3 + ...190000_add_playlist_url_to_users_table.php | 17 ++ package.json | 1 + resources/css/app.css | 11 + resources/js/components/playlist-setting.tsx | 80 ++++++++ .../js/components/profile-winamp-button.tsx | 89 ++++++++ resources/js/components/search-dialog.tsx | 9 +- resources/js/layouts/app-layout.tsx | 7 + resources/js/lib/search-actions.ts | 15 +- resources/js/lib/winamp.ts | 145 +++++++++++++ resources/js/pages/privacy/show.tsx | 2 +- resources/js/pages/user-profile/edit.tsx | 5 + resources/js/pages/user/show.tsx | 12 ++ routes/web.php | 7 + .../Controllers/ExportControllerTest.php | 2 + .../Controllers/UserControllerTest.php | 23 +++ .../UserPlaylistControllerTest.php | 76 +++++++ .../Controllers/UserProfileControllerTest.php | 6 + .../Controllers/UserTimeoutControllerTest.php | 16 ++ tests/Feature/RateLimitTest.php | 7 + tests/Unit/ProfileSync.test.tsx | 1 + tests/Unit/ProfileWinampButton.test.tsx | 149 ++++++++++++++ tests/Unit/Queries/PlaylistQueryTest.php | 64 ++++++ tests/Unit/SearchActions.test.ts | 12 ++ tests/Unit/SearchDialogComposition.test.tsx | 4 + .../AudiusPlaylistProviderTest.php | 120 +++++++++++ .../NullPlaylistProviderTest.php | 9 + tests/Unit/Winamp.test.tsx | 194 ++++++++++++++++++ 44 files changed, 1367 insertions(+), 6 deletions(-) create mode 100644 app/Actions/DeleteUserPlaylist.php create mode 100644 app/Actions/UpdateUserPlaylist.php create mode 100644 app/Contracts/Services/PlaylistProvider.php create mode 100644 app/Http/Controllers/UserPlaylistController.php create mode 100644 app/Http/Requests/UpdateUserPlaylistRequest.php create mode 100644 app/Queries/PlaylistQuery.php create mode 100644 app/Rules/PlaylistUrl.php create mode 100644 app/Services/PlaylistProviders/AudiusPlaylistProvider.php create mode 100644 app/Services/PlaylistProviders/NullPlaylistProvider.php create mode 100644 database/migrations/2026_11_06_190000_add_playlist_url_to_users_table.php create mode 100644 resources/js/components/playlist-setting.tsx create mode 100644 resources/js/components/profile-winamp-button.tsx create mode 100644 resources/js/lib/winamp.ts create mode 100644 tests/Feature/Controllers/UserPlaylistControllerTest.php create mode 100644 tests/Unit/ProfileWinampButton.test.tsx create mode 100644 tests/Unit/Queries/PlaylistQueryTest.php create mode 100644 tests/Unit/Services/PlaylistProviders/AudiusPlaylistProviderTest.php create mode 100644 tests/Unit/Services/PlaylistProviders/NullPlaylistProviderTest.php create mode 100644 tests/Unit/Winamp.test.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 66a5d66ecb..e2c866acda 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -159,6 +159,16 @@ Share a stream you are running from the **…** menu on your own profile: **Go l Sources: [go-live button](resources/js/components/profile-live-button.tsx), [live avatar and card](resources/js/components/live-stream-card.tsx), [validation](app/Http/Requests/UpdateUserLiveRequest.php), [YouTube links](app/Services/LivePlatforms/YouTube.php), [Twitch links](app/Services/LivePlatforms/Twitch.php), [ending streams](app/Jobs/EndLiveStreams.php). +### Winamp + +On a computer, signed in, search for **winamp** (or **webamp**, or **llama**) and choose **Open Winamp** to open a Winamp player, built on Webamp, that keeps playing while you move between pages. It starts with a short playlist of lo-fi beats from Audius, and nothing plays until you press play. Closing it with its own close button stops the music; opening it again brings the same player back. Phones and tablets don't offer it. + +Share a playlist of your own in profile settings, under **Winamp playlist**: paste a link to a public playlist or album on Audius (`https://audius.co/…/playlist/…` or `https://audius.co/…/album/…`). Your profile then shows a music-note button on computers that loads your playlist into Winamp and starts it, for anyone looking at it, signed in or not. Only the first 200 tracks Winamp can play are loaded. Tracks Audius doesn't let everyone stream, such as ones only for an artist's supporters, are left out. The playlist is read from Audius at most every ten minutes, so changes you make there can take that long to show. **Remove** stops sharing it. Only the link is stored, and it is included in your export. + +The music comes from Audius because Winamp needs the audio itself to draw its visualizer and run its equalizer, and Audius lets other sites play its tracks. Spotify, YouTube, and Apple Music only play inside their own players, so their music can't play in Winamp. Once you open Winamp, the audio loads straight from Audius to your browser, under Audius's own privacy policy. + +Sources: [player](resources/js/lib/winamp.ts), [profile button](resources/js/components/profile-winamp-button.tsx), [playlist setting](resources/js/components/playlist-setting.tsx), [Audius playlists](app/Services/PlaylistProviders/AudiusPlaylistProvider.php). + ### Achievement badges and XP Profiles have **16 achievement badges**. Earned badges appear in a single row, with **+N more** when more have been earned than fit. Hover over the row on desktop or tap it on mobile to toggle a popover with every badge, including locked badges and their progress. Profiles with no earned badges show **View achievements** instead. Select a badge in the popover, or the profile's rank or unlocked count, to open the detailed achievements dialog. diff --git a/PRODUCT.md b/PRODUCT.md index 13dbe7437e..04066a60d1 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -35,6 +35,7 @@ What exists, one line each (details in the knowledge base): - **Logs:** Following, Latest, and Trending. - **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries). - **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube. +- **Winamp:** a Winamp player on computers, opened from search, playing music from Audius; members can share an Audius playlist that visitors play from their profile. - **Organization pages:** GitHub organizations get a page mirrored from GitHub once an approved commit mentions them, and `@organization` mentions link to it. Their members post commits as the organization, which readers see from the organization alone. - **Remotes:** private one-to-one and group messages (orgs), and calls, one-to-one or in an org. - **Notifications:** in-app activity and on-site Remotes alerts by default; off-site browser and PWA notifications require a separate opt-in. Sound is controlled independently and plays while the site is open. diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index e224493022..efbabff963 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -111,6 +111,7 @@ public function handle(User $user): array 'live_platform' => $user->live_platform?->value, 'live_url' => $user->live_url, 'live_ends_at' => $user->live_ends_at?->toIso8601String(), + 'playlist_url' => $user->playlist_url, 'last_seen_at' => $user->last_seen_at?->toDateString(), 'commit_rules_accepted_at' => $user->commit_rules_accepted_at?->toIso8601String(), 'emails_unsubscribed_at' => $user->emails_unsubscribed_at?->toIso8601String(), diff --git a/app/Actions/DeleteUserPlaylist.php b/app/Actions/DeleteUserPlaylist.php new file mode 100644 index 0000000000..f9b08817c1 --- /dev/null +++ b/app/Actions/DeleteUserPlaylist.php @@ -0,0 +1,21 @@ +update(['playlist_url' => null]); + + event(new ProfilesUpdated([$user->id])); + event(new UsersUpdated([$user->id], UserUpdate::Account)); + } +} diff --git a/app/Actions/UpdateUserPlaylist.php b/app/Actions/UpdateUserPlaylist.php new file mode 100644 index 0000000000..ba49a3b9eb --- /dev/null +++ b/app/Actions/UpdateUserPlaylist.php @@ -0,0 +1,21 @@ +update(['playlist_url' => $url]); + + event(new ProfilesUpdated([$user->id])); + event(new UsersUpdated([$user->id], UserUpdate::Account)); + } +} diff --git a/app/Contracts/Services/PlaylistProvider.php b/app/Contracts/Services/PlaylistProvider.php new file mode 100644 index 0000000000..328d0bf20d --- /dev/null +++ b/app/Contracts/Services/PlaylistProvider.php @@ -0,0 +1,20 @@ +|null + * + * @throws HttpClientException + */ + public function tracks(string $url): ?array; +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index b2d6c3e704..dda6d3d683 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -15,6 +15,7 @@ use App\Queries\FollowersYouKnowQuery; use App\Queries\GitHubReadmeQuery; use App\Queries\PinnedCommitQuery; +use App\Queries\PlaylistQuery; use App\Queries\PushAccessQuery; use App\Queries\UserAchievementsQuery; use App\Queries\UserActivityQuery; @@ -38,7 +39,7 @@ /** * Profiles are public, so guests and crawlers read the same page, with the profile's link preview. */ - public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PageMetadata $metadata): Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PlaylistQuery $playlist, PageMetadata $metadata): Response { $blockerId = $currentUser?->blockerIdWith($user); @@ -67,6 +68,8 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser return Inertia::render('user/show', [ 'user' => Inertia::live(fn (): ?array => ShownStreams::user($getCountedUser()), 'profile', $profile), 'bioProfiles' => fn (): array => $bioProfiles->get($user), + 'hasPlaylist' => Inertia::live(fn (): bool => $user->playlist_url !== null, 'profile', $profile), + 'playlist' => Inertia::optional(fn (): array => $playlist->get($user)), 'pasteisReceived' => Inertia::live(fn (): ?int => $currentUser?->pasteis_mode ? $user->pasteis_received : null, 'profile', $profile), 'isFollowing' => $relationship(fn (): bool => $currentUser?->isFollowing($user) ?? false), 'followsYou' => $relationship(fn (): bool => $currentUser instanceof User && $user->isFollowing($currentUser)), diff --git a/app/Http/Controllers/UserPlaylistController.php b/app/Http/Controllers/UserPlaylistController.php new file mode 100644 index 0000000000..478ed3def3 --- /dev/null +++ b/app/Http/Controllers/UserPlaylistController.php @@ -0,0 +1,29 @@ +handle($user, $request->playlistUrl()); + + return back(); + } + + public function destroy(#[CurrentUser] User $user, DeleteUserPlaylist $action): RedirectResponse + { + $action->handle($user); + + return back(); + } +} diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 5cb1fbda9e..819c67e87d 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -29,6 +29,7 @@ public function edit(#[CurrentUser] User $user, CommitTranslations $translations 'githubSyncedAt' => $user->github_synced_at?->toIso8601String(), 'activityGraphUrl' => route('user-activity-graph.show', $user), 'profileUrl' => route('user.show', $user), + 'playlistUrl' => $user->playlist_url, ]); } diff --git a/app/Http/Requests/UpdateUserPlaylistRequest.php b/app/Http/Requests/UpdateUserPlaylistRequest.php new file mode 100644 index 0000000000..722c2b6149 --- /dev/null +++ b/app/Http/Requests/UpdateUserPlaylistRequest.php @@ -0,0 +1,32 @@ +> + */ + public function rules(): array + { + return [ + 'playlist_url' => ['bail', 'required', 'string', 'max:255', new PlaylistUrl()], + ]; + } + + public function playlistUrl(): string + { + return $this->string('playlist_url')->value(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 825d8d31b2..8e050a27b2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -62,6 +62,7 @@ * @property-read LivePlatform|null $live_platform * @property-read string|null $live_url * @property-read CarbonInterface|null $live_ends_at + * @property-read string|null $playlist_url * @property-read Log $log * @property-read CarbonInterface|null $onboarded_at * @property-read CarbonInterface|null $github_follows_imported_at @@ -125,6 +126,7 @@ 'live_platform', 'live_url', 'live_ends_at', + 'playlist_url', 'last_seen_at', 'banned_at', 'commit_rules_accepted_at', @@ -179,6 +181,7 @@ final class User extends Authenticatable 'live_platform' => null, 'live_url' => null, 'live_ends_at' => null, + 'playlist_url' => null, 'last_seen_at' => null, 'banned_at' => null, 'commit_rules_accepted_at' => null, @@ -232,6 +235,7 @@ public function casts(): array 'live_platform' => LivePlatform::class, 'live_url' => 'string', 'live_ends_at' => 'datetime', + 'playlist_url' => 'string', 'last_seen_at' => 'date', 'banned_at' => 'datetime', 'commit_rules_accepted_at' => 'datetime', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ae397a5eaf..c31c77064b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,7 @@ use App\Contracts\Services\HeadlineProvider; use App\Contracts\Services\LinkPreviewProvider; use App\Contracts\Services\ModerationProvider; +use App\Contracts\Services\PlaylistProvider; use App\Contracts\Services\PushNotificationProvider; use App\Contracts\Services\RelevanceProvider; use App\Contracts\Services\ToolDecisionProvider; @@ -34,6 +35,8 @@ use App\Services\LinkPreviewProviders\NullLinkPreviewProvider; use App\Services\ModerationProviders\NullModerationProvider; use App\Services\ModerationProviders\OpenAIModerationProvider; +use App\Services\PlaylistProviders\AudiusPlaylistProvider; +use App\Services\PlaylistProviders\NullPlaylistProvider; use App\Services\PushNotificationProviders\NullPushNotificationProvider; use App\Services\PushNotificationProviders\WebPushNotificationProvider; use App\Services\RelevanceProviders\NullRelevanceProvider; @@ -70,6 +73,7 @@ final class AppServiceProvider extends ServiceProvider HeadlineProvider::class => OpenAIHeadlineProvider::class, LinkPreviewProvider::class => HttpLinkPreviewProvider::class, ModerationProvider::class => OpenAIModerationProvider::class, + PlaylistProvider::class => AudiusPlaylistProvider::class, PushNotificationProvider::class => WebPushNotificationProvider::class, RelevanceProvider::class => OpenAIRelevanceProvider::class, ToolDecisionProvider::class => OpenAIToolDecisionProvider::class, @@ -90,6 +94,7 @@ final class AppServiceProvider extends ServiceProvider HeadlineProvider::class => NullHeadlineProvider::class, LinkPreviewProvider::class => NullLinkPreviewProvider::class, ModerationProvider::class => NullModerationProvider::class, + PlaylistProvider::class => NullPlaylistProvider::class, PushNotificationProvider::class => NullPushNotificationProvider::class, RelevanceProvider::class => NullRelevanceProvider::class, ToolDecisionProvider::class => NullToolDecisionProvider::class, @@ -240,6 +245,8 @@ private function bootRateLimitingDefaults(): void $request->user() instanceof User ? $this->userId($request) : 'ip:'.$request->ip(), )); + RateLimiter::for('playlist-links', fn (Request $request): Limit => Limit::perMinute(10)->by($this->userId($request))); + // Every issue lookup GitHub hasn't answered in the last five minutes costs a request out of the shared hourly budget, // which also keeps SSH keys in sync for the CLI, so it gets a budget of its own... RateLimiter::for('issue-suggestions', fn (Request $request): array => [ diff --git a/app/Queries/PlaylistQuery.php b/app/Queries/PlaylistQuery.php new file mode 100644 index 0000000000..e55956d1dd --- /dev/null +++ b/app/Queries/PlaylistQuery.php @@ -0,0 +1,47 @@ + + */ + public function get(User $user): array + { + $url = $user->playlist_url; + + if ($url === null) { + return []; + } + + $key = 'playlist:'.hash('xxh128', $url); + + try { + return Cache::remember($key, self::TTL, fn (): array => $this->playlists->tracks($url) ?? []); + } catch (HttpClientException) { + Cache::put($key, [], self::FAILURE_TTL); + + return []; + } + } +} diff --git a/app/Rules/PlaylistUrl.php b/app/Rules/PlaylistUrl.php new file mode 100644 index 0000000000..484f42b76c --- /dev/null +++ b/app/Rules/PlaylistUrl.php @@ -0,0 +1,28 @@ +tracks($value) : null; + } catch (HttpClientException) { + $fail("Audius isn't answering. Try again in a moment."); + + return; + } + + if ($tracks === null) { + $fail('Use a link to a public playlist or album on Audius.'); + } + } +} diff --git a/app/Services/PlaylistProviders/AudiusPlaylistProvider.php b/app/Services/PlaylistProviders/AudiusPlaylistProvider.php new file mode 100644 index 0000000000..938c657be9 --- /dev/null +++ b/app/Services/PlaylistProviders/AudiusPlaylistProvider.php @@ -0,0 +1,45 @@ +connectTimeout(3)->timeout(5) + ->get('https://api.audius.co/v1/resolve', ['url' => $url, 'app_name' => self::APP_NAME]); + + if ($response->notFound() || $response->throw()->json('data.0.is_private') === true) { + return null; + } + + return array_values($response->collect('data.0.tracks') + ->map(fn (mixed $track): Fluent => new Fluent(is_array($track) ? $track : [])) + ->filter(fn (Fluent $track): bool => $track->boolean('access.stream') + && ! $track->boolean('is_delete') + && $track->string('id')->isNotEmpty() + && $track->string('title')->trim()->isNotEmpty()) + ->take(self::MOST_TRACKS) + ->map(fn (Fluent $track): array => [ + 'artist' => $track->string('user.name')->trim()->value(), + 'title' => $track->string('title')->trim()->value(), + 'duration' => $track->integer('duration'), + 'url' => 'https://api.audius.co/v1/tracks/'.rawurlencode($track->string('id')->value()).'/stream?app_name='.self::APP_NAME, + ]) + ->all()); + } +} diff --git a/app/Services/PlaylistProviders/NullPlaylistProvider.php b/app/Services/PlaylistProviders/NullPlaylistProvider.php new file mode 100644 index 0000000000..157f5b37df --- /dev/null +++ b/app/Services/PlaylistProviders/NullPlaylistProvider.php @@ -0,0 +1,15 @@ +=24.0.0", "@vitest/browser-playwright": "5.0.5", "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.5", "@vitest/coverage-v8": "5.0.5", "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg=="], + "webamp": ["webamp@2.3.1", "", {}, "sha512-u2NpSKYsUfDKltcvteF3vDbAzp+kDiymr5Sn0Vgfl+Uk9Kvd4GmuaF3stOxKNLbDH+RkJ2YZfus7O3W2axq4Qg=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], diff --git a/database/migrations/2026_11_06_190000_add_playlist_url_to_users_table.php b/database/migrations/2026_11_06_190000_add_playlist_url_to_users_table.php new file mode 100644 index 0000000000..8013ca8f9b --- /dev/null +++ b/database/migrations/2026_11_06_190000_add_playlist_url_to_users_table.php @@ -0,0 +1,17 @@ +string('playlist_url')->nullable(); + }); + } +}; diff --git a/package.json b/package.json index 35abb1442c..1178c6aa8f 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "tailwind-merge": "^3.7.0", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", + "webamp": "2.3.1", "yet-another-react-lightbox": "^3.33.0" }, "devDependencies": { diff --git a/resources/css/app.css b/resources/css/app.css index 8a3d13535d..f8b878946e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1113,3 +1113,14 @@ html body[data-scroll-locked] { box-shadow: inset 3px 0 0 color-mix(in oklab, var(--primary) 70%, transparent); } + +[data-winamp] { + position: fixed; + inset: 0; + z-index: 40; + pointer-events: none; +} + +[data-winamp] #webamp { + pointer-events: auto; +} diff --git a/resources/js/components/playlist-setting.tsx b/resources/js/components/playlist-setting.tsx new file mode 100644 index 0000000000..b530e4f183 --- /dev/null +++ b/resources/js/components/playlist-setting.tsx @@ -0,0 +1,80 @@ +import { Form, router } from '@inertiajs/react'; +import { Save, Unlink } from 'lucide-react'; +import { toast } from 'sonner'; +import UserPlaylistController from '@/actions/App/Http/Controllers/UserPlaylistController'; +import Heading from '@/components/heading'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; + +export function PlaylistSetting({ url }: { url: string | null }) { + const remove = () => + router.delete(UserPlaylistController.destroy.url(), { + preserveScroll: true, + optimistic: () => ({ playlistUrl: null }), + onSuccess: () => router.flushAll(), + onError: () => + toast.error( + 'Could not remove your playlist. Please try again.', + ), + }); + + return ( +
+ +
router.flushAll()} + className="grid max-w-md gap-2" + > + {({ processing, errors }) => ( + <> + + +
+ +
+
+ + {url !== null && ( + + )} +
+ + )} +
+
+ ); +} diff --git a/resources/js/components/profile-winamp-button.tsx b/resources/js/components/profile-winamp-button.tsx new file mode 100644 index 0000000000..3c36489830 --- /dev/null +++ b/resources/js/components/profile-winamp-button.tsx @@ -0,0 +1,89 @@ +import { router } from '@inertiajs/react'; +import { Music } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; +import type { WinampTrack } from '@/lib/winamp'; +import { openWinamp, preloadWinamp } from '@/lib/winamp'; + +export function ProfileWinampButton({ + handle, + playlist, +}: { + handle: string; + playlist: WinampTrack[] | undefined; +}) { + const [isWaiting, setIsWaiting] = useState(false); + const isFetching = useRef(false); + const playWhenFetched = useRef(false); + const isLoaded = playlist !== undefined && playlist.length > 0; + + const play = (tracks: WinampTrack[]) => { + if (tracks.length === 0) { + toast.error(`@${handle}'s playlist has nothing to play right now.`); + + return; + } + + void openWinamp(tracks); + }; + + const prepare = () => { + preloadWinamp(); + + if (isFetching.current || isLoaded) { + return; + } + + isFetching.current = true; + + router.reload({ + only: ['playlist'], + onSuccess: (page) => { + if (playWhenFetched.current) { + play((page.props.playlist ?? []) as WinampTrack[]); + } + }, + onFinish: () => { + isFetching.current = false; + playWhenFetched.current = false; + setIsWaiting(false); + }, + }); + }; + + const click = () => { + if (isLoaded) { + void openWinamp(playlist); + + return; + } + + playWhenFetched.current = true; + setIsWaiting(true); + prepare(); + }; + + return ( + + ); +} diff --git a/resources/js/components/search-dialog.tsx b/resources/js/components/search-dialog.tsx index ac99d227fb..d0e8ae6540 100644 --- a/resources/js/components/search-dialog.tsx +++ b/resources/js/components/search-dialog.tsx @@ -7,6 +7,7 @@ import { Flame, History, Lightbulb, + Music, Radio, Search, Square, @@ -24,6 +25,7 @@ import { OrganizationAvatar } from '@/components/organization-avatar'; import { DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import { Spinner } from '@/components/ui/spinner'; import { UserAvatar } from '@/components/user-avatar'; +import { useMediaQuery } from '@/hooks/use-media-query'; import { useNow } from '@/hooks/use-now'; import { clearRecentSearches, @@ -47,6 +49,7 @@ import { formatRelativeTime, laneColor, } from '@/lib/utils'; +import { winampMedia } from '@/lib/winamp'; import { show } from '@/routes/user'; import type { Commit, CommitAuthor, Organization, User } from '@/types'; @@ -108,6 +111,7 @@ export function SearchDialog({ const [activeIndex, setActiveIndex] = useState(0); const isPicking = onPickDeveloper !== undefined; const isMember = userId !== null; + const hasWinamp = useMediaQuery(winampMedia); const term = query.trim(); const developerTerm = term.replace(/^@/u, ''); @@ -148,7 +152,9 @@ export function SearchDialog({ visibleCommits, commitQuery, !isMember, - onAction && !isPicking ? searchActions(term, { isLive }) : [], + onAction && !isPicking + ? searchActions(term, { isLive, hasWinamp }) + : [], ); const isLoading = developers.isLoading || commits.isLoading; const hasSearched = @@ -795,6 +801,7 @@ const actionLabels: Record< bug: { label: 'Report a bug', icon: Bug, command: '/bug' }, 'go-live': { label: 'Go live', icon: Radio }, 'end-stream': { label: 'End stream', icon: Square }, + winamp: { label: 'Open Winamp', icon: Music }, }; function ActionResult({ action }: { action: SearchAction }) { diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx index df11584357..6b2d853a58 100644 --- a/resources/js/layouts/app-layout.tsx +++ b/resources/js/layouts/app-layout.tsx @@ -46,6 +46,7 @@ import type { IssueKind } from '@/lib/issue-commands'; import { userChannel } from '@/lib/realtime'; import type { SearchAction } from '@/lib/search-actions'; import { cn } from '@/lib/utils'; +import { openWinamp } from '@/lib/winamp'; import type { AppLayoutProps } from '@/types'; export default function AppLayout({ children }: AppLayoutProps) { @@ -94,6 +95,12 @@ export default function AppLayout({ children }: AppLayoutProps) { return; } + if (action === 'winamp') { + void openWinamp(); + + return; + } + setComposingIssue(action === 'commit' ? null : action); setIsComposing(true); }; diff --git a/resources/js/lib/search-actions.ts b/resources/js/lib/search-actions.ts index ef7a876e3e..fdea448306 100644 --- a/resources/js/lib/search-actions.ts +++ b/resources/js/lib/search-actions.ts @@ -3,7 +3,12 @@ import type { IssueKind } from '@/lib/issue-commands'; /** * Things search can do instead of find, so "commit", "bug" or "go live" are a few keystrokes from anywhere. */ -export type SearchAction = 'commit' | IssueKind | 'go-live' | 'end-stream'; +export type SearchAction = + | 'commit' + | IssueKind + | 'go-live' + | 'end-stream' + | 'winamp'; /** * An action is offered once the search is the start of one of its words. Ending a stream only answers to being asked @@ -15,6 +20,7 @@ const keywords: Record = { bug: ['bug', 'report a bug', 'bug report'], 'go-live': ['go live', 'live', 'stream', 'start stream'], 'end-stream': ['end stream', 'stop stream', 'end live', 'stop live'], + winamp: ['winamp', 'webamp', 'llama'], }; /** @@ -28,7 +34,7 @@ const shortestWord = 3; */ export function searchActions( term: string, - { isLive }: { isLive: boolean }, + { isLive, hasWinamp = false }: { isLive: boolean; hasWinamp?: boolean }, ): SearchAction[] { const typed = term.trim().replace(/\s+/gu, ' ').toLowerCase(); @@ -47,9 +53,12 @@ export function searchActions( 'feature', 'bug', isLive ? 'end-stream' : 'go-live', + ...(hasWinamp ? (['winamp'] as const) : []), ]; return available.filter((action) => - keywords[action].some((keyword) => keyword.startsWith(typed)), + keywords[action].some((keyword) => + action === 'winamp' ? keyword === typed : keyword.startsWith(typed), + ), ); } diff --git a/resources/js/lib/winamp.ts b/resources/js/lib/winamp.ts new file mode 100644 index 0000000000..abb6853d09 --- /dev/null +++ b/resources/js/lib/winamp.ts @@ -0,0 +1,145 @@ +import { toast } from 'sonner'; +import type Webamp from 'webamp'; + +export type WinampTrack = { + artist: string; + title: string; + duration: number; + url: string; +}; + +export const winampMedia = '(hover: hover) and (pointer: fine)'; + +const audius = (id: string) => + `https://api.audius.co/v1/tracks/${id}/stream?app_name=devnull`; + +const lofiBeats: WinampTrack[] = [ + { + artist: 'Broey.', + title: 'Paradise', + duration: 143, + url: audius('emKAp'), + }, + { + artist: 'harris cole', + title: 'game over!', + duration: 207, + url: audius('w76Mk'), + }, + { artist: 'Byte', title: 'snow', duration: 120, url: audius('k4mWX') }, + { artist: 'Milkz', title: 'astray', duration: 95, url: audius('D8a7l') }, + { artist: 'j3ra', title: 'Flourish', duration: 73, url: audius('nlzRQ') }, + { + artist: 'harris cole', + title: "louie's lullaby", + duration: 172, + url: audius('z863j'), + }, +]; + +let Winamp: typeof Webamp | null = null; +let importing: Promise | null = null; +let player: Webamp | null = null; +let opening: Promise | null = null; + +const toWebamp = (tracks: WinampTrack[]) => + tracks.map(({ artist, title, duration, url }) => ({ + metaData: { artist, title }, + duration, + url, + })); + +function play(webamp: Webamp | null, tracks?: WinampTrack[]) { + if (webamp !== null && tracks !== undefined) { + webamp.setTracksToPlay(toWebamp(tracks)); + } + + return webamp; +} + +function importWinamp(): Promise { + importing ??= import('webamp') + .then(({ default: loaded }) => { + Winamp = loaded; + + return loaded; + }) + .finally(() => { + importing = null; + }); + + return importing; +} + +function start( + Loaded: typeof Webamp, + tracks?: WinampTrack[], +): Promise { + if (!Loaded.browserIsSupported()) { + return Promise.resolve(null); + } + + const desktop = document.createElement('div'); + desktop.dataset.winamp = ''; + document.body.append(desktop); + + const webamp = new Loaded({ + initialTracks: tracks === undefined ? toWebamp(lofiBeats) : undefined, + enableMediaSession: true, + zIndex: 40, + }); + + play(webamp, tracks); + + return webamp.renderInto(desktop).then( + () => { + player = webamp; + + return webamp; + }, + (error: unknown) => { + webamp.dispose(); + desktop.remove(); + + throw error; + }, + ); +} + +function load(tracks?: WinampTrack[]): Promise { + if (player !== null) { + player.reopen(); + + return Promise.resolve(play(player, tracks)); + } + + if (opening !== null) { + return opening.then((webamp) => play(webamp, tracks)); + } + + opening = ( + Winamp === null + ? importWinamp().then((loaded) => start(loaded, tracks)) + : start(Winamp, tracks) + ).finally(() => { + opening = null; + }); + + return opening; +} + +export function preloadWinamp(): void { + if (Winamp === null) { + void importWinamp().catch(() => undefined); + } +} + +export async function openWinamp(tracks?: WinampTrack[]): Promise { + try { + if ((await load(tracks)) === null) { + toast.error("Winamp doesn't play in this browser."); + } + } catch { + toast.error('Could not open Winamp. Please try again.'); + } +} diff --git a/resources/js/pages/privacy/show.tsx b/resources/js/pages/privacy/show.tsx index 3cc699a584..92ff3054e6 100644 --- a/resources/js/pages/privacy/show.tsx +++ b/resources/js/pages/privacy/show.tsx @@ -258,7 +258,7 @@ export default function Show() { items={[ 'Service providers that process data for us, such as hosting, storage, network, and AI providers, and GitHub for sign-in.', 'Moderators and others who help us run the service.', - 'Third parties whose content you load, such as GitHub for avatars and the providers of embedded players once you press play, under their own privacy policies, for which we are not responsible.', + 'Third parties whose content you load, such as GitHub for avatars, the providers of embedded players once you press play, and Audius for the music in Winamp once you open it, under their own privacy policies, for which we are not responsible.', 'Authorities and others, when required by law or when we believe it is necessary to protect the service, its users, us, or anyone else.', 'A buyer, successor, or other party in a merger, acquisition, financing, reorganization, or sale of assets.', 'Professional advisers, such as lawyers, accountants, and insurers.', diff --git a/resources/js/pages/user-profile/edit.tsx b/resources/js/pages/user-profile/edit.tsx index 13647835eb..66d32a7fe0 100644 --- a/resources/js/pages/user-profile/edit.tsx +++ b/resources/js/pages/user-profile/edit.tsx @@ -8,6 +8,7 @@ import { GitHubProfile } from '@/components/github-profile'; import Heading from '@/components/heading'; import LanePicker from '@/components/lane-picker'; import { PasteisModeSetting } from '@/components/pasteis-mode-setting'; +import { PlaylistSetting } from '@/components/playlist-setting'; import { RemotesSetting } from '@/components/remotes-setting'; import TimezonePicker from '@/components/timezone-picker'; import TranslationLanguageSetting from '@/components/translation-language-setting'; @@ -30,6 +31,7 @@ export default function Edit({ githubSyncedAt, activityGraphUrl, profileUrl, + playlistUrl, }: { translationLanguage: string | null; translationLanguages: TranslationLanguage[]; @@ -38,6 +40,7 @@ export default function Edit({ githubSyncedAt: string | null; activityGraphUrl: string; profileUrl: string; + playlistUrl: string | null; }) { const { user } = useUser(); const now = useNow(); @@ -135,6 +138,8 @@ export default function Edit({ + + ; + hasPlaylist: boolean; + playlist?: WinampTrack[]; pasteisReceived: number | null; isFollowing: boolean; followsYou: boolean; @@ -284,6 +290,12 @@ export default function Show({ />
+ {hasPlaylist && ( + + )} name('user-live.update'); Route::delete('settings/live', [UserLiveController::class, 'destroy'])->name('user-live.destroy'); + // User Playlist... + Route::put('settings/playlist', [UserPlaylistController::class, 'update']) + ->middleware([PreventPostingInTimeout::class, 'throttle:playlist-links']) + ->name('user-playlist.update'); + Route::delete('settings/playlist', [UserPlaylistController::class, 'destroy'])->name('user-playlist.destroy'); + // Appearance... Route::get('settings/appearance', fn () => Inertia::render('appearance/update'))->name('appearance.edit'); diff --git a/tests/Feature/Controllers/ExportControllerTest.php b/tests/Feature/Controllers/ExportControllerTest.php index 385158730d..680ba37c0b 100644 --- a/tests/Feature/Controllers/ExportControllerTest.php +++ b/tests/Feature/Controllers/ExportControllerTest.php @@ -74,6 +74,7 @@ 'pasteis_received' => 17, 'pasteis_given_today' => 6, 'pasteis_given_on' => '2026-10-15', + 'playlist_url' => 'https://audius.co/zoe/playlist/focus', 'last_seen_at' => now()->toDateString(), 'remember_token' => 'never-export-this-token', ]); @@ -127,6 +128,7 @@ 'live_platform' => null, 'live_url' => null, 'live_ends_at' => null, + 'playlist_url' => 'https://audius.co/zoe/playlist/focus', 'last_seen_at' => now()->toDateString(), 'commit_rules_accepted_at' => $user->commit_rules_accepted_at->toIso8601String(), 'emails_unsubscribed_at' => null, diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 52c8c4ba28..35150030c2 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Contracts\Services\GitHubProvider; +use App\Contracts\Services\PlaylistProvider; use App\Enums\Log; use App\Http\Middleware\HandleInertiaRequests; use App\Models\Commit; @@ -51,6 +52,28 @@ ->where('commits.data.1.body', 'Older')); }); +it('offers the playlist of a member who shares one, reading its tracks only when asked', function (): void { + $track = ['artist' => 'Broey.', 'title' => 'Paradise', 'duration' => 143, 'url' => 'https://api.audius.co/v1/tracks/emKAp/stream?app_name=devnull']; + $this->mock(PlaylistProvider::class)->shouldReceive('tracks')->once()->with('https://audius.co/Byte/playlist/lofi')->andReturn([$track]); + $user = User::factory()->create(['playlist_url' => 'https://audius.co/Byte/playlist/lofi']); + + $this->get(route('user.show', $user)) + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('hasPlaylist', true) + ->missing('playlist') + ->missing('user.playlist_url') + ->reloadOnly('playlist', fn (AssertableInertia $reload): AssertableInertia => $reload + ->where('playlist', [$track]))); +}); + +it('offers no playlist for a member who shares none', function (): void { + $this->get(route('user.show', User::factory()->create())) + ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page + ->where('hasPlaylist', false) + ->reloadOnly('playlist', fn (AssertableInertia $reload): AssertableInertia => $reload + ->where('playlist', []))); +}); + it('says which accounts the bio and company mention are on dev/null', function (): void { Organization::factory()->create(['handle' => 'laravel']); User::factory()->create(['handle' => 'TaylorOtwell']); diff --git a/tests/Feature/Controllers/UserPlaylistControllerTest.php b/tests/Feature/Controllers/UserPlaylistControllerTest.php new file mode 100644 index 0000000000..29a68e5d85 --- /dev/null +++ b/tests/Feature/Controllers/UserPlaylistControllerTest.php @@ -0,0 +1,76 @@ +mock(PlaylistProvider::class)->shouldReceive('tracks')->with('https://audius.co/Byte/playlist/lofi')->andReturn([]); + $user = User::factory()->create(); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->put(route('user-playlist.update'), ['playlist_url' => ' https://audius.co/Byte/playlist/lofi ']) + ->assertRedirectToRoute('user-profile.edit'); + + expect($user->refresh()->playlist_url)->toBe('https://audius.co/Byte/playlist/lofi'); + + Event::assertDispatched(ProfilesUpdated::class, fn (ProfilesUpdated $event): bool => $event->profileIds === [$user->id]); + Event::assertDispatched(UsersUpdated::class, fn (UsersUpdated $event): bool => $event->userIds === [$user->id] && $event->update === UserUpdate::Account); +}); + +it('stops sharing the playlist', function (): void { + Event::fake([ProfilesUpdated::class, UsersUpdated::class]); + $user = User::factory()->create(['playlist_url' => 'https://audius.co/Byte/playlist/lofi']); + + $this->actingAs($user)->fromRoute('user-profile.edit') + ->delete(route('user-playlist.destroy')) + ->assertRedirectToRoute('user-profile.edit'); + + expect($user->refresh()->playlist_url)->toBeNull(); + + Event::assertDispatched(ProfilesUpdated::class, fn (ProfilesUpdated $event): bool => $event->profileIds === [$user->id]); + Event::assertDispatched(UsersUpdated::class, fn (UsersUpdated $event): bool => $event->userIds === [$user->id] && $event->update === UserUpdate::Account); +}); + +it('rejects a link that is not to a playlist', function (): void { + $this->mock(PlaylistProvider::class)->shouldReceive('tracks')->with('https://audius.co/broeybeats/paradise')->andReturnNull(); + $user = User::factory()->create(); + + $this->actingAs($user)->putJson(route('user-playlist.update'), ['playlist_url' => 'https://audius.co/broeybeats/paradise']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['playlist_url' => 'Use a link to a public playlist or album on Audius.']); + + expect($user->refresh()->playlist_url)->toBeNull(); +}); + +it('rejects links without asking Audius when they cannot be one', function (mixed $url): void { + $this->mock(PlaylistProvider::class)->shouldNotReceive('tracks'); + + $this->actingAs(User::factory()->create())->putJson(route('user-playlist.update'), ['playlist_url' => $url]) + ->assertUnprocessable() + ->assertJsonValidationErrors('playlist_url'); +})->with([ + 'missing' => [null], + 'not a string' => [['https://audius.co/Byte/playlist/lofi']], + 'too long' => ['https://audius.co/Byte/playlist/'.str_repeat('a', 240)], +]); + +it('asks to try again when Audius is not answering', function (): void { + $this->mock(PlaylistProvider::class)->shouldReceive('tracks')->andThrow(new ConnectionException('Timed out')); + + $this->actingAs(User::factory()->create())->putJson(route('user-playlist.update'), ['playlist_url' => 'https://audius.co/Byte/playlist/lofi']) + ->assertUnprocessable() + ->assertJsonValidationErrors(['playlist_url' => "Audius isn't answering. Try again in a moment."]); +}); + +it('requires authentication to share or stop sharing a playlist', function (): void { + $this->putJson(route('user-playlist.update'), ['playlist_url' => 'https://audius.co/Byte/playlist/lofi'])->assertUnauthorized(); + $this->deleteJson(route('user-playlist.destroy'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/UserProfileControllerTest.php b/tests/Feature/Controllers/UserProfileControllerTest.php index 5e06c7f23a..473ec3b65a 100644 --- a/tests/Feature/Controllers/UserProfileControllerTest.php +++ b/tests/Feature/Controllers/UserProfileControllerTest.php @@ -57,6 +57,12 @@ ->where('auth.user.timezone', $timezone)); })->with([null, 'Europe/Budapest']); +it('shows the playlist link the user shares', function (?string $url): void { + $this->actingAs(User::factory()->create(['playlist_url' => $url])) + ->get(route('user-profile.edit')) + ->assertInertia(fn ($page) => $page->where('playlistUrl', $url)); +})->with([null, 'https://audius.co/Byte/playlist/lofi']); + it('redirects settings to profile settings', function (): void { $user = User::factory()->create(); diff --git a/tests/Feature/Controllers/UserTimeoutControllerTest.php b/tests/Feature/Controllers/UserTimeoutControllerTest.php index 770afcab35..e1d05428e3 100644 --- a/tests/Feature/Controllers/UserTimeoutControllerTest.php +++ b/tests/Feature/Controllers/UserTimeoutControllerTest.php @@ -195,6 +195,22 @@ expect($user->refresh()->live_url)->toBeNull(); }); +it('stops a user in timeout from sharing a playlist, but lets them stop sharing it', function (): void { + $user = User::factory()->create(['playlist_url' => 'https://audius.co/rj/playlist/focus']); + UserTimeout::factory()->for($user)->create(); + + $this->actingAs($user) + ->putJson(route('user-playlist.update'), ['playlist_url' => 'https://audius.co/buymycourse/playlist/ads']) + ->assertForbidden() + ->assertJsonPath('timeout.reason', 'Spam or self-promotion'); + + expect($user->refresh()->playlist_url)->toBe('https://audius.co/rj/playlist/focus'); + + $this->delete(route('user-playlist.destroy'))->assertRedirect(); + + expect($user->refresh()->playlist_url)->toBeNull(); +}); + it('stops a user in timeout from starting, renaming, or growing orgs, and from messaging or pinning in them', function (): void { $user = User::factory()->create(); [$member, $newcomer] = followersOf($user, 2); diff --git a/tests/Feature/RateLimitTest.php b/tests/Feature/RateLimitTest.php index c49c278419..ef9aaf47a4 100644 --- a/tests/Feature/RateLimitTest.php +++ b/tests/Feature/RateLimitTest.php @@ -49,6 +49,13 @@ 120, 60, ], + 'playlist links' => [ + fn (): array => [ + ['PUT', route('user-playlist.update')], + ], + 10, + 60, + ], 'issue suggestions' => [ fn (): array => [ ['GET', route('issue-suggestion.index', ['q' => '722'])], diff --git a/tests/Unit/ProfileSync.test.tsx b/tests/Unit/ProfileSync.test.tsx index e3e19c57ce..0669f450de 100644 --- a/tests/Unit/ProfileSync.test.tsx +++ b/tests/Unit/ProfileSync.test.tsx @@ -106,6 +106,7 @@ test.each(suggestionStates)( githubSyncedAt: null, activityGraphUrl: '/activity', profileUrl: '/@viewer', + playlistUrl: null, }), (props) => 'options' in props && 'onSuccess' in props, ); diff --git a/tests/Unit/ProfileWinampButton.test.tsx b/tests/Unit/ProfileWinampButton.test.tsx new file mode 100644 index 0000000000..f3845e8579 --- /dev/null +++ b/tests/Unit/ProfileWinampButton.test.tsx @@ -0,0 +1,149 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement } from 'react'; +import { beforeEach, test, vi } from 'vite-plus/test'; + +type Element = ReactElement>; +type Visit = { + only: string[]; + onSuccess: (page: { props: { playlist?: unknown } }) => void; + onFinish: () => void; +}; + +const slots: unknown[] = []; +let cursor = 0; +const visits: Visit[] = []; +const openWinamp = vi.fn(() => Promise.resolve()); +const preloadWinamp = vi.fn(); +const toastError = vi.fn(); + +vi.doMock('react', () => ({ + ...React, + useState: (initial: unknown) => { + const index = cursor++; + + if (!(index in slots)) { + slots[index] = initial; + } + + return [ + slots[index], + (value: unknown) => { + slots[index] = value; + }, + ]; + }, + useRef: (initial: unknown) => { + const index = cursor++; + + if (!(index in slots)) { + slots[index] = { current: initial }; + } + + return slots[index]; + }, +})); +vi.doMock('@inertiajs/react', () => ({ + router: { reload: (visit: Visit) => visits.push(visit) }, +})); +vi.doMock('sonner', () => ({ toast: { error: toastError } })); +vi.doMock('@/lib/winamp', () => ({ openWinamp, preloadWinamp })); + +const { ProfileWinampButton } = + await import('../../resources/js/components/profile-winamp-button'); + +const track = { + artist: 'Broey.', + title: 'Paradise', + duration: 143, + url: 'https://api.audius.co/v1/tracks/emKAp/stream?app_name=devnull', +}; + +function render(playlist?: (typeof track)[]): Element { + cursor = 0; + + return ProfileWinampButton({ handle: 'byte', playlist }) as Element; +} + +function trigger(element: Element, handler: string, focusVisible = true) { + (element.props[handler] as (event: unknown) => void)({ + currentTarget: { matches: (selector: string) => selector === ':focus-visible' && focusVisible }, + }); +} + +beforeEach(() => { + slots.length = 0; + visits.length = 0; + openWinamp.mockClear(); + preloadWinamp.mockClear(); + toastError.mockClear(); +}); + +test('loads Winamp and the playlist once while the pointer is on the button or it has keyboard focus', () => { + trigger(render(), 'onPointerEnter'); + trigger(render(), 'onFocus'); + trigger(render(), 'onFocus', false); + + assert.equal(preloadWinamp.mock.calls.length, 2); + assert.equal(visits.length, 1); + assert.deepEqual(visits[0].only, ['playlist']); + assert.equal(render().props['aria-busy'], false); + + visits[0].onSuccess({ props: { playlist: [track] } }); + visits[0].onFinish(); + trigger(render([track]), 'onPointerEnter'); + + assert.equal(openWinamp.mock.calls.length, 0); + assert.equal(preloadWinamp.mock.calls.length, 3); + assert.equal(visits.length, 1); +}); + +test('plays a loaded playlist straight from the click', () => { + trigger(render([track]), 'onClick'); + + assert.deepEqual(openWinamp.mock.calls, [[[track]]]); + assert.equal(visits.length, 0); +}); + +test('waits for the playlist when clicked before it loaded', () => { + trigger(render(), 'onPointerEnter'); + trigger(render(), 'onClick'); + trigger(render(), 'onClick'); + + assert.equal(render().props['aria-busy'], true); + assert.equal(visits.length, 1); + + visits[0].onSuccess({ props: { playlist: [track] } }); + visits[0].onFinish(); + + assert.deepEqual(openWinamp.mock.calls, [[[track]]]); + assert.equal(render().props['aria-busy'], false); +}); + +test('fetches an empty playlist again and says so when it still has nothing to play', () => { + trigger(render([]), 'onPointerEnter'); + trigger(render([]), 'onClick'); + visits[0].onSuccess({ props: { playlist: [] } }); + visits[0].onFinish(); + trigger(render(), 'onClick'); + visits[1].onSuccess({ props: {} }); + visits[1].onFinish(); + + assert.equal(visits.length, 2); + assert.equal(openWinamp.mock.calls.length, 0); + assert.deepEqual(toastError.mock.calls, [ + ["@byte's playlist has nothing to play right now."], + ["@byte's playlist has nothing to play right now."], + ]); +}); + +test('does not start playing later when loading the playlist failed', () => { + trigger(render(), 'onClick'); + visits[0].onFinish(); + trigger(render(), 'onPointerEnter'); + visits[1].onSuccess({ props: { playlist: [track] } }); + visits[1].onFinish(); + + assert.equal(openWinamp.mock.calls.length, 0); +}); diff --git a/tests/Unit/Queries/PlaylistQueryTest.php b/tests/Unit/Queries/PlaylistQueryTest.php new file mode 100644 index 0000000000..a135a8fb68 --- /dev/null +++ b/tests/Unit/Queries/PlaylistQueryTest.php @@ -0,0 +1,64 @@ + 'Broey.', 'title' => 'Paradise', 'duration' => 143, 'url' => 'https://api.audius.co/v1/tracks/emKAp/stream?app_name=devnull']; + +it("reads the tracks of a member's playlist once every ten minutes", function () use ($track): void { + $this->mock(PlaylistProvider::class)->shouldReceive('tracks') + ->with('https://audius.co/Byte/playlist/lofi') + ->twice() + ->andReturn([$track]); + $user = User::factory()->create(['playlist_url' => 'https://audius.co/Byte/playlist/lofi']); + $playlist = resolve(PlaylistQuery::class); + + expect($playlist->get($user))->toBe([$track]); + + $this->travel(599)->seconds(); + expect($playlist->get($user))->toBe([$track]); + + $this->travel(2)->seconds(); + expect($playlist->get($user))->toBe([$track]); +}); + +it('shares the tracks between members who share the playlist', function () use ($track): void { + $this->mock(PlaylistProvider::class)->shouldReceive('tracks')->once()->andReturn([$track]); + $playlist = resolve(PlaylistQuery::class); + + foreach (User::factory(2)->create(['playlist_url' => 'https://audius.co/Byte/playlist/lofi']) as $user) { + expect($playlist->get($user))->toBe([$track]); + } +}); + +it('finds no tracks for a playlist that is gone', function (): void { + $this->mock(PlaylistProvider::class)->shouldReceive('tracks')->andReturnNull(); + + expect(resolve(PlaylistQuery::class)->get(User::factory()->create(['playlist_url' => 'https://audius.co/Byte/playlist/gone'])))->toBe([]); +}); + +it('finds no tracks for a member without a playlist', function (): void { + $this->mock(PlaylistProvider::class)->shouldNotReceive('tracks'); + + expect(resolve(PlaylistQuery::class)->get(User::factory()->create()))->toBe([]); +}); + +it('waits a minute before asking Audius again after it fails', function () use ($track): void { + $this->mock(PlaylistProvider::class)->shouldReceive('tracks') + ->twice() + ->andReturnUsing(fn () => throw new ConnectionException('Timed out'), fn (): array => [$track]); + $user = User::factory()->create(['playlist_url' => 'https://audius.co/Byte/playlist/lofi']); + $playlist = resolve(PlaylistQuery::class); + + expect($playlist->get($user))->toBe([]); + + $this->travel(59)->seconds(); + expect($playlist->get($user))->toBe([]); + + $this->travel(2)->seconds(); + expect($playlist->get($user))->toBe([$track]); +}); diff --git a/tests/Unit/SearchActions.test.ts b/tests/Unit/SearchActions.test.ts index 966b4e1a34..853b20573d 100644 --- a/tests/Unit/SearchActions.test.ts +++ b/tests/Unit/SearchActions.test.ts @@ -39,3 +39,15 @@ await test('ends a stream instead of starting one, only when asked by name', () assert.deepEqual(searchActions('stop', live), ['end-stream']); assert.deepEqual(searchActions('end stream', offline), []); }); + +await test('opens Winamp only where it can play', () => { + const desktop = { isLive: false, hasWinamp: true }; + + assert.deepEqual(searchActions('winamp', desktop), ['winamp']); + assert.deepEqual(searchActions('Webamp', desktop), ['winamp']); + assert.deepEqual(searchActions(' llama ', desktop), ['winamp']); + assert.deepEqual(searchActions('web', desktop), []); + assert.deepEqual(searchActions('win', desktop), []); + assert.deepEqual(searchActions('winam', desktop), []); + assert.deepEqual(searchActions('winamp', offline), []); +}); diff --git a/tests/Unit/SearchDialogComposition.test.tsx b/tests/Unit/SearchDialogComposition.test.tsx index e11c767f3a..3396039861 100644 --- a/tests/Unit/SearchDialogComposition.test.tsx +++ b/tests/Unit/SearchDialogComposition.test.tsx @@ -26,6 +26,10 @@ vi.doMock('../../resources/js/lib/storage', () => ({ }, })); +vi.doMock('../../resources/js/hooks/use-media-query', () => ({ + useMediaQuery: () => false, +})); + vi.doMock('react', () => ({ ...React, useEffect: (effect: () => unknown) => { diff --git a/tests/Unit/Services/PlaylistProviders/AudiusPlaylistProviderTest.php b/tests/Unit/Services/PlaylistProviders/AudiusPlaylistProviderTest.php new file mode 100644 index 0000000000..0824e7dea1 --- /dev/null +++ b/tests/Unit/Services/PlaylistProviders/AudiusPlaylistProviderTest.php @@ -0,0 +1,120 @@ + $overrides + * @return array + */ +function audiusTrack(array $overrides = []): array +{ + return [ + 'id' => 'emKAp', + 'title' => 'Paradise', + 'duration' => 143, + 'user' => ['handle' => 'broeybeats', 'name' => 'Broey.'], + 'access' => ['stream' => true, 'download' => false], + 'is_delete' => false, + ...$overrides, + ]; +} + +it('reads the tracks of a playlist', function (): void { + Http::fake(['api.audius.co/*' => Http::response(['data' => [[ + 'id' => 'nl1bL', + 'playlist_name' => 'lofi Chillhop Beats', + 'tracks' => [ + audiusTrack(), + audiusTrack(['id' => 'w76Mk', 'title' => ' game over! ', 'duration' => 207, 'user' => ['name' => 'harris cole']]), + ], + ]]])]); + + expect(new AudiusPlaylistProvider()->tracks('https://audius.co/Byte/playlist/lofi-chillhop-beats-906'))->toBe([ + ['artist' => 'Broey.', 'title' => 'Paradise', 'duration' => 143, 'url' => 'https://api.audius.co/v1/tracks/emKAp/stream?app_name=devnull'], + ['artist' => 'harris cole', 'title' => 'game over!', 'duration' => 207, 'url' => 'https://api.audius.co/v1/tracks/w76Mk/stream?app_name=devnull'], + ]); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.audius.co/v1/resolve?url=https%3A%2F%2Faudius.co%2FByte%2Fplaylist%2Flofi-chillhop-beats-906&app_name=devnull'); +}); + +it('reads albums too', function (): void { + Http::fake(['api.audius.co/*' => Http::response(['data' => [['tracks' => [audiusTrack()]]]])]); + + expect(new AudiusPlaylistProvider()->tracks('https://www.audius.co/Byte/album/snow'))->toHaveCount(1); +}); + +it('leaves out tracks Winamp cannot play', function (array $track): void { + Http::fake(['api.audius.co/*' => Http::response(['data' => [['tracks' => [audiusTrack(), $track, 'not a track']]]])]); + + expect(new AudiusPlaylistProvider()->tracks('https://audius.co/Byte/playlist/lofi'))->toHaveCount(1); +})->with([ + 'gated' => [audiusTrack(['access' => ['stream' => false, 'download' => false]])], + 'deleted' => [audiusTrack(['is_delete' => true])], + 'without an id' => [audiusTrack(['id' => null])], + 'without a title' => [audiusTrack(['title' => ' '])], +]); + +it('reads the first 200 tracks Winamp can play', function (): void { + Http::fake(['api.audius.co/*' => Http::response(['data' => [['tracks' => [ + audiusTrack(['is_delete' => true]), + ...array_map(fn (int $index): array => audiusTrack(['id' => "track{$index}"]), range(1, 201)), + ]]]])]); + + $tracks = new AudiusPlaylistProvider()->tracks('https://audius.co/Byte/playlist/lofi'); + + expect($tracks)->toHaveCount(200) + ->and($tracks[0]['url'])->toBe('https://api.audius.co/v1/tracks/track1/stream?app_name=devnull') + ->and($tracks[199]['url'])->toBe('https://api.audius.co/v1/tracks/track200/stream?app_name=devnull'); +}); + +it('finds no playlist behind a link that is not to one on Audius', function (string $url): void { + Http::fake(); + + expect(new AudiusPlaylistProvider()->tracks($url))->toBeNull(); + + Http::assertNothingSent(); +})->with([ + 'a track' => ['https://audius.co/broeybeats/paradise-51920'], + 'a profile' => ['https://audius.co/Byte'], + 'another site' => ['https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M'], + 'a look-alike host' => ['https://audius.co.example.com/Byte/playlist/lofi'], + 'not over https' => ['http://audius.co/Byte/playlist/lofi'], + 'with a query' => ['https://audius.co/Byte/playlist/lofi?ref=share'], + 'a deeper path' => ['https://audius.co/Byte/playlist/lofi/edit'], +]); + +it('finds no playlist Audius does not know', function (): void { + Http::fake(['api.audius.co/*' => Http::response(['code' => 404, 'error' => 'Playlist not found'], 404)]); + + expect(new AudiusPlaylistProvider()->tracks('https://audius.co/nobody/playlist/nothing'))->toBeNull(); +}); + +it('finds no playlist its owner keeps private', function (): void { + Http::fake(['api.audius.co/*' => Http::response(['data' => [['is_private' => true, 'tracks' => [audiusTrack()]]]])]); + + expect(new AudiusPlaylistProvider()->tracks('https://audius.co/Byte/playlist/lofi'))->toBeNull(); +}); + +it('finds no tracks in an answer without any', function (): void { + Http::fake(['api.audius.co/*' => Http::response(['data' => []])]); + + expect(new AudiusPlaylistProvider()->tracks('https://audius.co/Byte/playlist/lofi'))->toBe([]); +}); + +it('fails when Audius is down', function (): void { + Http::fake(['api.audius.co/*' => Http::response('Bad gateway', 502)]); + + new AudiusPlaylistProvider()->tracks('https://audius.co/Byte/playlist/lofi'); +})->throws(RequestException::class); + +it('fails when Audius cannot be reached', function (): void { + Http::fake(['api.audius.co/*' => Http::failedConnection()]); + + new AudiusPlaylistProvider()->tracks('https://audius.co/Byte/playlist/lofi'); +})->throws(ConnectionException::class); diff --git a/tests/Unit/Services/PlaylistProviders/NullPlaylistProviderTest.php b/tests/Unit/Services/PlaylistProviders/NullPlaylistProviderTest.php new file mode 100644 index 0000000000..af7e769575 --- /dev/null +++ b/tests/Unit/Services/PlaylistProviders/NullPlaylistProviderTest.php @@ -0,0 +1,9 @@ +tracks('https://audius.co/Byte/playlist/lofi'))->toBe([]); +}); diff --git a/tests/Unit/Winamp.test.tsx b/tests/Unit/Winamp.test.tsx new file mode 100644 index 0000000000..bc4edda0f8 --- /dev/null +++ b/tests/Unit/Winamp.test.tsx @@ -0,0 +1,194 @@ +/// +import assert from 'node:assert/strict'; +import { beforeEach, test, vi } from 'vite-plus/test'; + +const instances: Array>> = []; +const options: unknown[] = []; +const appended: Array<{ dataset: Record; remove: () => void }> = []; +const error = vi.fn(); +let supported = true; +let failRender = false; +let failImport = false; + +vi.doMock('sonner', () => ({ toast: { error } })); +const webamp = () => { + if (failImport) { + throw new Error('chunk'); + } + + return { + default: class { + static browserIsSupported = () => supported; + + reopen = vi.fn(); + setTracksToPlay = vi.fn(); + dispose = vi.fn(); + renderInto = vi.fn(() => + failRender ? Promise.reject(new Error('chunk')) : Promise.resolve(), + ); + + constructor(given: unknown) { + options.push(given); + instances.push(this as unknown as Record>); + } + }, + }; +}; + +vi.doMock('webamp', webamp); + +Object.assign(globalThis, { + document: { + createElement: () => { + const element = { + dataset: {}, + remove: () => appended.splice(appended.indexOf(element), 1), + }; + + return element; + }, + body: { append: (element: (typeof appended)[number]) => appended.push(element) }, + }, +}); + +const track = { + artist: 'Broey.', + title: 'Paradise', + duration: 143, + url: 'https://api.audius.co/v1/tracks/emKAp/stream?app_name=devnull', +}; + +beforeEach(() => { + vi.resetModules(); + instances.length = 0; + options.length = 0; + appended.length = 0; + error.mockClear(); + supported = true; + failRender = false; + failImport = false; +}); + +test('opens one player with the default playlist and reopens it after that', async () => { + const { openWinamp } = await import('../../resources/js/lib/winamp'); + + await Promise.all([openWinamp(), openWinamp()]); + await openWinamp(); + + assert.equal(instances.length, 1); + assert.equal(appended.length, 1); + assert.equal(appended[0].dataset.winamp, ''); + assert.equal(instances[0].reopen.mock.calls.length, 1); + assert.equal(instances[0].setTracksToPlay.mock.calls.length, 0); + assert.deepEqual( + (options[0] as { initialTracks: unknown[]; zIndex: number }).zIndex, + 40, + ); + assert.equal( + (options[0] as { initialTracks: unknown[] }).initialTracks.length, + 6, + ); +}); + +test('plays a playlist in the open player', async () => { + const { openWinamp } = await import('../../resources/js/lib/winamp'); + + await openWinamp([track]); + await openWinamp([track, track]); + + assert.equal(instances.length, 1); + assert.deepEqual(instances[0].setTracksToPlay.mock.calls, [ + [[{ metaData: { artist: 'Broey.', title: 'Paradise' }, duration: 143, url: track.url }]], + [ + [ + { metaData: { artist: 'Broey.', title: 'Paradise' }, duration: 143, url: track.url }, + { metaData: { artist: 'Broey.', title: 'Paradise' }, duration: 143, url: track.url }, + ], + ], + ]); +}); + +test('starts a playlist before returning once Winamp is preloaded', async () => { + const { openWinamp, preloadWinamp } = await import('../../resources/js/lib/winamp'); + + preloadWinamp(); + preloadWinamp(); + await vi.dynamicImportSettled(); + + const opened = openWinamp([track]); + + assert.equal(instances.length, 1); + assert.equal(instances[0].setTracksToPlay.mock.calls.length, 1); + assert.equal((options[0] as { initialTracks: unknown }).initialTracks, undefined); + + await opened; + preloadWinamp(); + await openWinamp(); + + assert.equal(instances.length, 1); + assert.equal(instances[0].reopen.mock.calls.length, 1); + assert.equal(error.mock.calls.length, 0); +}); + +test('plays a playlist once the player that is already opening is ready', async () => { + const { openWinamp } = await import('../../resources/js/lib/winamp'); + + await Promise.all([openWinamp(), openWinamp([track])]); + + assert.equal(instances.length, 1); + assert.equal(instances[0].setTracksToPlay.mock.calls.length, 1); +}); + +test('lets a search open join a playlist that is already opening', async () => { + const { openWinamp } = await import('../../resources/js/lib/winamp'); + + await Promise.all([openWinamp([track]), openWinamp()]); + + assert.equal(instances.length, 1); + assert.equal((options[0] as { initialTracks: unknown }).initialTracks, undefined); + assert.equal(instances[0].setTracksToPlay.mock.calls.length, 1); +}); + +test('loads Winamp again when preloading it failed', async () => { + const { openWinamp, preloadWinamp } = await import('../../resources/js/lib/winamp'); + failImport = true; + vi.doMock('webamp', webamp); + + preloadWinamp(); + await vi.dynamicImportSettled(); + failImport = false; + vi.doMock('webamp', webamp); + await openWinamp([track]); + + assert.equal(instances.length, 1); + assert.equal(instances[0].setTracksToPlay.mock.calls.length, 1); + assert.equal(error.mock.calls.length, 0); +}); + +test('says so and leaves nothing behind when the player fails to open, then tries again', async () => { + const { openWinamp } = await import('../../resources/js/lib/winamp'); + failRender = true; + + await openWinamp([track]); + + assert.deepEqual(error.mock.calls, [['Could not open Winamp. Please try again.']]); + assert.equal(appended.length, 0); + assert.equal(instances[0].dispose.mock.calls.length, 1); + + failRender = false; + await openWinamp([track]); + + assert.equal(instances.length, 2); + assert.equal(appended.length, 1); + assert.equal(instances[1].setTracksToPlay.mock.calls.length, 1); +}); + +test('says so in browsers Winamp does not play in', async () => { + const { openWinamp } = await import('../../resources/js/lib/winamp'); + supported = false; + + await openWinamp(); + + assert.equal(instances.length, 0); + assert.deepEqual(error.mock.calls, [["Winamp doesn't play in this browser."]]); +});