diff --git a/app/Casts/UserPreferencesCast.php b/app/Casts/UserPreferencesCast.php index 3d44ed6a..078a8d11 100644 --- a/app/Casts/UserPreferencesCast.php +++ b/app/Casts/UserPreferencesCast.php @@ -4,15 +4,12 @@ namespace App\Casts; use App\Values\UserPreferences; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; -use Illuminate\Support\Arr; class UserPreferencesCast implements CastsAttributes { public function get($model, string $key, $value, array $attributes): UserPreferences { - $arr = json_decode($value, true) ?: []; - - return UserPreferences::make(Arr::get($arr, 'lastfm_session_key')); + return UserPreferences::fromArray(json_decode($value, true) ?: []); } /** @param UserPreferences|null $value */ diff --git a/app/Http/Controllers/API/UpdateUserPreferenceController.php b/app/Http/Controllers/API/UpdateUserPreferenceController.php new file mode 100644 index 00000000..42e319ac --- /dev/null +++ b/app/Http/Controllers/API/UpdateUserPreferenceController.php @@ -0,0 +1,20 @@ +savePreference($user, $request->key, $request->value); + + return response()->noContent(); + } +} diff --git a/app/Http/Requests/API/UpdateUserPreferencesRequest.php b/app/Http/Requests/API/UpdateUserPreferencesRequest.php new file mode 100644 index 00000000..13f100ba --- /dev/null +++ b/app/Http/Requests/API/UpdateUserPreferencesRequest.php @@ -0,0 +1,21 @@ + */ + public function rules(): array + { + return [ + 'key' => ['required', 'string', new CustomizableUserPreference()], + 'value' => 'sometimes', + ]; + } +} diff --git a/app/Listeners/LoveTrackOnLastfm.php b/app/Listeners/LoveTrackOnLastfm.php index 58fca7e5..af19b061 100644 --- a/app/Listeners/LoveTrackOnLastfm.php +++ b/app/Listeners/LoveTrackOnLastfm.php @@ -16,7 +16,7 @@ class LoveTrackOnLastfm implements ShouldQueue { if ( !LastfmService::enabled() || - !$event->interaction->user->lastfm_session_key || + !$event->interaction->user->preferences->lastFmSessionKey || $event->interaction->song->artist->is_unknown ) { return; diff --git a/app/Listeners/UpdateLastfmNowPlaying.php b/app/Listeners/UpdateLastfmNowPlaying.php index e7ac057d..7b576d57 100644 --- a/app/Listeners/UpdateLastfmNowPlaying.php +++ b/app/Listeners/UpdateLastfmNowPlaying.php @@ -14,7 +14,11 @@ class UpdateLastfmNowPlaying implements ShouldQueue public function handle(PlaybackStarted $event): void { - if (!LastfmService::enabled() || !$event->user->lastfm_session_key || $event->song->artist->is_unknown) { + if ( + !LastfmService::enabled() + || !$event->user->preferences->lastFmSessionKey + || $event->song->artist->is_unknown + ) { return; } diff --git a/app/Models/User.php b/app/Models/User.php index e8064d31..5c9ca1f1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -20,7 +20,6 @@ use Laravel\Sanctum\PersonalAccessToken; * @property UserPreferences $preferences * @property int $id * @property bool $is_admin - * @property ?string $lastfm_session_key * @property string $name * @property string $email * @property string $password @@ -80,14 +79,6 @@ class User extends Authenticatable return Attribute::get(fn (): string => gravatar($this->email)); } - /** - * Get the user's Last.fm session key. - */ - protected function lastfmSessionKey(): Attribute - { - return Attribute::get(fn (): ?string => $this->preferences->lastFmSessionKey); - } - protected function isProspect(): Attribute { return Attribute::get(fn (): bool => (bool) $this->invitation_token); @@ -98,6 +89,6 @@ class User extends Authenticatable */ public function connectedToLastfm(): bool { - return (bool) $this->lastfm_session_key; + return (bool) $this->preferences->lastFmSessionKey; } } diff --git a/app/Rules/CustomizableUserPreference.php b/app/Rules/CustomizableUserPreference.php new file mode 100644 index 00000000..ff6de6f8 --- /dev/null +++ b/app/Rules/CustomizableUserPreference.php @@ -0,0 +1,19 @@ + $song->artist->name, 'track' => $song->title, 'timestamp' => $timestamp, - 'sk' => $user->lastfm_session_key, + 'sk' => $user->preferences->lastFmSessionKey, 'method' => 'track.scrobble', ]; @@ -80,7 +80,7 @@ class LastfmService implements MusicEncyclopedia attempt(fn () => $this->client->post('/', [ 'track' => $song->title, 'artist' => $song->artist->name, - 'sk' => $user->lastfm_session_key, + 'sk' => $user->preferences->lastFmSessionKey, 'method' => $love ? 'track.love' : 'track.unlove', ], false)); } @@ -95,7 +95,7 @@ class LastfmService implements MusicEncyclopedia return $this->client->postAsync('/', [ 'track' => $song->title, 'artist' => $song->artist->name, - 'sk' => $user->lastfm_session_key, + 'sk' => $user->preferences->lastFmSessionKey, 'method' => $love ? 'track.love' : 'track.unlove', ], false); } @@ -110,7 +110,7 @@ class LastfmService implements MusicEncyclopedia 'artist' => $song->artist->name, 'track' => $song->title, 'duration' => $song->length, - 'sk' => $user->lastfm_session_key, + 'sk' => $user->preferences->lastFmSessionKey, 'method' => 'track.updateNowPlaying', ]; diff --git a/app/Services/UploadService.php b/app/Services/UploadService.php index 812809e0..cf6a6ae8 100644 --- a/app/Services/UploadService.php +++ b/app/Services/UploadService.php @@ -29,10 +29,15 @@ class UploadService $targetPathName = $uploadDirectory . $targetFileName; try { - $result = $this->scanner->setFile($targetPathName)->scan(ScanConfiguration::make(owner: $uploader)); - } catch (Throwable) { + $result = $this->scanner->setFile($targetPathName)->scan( + ScanConfiguration::make( + owner: $uploader, + makePublic: $uploader->preferences->makeUploadsPublic + ) + ); + } catch (Throwable $e) { File::delete($targetPathName); - throw new SongUploadFailedException('Unknown error'); + throw new SongUploadFailedException($e->getMessage()); } if ($result->isError()) { diff --git a/app/Services/UserService.php b/app/Services/UserService.php index d5b9068a..dfee7303 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -45,4 +45,11 @@ class UserService { $user->delete(); } + + public function savePreference(User $user, string $key, mixed $value): void + { + $user->preferences = $user->preferences->set($key, $value); + + $user->save(); + } } diff --git a/app/Values/Equalizer.php b/app/Values/Equalizer.php new file mode 100644 index 00000000..44c60e2b --- /dev/null +++ b/app/Values/Equalizer.php @@ -0,0 +1,43 @@ +|null $gains */ + private function __construct(public ?string $name, public int $preamp, public array $gains) + { + } + + public static function tryMake(array|string $data): self + { + try { + if (is_string($data)) { + $data = ['name' => $data]; + } + + return new self(Arr::get($data, 'name') ?? null, Arr::get($data, 'preamp'), Arr::get($data, 'gains')); + } catch (Throwable) { + return self::default(); + } + } + + public static function default(): self + { + return new self(name: 'Default', preamp: 0, gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'preamp' => $this->preamp, + 'gains' => $this->gains, + ]; + } +} diff --git a/app/Values/UserPreferences.php b/app/Values/UserPreferences.php index 6c100a18..59380e02 100644 --- a/app/Values/UserPreferences.php +++ b/app/Values/UserPreferences.php @@ -4,22 +4,136 @@ namespace App\Values; use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; +use Webmozart\Assert\Assert; final class UserPreferences implements Arrayable, JsonSerializable { - private function __construct(public ?string $lastFmSessionKey = null) - { + private const CASTS = [ + 'volume' => 'float', + 'show_now_playing_notification' => 'boolean', + 'confirm_before_closing' => 'boolean', + 'transcode_on_mobile' => 'boolean', + 'show_album_art_overlay' => 'boolean', + 'lyrics_zoom_level' => 'integer', + 'make_uploads_public' => 'boolean', + ]; + + private const CUSTOMIZABLE_KEYS = [ + 'volume', + 'repeat_mode', + 'equalizer', + 'artists_view_mode', + 'albums_view_mode', + 'theme', + 'show_now_playing_notification', + 'confirm_before_closing', + 'transcode_on_mobile', + 'show_album_art_overlay', + 'make_uploads_public', + 'support_bar_no_bugging', + 'lyrics_zoom_level', + 'visualizer', + 'active_extra_panel_tab', + ]; + + private const ALL_KEYS = self::CUSTOMIZABLE_KEYS + ['lastfm_session_key']; + + private function __construct( + public float $volume, + public string $repeatMode, + public Equalizer $equalizer, + public string $artistsViewMode, + public string $albumsViewMode, + public string $theme, + public bool $showNowPlayingNotification, + public bool $confirmBeforeClosing, + public bool $transcodeOnMobile, + public bool $showAlbumArtOverlay, + public bool $makeUploadsPublic, + public bool $supportBarNoBugging, + public int $lyricsZoomLevel, + public string $visualizer, + public ?string $activeExtraPanelTab, + public ?string $lastFmSessionKey + ) { + Assert::oneOf($this->repeatMode, ['NO_REPEAT', 'REPEAT_ALL', 'REPEAT_ONE']); + Assert::oneOf($this->artistsViewMode, ['list', 'thumbnails']); + Assert::oneOf($this->albumsViewMode, ['list', 'thumbnails']); + Assert::oneOf($this->activeExtraPanelTab, [null, 'Lyrics', 'Artist', 'Album', 'YouTube']); } - public static function make(?string $lastFmSessionKey = null): self + public static function fromArray(array $data): self { - return new self($lastFmSessionKey); + return new self( + volume: $data['volume'] ?? 7.0, + repeatMode: $data['repeat_mode'] ?? 'NO_REPEAT', + equalizer: isset($data['equalizer']) ? Equalizer::tryMake($data['equalizer']) : Equalizer::default(), + artistsViewMode: $data['artists_view_mode'] ?? 'thumbnails', + albumsViewMode: $data['albums_view_mode'] ?? 'thumbnails', + theme: $data['theme'] ?? 'classic', + showNowPlayingNotification: $data['show_now_playing_notification'] ?? true, + confirmBeforeClosing: $data['confirm_before_closing'] ?? false, + transcodeOnMobile: $data['transcode_on_mobile'] ?? true, + showAlbumArtOverlay: $data['show_album_art_overlay'] ?? true, + makeUploadsPublic: $data['make_uploads_public'] ?? false, + supportBarNoBugging: $data['support_bar_no_bugging'] ?? false, + lyricsZoomLevel: $data['lyrics_zoom_level'] ?? 1, + visualizer: $data['visualizer'] ?? 'default', + activeExtraPanelTab: $data['active_extra_panel_tab'] ?? null, + lastFmSessionKey: $data['lastfm_session_key'] ?? null, + ); + } + + public static function customizable(string $key): bool + { + return in_array($key, self::CUSTOMIZABLE_KEYS, true); + } + + public function set(string $key, mixed $value): self + { + self::assertValidKey($key); + + $cast = self::CASTS[$key] ?? null; + + $value = match ($cast) { + 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN), + 'integer' => (int) $value, + 'float' => (float) $value, + default => $value, + }; + + $arr = $this->toArray(); + $arr[$key] = $value; + + return self::fromArray($arr); + } + + public static function assertValidKey(string $key): void + { + Assert::inArray($key, self::ALL_KEYS); } /** @return array */ public function toArray(): array { - return ['lastfm_session_key' => $this->lastFmSessionKey]; + return [ + 'theme' => $this->theme, + 'show_now_playing_notification' => $this->showNowPlayingNotification, + 'confirm_before_closing' => $this->confirmBeforeClosing, + 'show_album_art_overlay' => $this->showAlbumArtOverlay, + 'transcode_on_mobile' => $this->transcodeOnMobile, + 'make_uploads_public' => $this->makeUploadsPublic, + 'lastfm_session_key' => $this->lastFmSessionKey, + 'support_bar_no_bugging' => $this->supportBarNoBugging, + 'artists_view_mode' => $this->artistsViewMode, + 'albums_view_mode' => $this->albumsViewMode, + 'repeat_mode' => $this->repeatMode, + 'volume' => $this->volume, + 'equalizer' => $this->equalizer->toArray(), + 'lyrics_zoom_level' => $this->lyricsZoomLevel, + 'visualizer' => $this->visualizer, + 'active_extra_panel_tab' => $this->activeExtraPanelTab, + ]; } /** @return array */ diff --git a/resources/assets/js/App.vue b/resources/assets/js/App.vue index 855c04cf..0bbef1da 100644 --- a/resources/assets/js/App.vue +++ b/resources/assets/js/App.vue @@ -71,8 +71,8 @@ const { offline } = useNetworkStatus() * Request for notification permission if it's not provided and the user is OK with notifications. */ const requestNotificationPermission = async () => { - if (preferences.notify && window.Notification && window.Notification.permission !== 'granted') { - preferences.notify = await window.Notification.requestPermission() === 'denied' + if (preferences.show_now_playing_notification && window.Notification && window.Notification.permission !== 'granted') { + preferences.show_now_playing_notification = await window.Notification.requestPermission() === 'denied' } } @@ -109,7 +109,7 @@ const init = async () => { await requestNotificationPermission() window.addEventListener('beforeunload', (e: BeforeUnloadEvent) => { - if (uploadService.shouldWarnUponWindowUnload() || preferences.confirmClosing) { + if (uploadService.shouldWarnUponWindowUnload() || preferences.confirm_before_closing) { e.preventDefault() e.returnValue = '' } diff --git a/resources/assets/js/components/layout/app-footer/FooterExtraControls.vue b/resources/assets/js/components/layout/app-footer/FooterExtraControls.vue index 451fe2fc..ef89e7de 100644 --- a/resources/assets/js/components/layout/app-footer/FooterExtraControls.vue +++ b/resources/assets/js/components/layout/app-footer/FooterExtraControls.vue @@ -23,7 +23,7 @@ - +