feat: store perferences on server and make upload visibility a preference

This commit is contained in:
Phan An 2024-01-23 23:50:50 +01:00
parent 84ce42da08
commit f3689f61d4
47 changed files with 407 additions and 163 deletions

View file

@ -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 */

View 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();
}
}

View 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',
];
}
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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;
}
}

View 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.';
}
}

View file

@ -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',
];

View file

@ -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()) {

View file

@ -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
View 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,
];
}
}

View file

@ -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> */

View file

@ -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 = ''
}

View file

@ -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'))

View file

@ -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')

View file

@ -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>

View file

@ -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()

View file

@ -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))

View file

@ -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)
})
}
}

View file

@ -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()

View file

@ -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 albums 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>

View file

@ -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()

View file

@ -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 {

View file

@ -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()

View file

@ -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 {

View file

@ -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())

View file

@ -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())

View file

@ -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'))

View file

@ -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('_')

View file

@ -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) {

View file

@ -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>

View file

@ -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]

View file

@ -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(

View file

@ -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
}

View file

@ -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()

View file

@ -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 = () => {

View file

@ -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 () {

View file

@ -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)

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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')
})

View file

@ -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()}`
},

View file

@ -5,7 +5,7 @@ export const visualizerStore = {
return visualizers
},
getVisualizerById (id: string) {
getVisualizerById (id: Visualizer['id']) {
return visualizers.find(visualizer => visualizer.id === id)
}
}

View file

@ -303,8 +303,7 @@ interface SongRow {
}
interface EqualizerPreset {
id: number
name: string
name: string | null
preamp: number
gains: number[]
}

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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);
}
}