mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: store perferences on server and make upload visibility a preference
This commit is contained in:
parent
84ce42da08
commit
f3689f61d4
47 changed files with 407 additions and 163 deletions
|
@ -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 */
|
||||
|
|
20
app/Http/Controllers/API/UpdateUserPreferenceController.php
Normal file
20
app/Http/Controllers/API/UpdateUserPreferenceController.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\API\UpdateUserPreferencesRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\UserService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class UpdateUserPreferenceController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __invoke(UpdateUserPreferencesRequest $request, UserService $service, Authenticatable $user)
|
||||
{
|
||||
$service->savePreference($user, $request->key, $request->value);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
21
app/Http/Requests/API/UpdateUserPreferencesRequest.php
Normal file
21
app/Http/Requests/API/UpdateUserPreferencesRequest.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\API;
|
||||
|
||||
use App\Rules\CustomizableUserPreference;
|
||||
|
||||
/**
|
||||
* @property-read string $key
|
||||
* @property-read string $value
|
||||
*/
|
||||
class UpdateUserPreferencesRequest extends Request
|
||||
{
|
||||
/** @return array<mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'key' => ['required', 'string', new CustomizableUserPreference()],
|
||||
'value' => 'sometimes',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
19
app/Rules/CustomizableUserPreference.php
Normal file
19
app/Rules/CustomizableUserPreference.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Values\UserPreferences;
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
|
||||
class CustomizableUserPreference implements Rule
|
||||
{
|
||||
public function passes($attribute, $value): bool
|
||||
{
|
||||
return UserPreferences::customizable($value);
|
||||
}
|
||||
|
||||
public function message(): string
|
||||
{
|
||||
return 'Invalid or uncustomizable user preference key.';
|
||||
}
|
||||
}
|
|
@ -64,7 +64,7 @@ class LastfmService implements MusicEncyclopedia
|
|||
'artist' => $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',
|
||||
];
|
||||
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
43
app/Values/Equalizer.php
Normal file
43
app/Values/Equalizer.php
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Values;
|
||||
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Throwable;
|
||||
|
||||
final class Equalizer implements Arrayable
|
||||
{
|
||||
/** @param array<int>|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<mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'preamp' => $this->preamp,
|
||||
'gains' => $this->gains,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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<mixed> */
|
||||
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<mixed> */
|
||||
|
|
|
@ -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 = ''
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
<Icon :icon="faSliders" />
|
||||
</button>
|
||||
|
||||
<Volume />
|
||||
<VolumeSlider />
|
||||
|
||||
<button
|
||||
v-if="isFullscreenSupported()"
|
||||
|
@ -43,7 +43,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||
import { eventBus, isAudioContextSupported as useEqualizer, isFullscreenSupported } from '@/utils'
|
||||
import { useRouter } from '@/composables'
|
||||
|
||||
import Volume from '@/components/ui/Volume.vue'
|
||||
import VolumeSlider from '@/components/ui/VolumeSlider.vue'
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
const fullscreenButtonTitle = computed(() => (isFullscreen.value ? 'Exit fullscreen mode' : 'Enter fullscreen mode'))
|
||||
|
|
|
@ -39,7 +39,7 @@ new class extends UnitTestCase {
|
|||
it('renders without a current song', () => expect(this.renderComponent()[0].html()).toMatchSnapshot())
|
||||
|
||||
it('sets the active tab to the preference', async () => {
|
||||
preferenceStore.activeExtraPanelTab = 'YouTube'
|
||||
preferenceStore.active_extra_panel_tab = 'YouTube'
|
||||
this.renderComponent(ref(factory<Song>('song')))
|
||||
const tab = screen.getByTestId<HTMLElement>('extra-drawer-youtube')
|
||||
|
||||
|
|
|
@ -114,13 +114,13 @@ const fetchSongInfo = async (_song: Song) => {
|
|||
}
|
||||
|
||||
watch(song, song => song && fetchSongInfo(song), { immediate: true })
|
||||
watch(activeTab, tab => (preferenceStore.activeExtraPanelTab = tab))
|
||||
watch(activeTab, tab => (preferenceStore.active_extra_panel_tab = tab))
|
||||
|
||||
const openAboutKoelModal = () => eventBus.emit('MODAL_SHOW_ABOUT_KOEL')
|
||||
const onProfileLinkClick = () => isMobile.any && (activeTab.value = null)
|
||||
const logout = () => eventBus.emit('LOG_OUT')
|
||||
|
||||
onMounted(() => isMobile.any || (activeTab.value = preferenceStore.activeExtraPanelTab))
|
||||
onMounted(() => isMobile.any || (activeTab.value = preferenceStore.active_extra_panel_tab))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -34,7 +34,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('does not have a translucent over if configured not so', async () => {
|
||||
preferenceStore.state.showAlbumArtOverlay = false
|
||||
preferenceStore.state.show_album_art_overlay = false
|
||||
|
||||
this.renderComponent()
|
||||
|
||||
|
|
|
@ -68,7 +68,7 @@ const { onRouteChanged, getCurrentScreen } = useRouter()
|
|||
|
||||
const currentSong = requireInjection(CurrentSongKey, ref(undefined))
|
||||
|
||||
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
|
||||
const showAlbumArtOverlay = toRef(preferenceStore.state, 'show_album_art_overlay')
|
||||
const screen = ref<ScreenName>('Home')
|
||||
|
||||
onRouteChanged(route => (screen.value = route.screen))
|
||||
|
|
|
@ -12,7 +12,7 @@ new class extends UnitTestCase {
|
|||
protected afterEach () {
|
||||
super.afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
preferenceStore.state.supportBarNoBugging = false
|
||||
preferenceStore.state.support_bar_no_bugging = false
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -30,7 +30,7 @@ new class extends UnitTestCase {
|
|||
it('shows after a delay', async () => expect((await this.renderComponent()).html()).toMatchSnapshot())
|
||||
|
||||
it('does not show if user so demands', async () => {
|
||||
preferenceStore.state.supportBarNoBugging = true
|
||||
preferenceStore.state.support_bar_no_bugging = true
|
||||
preferenceStore.initialized.value = true
|
||||
expect((await this.renderComponent()).queryByTestId('support-bar')).toBeNull()
|
||||
})
|
||||
|
@ -52,7 +52,7 @@ new class extends UnitTestCase {
|
|||
await this.user.click(screen.getByRole('button', { name: 'Don\'t bug me again' }))
|
||||
|
||||
expect(await screen.queryByTestId('support-bar')).toBeNull()
|
||||
expect(preferenceStore.state.supportBarNoBugging).toBe(true)
|
||||
expect(preferenceStore.state.support_bar_no_bugging).toBe(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,13 +29,13 @@ const setUpShowBarTimeout = () => setTimeout(() => (shown.value = true), delayUn
|
|||
const close = () => shown.value = false
|
||||
|
||||
const stopBugging = () => {
|
||||
preferenceStore.set('supportBarNoBugging', true)
|
||||
preferenceStore.set('support_bar_no_bugging', true)
|
||||
close()
|
||||
}
|
||||
|
||||
watch(preferenceStore.initialized, initialized => {
|
||||
if (!initialized) return
|
||||
if (preferenceStore.state.supportBarNoBugging || isMobile.any) return
|
||||
if (preferenceStore.state.support_bar_no_bugging || isMobile.any) return
|
||||
if (isPlus.value) return
|
||||
|
||||
setUpShowBarTimeout()
|
||||
|
|
|
@ -1,26 +1,32 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="!isPhone" class="form-row">
|
||||
<div class="form-row" v-if="isPlus">
|
||||
<label>
|
||||
<CheckBox v-model="preferences.notify" name="notify" />
|
||||
<CheckBox v-model="preferences.make_uploads_public" name="make_upload_public" />
|
||||
Make uploaded songs public by default
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isPhone" class="form-row">
|
||||
<label>
|
||||
<CheckBox v-model="preferences.show_now_playing_notification" name="notify" />
|
||||
Show “Now Playing” song notification
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="!isPhone" class="form-row">
|
||||
<label>
|
||||
<CheckBox v-model="preferences.confirmClosing" name="confirm_closing" />
|
||||
<CheckBox v-model="preferences.confirm_before_closing" name="confirm_closing" />
|
||||
Confirm before closing Koel
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isPhone" class="form-row">
|
||||
<label>
|
||||
<CheckBox v-model="preferences.transcodeOnMobile" name="transcode_on_mobile" />
|
||||
<CheckBox v-model="preferences.transcode_on_mobile" name="transcode_on_mobile" />
|
||||
Convert and play media at 128kbps on mobile
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<CheckBox v-model="preferences.showAlbumArtOverlay" name="show_album_art_overlay" />
|
||||
<CheckBox v-model="preferences.show_album_art_overlay" name="show_album_art_overlay" />
|
||||
Show a translucent, blurred overlay of the current album’s art
|
||||
</label>
|
||||
</div>
|
||||
|
@ -30,9 +36,12 @@
|
|||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import { preferenceStore as preferences } from '@/stores'
|
||||
import { useKoelPlus } from '@/composables'
|
||||
|
||||
import CheckBox from '@/components/ui/CheckBox.vue'
|
||||
|
||||
const isPhone = isMobile.phone
|
||||
const { isPlus } = useKoelPlus()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -38,7 +38,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it.each<[ArtistAlbumViewMode]>([['list'], ['thumbnails']])('sets layout from preferences', async (mode) => {
|
||||
preferenceStore.albumsViewMode = mode
|
||||
preferenceStore.albums_view_mode = mode
|
||||
|
||||
await this.renderComponent()
|
||||
|
||||
|
|
|
@ -62,7 +62,7 @@ const {
|
|||
makeScrollable
|
||||
} = useInfiniteScroll(async () => await fetchAlbums())
|
||||
|
||||
watch(viewMode, () => (preferences.albumsViewMode = viewMode.value))
|
||||
watch(viewMode, () => (preferences.albums_view_mode = viewMode.value))
|
||||
|
||||
let initialized = false
|
||||
const loading = ref(false)
|
||||
|
@ -85,7 +85,7 @@ useRouter().onScreenActivated('Albums', async () => {
|
|||
if (libraryEmpty.value) return
|
||||
|
||||
if (!initialized) {
|
||||
viewMode.value = preferences.albumsViewMode || 'thumbnails'
|
||||
viewMode.value = preferences.albums_view_mode || 'thumbnails'
|
||||
initialized = true
|
||||
|
||||
try {
|
||||
|
|
|
@ -39,7 +39,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it.each<[ArtistAlbumViewMode]>([['list'], ['thumbnails']])('sets layout:%s from preferences', async (mode) => {
|
||||
preferenceStore.artistsViewMode = mode
|
||||
preferenceStore.artists_view_mode = mode
|
||||
|
||||
await this.renderComponent()
|
||||
|
||||
|
|
|
@ -62,7 +62,7 @@ const {
|
|||
makeScrollable
|
||||
} = useInfiniteScroll(async () => await fetchArtists())
|
||||
|
||||
watch(viewMode, () => preferences.artistsViewMode = viewMode.value)
|
||||
watch(viewMode, () => preferences.artists_view_mode = viewMode.value)
|
||||
|
||||
let initialized = false
|
||||
const loading = ref(false)
|
||||
|
@ -84,7 +84,7 @@ const fetchArtists = async () => {
|
|||
useRouter().onScreenActivated('Artists', async () => {
|
||||
if (libraryEmpty.value) return
|
||||
if (!initialized) {
|
||||
viewMode.value = preferences.artistsViewMode || 'thumbnails'
|
||||
viewMode.value = preferences.artists_view_mode || 'thumbnails'
|
||||
initialized = true
|
||||
|
||||
try {
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
<form id="equalizer" ref="root" data-testid="equalizer" tabindex="0" @keydown.esc="close">
|
||||
<header>
|
||||
<label class="select-wrapper">
|
||||
<select v-model="selectedPresetId" title="Select equalizer">
|
||||
<option disabled value="-1">Preset</option>
|
||||
<option v-for="preset in presets" :key="preset.id" :value="preset.id">{{ preset.name }}</option>
|
||||
<select v-model="selectedPresetName" title="Select equalizer">
|
||||
<option disabled :value="null">Preset</option>
|
||||
<option v-for="preset in presets" :key="preset.name" :value="preset.name">{{ preset.name }}</option>
|
||||
</select>
|
||||
<Icon :icon="faCaretDown" class="arrow text-highlight" size="sm" />
|
||||
</label>
|
||||
|
@ -51,13 +51,13 @@ const emit = defineEmits<{ (e: 'close'): void }>()
|
|||
const bands = audioService.bands
|
||||
const root = ref<HTMLElement>()
|
||||
const preampGain = ref(0)
|
||||
const selectedPresetId = ref(-1)
|
||||
const selectedPresetName = ref<string|null>(null)
|
||||
|
||||
watch(preampGain, value => audioService.changePreampGain(value))
|
||||
|
||||
watch(selectedPresetId, () => {
|
||||
if (selectedPresetId.value !== -1) {
|
||||
loadPreset(equalizerStore.getPresetById(selectedPresetId.value) || presets[0])
|
||||
watch(selectedPresetName, (value) => {
|
||||
if (value !== null) {
|
||||
loadPreset(equalizerStore.getPresetByName(value) || presets[0])
|
||||
}
|
||||
|
||||
save()
|
||||
|
@ -66,7 +66,7 @@ watch(selectedPresetId, () => {
|
|||
const createSliders = () => {
|
||||
const config = equalizerStore.getConfig()
|
||||
|
||||
selectedPresetId.value = config.id
|
||||
selectedPresetName.value = config.name
|
||||
preampGain.value = config.preamp
|
||||
|
||||
if (!root.value) {
|
||||
|
@ -95,10 +95,10 @@ const createSliders = () => {
|
|||
}
|
||||
|
||||
// User has customized the equalizer. No preset should be selected.
|
||||
selectedPresetId.value = -1
|
||||
|
||||
save()
|
||||
selectedPresetName.value = null
|
||||
})
|
||||
|
||||
el.noUiSlider.on('change', () => save())
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -119,7 +119,7 @@ const loadPreset = (preset: EqualizerPreset) => {
|
|||
})
|
||||
}
|
||||
|
||||
const save = () => equalizerStore.saveConfig(selectedPresetId.value, preampGain.value, bands.map(band => band.db))
|
||||
const save = () => equalizerStore.saveConfig(selectedPresetName.value, preampGain.value, bands.map(band => band.db))
|
||||
const close = () => emit('close')
|
||||
|
||||
onMounted(() => createSliders())
|
||||
|
|
|
@ -35,7 +35,7 @@ const { song } = toRefs(props)
|
|||
const { isAdmin } = useAuthorization()
|
||||
|
||||
const lyricsContainer = ref<HTMLElement>()
|
||||
const zoomLevel = ref(preferences.lyricsZoomLevel || 1)
|
||||
const zoomLevel = ref(preferences.lyrics_zoom_level || 1)
|
||||
|
||||
const showEditSongForm = () => eventBus.emit('MODAL_SHOW_EDIT_SONG_FORM', song.value, 'lyrics')
|
||||
|
||||
|
@ -54,7 +54,7 @@ const setFontSize = () => {
|
|||
|
||||
watch(zoomLevel, level => {
|
||||
setFontSize()
|
||||
preferences.lyricsZoomLevel = level
|
||||
preferences.lyrics_zoom_level = level
|
||||
})
|
||||
|
||||
onMounted(() => setFontSize())
|
||||
|
|
|
@ -9,7 +9,7 @@ new class extends UnitTestCase {
|
|||
protected test () {
|
||||
it('changes mode', async () => {
|
||||
const mock = this.mock(playbackService, 'changeRepeatMode')
|
||||
preferenceStore.state.repeatMode = 'NO_REPEAT'
|
||||
preferenceStore.state.repeat_mode = 'NO_REPEAT'
|
||||
this.render(RepeatModeSwitch)
|
||||
|
||||
await this.user.click(screen.getByRole('button'))
|
||||
|
|
|
@ -21,7 +21,7 @@ import { computed, toRef } from 'vue'
|
|||
import { playbackService } from '@/services'
|
||||
import { preferenceStore } from '@/stores'
|
||||
|
||||
const mode = toRef(preferenceStore.state, 'repeatMode')
|
||||
const mode = toRef(preferenceStore.state, 'repeat_mode')
|
||||
|
||||
const readableMode = computed(() => mode.value
|
||||
.split('_')
|
||||
|
|
|
@ -3,7 +3,7 @@ import UnitTestCase from '@/__tests__/UnitTestCase'
|
|||
import { fireEvent, screen } from '@testing-library/vue'
|
||||
import { socketService, volumeManager } from '@/services'
|
||||
import { preferenceStore } from '@/stores'
|
||||
import Volume from './Volume.vue'
|
||||
import Volume from './VolumeSlider.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected beforeEach (cb?: Closure) {
|
|
@ -30,7 +30,7 @@
|
|||
step="0.1"
|
||||
title="Volume"
|
||||
type="range"
|
||||
@change="broadcastVolume"
|
||||
@change="onVolumeChanged"
|
||||
@input="setVolume"
|
||||
>
|
||||
</span>
|
||||
|
@ -40,6 +40,7 @@
|
|||
import { faVolumeHigh, faVolumeLow, faVolumeMute } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed } from 'vue'
|
||||
import { socketService, volumeManager } from '@/services'
|
||||
import { preferenceStore } from '@/stores'
|
||||
|
||||
const volume = volumeManager.volume
|
||||
|
||||
|
@ -56,7 +57,8 @@ const setVolume = (e: Event) => volumeManager.set(parseFloat((e.target as HTMLIn
|
|||
/**
|
||||
* Broadcast the volume changed event to remote controller.
|
||||
*/
|
||||
const broadcastVolume = (e: Event) => {
|
||||
const onVolumeChanged = (e: Event) => {
|
||||
preferenceStore.volume = parseFloat((e.target as HTMLInputElement).value)
|
||||
socketService.broadcast('SOCKET_VOLUME_CHANGED', parseFloat((e.target as HTMLInputElement).value))
|
||||
}
|
||||
</script>
|
|
@ -1,84 +1,70 @@
|
|||
export const equalizerPresets: EqualizerPreset[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Default',
|
||||
preamp: 0,
|
||||
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Classical',
|
||||
preamp: -1,
|
||||
gains: [-1, -1, -1, -1, -1, -1, -7, -7, -7, -9]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Club',
|
||||
preamp: -6.7,
|
||||
gains: [-1, -1, 8, 5, 5, 5, 3, -1, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Dance',
|
||||
preamp: -4.3,
|
||||
gains: [9, 7, 2, -1, -1, -5, -7, -7, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Full Bass',
|
||||
preamp: -7.2,
|
||||
gains: [-8, 9, 9, 5, 1, -4, -8, -10, -11, -11]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Full Treble',
|
||||
preamp: -12,
|
||||
gains: [-9, -9, -9, -4, 2, 11, 16, 16, 16, 16]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Headphone',
|
||||
preamp: -8,
|
||||
gains: [4, 11, 5, -3, -2, 1, 4, 9, 12, 14]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Large Hall',
|
||||
preamp: -7.2,
|
||||
gains: [10, 10, 5, 5, -1, -4, -4, -4, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Live',
|
||||
preamp: -5.3,
|
||||
gains: [-4, -1, 4, 5, 5, 5, 4, 2, 2, 2]
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Pop',
|
||||
preamp: -6.2,
|
||||
gains: [-1, 4, 7, 8, 5, -1, -2, -2, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Reggae',
|
||||
preamp: -8.2,
|
||||
gains: [-1, -1, -1, -5, -1, 6, 6, -1, -1, -1]
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Rock',
|
||||
preamp: -10,
|
||||
gains: [8, 4, -5, -8, -3, 4, 8, 11, 11, 11]
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Soft Rock',
|
||||
preamp: -5.3,
|
||||
gains: [4, 4, 2, -1, -4, -5, -3, -1, 2, 8]
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'Techno',
|
||||
preamp: -7.7,
|
||||
gains: [8, 5, -1, -5, -4, -1, 8, 9, 9, 8]
|
||||
|
|
|
@ -92,7 +92,7 @@ const connected = ref(false)
|
|||
const muted = ref(false)
|
||||
const showingVolumeSlider = ref(false)
|
||||
const retries = ref(0)
|
||||
const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay')
|
||||
const showAlbumArtOverlay = toRef(preferenceStore.state, 'show_album_art_overlay')
|
||||
const volume = ref(DEFAULT_VOLUME)
|
||||
|
||||
const inStandaloneMode = ref(
|
||||
|
|
|
@ -37,6 +37,10 @@ class Http {
|
|||
return (await this.request<T>('put', url, data)).data
|
||||
}
|
||||
|
||||
public async patch<T> (url: string, data: Record<string, any>) {
|
||||
return (await this.request<T>('patch', url, data)).data
|
||||
}
|
||||
|
||||
public async delete<T> (url: string, data: Record<string, any> = {}) {
|
||||
return (await this.request<T>('delete', url, data)).data
|
||||
}
|
||||
|
|
|
@ -115,7 +115,7 @@ new class extends UnitTestCase {
|
|||
'when song ends, if repeat mode is %s then restart() is called %d times and playNext() is called %d times',
|
||||
(repeatMode, restartCalls, playNextCalls) => {
|
||||
commonStore.state.uses_last_fm = false // so that no scrobbling is made unnecessarily
|
||||
preferences.repeatMode = repeatMode
|
||||
preferences.repeat_mode = repeatMode
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
const restartMock = this.mock(playbackService, 'restart')
|
||||
const playNextMock = this.mock(playbackService, 'playNext')
|
||||
|
@ -219,10 +219,10 @@ new class extends UnitTestCase {
|
|||
['REPEAT_ONE', 'NO_REPEAT']
|
||||
])('it switches from repeat mode %s to repeat mode %s', (fromMode, toMode) => {
|
||||
playbackService.init(document.querySelector('.plyr')!)
|
||||
preferences.repeatMode = fromMode
|
||||
preferences.repeat_mode = fromMode
|
||||
playbackService.changeRepeatMode()
|
||||
|
||||
expect(preferences.repeatMode).toEqual(toMode)
|
||||
expect(preferences.repeat_mode).toEqual(toMode)
|
||||
})
|
||||
|
||||
it('restarts song if playPrev is triggered after 5 seconds', async () => {
|
||||
|
@ -243,7 +243,7 @@ new class extends UnitTestCase {
|
|||
const stopMock = this.mock(playbackService, 'stop')
|
||||
this.setReadOnlyProperty(playbackService.player!.media, 'currentTime', 4)
|
||||
this.setReadOnlyProperty(playbackService, 'previous', undefined)
|
||||
preferences.repeatMode = 'NO_REPEAT'
|
||||
preferences.repeat_mode = 'NO_REPEAT'
|
||||
|
||||
await playbackService.playPrev()
|
||||
|
||||
|
@ -267,7 +267,7 @@ new class extends UnitTestCase {
|
|||
playbackService.init(document.querySelector('.plyr')!)
|
||||
|
||||
this.setReadOnlyProperty(playbackService, 'next', undefined)
|
||||
preferences.repeatMode = 'NO_REPEAT'
|
||||
preferences.repeat_mode = 'NO_REPEAT'
|
||||
const stopMock = this.mock(playbackService, 'stop')
|
||||
|
||||
await playbackService.playNext()
|
||||
|
|
|
@ -99,7 +99,7 @@ class PlaybackService {
|
|||
}
|
||||
|
||||
public showNotification (song: Song) {
|
||||
if (preferences.notify) {
|
||||
if (preferences.show_now_playing_notification) {
|
||||
try {
|
||||
const notification = new window.Notification(`♫ ${song.title}`, {
|
||||
icon: song.album_cover,
|
||||
|
@ -168,7 +168,7 @@ class PlaybackService {
|
|||
}
|
||||
|
||||
public get isTranscoding () {
|
||||
return isMobile.any && preferences.transcodeOnMobile
|
||||
return isMobile.any && preferences.transcode_on_mobile
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -180,7 +180,7 @@ class PlaybackService {
|
|||
return queueStore.next
|
||||
}
|
||||
|
||||
if (preferences.repeatMode === 'REPEAT_ALL') {
|
||||
if (preferences.repeat_mode === 'REPEAT_ALL') {
|
||||
return queueStore.first
|
||||
}
|
||||
}
|
||||
|
@ -194,7 +194,7 @@ class PlaybackService {
|
|||
return queueStore.previous
|
||||
}
|
||||
|
||||
if (preferences.repeatMode === 'REPEAT_ALL') {
|
||||
if (preferences.repeat_mode === 'REPEAT_ALL') {
|
||||
return queueStore.last
|
||||
}
|
||||
}
|
||||
|
@ -204,13 +204,13 @@ class PlaybackService {
|
|||
* The selected mode will be stored into local storage as well.
|
||||
*/
|
||||
public changeRepeatMode () {
|
||||
let index = this.repeatModes.indexOf(preferences.repeatMode) + 1
|
||||
let index = this.repeatModes.indexOf(preferences.repeat_mode) + 1
|
||||
|
||||
if (index >= this.repeatModes.length) {
|
||||
index = 0
|
||||
}
|
||||
|
||||
preferences.repeatMode = this.repeatModes[index]
|
||||
preferences.repeat_mode = this.repeatModes[index]
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -226,7 +226,7 @@ class PlaybackService {
|
|||
return
|
||||
}
|
||||
|
||||
if (!this.previous && preferences.repeatMode === 'NO_REPEAT') {
|
||||
if (!this.previous && preferences.repeat_mode === 'NO_REPEAT') {
|
||||
await this.stop()
|
||||
} else {
|
||||
this.previous && await this.play(this.previous)
|
||||
|
@ -238,7 +238,7 @@ class PlaybackService {
|
|||
* If the next song is not found and the current mode is NO_REPEAT, we stop completely.
|
||||
*/
|
||||
public async playNext () {
|
||||
if (!this.next && preferences.repeatMode === 'NO_REPEAT') {
|
||||
if (!this.next && preferences.repeat_mode === 'NO_REPEAT') {
|
||||
await this.stop() // Nothing lasts forever, even cold November rain.
|
||||
} else {
|
||||
this.next && await this.play(this.next)
|
||||
|
@ -361,7 +361,7 @@ class PlaybackService {
|
|||
songStore.scrobble(queueStore.current!)
|
||||
}
|
||||
|
||||
preferences.repeatMode === 'REPEAT_ONE' ? this.restart() : this.playNext()
|
||||
preferences.repeat_mode === 'REPEAT_ONE' ? this.restart() : this.playNext()
|
||||
})
|
||||
|
||||
let timeUpdateHandler = () => {
|
||||
|
|
|
@ -14,17 +14,13 @@ export class VolumeManager {
|
|||
return this.volume.value
|
||||
}
|
||||
|
||||
public set (volume: number, persist = true) {
|
||||
if (persist) {
|
||||
preferenceStore.volume = volume
|
||||
}
|
||||
|
||||
public set (volume: number) {
|
||||
this.volume.value = volume
|
||||
this.input.value = String(volume)
|
||||
}
|
||||
|
||||
public mute () {
|
||||
this.set(0, false)
|
||||
this.set(0)
|
||||
}
|
||||
|
||||
public unmute () {
|
||||
|
|
|
@ -73,7 +73,7 @@ export const commonStore = {
|
|||
this.state.current_user.preferences = this.state.current_user.preferences || {}
|
||||
|
||||
userStore.init(this.state.current_user)
|
||||
preferenceStore.init(this.state.current_user)
|
||||
preferenceStore.init(this.state.current_user.preferences)
|
||||
playlistStore.init(this.state.playlists)
|
||||
playlistFolderStore.init(this.state.playlist_folders)
|
||||
settingStore.init(this.state.settings)
|
||||
|
|
|
@ -2,33 +2,45 @@ import { preferenceStore as preferences } from '@/stores'
|
|||
import { equalizerPresets as presets } from '@/config'
|
||||
|
||||
export const equalizerStore = {
|
||||
getPresetById (id: number) {
|
||||
return presets.find(preset => preset.id === id)
|
||||
getPresetByName (name: EqualizerPreset['name']) {
|
||||
return presets.find(preset => preset.name === name)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current equalizer config.
|
||||
*/
|
||||
getConfig () {
|
||||
if (preferences.equalizer.id === -1) {
|
||||
return preferences.equalizer
|
||||
let config: EqualizerPreset|undefined
|
||||
|
||||
if (this.isCustom(preferences.equalizer)) return preferences.equalizer
|
||||
|
||||
if (preferences.equalizer.name !== null) {
|
||||
config = this.getPresetByName(preferences.equalizer.name)
|
||||
}
|
||||
|
||||
// If the user chose a preset (instead of customizing one), just return it.
|
||||
return this.getPresetById(preferences.equalizer.id) || presets[0]
|
||||
return config || presets[0]
|
||||
},
|
||||
|
||||
isCustom(preset: any) {
|
||||
return typeof preset === 'object'
|
||||
&& preset !== null
|
||||
&& preset.name === null
|
||||
&& typeof preset.preamp === 'number'
|
||||
&& Array.isArray(preset.gains)
|
||||
&& preset.gains.length === 10
|
||||
&& preset.gains.every((gain: any) => typeof gain === 'number')
|
||||
},
|
||||
|
||||
/**
|
||||
* Save the current equalizer config.
|
||||
*/
|
||||
saveConfig (id: number, preamp: number, gains: number[]) {
|
||||
const preset = this.getPresetById(id)
|
||||
saveConfig (name: EqualizerPreset['name'] | null, preamp: number, gains: number[]) {
|
||||
const preset = name ? this.getPresetByName(name) : null
|
||||
|
||||
preferences.equalizer = preset || {
|
||||
preamp,
|
||||
gains,
|
||||
id: -1,
|
||||
name: 'Custom'
|
||||
name: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,52 +1,51 @@
|
|||
import { reactive, ref } from 'vue'
|
||||
import { localStorageService } from '@/services'
|
||||
import { http, localStorageService } from '@/services'
|
||||
|
||||
interface Preferences extends Record<string, any> {
|
||||
volume: number
|
||||
notify: boolean
|
||||
repeatMode: RepeatMode
|
||||
confirmClosing: boolean
|
||||
show_now_playing_notification: boolean
|
||||
repeat_mode: RepeatMode
|
||||
confirm_before_closing: boolean
|
||||
equalizer: EqualizerPreset,
|
||||
artistsViewMode: ArtistAlbumViewMode | null,
|
||||
albumsViewMode: ArtistAlbumViewMode | null,
|
||||
transcodeOnMobile: boolean
|
||||
supportBarNoBugging: boolean
|
||||
showAlbumArtOverlay: boolean
|
||||
lyricsZoomLevel: number | null
|
||||
artists_view_mode: ArtistAlbumViewMode | null,
|
||||
albums_view_mode: ArtistAlbumViewMode | null,
|
||||
transcode_on_mobile: boolean
|
||||
support_bar_no_bugging: boolean
|
||||
show_album_art_overlay: boolean
|
||||
lyrics_zoom_level: number | null
|
||||
theme?: Theme['id'] | null
|
||||
visualizer?: Visualizer['id'] | null
|
||||
activeExtraPanelTab: ExtraPanelTab | null
|
||||
active_extra_panel_tab: ExtraPanelTab | null
|
||||
make_uploads_public: boolean
|
||||
}
|
||||
|
||||
const preferenceStore = {
|
||||
storeKey: '',
|
||||
initialized: ref(false),
|
||||
|
||||
state: reactive<Preferences>({
|
||||
volume: 7,
|
||||
notify: true,
|
||||
repeatMode: 'NO_REPEAT',
|
||||
confirmClosing: false,
|
||||
show_now_playing_notification: true,
|
||||
repeat_mode: 'NO_REPEAT',
|
||||
confirm_before_closing: false,
|
||||
equalizer: {
|
||||
id: 0,
|
||||
name: 'Default',
|
||||
preamp: 0,
|
||||
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
},
|
||||
artistsViewMode: null,
|
||||
albumsViewMode: null,
|
||||
transcodeOnMobile: false,
|
||||
supportBarNoBugging: false,
|
||||
showAlbumArtOverlay: true,
|
||||
lyricsZoomLevel: 1,
|
||||
artists_view_mode: null,
|
||||
albums_view_mode: null,
|
||||
transcode_on_mobile: false,
|
||||
support_bar_no_bugging: false,
|
||||
show_album_art_overlay: true,
|
||||
lyrics_zoom_level: 1,
|
||||
theme: null,
|
||||
visualizer: 'default',
|
||||
activeExtraPanelTab: null
|
||||
active_extra_panel_tab: null,
|
||||
make_uploads_public: false
|
||||
}),
|
||||
|
||||
init (user: User): void {
|
||||
this.storeKey = `preferences_${user.id}`
|
||||
Object.assign(this.state, localStorageService.get(this.storeKey, this.state))
|
||||
init (preferences: Preferences): void {
|
||||
Object.assign(this.state, preferences)
|
||||
this.setupProxy()
|
||||
|
||||
this.initialized.value = true
|
||||
|
@ -65,17 +64,15 @@ const preferenceStore = {
|
|||
})
|
||||
},
|
||||
|
||||
set (key: keyof Preferences, val: any) {
|
||||
this.state[key] = val
|
||||
this.save()
|
||||
set (key: keyof Preferences, value: any) {
|
||||
if (this.state[key] === value) return
|
||||
|
||||
this.state[key] = value
|
||||
http.silently.patch('me/preferences', { key, value })
|
||||
},
|
||||
|
||||
get (key: string) {
|
||||
return this.state?.[key]
|
||||
},
|
||||
|
||||
save () {
|
||||
localStorageService.set(this.storeKey, this.state)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ new class extends UnitTestCase {
|
|||
protected afterEach () {
|
||||
super.afterEach(() => {
|
||||
isMobile.any = false
|
||||
preferenceStore.transcodeOnMobile = false
|
||||
preferenceStore.transcode_on_mobile = false
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -152,7 +152,7 @@ new class extends UnitTestCase {
|
|||
expect(songStore.getSourceUrl(song)).toBe('http://test/play/foo?t=hadouken')
|
||||
|
||||
isMobile.any = true
|
||||
preferenceStore.transcodeOnMobile = true
|
||||
preferenceStore.transcode_on_mobile = true
|
||||
expect(songStore.getSourceUrl(song)).toBe('http://test/play/foo/1/128?t=hadouken')
|
||||
})
|
||||
|
||||
|
|
|
@ -126,7 +126,7 @@ export const songStore = {
|
|||
},
|
||||
|
||||
getSourceUrl: (song: Song) => {
|
||||
return isMobile.any && preferenceStore.transcodeOnMobile
|
||||
return isMobile.any && preferenceStore.transcode_on_mobile
|
||||
? `${commonStore.state.cdn_url}play/${song.id}/1/128?t=${authService.getAudioToken()}`
|
||||
: `${commonStore.state.cdn_url}play/${song.id}?t=${authService.getAudioToken()}`
|
||||
},
|
||||
|
|
|
@ -5,7 +5,7 @@ export const visualizerStore = {
|
|||
return visualizers
|
||||
},
|
||||
|
||||
getVisualizerById (id: string) {
|
||||
getVisualizerById (id: Visualizer['id']) {
|
||||
return visualizers.find(visualizer => visualizer.id === id)
|
||||
}
|
||||
}
|
||||
|
|
3
resources/assets/js/types.d.ts
vendored
3
resources/assets/js/types.d.ts
vendored
|
@ -303,8 +303,7 @@ interface SongRow {
|
|||
}
|
||||
|
||||
interface EqualizerPreset {
|
||||
id: number
|
||||
name: string
|
||||
name: string | null
|
||||
preamp: number
|
||||
gains: number[]
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ use App\Http\Controllers\API\SongSearchController;
|
|||
use App\Http\Controllers\API\ToggleLikeSongController;
|
||||
use App\Http\Controllers\API\UnlikeMultipleSongsController;
|
||||
use App\Http\Controllers\API\UpdatePlaybackStatusController;
|
||||
use App\Http\Controllers\API\UpdateUserPreferenceController;
|
||||
use App\Http\Controllers\API\UploadAlbumCoverController;
|
||||
use App\Http\Controllers\API\UploadArtistImageController;
|
||||
use App\Http\Controllers\API\UploadController;
|
||||
|
@ -137,6 +138,7 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
|||
Route::apiResource('user', UserController::class);
|
||||
Route::get('me', [ProfileController::class, 'show']);
|
||||
Route::put('me', [ProfileController::class, 'update']);
|
||||
Route::patch('me/preferences', UpdateUserPreferenceController::class);
|
||||
|
||||
// Last.fm-related routes
|
||||
Route::post('lastfm/session-key', SetLastfmSessionKeyController::class);
|
||||
|
|
|
@ -20,7 +20,7 @@ class LastfmTest extends TestCase
|
|||
$this->postAs('api/lastfm/session-key', ['key' => 'foo'], $user)
|
||||
->assertNoContent();
|
||||
|
||||
self::assertSame('foo', $user->refresh()->lastfm_session_key);
|
||||
self::assertSame('foo', $user->refresh()->preferences->lastFmSessionKey);
|
||||
}
|
||||
|
||||
public function testConnectToLastfm(): void
|
||||
|
@ -68,7 +68,7 @@ class LastfmTest extends TestCase
|
|||
$this->get('lastfm/callback?token=lastfm-token&api_token=' . urlencode($token))
|
||||
->assertOk();
|
||||
|
||||
self::assertSame('my-session-key', $user->refresh()->lastfm_session_key);
|
||||
self::assertSame('my-session-key', $user->refresh()->preferences->lastFmSessionKey);
|
||||
// make sure the user's api token is deleted
|
||||
self::assertNull(PersonalAccessToken::findToken($token));
|
||||
}
|
||||
|
@ -95,17 +95,17 @@ class LastfmTest extends TestCase
|
|||
|
||||
$this->get('lastfm/callback?token=foo&api_token=my-token');
|
||||
|
||||
self::assertSame('my-session-key', $user->refresh()->lastfm_session_key);
|
||||
self::assertSame('my-session-key', $user->refresh()->preferences->lastFmSessionKey);
|
||||
}
|
||||
|
||||
public function testDisconnectUser(): void
|
||||
{
|
||||
$user = create_user();
|
||||
self::assertNotNull($user->lastfm_session_key);
|
||||
self::assertNotNull($user->preferences->lastFmSessionKey);
|
||||
|
||||
$this->deleteAs('api/lastfm/disconnect', [], $user);
|
||||
|
||||
$user->refresh();
|
||||
self::assertNull($user->lastfm_session_key);
|
||||
self::assertNull($user->preferences->lastFmSessionKey);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,4 +50,20 @@ class UploadServiceTest extends TestCase
|
|||
self::assertSame($song->owner_id, $user->id);
|
||||
self::assertSame(public_path("sandbox/media/__KOEL_UPLOADS_\${$user->id}__/full.mp3"), $song->path);
|
||||
}
|
||||
|
||||
public function testUploadingTakesIntoAccountUploadVisibilityPreference(): void
|
||||
{
|
||||
$user = create_user();
|
||||
$user->preferences->makeUploadsPublic = true;
|
||||
$user->save();
|
||||
|
||||
Setting::set('media_path', public_path('sandbox/media'));
|
||||
$song = $this->service->handleUploadedFile(UploadedFile::fromFile(test_path('songs/full.mp3')), $user); //@phpstan-ignore-line
|
||||
self::assertTrue($song->is_public);
|
||||
|
||||
$user->preferences->makeUploadsPublic = false;
|
||||
$user->save();
|
||||
$privateSongs = $this->service->handleUploadedFile(UploadedFile::fromFile(test_path('songs/full.mp3')), $user); //@phpstan-ignore-line
|
||||
self::assertFalse($privateSongs->is_public);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue