mirror of
https://github.com/koel/koel
synced 2024-11-24 05:03:05 +00:00
feat(plust): playlist collaboration
This commit is contained in:
parent
fb6f975067
commit
9dc23f319e
115 changed files with 1211 additions and 420 deletions
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
namespace App\Facades;
|
namespace App\Facades;
|
||||||
|
|
||||||
use App\Services\License\FakePlusLicenseService;
|
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -16,9 +15,4 @@ class License extends Facade
|
||||||
{
|
{
|
||||||
return 'License';
|
return 'License';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fakePlusLicense(): void
|
|
||||||
{
|
|
||||||
self::swap(app(FakePlusLicenseService::class));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,3 +71,8 @@ function attempt_unless($condition, callable $callback, bool $log = true): mixed
|
||||||
{
|
{
|
||||||
return !value($condition) ? attempt($callback, $log) : null;
|
return !value($condition) ? attempt($callback, $log) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gravatar(string $email, int $size = 192): string
|
||||||
|
{
|
||||||
|
return sprintf('https://www.gravatar.com/avatar/%s?s=192&d=robohash', md5($email));
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ use App\Http\Resources\PlaylistResource;
|
||||||
use App\Http\Resources\QueueStateResource;
|
use App\Http\Resources\QueueStateResource;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Repositories\PlaylistRepository;
|
||||||
use App\Repositories\SettingRepository;
|
use App\Repositories\SettingRepository;
|
||||||
use App\Repositories\SongRepository;
|
use App\Repositories\SongRepository;
|
||||||
use App\Services\ApplicationInformationService;
|
use App\Services\ApplicationInformationService;
|
||||||
|
@ -26,6 +27,7 @@ class FetchInitialDataController extends Controller
|
||||||
ITunesService $iTunesService,
|
ITunesService $iTunesService,
|
||||||
SettingRepository $settingRepository,
|
SettingRepository $settingRepository,
|
||||||
SongRepository $songRepository,
|
SongRepository $songRepository,
|
||||||
|
PlaylistRepository $playlistRepository,
|
||||||
ApplicationInformationService $applicationInformationService,
|
ApplicationInformationService $applicationInformationService,
|
||||||
QueueService $queueService,
|
QueueService $queueService,
|
||||||
LicenseServiceInterface $licenseService,
|
LicenseServiceInterface $licenseService,
|
||||||
|
@ -35,7 +37,7 @@ class FetchInitialDataController extends Controller
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'settings' => $user->is_admin ? $settingRepository->getAllAsKeyValueArray() : [],
|
'settings' => $user->is_admin ? $settingRepository->getAllAsKeyValueArray() : [],
|
||||||
'playlists' => PlaylistResource::collection($user->playlists),
|
'playlists' => PlaylistResource::collection($playlistRepository->getAllAccessibleByUser($user)),
|
||||||
'playlist_folders' => PlaylistFolderResource::collection($user->playlist_folders),
|
'playlist_folders' => PlaylistFolderResource::collection($user->playlist_folders),
|
||||||
'current_user' => UserResource::make($user, true),
|
'current_user' => UserResource::make($user, true),
|
||||||
'uses_last_fm' => LastfmService::used(),
|
'uses_last_fm' => LastfmService::used(),
|
||||||
|
|
|
@ -8,6 +8,7 @@ use App\Http\Requests\API\ObjectStorage\S3\PutSongRequest;
|
||||||
use App\Http\Requests\API\ObjectStorage\S3\RemoveSongRequest;
|
use App\Http\Requests\API\ObjectStorage\S3\RemoveSongRequest;
|
||||||
use App\Services\S3Service;
|
use App\Services\S3Service;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class SongController extends Controller
|
class SongController extends Controller
|
||||||
{
|
{
|
||||||
|
@ -17,19 +18,19 @@ class SongController extends Controller
|
||||||
|
|
||||||
public function put(PutSongRequest $request)
|
public function put(PutSongRequest $request)
|
||||||
{
|
{
|
||||||
$artist = array_get($request->tags, 'artist', '');
|
$artist = Arr::get($request->tags, 'artist', '');
|
||||||
|
|
||||||
$song = $this->s3Service->createSongEntry(
|
$song = $this->s3Service->createSongEntry(
|
||||||
$request->bucket,
|
$request->bucket,
|
||||||
$request->key,
|
$request->key,
|
||||||
$artist,
|
$artist,
|
||||||
array_get($request->tags, 'album'),
|
Arr::get($request->tags, 'album'),
|
||||||
trim(array_get($request->tags, 'albumartist')),
|
trim(Arr::get($request->tags, 'albumartist')),
|
||||||
array_get($request->tags, 'cover'),
|
Arr::get($request->tags, 'cover'),
|
||||||
trim(array_get($request->tags, 'title', '')),
|
trim(Arr::get($request->tags, 'title', '')),
|
||||||
(int) array_get($request->tags, 'duration', 0),
|
(int) Arr::get($request->tags, 'duration', 0),
|
||||||
(int) array_get($request->tags, 'track'),
|
(int) Arr::get($request->tags, 'track'),
|
||||||
(string) array_get($request->tags, 'lyrics', '')
|
(string) Arr::get($request->tags, 'lyrics', '')
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($song);
|
return response()->json($song);
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API\PlaylistCollaboration;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\PlaylistResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\PlaylistCollaborationService;
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
|
||||||
|
class AcceptPlaylistCollaborationController extends Controller
|
||||||
|
{
|
||||||
|
/** @param User $user */
|
||||||
|
public function __invoke(PlaylistCollaborationService $service, Authenticatable $user)
|
||||||
|
{
|
||||||
|
$playlist = $service->acceptUsingToken(request()->input('token'), $user);
|
||||||
|
|
||||||
|
return PlaylistResource::make($playlist);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API\PlaylistCollaboration;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\PlaylistCollaborationTokenResource;
|
||||||
|
use App\Models\Playlist;
|
||||||
|
use App\Services\PlaylistCollaborationService;
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
|
||||||
|
class CreatePlaylistCollaborationTokenController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(
|
||||||
|
Playlist $playlist,
|
||||||
|
PlaylistCollaborationService $collaborationService,
|
||||||
|
Authenticatable $user
|
||||||
|
) {
|
||||||
|
$this->authorize('invite-collaborators', $playlist);
|
||||||
|
|
||||||
|
return PlaylistCollaborationTokenResource::make($collaborationService->createToken($playlist));
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ use App\Models\Playlist;
|
||||||
use App\Models\PlaylistFolder;
|
use App\Models\PlaylistFolder;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Repositories\PlaylistFolderRepository;
|
use App\Repositories\PlaylistFolderRepository;
|
||||||
|
use App\Repositories\PlaylistRepository;
|
||||||
use App\Services\PlaylistService;
|
use App\Services\PlaylistService;
|
||||||
use App\Values\SmartPlaylistRuleGroupCollection;
|
use App\Values\SmartPlaylistRuleGroupCollection;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
@ -23,6 +24,7 @@ class PlaylistController extends Controller
|
||||||
/** @param User $user */
|
/** @param User $user */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private PlaylistService $playlistService,
|
private PlaylistService $playlistService,
|
||||||
|
private PlaylistRepository $playlistRepository,
|
||||||
private PlaylistFolderRepository $folderRepository,
|
private PlaylistFolderRepository $folderRepository,
|
||||||
private ?Authenticatable $user
|
private ?Authenticatable $user
|
||||||
) {
|
) {
|
||||||
|
@ -30,7 +32,7 @@ class PlaylistController extends Controller
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
return PlaylistResource::collection($this->user->playlists);
|
return PlaylistResource::collection($this->playlistRepository->getAllAccessibleByUser($this->user));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(PlaylistStoreRequest $request)
|
public function store(PlaylistStoreRequest $request)
|
||||||
|
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\API;
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Facades\License;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\API\AddSongsToPlaylistRequest;
|
use App\Http\Requests\API\AddSongsToPlaylistRequest;
|
||||||
use App\Http\Requests\API\RemoveSongsFromPlaylistRequest;
|
use App\Http\Requests\API\RemoveSongsFromPlaylistRequest;
|
||||||
|
use App\Http\Resources\CollaborativeSongResource;
|
||||||
use App\Http\Resources\SongResource;
|
use App\Http\Resources\SongResource;
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
@ -27,35 +29,39 @@ class PlaylistSongController extends Controller
|
||||||
|
|
||||||
public function index(Playlist $playlist)
|
public function index(Playlist $playlist)
|
||||||
{
|
{
|
||||||
$this->authorize('own', $playlist);
|
if ($playlist->is_smart) {
|
||||||
|
$this->authorize('own', $playlist);
|
||||||
|
return SongResource::collection($this->smartPlaylistService->getSongs($playlist, $this->user));
|
||||||
|
}
|
||||||
|
|
||||||
return SongResource::collection(
|
$this->authorize('collaborate', $playlist);
|
||||||
$playlist->is_smart
|
|
||||||
? $this->smartPlaylistService->getSongs($playlist, $this->user)
|
$songs = $this->songRepository->getByStandardPlaylist($playlist, $this->user);
|
||||||
: $this->songRepository->getByStandardPlaylist($playlist, $this->user)
|
|
||||||
);
|
return License::isPlus()
|
||||||
|
? CollaborativeSongResource::collection($songs)
|
||||||
|
: SongResource::collection($songs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Playlist $playlist, AddSongsToPlaylistRequest $request)
|
public function store(Playlist $playlist, AddSongsToPlaylistRequest $request)
|
||||||
{
|
{
|
||||||
$this->authorize('own', $playlist);
|
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN, 'Smart playlist content is automatically generated');
|
||||||
|
|
||||||
$this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user)
|
$this->authorize('collaborate', $playlist);
|
||||||
->each(fn ($song) => $this->authorize('access', $song));
|
|
||||||
|
|
||||||
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
|
$songs = $this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user);
|
||||||
|
$songs->each(fn ($song) => $this->authorize('access', $song));
|
||||||
|
|
||||||
$this->playlistService->addSongsToPlaylist($playlist, $request->songs);
|
$this->playlistService->addSongsToPlaylist($playlist, $songs, $this->user);
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
|
public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
|
||||||
{
|
{
|
||||||
$this->authorize('own', $playlist);
|
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN, 'Smart playlist content is automatically generated');
|
||||||
|
|
||||||
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
|
$this->authorize('collaborate', $playlist);
|
||||||
|
|
||||||
$this->playlistService->removeSongsFromPlaylist($playlist, $request->songs);
|
$this->playlistService->removeSongsFromPlaylist($playlist, $request->songs);
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
|
|
23
app/Http/Resources/CollaborativeSongResource.php
Normal file
23
app/Http/Resources/CollaborativeSongResource.php
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class CollaborativeSongResource extends SongResource
|
||||||
|
{
|
||||||
|
/** @return array<mixed> */
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return array_merge(parent::toArray($request), [
|
||||||
|
'collaboration' => [
|
||||||
|
'user' => [
|
||||||
|
'avatar' => gravatar($this->song->collaborator_email),
|
||||||
|
'name' => $this->song->collaborator_name,
|
||||||
|
],
|
||||||
|
'added_at' => $this->song->added_at,
|
||||||
|
'fmt_added_at' => $this->song->added_at ? Carbon::make($this->song->added_at)->diffForHumans() : null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
23
app/Http/Resources/PlaylistCollaborationTokenResource.php
Normal file
23
app/Http/Resources/PlaylistCollaborationTokenResource.php
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Models\PlaylistCollaborationToken;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class PlaylistCollaborationTokenResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function __construct(private PlaylistCollaborationToken $token)
|
||||||
|
{
|
||||||
|
parent::__construct($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<mixed> */
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'playlist_collaboration_tokens',
|
||||||
|
'token' => $this->token->token,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Resources;
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use App\Facades\License;
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
@ -23,6 +24,7 @@ class PlaylistResource extends JsonResource
|
||||||
'user_id' => $this->playlist->user_id,
|
'user_id' => $this->playlist->user_id,
|
||||||
'is_smart' => $this->playlist->is_smart,
|
'is_smart' => $this->playlist->is_smart,
|
||||||
'rules' => $this->playlist->rules,
|
'rules' => $this->playlist->rules,
|
||||||
|
'collaborators' => License::isPlus() ? UserResource::collection($this->playlist->collaborators) : [],
|
||||||
'own_songs_only' => $this->playlist->own_songs_only,
|
'own_songs_only' => $this->playlist->own_songs_only,
|
||||||
'created_at' => $this->playlist->created_at,
|
'created_at' => $this->playlist->created_at,
|
||||||
];
|
];
|
||||||
|
|
|
@ -7,7 +7,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
class SongResource extends JsonResource
|
class SongResource extends JsonResource
|
||||||
{
|
{
|
||||||
public function __construct(private Song $song)
|
public function __construct(protected Song $song)
|
||||||
{
|
{
|
||||||
parent::__construct($song);
|
parent::__construct($song);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Casts\SmartPlaylistRulesCast;
|
use App\Casts\SmartPlaylistRulesCast;
|
||||||
|
use App\Facades\License as LicenseFacade;
|
||||||
use App\Values\SmartPlaylistRuleGroupCollection;
|
use App\Values\SmartPlaylistRuleGroupCollection;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
@ -10,11 +11,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Scout\Searchable;
|
use Laravel\Scout\Searchable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property string $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property bool $is_smart
|
* @property bool $is_smart
|
||||||
* @property int $user_id
|
* @property int $user_id
|
||||||
|
@ -26,6 +29,7 @@ use Laravel\Scout\Searchable;
|
||||||
* @property ?SmartPlaylistRuleGroupCollection $rules
|
* @property ?SmartPlaylistRuleGroupCollection $rules
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property bool $own_songs_only
|
* @property bool $own_songs_only
|
||||||
|
* @property Collection|array<array-key, User> $collaborators
|
||||||
*/
|
*/
|
||||||
class Playlist extends Model
|
class Playlist extends Model
|
||||||
{
|
{
|
||||||
|
@ -33,18 +37,28 @@ class Playlist extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $hidden = ['user_id', 'created_at', 'updated_at'];
|
protected $hidden = ['user_id', 'created_at', 'updated_at'];
|
||||||
protected $guarded = ['id'];
|
protected $guarded = [];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'rules' => SmartPlaylistRulesCast::class,
|
'rules' => SmartPlaylistRulesCast::class,
|
||||||
'own_songs_only' => 'bool',
|
'own_songs_only' => 'bool',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
protected $keyType = 'string';
|
||||||
protected $appends = ['is_smart'];
|
protected $appends = ['is_smart'];
|
||||||
|
protected $with = ['collaborators'];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(static function (Playlist $playlist): void {
|
||||||
|
$playlist->id ??= Str::uuid()->toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function songs(): BelongsToMany
|
public function songs(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Song::class);
|
return $this->belongsToMany(Song::class)->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
|
@ -57,6 +71,16 @@ class Playlist extends Model
|
||||||
return $this->belongsTo(PlaylistFolder::class);
|
return $this->belongsTo(PlaylistFolder::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function collaborationTokens(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PlaylistCollaborationToken::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collaborators(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'playlist_collaborators')->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
protected function isSmart(): Attribute
|
protected function isSmart(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(fn (): bool => (bool) $this->rule_groups?->isNotEmpty());
|
return Attribute::get(fn (): bool => (bool) $this->rule_groups?->isNotEmpty());
|
||||||
|
@ -68,6 +92,61 @@ class Playlist extends Model
|
||||||
return Attribute::get(fn () => $this->rules);
|
return Attribute::get(fn () => $this->rules);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ownedBy(User $user): bool
|
||||||
|
{
|
||||||
|
return $this->user_id === $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inFolder(PlaylistFolder $folder): bool
|
||||||
|
{
|
||||||
|
return $this->folder_id === $folder->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addCollaborator(User $user): void
|
||||||
|
{
|
||||||
|
if (!$this->hasCollaborator($user)) {
|
||||||
|
$this->collaborators()->attach($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCollaborator(User $user): bool
|
||||||
|
{
|
||||||
|
return $this->collaborators->contains(static function (User $collaborator) use ($user): bool {
|
||||||
|
return $collaborator->is($user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection|array<array-key, Song>|Song|array<string> $songs
|
||||||
|
*/
|
||||||
|
public function addSongs(Collection|Song|array $songs, ?User $collaborator = null): void
|
||||||
|
{
|
||||||
|
$collaborator ??= $this->user;
|
||||||
|
|
||||||
|
if (!is_array($songs)) {
|
||||||
|
$songs = Collection::wrap($songs)->pluck('id')->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->songs()->attach($songs, ['user_id' => $collaborator->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection|array<array-key, Song>|Song|array<string> $songs
|
||||||
|
*/
|
||||||
|
public function removeSongs(Collection|Song|array $songs): void
|
||||||
|
{
|
||||||
|
if (!is_array($songs)) {
|
||||||
|
$songs = Collection::wrap($songs)->pluck('id')->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->songs()->detach($songs);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isCollaborative(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::get(fn (): bool => LicenseFacade::isPlus() && $this->collaborators->isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<mixed> */
|
/** @return array<mixed> */
|
||||||
public function toSearchableArray(): array
|
public function toSearchableArray(): array
|
||||||
{
|
{
|
||||||
|
|
35
app/Models/PlaylistCollaborationToken.php
Normal file
35
app/Models/PlaylistCollaborationToken.php
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property string $token
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property-read bool $expired
|
||||||
|
* @property Playlist $playlist
|
||||||
|
*/
|
||||||
|
class PlaylistCollaborationToken extends Model
|
||||||
|
{
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(static function (PlaylistCollaborationToken $token): void {
|
||||||
|
$token->token ??= Str::uuid()->toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function playlist(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Playlist::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function expired(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::get(fn (): bool => $this->created_at->addDays(7)->isPast());
|
||||||
|
}
|
||||||
|
}
|
|
@ -40,4 +40,9 @@ class PlaylistFolder extends Model
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ownedBy(User $user): bool
|
||||||
|
{
|
||||||
|
return $this->user_id === $user->id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,11 @@ use Laravel\Scout\Searchable;
|
||||||
* @property int $owner_id
|
* @property int $owner_id
|
||||||
* @property bool $is_public
|
* @property bool $is_public
|
||||||
* @property User $owner
|
* @property User $owner
|
||||||
|
*
|
||||||
|
* // The following are only available for collaborative playlists
|
||||||
|
* @property-read ?string $collaborator_email The email of the user who added the song to the playlist
|
||||||
|
* @property-read ?string $collaborator_name The name of the user who added the song to the playlist
|
||||||
|
* @property-read ?string $added_at The date the song was added to the playlist
|
||||||
*/
|
*/
|
||||||
class Song extends Model
|
class Song extends Model
|
||||||
{
|
{
|
||||||
|
|
|
@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
@ -32,6 +33,7 @@ use Laravel\Sanctum\PersonalAccessToken;
|
||||||
* @property ?string $invitation_token
|
* @property ?string $invitation_token
|
||||||
* @property ?Carbon $invited_at
|
* @property ?Carbon $invited_at
|
||||||
* @property-read bool $is_prospect
|
* @property-read bool $is_prospect
|
||||||
|
* @property Collection|array<array-key, Playlist> $collaboratedPlaylists
|
||||||
*/
|
*/
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
|
@ -58,6 +60,11 @@ class User extends Authenticatable
|
||||||
return $this->hasMany(Playlist::class);
|
return $this->hasMany(Playlist::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function collaboratedPlaylists(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Playlist::class, 'playlist_collaborators')->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
public function playlist_folders(): HasMany // @phpcs:ignore
|
public function playlist_folders(): HasMany // @phpcs:ignore
|
||||||
{
|
{
|
||||||
return $this->hasMany(PlaylistFolder::class);
|
return $this->hasMany(PlaylistFolder::class);
|
||||||
|
@ -70,9 +77,7 @@ class User extends Authenticatable
|
||||||
|
|
||||||
protected function avatar(): Attribute
|
protected function avatar(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::get(
|
return Attribute::get(fn (): string => gravatar($this->email));
|
||||||
fn () => sprintf('https://www.gravatar.com/avatar/%s?s=192&d=robohash', md5($this->email))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -9,6 +9,6 @@ class PlaylistFolderPolicy
|
||||||
{
|
{
|
||||||
public function own(User $user, PlaylistFolder $folder): bool
|
public function own(User $user, PlaylistFolder $folder): bool
|
||||||
{
|
{
|
||||||
return $folder->user->is($user);
|
return $folder->ownedBy($user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Policies;
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Facades\License;
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
|
@ -9,11 +10,29 @@ class PlaylistPolicy
|
||||||
{
|
{
|
||||||
public function own(User $user, Playlist $playlist): bool
|
public function own(User $user, Playlist $playlist): bool
|
||||||
{
|
{
|
||||||
return $playlist->user->is($user);
|
return $playlist->ownedBy($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function download(User $user, Playlist $playlist): bool
|
public function download(User $user, Playlist $playlist): bool
|
||||||
{
|
{
|
||||||
return $this->own($user, $playlist);
|
return $this->own($user, $playlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function inviteCollaborators(User $user, Playlist $playlist): bool
|
||||||
|
{
|
||||||
|
return $this->own($user, $playlist) && !$playlist->is_smart && License::isPlus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collaborate(User $user, Playlist $playlist): bool
|
||||||
|
{
|
||||||
|
if ($this->own($user, $playlist)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!License::isPlus()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $playlist->hasCollaborator($user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
21
app/Repositories/PlaylistRepository.php
Normal file
21
app/Repositories/PlaylistRepository.php
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use App\Facades\License;
|
||||||
|
use App\Models\Playlist;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class PlaylistRepository extends Repository
|
||||||
|
{
|
||||||
|
/** @return array<array-key, Playlist>|Collection<Playlist> */
|
||||||
|
public function getAllAccessibleByUser(User $user): Collection
|
||||||
|
{
|
||||||
|
if (License::isCommunity()) {
|
||||||
|
return $user->playlists;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->playlists->merge($user->collaboratedPlaylists);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Repositories;
|
namespace App\Repositories;
|
||||||
|
|
||||||
use App\Builders\SongBuilder;
|
use App\Builders\SongBuilder;
|
||||||
|
use App\Facades\License;
|
||||||
use App\Models\Album;
|
use App\Models\Album;
|
||||||
use App\Models\Artist;
|
use App\Models\Artist;
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
|
@ -182,8 +183,18 @@ class SongRepository extends Repository
|
||||||
->withMetaFor($scopedUser)
|
->withMetaFor($scopedUser)
|
||||||
->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
|
->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
|
||||||
->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id')
|
->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id')
|
||||||
|
->when(License::isPlus(), static function (SongBuilder $query): SongBuilder {
|
||||||
|
return
|
||||||
|
$query->join('users as collaborators', 'playlist_song.user_id', '=', 'collaborators.id')
|
||||||
|
->addSelect(
|
||||||
|
'collaborators.name as collaborator_name',
|
||||||
|
'collaborators.email as collaborator_email',
|
||||||
|
'playlist_song.created_at as added_at'
|
||||||
|
);
|
||||||
|
})
|
||||||
->where('playlists.id', $playlist->id)
|
->where('playlists.id', $playlist->id)
|
||||||
->orderBy('songs.title')
|
->orderBy('songs.title')
|
||||||
|
->logSql()
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
38
app/Services/PlaylistCollaborationService.php
Normal file
38
app/Services/PlaylistCollaborationService.php
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Facades\License;
|
||||||
|
use App\Models\Playlist;
|
||||||
|
use App\Models\PlaylistCollaborationToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use Webmozart\Assert\Assert;
|
||||||
|
|
||||||
|
class PlaylistCollaborationService
|
||||||
|
{
|
||||||
|
public function createToken(Playlist $playlist): PlaylistCollaborationToken
|
||||||
|
{
|
||||||
|
Assert::true(License::isPlus(), 'Playlist collaboration is only available with Koel Plus.');
|
||||||
|
Assert::false($playlist->is_smart, 'Smart playlists are not collaborative.');
|
||||||
|
|
||||||
|
return $playlist->collaborationTokens()->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acceptUsingToken(string $token, User $user): Playlist
|
||||||
|
{
|
||||||
|
Assert::true(License::isPlus(), 'Playlist collaboration is only available with Koel Plus.');
|
||||||
|
|
||||||
|
/** @var PlaylistCollaborationToken $collaborationToken */
|
||||||
|
$collaborationToken = PlaylistCollaborationToken::query()->where('token', $token)->firstOrFail();
|
||||||
|
|
||||||
|
Assert::false($collaborationToken->expired, 'The token has expired.');
|
||||||
|
|
||||||
|
if ($collaborationToken->playlist->ownedBy($user)) {
|
||||||
|
return $collaborationToken->playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
$collaborationToken->playlist->addCollaborator($user);
|
||||||
|
|
||||||
|
return $collaborationToken->playlist;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,11 +2,14 @@
|
||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\PlaylistBothSongsAndRulesProvidedException;
|
||||||
use App\Facades\License;
|
use App\Facades\License;
|
||||||
use App\Models\Playlist;
|
use App\Models\Playlist;
|
||||||
use App\Models\PlaylistFolder as Folder;
|
use App\Models\PlaylistFolder as Folder;
|
||||||
|
use App\Models\Song;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Values\SmartPlaylistRuleGroupCollection;
|
use App\Values\SmartPlaylistRuleGroupCollection;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Webmozart\Assert\Assert;
|
use Webmozart\Assert\Assert;
|
||||||
|
@ -22,9 +25,11 @@ class PlaylistService
|
||||||
bool $ownSongsOnly = false
|
bool $ownSongsOnly = false
|
||||||
): Playlist {
|
): Playlist {
|
||||||
if ($folder) {
|
if ($folder) {
|
||||||
Assert::true($user->is($folder->user), 'The playlist folder does not belong to the user');
|
Assert::true($folder->ownedBy($user), 'The playlist folder does not belong to the user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw_if($songs && $ruleGroups, new PlaylistBothSongsAndRulesProvidedException());
|
||||||
|
|
||||||
throw_if($ownSongsOnly && (!$ruleGroups || !License::isPlus()), new InvalidArgumentException(
|
throw_if($ownSongsOnly && (!$ruleGroups || !License::isPlus()), new InvalidArgumentException(
|
||||||
'"Own songs only" option only works with smart playlists and Plus license.'
|
'"Own songs only" option only works with smart playlists and Plus license.'
|
||||||
));
|
));
|
||||||
|
@ -40,7 +45,7 @@ class PlaylistService
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$playlist->is_smart && $songs) {
|
if (!$playlist->is_smart && $songs) {
|
||||||
$playlist->songs()->sync($songs);
|
$playlist->addSongs($songs, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $playlist;
|
return $playlist;
|
||||||
|
@ -56,7 +61,7 @@ class PlaylistService
|
||||||
bool $ownSongsOnly = false
|
bool $ownSongsOnly = false
|
||||||
): Playlist {
|
): Playlist {
|
||||||
if ($folder) {
|
if ($folder) {
|
||||||
Assert::true($playlist->user->is($folder->user), 'The playlist folder does not belong to the user');
|
Assert::true($playlist->ownedBy($folder->user), 'The playlist folder does not belong to the user');
|
||||||
}
|
}
|
||||||
|
|
||||||
throw_if($ownSongsOnly && (!$playlist->is_smart || !License::isPlus()), new InvalidArgumentException(
|
throw_if($ownSongsOnly && (!$playlist->is_smart || !License::isPlus()), new InvalidArgumentException(
|
||||||
|
@ -73,13 +78,16 @@ class PlaylistService
|
||||||
return $playlist;
|
return $playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addSongsToPlaylist(Playlist $playlist, array $songIds): void
|
public function addSongsToPlaylist(Playlist $playlist, Collection|Song|array $songs, User $user): void
|
||||||
{
|
{
|
||||||
$playlist->songs()->syncWithoutDetaching($songIds);
|
$playlist->addSongs(
|
||||||
|
Collection::wrap($songs)->filter(static fn ($song): bool => !$playlist->songs->contains($song)),
|
||||||
|
$user
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeSongsFromPlaylist(Playlist $playlist, array $songIds): void
|
public function removeSongsFromPlaylist(Playlist $playlist, Collection|Song|array $songs): void
|
||||||
{
|
{
|
||||||
$playlist->songs()->detach($songIds);
|
$playlist->removeSongs($songs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ return new class extends Migration
|
||||||
});
|
});
|
||||||
|
|
||||||
Song::all()->each(static function (Song $song): void {
|
Song::all()->each(static function (Song $song): void {
|
||||||
$song->id = Str::uuid();
|
$song->id = Str::uuid()->toString();
|
||||||
$song->save();
|
$song->save();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,8 @@ return new class extends Migration
|
||||||
Schema::table('playlists', static function (Blueprint $table): void {
|
Schema::table('playlists', static function (Blueprint $table): void {
|
||||||
$table->string('folder_id', 36)->nullable();
|
$table->string('folder_id', 36)->nullable();
|
||||||
$table->foreign('folder_id')
|
$table->foreign('folder_id')
|
||||||
->references('id')->on('playlist_folders')
|
->references('id')
|
||||||
|
->on('playlist_folders')
|
||||||
->cascadeOnUpdate()
|
->cascadeOnUpdate()
|
||||||
->nullOnDelete();
|
->nullOnDelete();
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Playlist;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
|
Schema::table('playlist_song', static function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['playlist_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('playlists', static function (Blueprint $table): void {
|
||||||
|
$table->string('id', 36)->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('playlist_song', static function (Blueprint $table): void {
|
||||||
|
$table->string('playlist_id', 36)->change();
|
||||||
|
$table->foreign('playlist_id')->references('id')->on('playlists')->cascadeOnDelete()->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
Playlist::all()->each(static function (Playlist $playlist): void {
|
||||||
|
$oldId = $playlist->id;
|
||||||
|
$newId = Str::uuid()->toString();
|
||||||
|
|
||||||
|
$playlist->id = $newId;
|
||||||
|
$playlist->save();
|
||||||
|
|
||||||
|
DB::table('playlist_song')->where('playlist_id', $oldId)->update([
|
||||||
|
'playlist_id' => $newId,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('playlist_collaborators', static function (Blueprint $table): void {
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->unsignedInteger('user_id');
|
||||||
|
$table->string('playlist_id', 36);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('playlist_collaborators', static function (Blueprint $table): void {
|
||||||
|
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete()->cascadeOnUpdate();
|
||||||
|
$table->foreign('playlist_id')->references('id')->on('playlists')->cascadeOnDelete()->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Playlist;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::disableForeignKeyConstraints();
|
||||||
|
|
||||||
|
Schema::table('playlist_song', static function (Blueprint $table): void {
|
||||||
|
$table->unsignedInteger('user_id')->nullable()->index();
|
||||||
|
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete()->cascadeOnUpdate();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Playlist::query()->get()->each(static function (Playlist $playlist): void {
|
||||||
|
DB::table('playlist_song')->where('playlist_id', $playlist->id)->update([
|
||||||
|
'user_id' => $playlist->user_id,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('playlist_song', static function (Blueprint $table): void {
|
||||||
|
$table->unsignedInteger('user_id')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::enableForeignKeyConstraints();
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('playlist_collaboration_tokens', static function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('playlist_id', 36)->nullable(false);
|
||||||
|
$table->string('token', 36)->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('playlist_collaboration_tokens', static function (Blueprint $table): void {
|
||||||
|
$table->foreign('playlist_id')->references('id')->on('playlists')->cascadeOnDelete()->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -20,7 +20,7 @@ export default factory
|
||||||
.define('album', faker => albumFactory(faker), albumStates)
|
.define('album', faker => albumFactory(faker), albumStates)
|
||||||
.define('album-track', faker => albumTrackFactory(faker))
|
.define('album-track', faker => albumTrackFactory(faker))
|
||||||
.define('album-info', faker => albumInfoFactory(faker))
|
.define('album-info', faker => albumInfoFactory(faker))
|
||||||
.define('song', faker => songFactory(faker), songStates)
|
.define('song', () => songFactory(), songStates)
|
||||||
.define('interaction', faker => interactionFactory(faker))
|
.define('interaction', faker => interactionFactory(faker))
|
||||||
.define('genre', faker => genreFactory(faker))
|
.define('genre', faker => genreFactory(faker))
|
||||||
.define('video', faker => youTubeVideoFactory(faker))
|
.define('video', faker => youTubeVideoFactory(faker))
|
||||||
|
|
|
@ -3,12 +3,14 @@ import { Faker } from '@faker-js/faker'
|
||||||
|
|
||||||
export default (faker: Faker): Playlist => ({
|
export default (faker: Faker): Playlist => ({
|
||||||
type: 'playlists',
|
type: 'playlists',
|
||||||
id: faker.datatype.number(),
|
user_id: faker.datatype.number({ min: 1, max: 1000 }),
|
||||||
|
id: faker.datatype.uuid(),
|
||||||
folder_id: faker.datatype.uuid(),
|
folder_id: faker.datatype.uuid(),
|
||||||
name: faker.random.word(),
|
name: faker.random.word(),
|
||||||
is_smart: false,
|
is_smart: false,
|
||||||
rules: [],
|
rules: [],
|
||||||
ownSongsOnly: false
|
own_songs_only: false,
|
||||||
|
collaborators: []
|
||||||
})
|
})
|
||||||
|
|
||||||
export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 'type'>> = {
|
export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 'type'>> = {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Faker, faker } from '@faker-js/faker'
|
import { faker } from '@faker-js/faker'
|
||||||
import { genres } from '@/config'
|
import { genres } from '@/config'
|
||||||
|
|
||||||
const generate = (partOfCompilation = false): Song => {
|
const generate = (partOfCompilation = false): Song => {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
@ -68,7 +69,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to album', async () => {
|
it('goes to album', async () => {
|
||||||
const mock = this.mock(this.router, 'go')
|
const mock = this.mock(Router, 'go')
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Album'))
|
await this.user.click(screen.getByText('Go to Album'))
|
||||||
|
@ -85,7 +86,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to artist', async () => {
|
it('goes to artist', async () => {
|
||||||
const mock = this.mock(this.router, 'go')
|
const mock = this.mock(Router, 'go')
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Artist'))
|
await this.user.click(screen.getByText('Go to Artist'))
|
||||||
|
|
|
@ -39,7 +39,6 @@ import { mediaInfoService, playbackService } from '@/services'
|
||||||
import { useRouter, useThirdPartyServices } from '@/composables'
|
import { useRouter, useThirdPartyServices } from '@/composables'
|
||||||
|
|
||||||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||||
import { defaultCover } from '@/utils'
|
|
||||||
|
|
||||||
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
|
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
import { downloadService, playbackService } from '@/services'
|
import { downloadService, playbackService } from '@/services'
|
||||||
import { commonStore, songStore } from '@/stores'
|
import { commonStore, songStore } from '@/stores'
|
||||||
import ArtistContextMenu from './ArtistContextMenu.vue'
|
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
|
import ArtistContextMenu from './ArtistContextMenu.vue'
|
||||||
|
|
||||||
let artist: Artist
|
let artist: Artist
|
||||||
|
|
||||||
|
@ -16,7 +17,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
const rendered = this.render(ArtistContextMenu)
|
const rendered = this.render(ArtistContextMenu)
|
||||||
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, artist)
|
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, artist)
|
||||||
await this.tick(2)
|
await this.tick(2)
|
||||||
|
|
||||||
return rendered
|
return rendered
|
||||||
|
@ -68,7 +69,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to artist', async () => {
|
it('goes to artist', async () => {
|
||||||
const mock = this.mock(this.router, 'go')
|
const mock = this.mock(Router, 'go')
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
await screen.getByText('Go to Artist').click()
|
await screen.getByText('Go to Artist').click()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import { expect, it, Mock } from 'vitest'
|
import { expect, it, Mock } from 'vitest'
|
||||||
import { userStore } from '@/stores'
|
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { authService } from '@/services'
|
||||||
import LoginFrom from './LoginForm.vue'
|
import LoginFrom from './LoginForm.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
@ -21,11 +21,11 @@ new class extends UnitTestCase {
|
||||||
it('renders', () => expect(this.render(LoginFrom).html()).toMatchSnapshot())
|
it('renders', () => expect(this.render(LoginFrom).html()).toMatchSnapshot())
|
||||||
|
|
||||||
it('logs in', async () => {
|
it('logs in', async () => {
|
||||||
expect((await this.submitForm(this.mock(userStore, 'login'))).emitted().loggedin).toBeTruthy()
|
expect((await this.submitForm(this.mock(authService, 'login'))).emitted().loggedin).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fails to log in', async () => {
|
it('fails to log in', async () => {
|
||||||
const mock = this.mock(userStore, 'login').mockRejectedValue(new Error('Unauthenticated'))
|
const mock = this.mock(authService, 'login').mockRejectedValue(new Error('Unauthenticated'))
|
||||||
const { emitted } = await this.submitForm(mock)
|
const { emitted } = await this.submitForm(mock)
|
||||||
await this.tick()
|
await this.tick()
|
||||||
|
|
||||||
|
|
|
@ -13,8 +13,8 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { userStore } from '@/stores'
|
|
||||||
import { isDemo } from '@/utils'
|
import { isDemo } from '@/utils'
|
||||||
|
import { authService } from '@/services'
|
||||||
|
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||||
|
@ -33,7 +33,7 @@ const emit = defineEmits<{ (e: 'loggedin'): void }>()
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
try {
|
try {
|
||||||
await userStore.login(email.value, password.value)
|
await authService.login(email.value, password.value)
|
||||||
failed.value = false
|
failed.value = false
|
||||||
|
|
||||||
// Reset the password so that the next login will have this field empty.
|
// Reset the password so that the next login will have this field empty.
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
<Icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-green" fixed-width />
|
<Icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-green" fixed-width />
|
||||||
<Icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width />
|
<Icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width />
|
||||||
<Icon v-else-if="list.is_smart" :icon="faWandMagicSparkles" fixed-width />
|
<Icon v-else-if="list.is_smart" :icon="faWandMagicSparkles" fixed-width />
|
||||||
|
<Icon v-else-if="list.collaborators.length" :icon="faUsers" fixed-width />
|
||||||
<Icon v-else :icon="faFileLines" fixed-width />
|
<Icon v-else :icon="faFileLines" fixed-width />
|
||||||
<span>{{ list.name }}</span>
|
<span>{{ list.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
@ -21,7 +22,13 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faClockRotateLeft, faFileLines, faHeart, faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
|
import {
|
||||||
|
faClockRotateLeft,
|
||||||
|
faFileLines,
|
||||||
|
faHeart,
|
||||||
|
faUsers,
|
||||||
|
faWandMagicSparkles
|
||||||
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
import { computed, ref, toRefs } from 'vue'
|
import { computed, ref, toRefs } from 'vue'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
import { favoriteStore } from '@/stores'
|
import { favoriteStore } from '@/stores'
|
||||||
|
@ -110,7 +117,7 @@ onRouteChanged(route => {
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'Playlist':
|
case 'Playlist':
|
||||||
active.value = (list.value as Playlist).id === parseInt(route.params!.id)
|
active.value = (list.value as Playlist).id === route.params!.id
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
<li v-for="user in displayedCollaborators">
|
||||||
|
<UserAvatar :user="user" width="24" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<span v-if="remainderCount" class="more">
|
||||||
|
+{{ remainderCount }} more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, toRefs } from 'vue'
|
||||||
|
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ playlist: Playlist }>()
|
||||||
|
const { playlist } = toRefs(props)
|
||||||
|
|
||||||
|
const displayedCollaborators = computed(() => playlist.value.collaborators.slice(0, 3))
|
||||||
|
const remainderCount = computed(() => playlist.value.collaborators.length - displayedCollaborators.value.length)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
div {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-left: -.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more {
|
||||||
|
margin-left: .3rem;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,16 +1,21 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
import { songStore } from '@/stores'
|
import { songStore, userStore } from '@/stores'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService, playlistCollaborationService } from '@/services'
|
||||||
import { queueStore } from '@/stores'
|
import { queueStore } from '@/stores'
|
||||||
import { MessageToasterStub } from '@/__tests__/stubs'
|
import { MessageToasterStub } from '@/__tests__/stubs'
|
||||||
import PlaylistContextMenu from './PlaylistContextMenu.vue'
|
import PlaylistContextMenu from './PlaylistContextMenu.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
private async renderComponent (playlist: Playlist) {
|
private async renderComponent (playlist: Playlist, user: User | null = null) {
|
||||||
|
userStore.state.current = user || factory<User>('user', {
|
||||||
|
id: playlist.user_id
|
||||||
|
})
|
||||||
|
|
||||||
this.render(PlaylistContextMenu)
|
this.render(PlaylistContextMenu)
|
||||||
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playlist)
|
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playlist)
|
||||||
await this.tick(2)
|
await this.tick(2)
|
||||||
|
@ -52,7 +57,7 @@ new class extends UnitTestCase {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
|
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
|
||||||
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
await this.renderComponent(playlist)
|
await this.renderComponent(playlist)
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Play'))
|
await this.user.click(screen.getByText('Play'))
|
||||||
|
@ -68,7 +73,7 @@ new class extends UnitTestCase {
|
||||||
const playlist = factory<Playlist>('playlist')
|
const playlist = factory<Playlist>('playlist')
|
||||||
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([])
|
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([])
|
||||||
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
const warnMock = this.mock(MessageToasterStub.value, 'warning')
|
const warnMock = this.mock(MessageToasterStub.value, 'warning')
|
||||||
|
|
||||||
await this.renderComponent(playlist)
|
await this.renderComponent(playlist)
|
||||||
|
@ -88,7 +93,7 @@ new class extends UnitTestCase {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
|
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
|
||||||
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
await this.renderComponent(playlist)
|
await this.renderComponent(playlist)
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Shuffle'))
|
await this.user.click(screen.getByText('Shuffle'))
|
||||||
|
@ -104,7 +109,7 @@ new class extends UnitTestCase {
|
||||||
const playlist = factory<Playlist>('playlist')
|
const playlist = factory<Playlist>('playlist')
|
||||||
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([])
|
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([])
|
||||||
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
const warnMock = this.mock(MessageToasterStub.value, 'warning')
|
const warnMock = this.mock(MessageToasterStub.value, 'warning')
|
||||||
|
|
||||||
await this.renderComponent(playlist)
|
await this.renderComponent(playlist)
|
||||||
|
@ -135,5 +140,33 @@ new class extends UnitTestCase {
|
||||||
expect(toastMock).toHaveBeenCalledWith('Playlist added to queue.')
|
expect(toastMock).toHaveBeenCalledWith('Playlist added to queue.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not have an option to edit or delete if the playlist is not owned by the current user', async () => {
|
||||||
|
const user = factory<User>('user')
|
||||||
|
const playlist = factory<Playlist>('playlist', {
|
||||||
|
user_id: user.id + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.renderComponent(playlist, user)
|
||||||
|
|
||||||
|
expect(screen.queryByText('Edit…')).toBeNull()
|
||||||
|
expect(screen.queryByText('Delete')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invites collaborators', async () => {
|
||||||
|
this.enablePlusEdition()
|
||||||
|
const playlist = factory<Playlist>('playlist')
|
||||||
|
await this.renderComponent(playlist)
|
||||||
|
|
||||||
|
const createInviteLinkMock = this.mock(playlistCollaborationService, 'createInviteLink')
|
||||||
|
.mockResolvedValue('https://koel.app/invite/123')
|
||||||
|
|
||||||
|
await this.user.click(screen.getByText('Invite Collaborators'))
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(createInviteLinkMock).toHaveBeenCalledWith(playlist)
|
||||||
|
expect(await navigator.clipboard.readText()).equal('https://koel.app/invite/123')
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,25 +3,34 @@
|
||||||
<li @click="play">Play</li>
|
<li @click="play">Play</li>
|
||||||
<li @click="shuffle">Shuffle</li>
|
<li @click="shuffle">Shuffle</li>
|
||||||
<li @click="addToQueue">Add to Queue</li>
|
<li @click="addToQueue">Add to Queue</li>
|
||||||
<li class="separator" />
|
<template v-if="canInviteCollaborators">
|
||||||
<li @click="edit">Edit…</li>
|
<li class="separator"></li>
|
||||||
<li @click="destroy">Delete</li>
|
<li @click="inviteCollaborators">Invite Collaborators</li>
|
||||||
|
<li class="separator"></li>
|
||||||
|
</template>
|
||||||
|
<li v-if="ownedByCurrentUser" @click="edit">Edit…</li>
|
||||||
|
<li v-if="ownedByCurrentUser" @click="destroy">Delete</li>
|
||||||
</ContextMenuBase>
|
</ContextMenuBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { eventBus } from '@/utils'
|
import { copyText, eventBus } from '@/utils'
|
||||||
import { useContextMenu, useMessageToaster, useRouter } from '@/composables'
|
import { useAuthorization, useContextMenu, useMessageToaster, useKoelPlus, useRouter } from '@/composables'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService, playlistCollaborationService } from '@/services'
|
||||||
import { songStore, queueStore } from '@/stores'
|
import { songStore, queueStore } from '@/stores'
|
||||||
|
|
||||||
const { base, ContextMenuBase, open, trigger } = useContextMenu()
|
const { base, ContextMenuBase, open, trigger } = useContextMenu()
|
||||||
const { go } = useRouter()
|
const { go } = useRouter()
|
||||||
const { toastWarning, toastSuccess } = useMessageToaster()
|
const { toastWarning, toastSuccess } = useMessageToaster()
|
||||||
|
const { isPlus } = useKoelPlus()
|
||||||
|
const { currentUser } = useAuthorization()
|
||||||
|
|
||||||
const playlist = ref<Playlist>()
|
const playlist = ref<Playlist>()
|
||||||
|
|
||||||
|
const ownedByCurrentUser = computed(() => playlist.value?.user_id === currentUser.value?.id)
|
||||||
|
const canInviteCollaborators = computed(() => ownedByCurrentUser.value && isPlus.value && !playlist.value?.is_smart)
|
||||||
|
|
||||||
const edit = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value!))
|
const edit = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value!))
|
||||||
const destroy = () => trigger(() => eventBus.emit('PLAYLIST_DELETE', playlist.value!))
|
const destroy = () => trigger(() => eventBus.emit('PLAYLIST_DELETE', playlist.value!))
|
||||||
|
|
||||||
|
@ -58,6 +67,12 @@ const addToQueue = () => trigger(async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const inviteCollaborators = () => trigger(async () => {
|
||||||
|
const link = await playlistCollaborationService.createInviteLink(playlist.value!)
|
||||||
|
await copyText(link)
|
||||||
|
toastSuccess('Link copied to clipboard. Share it with your friends!')
|
||||||
|
})
|
||||||
|
|
||||||
eventBus.on('PLAYLIST_CONTEXT_MENU_REQUESTED', async (event, _playlist) => {
|
eventBus.on('PLAYLIST_CONTEXT_MENU_REQUESTED', async (event, _playlist) => {
|
||||||
playlist.value = _playlist
|
playlist.value = _playlist
|
||||||
await open(event.pageY, event.pageX)
|
await open(event.pageY, event.pageX)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
|
@ -41,7 +42,7 @@ new class extends UnitTestCase {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
|
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
|
||||||
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
await this.renderComponent(folder)
|
await this.renderComponent(folder)
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Play All'))
|
await this.user.click(screen.getByText('Play All'))
|
||||||
|
@ -58,7 +59,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([])
|
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([])
|
||||||
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
const warnMock = this.mock(MessageToasterStub.value, 'warning')
|
const warnMock = this.mock(MessageToasterStub.value, 'warning')
|
||||||
|
|
||||||
await this.renderComponent(folder)
|
await this.renderComponent(folder)
|
||||||
|
@ -78,7 +79,7 @@ new class extends UnitTestCase {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
|
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
|
||||||
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
await this.renderComponent(folder)
|
await this.renderComponent(folder)
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Shuffle All'))
|
await this.user.click(screen.getByText('Shuffle All'))
|
||||||
|
@ -103,7 +104,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([])
|
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([])
|
||||||
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
const queueMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
const warnMock = this.mock(MessageToasterStub.value, 'warning')
|
const warnMock = this.mock(MessageToasterStub.value, 'warning')
|
||||||
|
|
||||||
await this.renderComponent(folder)
|
await this.renderComponent(folder)
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import factory from 'factoria'
|
import factory from 'factoria'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { userStore } from '@/stores'
|
import { authService } from '@/services'
|
||||||
import { MessageToasterStub } from '@/__tests__/stubs'
|
import { MessageToasterStub } from '@/__tests__/stubs'
|
||||||
import ProfileForm from './ProfileForm.vue'
|
import ProfileForm from './ProfileForm.vue'
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
protected test () {
|
protected test () {
|
||||||
it('updates profile', async () => {
|
it('updates profile', async () => {
|
||||||
const updateMock = this.mock(userStore, 'updateProfile')
|
const updateMock = this.mock(authService, 'updateProfile')
|
||||||
const alertMock = this.mock(MessageToasterStub.value, 'success')
|
const alertMock = this.mock(MessageToasterStub.value, 'success')
|
||||||
|
|
||||||
await this.renderComponent(factory<User>('user'))
|
await this.renderComponent(factory<User>('user'))
|
||||||
|
|
|
@ -55,7 +55,8 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { UpdateCurrentProfileData, userStore } from '@/stores'
|
import { userStore } from '@/stores'
|
||||||
|
import { authService, UpdateCurrentProfileData } from '@/services'
|
||||||
import { isDemo, logger, parseValidationError } from '@/utils'
|
import { isDemo, logger, parseValidationError } from '@/utils'
|
||||||
import { useDialogBox, useMessageToaster } from '@/composables'
|
import { useDialogBox, useMessageToaster } from '@/composables'
|
||||||
|
|
||||||
|
@ -85,7 +86,7 @@ const update = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await userStore.updateProfile(Object.assign({}, profile.value))
|
await authService.updateProfile(Object.assign({}, profile.value))
|
||||||
profile.value.current_password = null
|
profile.value.current_password = null
|
||||||
delete profile.value.new_password
|
delete profile.value.new_password
|
||||||
toastSuccess('Profile updated.')
|
toastSuccess('Profile updated.')
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
|
@ -59,7 +60,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes back to list if album is deleted', async () => {
|
it('goes back to list if album is deleted', async () => {
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
const byIdMock = this.mock(albumStore, 'byId', null)
|
const byIdMock = this.mock(albumStore, 'byId', null)
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<SongListControls
|
<SongListControls
|
||||||
v-if="songs.length && (!isPhone || showingControls)"
|
v-if="songs.length && (!isPhone || showingControls)"
|
||||||
|
:config="config"
|
||||||
@filter="applyFilter"
|
@filter="applyFilter"
|
||||||
@play-all="playAll"
|
@play-all="playAll"
|
||||||
@play-selected="playSelected"
|
@play-selected="playSelected"
|
||||||
|
@ -92,7 +93,7 @@ import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
|
||||||
import { eventBus, logger, pluralize } from '@/utils'
|
import { eventBus, logger, pluralize } from '@/utils'
|
||||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||||
import { downloadService } from '@/services'
|
import { downloadService } from '@/services'
|
||||||
import { useDialogBox, useRouter, useSongList } from '@/composables'
|
import { useDialogBox, useRouter, useSongList, useSongListControls } from '@/composables'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||||
|
@ -119,7 +120,6 @@ let info = ref<ArtistInfo | null>()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
songList,
|
songList,
|
||||||
|
@ -134,6 +134,8 @@ const {
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(songs)
|
} = useSongList(songs)
|
||||||
|
|
||||||
|
const { SongListControls, config } = useSongListControls('Album')
|
||||||
|
|
||||||
const useLastfm = toRef(commonStore.state, 'uses_last_fm')
|
const useLastfm = toRef(commonStore.state, 'uses_last_fm')
|
||||||
const allowDownload = toRef(commonStore.state, 'allows_download')
|
const allowDownload = toRef(commonStore.state, 'allows_download')
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
@ -45,7 +46,7 @@ new class extends UnitTestCase {
|
||||||
it('shuffles', async () => {
|
it('shuffles', async () => {
|
||||||
const queueMock = this.mock(queueStore, 'fetchRandom')
|
const queueMock = this.mock(queueStore, 'fetchRandom')
|
||||||
const playMock = this.mock(playbackService, 'playFirstInQueue')
|
const playMock = this.mock(playbackService, 'playFirstInQueue')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
|
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<SongListControls
|
<SongListControls
|
||||||
v-if="totalSongCount && (!isPhone || showingControls)"
|
v-if="totalSongCount && (!isPhone || showingControls)"
|
||||||
|
:config="config"
|
||||||
@play-all="playAll"
|
@play-all="playAll"
|
||||||
@play-selected="playSelected"
|
@play-selected="playSelected"
|
||||||
/>
|
/>
|
||||||
|
@ -62,7 +63,7 @@ import { computed, ref, toRef, watch } from 'vue'
|
||||||
import { logger, pluralize, secondsToHumanReadable } from '@/utils'
|
import { logger, pluralize, secondsToHumanReadable } from '@/utils'
|
||||||
import { commonStore, queueStore, songStore } from '@/stores'
|
import { commonStore, queueStore, songStore } from '@/stores'
|
||||||
import { localStorageService, playbackService } from '@/services'
|
import { localStorageService, playbackService } from '@/services'
|
||||||
import { useMessageToaster, useKoelPlus, useRouter, useSongList } from '@/composables'
|
import { useMessageToaster, useKoelPlus, useRouter, useSongList, useSongListControls } from '@/composables'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||||
|
@ -74,7 +75,6 @@ const totalDuration = computed(() => secondsToHumanReadable(commonStore.state.so
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
ThumbnailStack,
|
ThumbnailStack,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
|
@ -89,6 +89,8 @@ const {
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(toRef(songStore.state, 'songs'))
|
} = useSongList(toRef(songStore.state, 'songs'))
|
||||||
|
|
||||||
|
const { SongListControls, config } = useSongListControls('Songs')
|
||||||
|
|
||||||
const { toastError } = useMessageToaster()
|
const { toastError } = useMessageToaster()
|
||||||
const { go, onScreenActivated } = useRouter()
|
const { go, onScreenActivated } = useRouter()
|
||||||
const { isPlus } = useKoelPlus()
|
const { isPlus } = useKoelPlus()
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { screen, waitFor } from '@testing-library/vue'
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
|
@ -57,7 +58,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes back to list if artist is deleted', async () => {
|
it('goes back to list if artist is deleted', async () => {
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
const byIdMock = this.mock(artistStore, 'byId', null)
|
const byIdMock = this.mock(artistStore, 'byId', null)
|
||||||
await this.renderComponent()
|
await this.renderComponent()
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<SongListControls
|
<SongListControls
|
||||||
v-if="songs.length && (!isPhone || showingControls)"
|
v-if="songs.length && (!isPhone || showingControls)"
|
||||||
|
:config="config"
|
||||||
@filter="applyFilter"
|
@filter="applyFilter"
|
||||||
@play-all="playAll"
|
@play-all="playAll"
|
||||||
@play-selected="playSelected"
|
@play-selected="playSelected"
|
||||||
|
@ -88,7 +89,7 @@ import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
|
||||||
import { eventBus, logger, pluralize } from '@/utils'
|
import { eventBus, logger, pluralize } from '@/utils'
|
||||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||||
import { downloadService } from '@/services'
|
import { downloadService } from '@/services'
|
||||||
import { useDialogBox, useRouter, useSongList, useThirdPartyServices } from '@/composables'
|
import { useDialogBox, useRouter, useSongList, useSongListControls, useThirdPartyServices } from '@/composables'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||||
|
@ -115,7 +116,6 @@ let info = ref<ArtistInfo | undefined | null>()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
songList,
|
songList,
|
||||||
|
@ -130,6 +130,8 @@ const {
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(songs)
|
} = useSongList(songs)
|
||||||
|
|
||||||
|
const { SongListControls, config } = useSongListControls('Artist')
|
||||||
|
|
||||||
const { useLastfm } = useThirdPartyServices()
|
const { useLastfm } = useThirdPartyServices()
|
||||||
const allowDownload = toRef(commonStore.state, 'allows_download')
|
const allowDownload = toRef(commonStore.state, 'allows_download')
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<SongListControls
|
<SongListControls
|
||||||
v-if="songs.length && (!isPhone || showingControls)"
|
v-if="songs.length && (!isPhone || showingControls)"
|
||||||
|
:config="config"
|
||||||
@filter="applyFilter"
|
@filter="applyFilter"
|
||||||
@play-all="playAll"
|
@play-all="playAll"
|
||||||
@play-selected="playSelected"
|
@play-selected="playSelected"
|
||||||
|
@ -63,7 +64,7 @@ import { faHeart } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { pluralize } from '@/utils'
|
import { pluralize } from '@/utils'
|
||||||
import { commonStore, favoriteStore } from '@/stores'
|
import { commonStore, favoriteStore } from '@/stores'
|
||||||
import { downloadService } from '@/services'
|
import { downloadService } from '@/services'
|
||||||
import { useRouter, useSongList } from '@/composables'
|
import { useRouter, useSongList, useSongListControls } from '@/composables'
|
||||||
import { nextTick, ref, toRef } from 'vue'
|
import { nextTick, ref, toRef } from 'vue'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
|
@ -72,7 +73,6 @@ import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
ThumbnailStack,
|
ThumbnailStack,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
|
@ -91,6 +91,8 @@ const {
|
||||||
sort
|
sort
|
||||||
} = useSongList(toRef(favoriteStore.state, 'songs'))
|
} = useSongList(toRef(favoriteStore.state, 'songs'))
|
||||||
|
|
||||||
|
const { SongListControls, config } = useSongListControls('Favorites')
|
||||||
|
|
||||||
const allowDownload = toRef(commonStore.state, 'allows_download')
|
const allowDownload = toRef(commonStore.state, 'allows_download')
|
||||||
|
|
||||||
const download = () => downloadService.fromFavorites()
|
const download = () => downloadService.fromFavorites()
|
||||||
|
|
|
@ -14,7 +14,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<SongListControls v-if="!isPhone || showingControls" @play-all="playAll" @play-selected="playSelected" />
|
<SongListControls
|
||||||
|
v-if="!isPhone || showingControls"
|
||||||
|
:config="config"
|
||||||
|
@play-all="playAll"
|
||||||
|
@play-selected="playSelected"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ScreenHeader>
|
</ScreenHeader>
|
||||||
<ScreenHeaderSkeleton v-else />
|
<ScreenHeaderSkeleton v-else />
|
||||||
|
@ -45,7 +50,7 @@ import { faTags } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { eventBus, logger, pluralize, secondsToHumanReadable } from '@/utils'
|
import { eventBus, logger, pluralize, secondsToHumanReadable } from '@/utils'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { genreStore, songStore } from '@/stores'
|
import { genreStore, songStore } from '@/stores'
|
||||||
import { useDialogBox, useRouter, useSongList } from '@/composables'
|
import { useDialogBox, useRouter, useSongList, useSongListControls } from '@/composables'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||||
|
@ -54,7 +59,6 @@ import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
ThumbnailStack,
|
ThumbnailStack,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
|
@ -68,6 +72,8 @@ const {
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(ref<Song[]>([]))
|
} = useSongList(ref<Song[]>([]))
|
||||||
|
|
||||||
|
const { SongListControls, config } = useSongListControls('Genre')
|
||||||
|
|
||||||
const { showErrorDialog } = useDialogBox()
|
const { showErrorDialog } = useDialogBox()
|
||||||
const { getRouteParam, go, onRouteChanged } = useRouter()
|
const { getRouteParam, go, onRouteChanged } = useRouter()
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ new class extends UnitTestCase {
|
||||||
await this.router.activateRoute({
|
await this.router.activateRoute({
|
||||||
path: `playlists/${playlist.id}`,
|
path: `playlists/${playlist.id}`,
|
||||||
screen: 'Playlist'
|
screen: 'Playlist'
|
||||||
}, { id: playlist.id.toString() })
|
}, { id: playlist.id })
|
||||||
|
|
||||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist, false))
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist, false))
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,8 @@
|
||||||
<ThumbnailStack :thumbnails="thumbnails" />
|
<ThumbnailStack :thumbnails="thumbnails" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="songs.length" #meta>
|
<template v-if="songs.length || playlist.collaborators.length" #meta>
|
||||||
|
<CollaboratorsBadge :playlist="playlist" v-if="playlist.collaborators.length" />
|
||||||
<span>{{ pluralize(songs, 'song') }}</span>
|
<span>{{ pluralize(songs, 'song') }}</span>
|
||||||
<span>{{ duration }}</span>
|
<span>{{ duration }}</span>
|
||||||
<a
|
<a
|
||||||
|
@ -70,26 +71,22 @@ import { ref, toRef, watch } from 'vue'
|
||||||
import { eventBus, pluralize } from '@/utils'
|
import { eventBus, pluralize } from '@/utils'
|
||||||
import { commonStore, playlistStore, songStore } from '@/stores'
|
import { commonStore, playlistStore, songStore } from '@/stores'
|
||||||
import { downloadService } from '@/services'
|
import { downloadService } from '@/services'
|
||||||
import { usePlaylistManagement, useRouter, useSongList } from '@/composables'
|
import { usePlaylistManagement, useRouter, useSongList, useAuthorization, useSongListControls } from '@/composables'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||||
|
import CollaboratorsBadge from '@/components/playlist/CollaboratorsBadge.vue'
|
||||||
|
|
||||||
const { onRouteChanged, triggerNotFound, getRouteParam, onScreenActivated } = useRouter()
|
const { currentUser } = useAuthorization()
|
||||||
|
const { triggerNotFound, getRouteParam, onScreenActivated } = useRouter()
|
||||||
|
|
||||||
const playlistId = ref<number>()
|
const playlistId = ref<string>()
|
||||||
const playlist = ref<Playlist>()
|
const playlist = ref<Playlist>()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const controlsConfig: Partial<SongListControlsConfig> = {
|
|
||||||
deletePlaylist: true,
|
|
||||||
refresh: true
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
ThumbnailStack,
|
ThumbnailStack,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
|
@ -105,9 +102,11 @@ const {
|
||||||
playSelected,
|
playSelected,
|
||||||
applyFilter,
|
applyFilter,
|
||||||
onScrollBreakpoint,
|
onScrollBreakpoint,
|
||||||
sort
|
sort,
|
||||||
|
config: listConfig
|
||||||
} = useSongList(ref<Song[]>([]))
|
} = useSongList(ref<Song[]>([]))
|
||||||
|
|
||||||
|
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
|
||||||
const { removeSongsFromPlaylist } = usePlaylistManagement()
|
const { removeSongsFromPlaylist } = usePlaylistManagement()
|
||||||
|
|
||||||
const allowDownload = toRef(commonStore.state, 'allows_download')
|
const allowDownload = toRef(commonStore.state, 'allows_download')
|
||||||
|
@ -131,10 +130,20 @@ watch(playlistId, async id => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
playlist.value = playlistStore.byId(id)
|
playlist.value = playlistStore.byId(id)
|
||||||
playlist.value ? await fetchSongs() : await triggerNotFound()
|
|
||||||
|
// reset this config value to its default to not cause rows to be mal-rendered
|
||||||
|
listConfig.collaborative = false
|
||||||
|
|
||||||
|
if (playlist.value) {
|
||||||
|
await fetchSongs()
|
||||||
|
listConfig.collaborative = playlist.value.collaborators.length > 0
|
||||||
|
controlsConfig.deletePlaylist = playlist.value.user_id === currentUser.value?.id
|
||||||
|
} else {
|
||||||
|
await triggerNotFound()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onScreenActivated('Playlist', async () => (playlistId.value = parseInt(getRouteParam('id')!)))
|
onScreenActivated('Playlist', () => (playlistId.value = getRouteParam('id')!))
|
||||||
|
|
||||||
eventBus.on('PLAYLIST_UPDATED', async updated => updated.id === playlistId.value && await fetchSongs())
|
eventBus.on('PLAYLIST_UPDATED', async updated => updated.id === playlistId.value && await fetchSongs())
|
||||||
.on('PLAYLIST_SONGS_REMOVED', async (playlist, removed) => {
|
.on('PLAYLIST_SONGS_REMOVED', async (playlist, removed) => {
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<SongListControls
|
<SongListControls
|
||||||
v-if="songs.length && (!isPhone || showingControls)"
|
v-if="songs.length && (!isPhone || showingControls)"
|
||||||
|
:config="config"
|
||||||
@filter="applyFilter"
|
@filter="applyFilter"
|
||||||
@clear-queue="clearQueue"
|
@clear-queue="clearQueue"
|
||||||
@play-all="playAll"
|
@play-all="playAll"
|
||||||
|
@ -50,11 +51,11 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faCoffee } from '@fortawesome/free-solid-svg-icons'
|
import { faCoffee } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { computed, ref, toRef } from 'vue'
|
import { computed, ref, toRef, watch } from 'vue'
|
||||||
import { logger, pluralize } from '@/utils'
|
import { logger, pluralize } from '@/utils'
|
||||||
import { commonStore, queueStore, songStore } from '@/stores'
|
import { commonStore, queueStore, songStore } from '@/stores'
|
||||||
import { localStorageService as storage, playbackService } from '@/services'
|
import { localStorageService as storage, playbackService } from '@/services'
|
||||||
import { useDialogBox, useRouter, useSongList } from '@/composables'
|
import { useDialogBox, useRouter, useSongList, useSongListControls } from '@/composables'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||||
|
@ -65,7 +66,6 @@ const { showErrorDialog } = useDialogBox()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
ThumbnailStack,
|
ThumbnailStack,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
|
@ -81,6 +81,8 @@ const {
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(toRef(queueStore.state, 'songs'))
|
} = useSongList(toRef(queueStore.state, 'songs'))
|
||||||
|
|
||||||
|
const { SongListControls, config } = useSongListControls('Queue')
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
|
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
|
||||||
|
|
||||||
|
@ -111,7 +113,6 @@ const removeSelected = () => {
|
||||||
if (!selectedSongs.value.length) return
|
if (!selectedSongs.value.length) return
|
||||||
|
|
||||||
const currentSongId = queueStore.current?.id
|
const currentSongId = queueStore.current?.id
|
||||||
|
|
||||||
queueStore.unqueue(selectedSongs.value)
|
queueStore.unqueue(selectedSongs.value)
|
||||||
|
|
||||||
if (currentSongId && selectedSongs.value.find(song => song.id === currentSongId)) {
|
if (currentSongId && selectedSongs.value.find(song => song.id === currentSongId)) {
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<SongListControls
|
<SongListControls
|
||||||
v-if="songs.length && (!isPhone || showingControls)"
|
v-if="songs.length && (!isPhone || showingControls)"
|
||||||
|
:config="config"
|
||||||
@filter="applyFilter"
|
@filter="applyFilter"
|
||||||
@play-all="playAll"
|
@play-all="playAll"
|
||||||
@play-selected="playSelected"
|
@play-selected="playSelected"
|
||||||
|
@ -41,7 +42,7 @@
|
||||||
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { pluralize } from '@/utils'
|
import { pluralize } from '@/utils'
|
||||||
import { recentlyPlayedStore } from '@/stores'
|
import { recentlyPlayedStore } from '@/stores'
|
||||||
import { useRouter, useSongList } from '@/composables'
|
import { useRouter, useSongList, useSongListControls } from '@/composables'
|
||||||
import { ref, toRef } from 'vue'
|
import { ref, toRef } from 'vue'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
|
@ -52,7 +53,6 @@ const recentlyPlayedSongs = toRef(recentlyPlayedStore.state, 'songs')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
ThumbnailStack,
|
ThumbnailStack,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
|
@ -69,6 +69,8 @@ const {
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(recentlyPlayedSongs)
|
} = useSongList(recentlyPlayedSongs)
|
||||||
|
|
||||||
|
const { SongListControls, config } = useSongListControls('RecentlyPlayed')
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
let loading = ref(false)
|
let loading = ref(false)
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { settingStore } from '@/stores'
|
import { settingStore } from '@/stores'
|
||||||
|
@ -11,7 +12,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('submits the settings form', async () => {
|
it('submits the settings form', async () => {
|
||||||
const updateMock = this.mock(settingStore, 'update')
|
const updateMock = this.mock(settingStore, 'update')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
|
|
||||||
settingStore.state.media_path = ''
|
settingStore.state.media_path = ''
|
||||||
this.render(SettingsScreen)
|
this.render(SettingsScreen)
|
||||||
|
@ -27,7 +28,7 @@ new class extends UnitTestCase {
|
||||||
|
|
||||||
it('confirms upon media path change', async () => {
|
it('confirms upon media path change', async () => {
|
||||||
const updateMock = this.mock(settingStore, 'update')
|
const updateMock = this.mock(settingStore, 'update')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
const confirmMock = this.mock(DialogBoxStub.value, 'confirm')
|
const confirmMock = this.mock(DialogBoxStub.value, 'confirm')
|
||||||
|
|
||||||
settingStore.state.media_path = '/old'
|
settingStore.state.media_path = '/old'
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
|
@ -19,7 +20,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to dedicated screen', async () => {
|
it('goes to dedicated screen', async () => {
|
||||||
const mock = this.mock(this.router, 'go')
|
const mock = this.mock(Router, 'go')
|
||||||
this.render(RecentlyPlayedSongs)
|
this.render(RecentlyPlayedSongs)
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('button', { name: 'View All' }))
|
await this.user.click(screen.getByRole('button', { name: 'View All' }))
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
<template #controls>
|
<template #controls>
|
||||||
<SongListControls
|
<SongListControls
|
||||||
v-if="songs.length && (!isPhone || showingControls)"
|
v-if="songs.length && (!isPhone || showingControls)"
|
||||||
|
:config="config"
|
||||||
@filter="applyFilter"
|
@filter="applyFilter"
|
||||||
@play-all="playAll"
|
@play-all="playAll"
|
||||||
@play-selected="playSelected"
|
@play-selected="playSelected"
|
||||||
|
@ -31,7 +32,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, ref, toRef } from 'vue'
|
import { computed, onMounted, ref, toRef } from 'vue'
|
||||||
import { searchStore } from '@/stores'
|
import { searchStore } from '@/stores'
|
||||||
import { useRouter, useSongList } from '@/composables'
|
import { useRouter, useSongList, useSongListControls } from '@/composables'
|
||||||
import { pluralize } from '@/utils'
|
import { pluralize } from '@/utils'
|
||||||
|
|
||||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||||
|
@ -42,7 +43,6 @@ const q = ref('')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
ThumbnailStack,
|
ThumbnailStack,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
|
@ -60,6 +60,7 @@ const {
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(toRef(searchStore.state, 'songs'))
|
} = useSongList(toRef(searchStore.state, 'songs'))
|
||||||
|
|
||||||
|
const { SongListControls, config } = useSongListControls('Search.Songs')
|
||||||
const decodedQ = computed(() => decodeURIComponent(q.value))
|
const decodedQ = computed(() => decodeURIComponent(q.value))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
@ -62,7 +63,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to album details screen', async () => {
|
it('goes to album details screen', async () => {
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
await this.renderComponent(factory<Song>('song'))
|
await this.renderComponent(factory<Song>('song'))
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Album'))
|
await this.user.click(screen.getByText('Go to Album'))
|
||||||
|
@ -71,7 +72,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to artist details screen', async () => {
|
it('goes to artist details screen', async () => {
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
await this.renderComponent(factory<Song>('song'))
|
await this.renderComponent(factory<Song>('song'))
|
||||||
|
|
||||||
await this.user.click(screen.getByText('Go to Artist'))
|
await this.user.click(screen.getByText('Go to Artist'))
|
||||||
|
|
|
@ -151,7 +151,7 @@ const visibilityActions = computed(() => {
|
||||||
|
|
||||||
const canBeRemovedFromPlaylist = computed(() => {
|
const canBeRemovedFromPlaylist = computed(() => {
|
||||||
if (!isCurrentScreen('Playlist')) return false
|
if (!isCurrentScreen('Playlist')) return false
|
||||||
const playlist = playlistStore.byId(parseInt(getRouteParam('id')!))
|
const playlist = playlistStore.byId(getRouteParam('id')!)
|
||||||
return playlist && !playlist.is_smart
|
return playlist && !playlist.is_smart
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -183,7 +183,7 @@ const viewArtistDetails = (artistId: number) => trigger(() => go(`artist/${artis
|
||||||
const download = () => trigger(() => downloadService.fromSongs(songs.value))
|
const download = () => trigger(() => downloadService.fromSongs(songs.value))
|
||||||
|
|
||||||
const removeFromPlaylist = () => trigger(async () => {
|
const removeFromPlaylist = () => trigger(async () => {
|
||||||
const playlist = playlistStore.byId(parseInt(getRouteParam('id')!))
|
const playlist = playlistStore.byId(getRouteParam('id')!)
|
||||||
if (!playlist) return
|
if (!playlist) return
|
||||||
|
|
||||||
await removeSongsFromPlaylist(playlist, songs.value)
|
await removeSongsFromPlaylist(playlist, songs.value)
|
||||||
|
@ -192,8 +192,8 @@ const removeFromPlaylist = () => trigger(async () => {
|
||||||
const removeFromQueue = () => trigger(() => queueStore.unqueue(songs.value))
|
const removeFromQueue = () => trigger(() => queueStore.unqueue(songs.value))
|
||||||
const removeFromFavorites = () => trigger(() => favoriteStore.unlike(songs.value))
|
const removeFromFavorites = () => trigger(() => favoriteStore.unlike(songs.value))
|
||||||
|
|
||||||
const copyUrl = () => trigger(() => {
|
const copyUrl = () => trigger(async () => {
|
||||||
copyText(songStore.getShareableUrl(songs.value[0]))
|
await copyText(songStore.getShareableUrl(songs.value[0]))
|
||||||
toastSuccess('URL copied to clipboard.')
|
toastSuccess('URL copied to clipboard.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -48,6 +48,10 @@
|
||||||
<Icon v-if="sortField === 'album_name' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight" />
|
<Icon v-if="sortField === 'album_name' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight" />
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
|
<template v-if="config.collaborative">
|
||||||
|
<span class="collaborator">User</span>
|
||||||
|
<span class="added-at">Added</span>
|
||||||
|
</template>
|
||||||
<span
|
<span
|
||||||
class="time"
|
class="time"
|
||||||
data-testid="header-length"
|
data-testid="header-length"
|
||||||
|
@ -77,7 +81,7 @@
|
||||||
:key="item.song.id"
|
:key="item.song.id"
|
||||||
:item="item"
|
:item="item"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@click="rowClicked(item, $event)"
|
@click="onClick(item, $event)"
|
||||||
@dragleave="onDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@dragstart="onDragStart(item, $event)"
|
@dragstart="onDragStart(item, $event)"
|
||||||
@dragenter.prevent="onDragEnter"
|
@dragenter.prevent="onDragEnter"
|
||||||
|
@ -137,7 +141,7 @@ const songRows = ref<SongRow[]>([])
|
||||||
|
|
||||||
watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected).map(row => row.song)), { deep: true })
|
watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected).map(row => row.song)), { deep: true })
|
||||||
|
|
||||||
const filteredSongRows = computed(() => {
|
const filteredSongRows = computed<SongRow[]>(() => {
|
||||||
const keywords = filterKeywords.value.trim().toLowerCase()
|
const keywords = filterKeywords.value.trim().toLowerCase()
|
||||||
|
|
||||||
if (!keywords) {
|
if (!keywords) {
|
||||||
|
@ -223,7 +227,7 @@ const clearSelection = () => songRows.value.forEach(row => (row.selected = false
|
||||||
const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows()
|
const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows()
|
||||||
const getAllSongsWithSort = () => songRows.value.map(row => row.song)
|
const getAllSongsWithSort = () => songRows.value.map(row => row.song)
|
||||||
|
|
||||||
const rowClicked = (row: SongRow, event: MouseEvent) => {
|
const onClick = (row: SongRow, event: MouseEvent) => {
|
||||||
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
|
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
|
||||||
if (isMobile.any) {
|
if (isMobile.any) {
|
||||||
toggleRow(row)
|
toggleRow(row)
|
||||||
|
@ -262,11 +266,12 @@ const selectRowsBetween = (first: SongRow, second: SongRow) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDragStart = (row: SongRow, event: DragEvent) => {
|
const onDragStart = async (row: SongRow, event: DragEvent) => {
|
||||||
// If the user is dragging an unselected row, clear the current selection.
|
// If the user is dragging an unselected row, clear the current selection.
|
||||||
if (!row.selected) {
|
if (!row.selected) {
|
||||||
clearSelection()
|
clearSelection()
|
||||||
row.selected = true
|
row.selected = true
|
||||||
|
await nextTick()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add "dragging" class to the wrapper so that we can disable pointer events on child elements.
|
// Add "dragging" class to the wrapper so that we can disable pointer events on child elements.
|
||||||
|
@ -373,8 +378,20 @@ onMounted(() => render())
|
||||||
flex-basis: 27%;
|
flex-basis: 27%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.collaborator {
|
||||||
|
flex-basis: 72px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&.added-at {
|
||||||
|
flex-basis: 144px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
&.extra {
|
&.extra {
|
||||||
flex-basis: 36px;
|
flex-basis: 36px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.play {
|
&.play {
|
||||||
|
@ -388,10 +405,6 @@ onMounted(() => render())
|
||||||
&.title-artist {
|
&.title-artist {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.extra {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-list-header {
|
.song-list-header {
|
||||||
|
@ -452,8 +465,8 @@ onMounted(() => render())
|
||||||
width: 200%;
|
width: 200%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-item :is(.track-number, .album, .time),
|
.song-item :is(.track-number, .album, .time, .added-at),
|
||||||
.song-list-header :is(.track-number, .album, .time) {
|
.song-list-header :is(.track-number, .album, .time, .added-at) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { take } from 'lodash'
|
import { merge, take } from 'lodash'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
|
@ -8,13 +8,18 @@ import { screen } from '@testing-library/vue'
|
||||||
import SongListControls from './SongListControls.vue'
|
import SongListControls from './SongListControls.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
private renderComponent (selectedSongCount = 1, screen: ScreenName = 'Songs') {
|
private renderComponent (selectedSongCount = 1, configOverrides: Partial<SongListControlsConfig> = {}) {
|
||||||
const songs = factory<Song>('song', 5)
|
const songs = factory<Song>('song', 5)
|
||||||
|
const config: SongListControlsConfig = merge({
|
||||||
this.router.activateRoute({
|
addTo: {
|
||||||
screen,
|
queue: true,
|
||||||
path: '_'
|
favorites: true
|
||||||
})
|
},
|
||||||
|
clearQueue: true,
|
||||||
|
deletePlaylist: true,
|
||||||
|
refresh: true,
|
||||||
|
filter: true
|
||||||
|
}, configOverrides)
|
||||||
|
|
||||||
return this.render(SongListControls, {
|
return this.render(SongListControls, {
|
||||||
global: {
|
global: {
|
||||||
|
@ -22,6 +27,9 @@ new class extends UnitTestCase {
|
||||||
[<symbol>SongsKey]: [ref(songs)],
|
[<symbol>SongsKey]: [ref(songs)],
|
||||||
[<symbol>SelectedSongsKey]: [ref(take(songs, selectedSongCount))]
|
[<symbol>SelectedSongsKey]: [ref(take(songs, selectedSongCount))]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
config
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -64,7 +72,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clears queue', async () => {
|
it('clears queue', async () => {
|
||||||
const { emitted } = this.renderComponent(0, 'Queue')
|
const { emitted } = this.renderComponent(0)
|
||||||
|
|
||||||
await this.user.click(screen.getByTitle('Clear current queue'))
|
await this.user.click(screen.getByTitle('Clear current queue'))
|
||||||
|
|
||||||
|
@ -72,7 +80,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('deletes current playlist', async () => {
|
it('deletes current playlist', async () => {
|
||||||
const { emitted } = this.renderComponent(0, 'Playlist')
|
const { emitted } = this.renderComponent(0)
|
||||||
|
|
||||||
await this.user.click(screen.getByTitle('Delete this playlist'))
|
await this.user.click(screen.getByTitle('Delete this playlist'))
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="el" class="song-list-controls" data-testid="song-list-controls">
|
<div ref="el" class="song-list-controls" data-testid="song-list-controls" v-if="config">
|
||||||
<div class="wrapper">
|
<div class="wrapper">
|
||||||
<BtnGroup uppercased>
|
<BtnGroup uppercased>
|
||||||
<template v-if="altPressed">
|
<template v-if="altPressed">
|
||||||
|
@ -97,10 +97,10 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faPlay, faRandom, faRotateRight, faTrashCan } from '@fortawesome/free-solid-svg-icons'
|
import { faPlay, faRandom, faRotateRight, faTrashCan } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, Ref, ref, watch } from 'vue'
|
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, Ref, ref, toRef, watch } from 'vue'
|
||||||
import { SelectedSongsKey, SongsKey } from '@/symbols'
|
import { SelectedSongsKey, SongsKey } from '@/symbols'
|
||||||
import { requireInjection } from '@/utils'
|
import { requireInjection } from '@/utils'
|
||||||
import { useFloatingUi, useSongListControls } from '@/composables'
|
import { useFloatingUi } from '@/composables'
|
||||||
|
|
||||||
import AddToMenu from '@/components/song/AddToMenu.vue'
|
import AddToMenu from '@/components/song/AddToMenu.vue'
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
|
@ -108,7 +108,8 @@ import BtnGroup from '@/components/ui/BtnGroup.vue'
|
||||||
|
|
||||||
const SongListFilter = defineAsyncComponent(() => import('@/components/song/SongListFilter.vue'))
|
const SongListFilter = defineAsyncComponent(() => import('@/components/song/SongListFilter.vue'))
|
||||||
|
|
||||||
const config = useSongListControls().getSongListControlsConfig()
|
const props = defineProps<{ config: SongListControlsConfig }>()
|
||||||
|
const config = toRef(props, 'config')
|
||||||
|
|
||||||
const [songs] = requireInjection<[Ref<Song[]>]>(SongsKey)
|
const [songs] = requireInjection<[Ref<Song[]>]>(SongsKey)
|
||||||
const [selectedSongs] = requireInjection(SelectedSongsKey)
|
const [selectedSongs] = requireInjection(SelectedSongsKey)
|
||||||
|
|
|
@ -25,6 +25,12 @@
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="album">{{ song.album_name }}</span>
|
<span class="album">{{ song.album_name }}</span>
|
||||||
|
<template v-if="config.collaborative">
|
||||||
|
<span class="collaborator">
|
||||||
|
<UserAvatar :user="collaborator" width="24" />
|
||||||
|
</span>
|
||||||
|
<span class="added-at" :title="song.collaboration.added_at">{{ song.collaboration.fmt_added_at }}</span>
|
||||||
|
</template>
|
||||||
<span class="time">{{ fmtLength }}</span>
|
<span class="time">{{ fmtLength }}</span>
|
||||||
<span class="extra">
|
<span class="extra">
|
||||||
<LikeButton :song="song" />
|
<LikeButton :song="song" />
|
||||||
|
@ -37,13 +43,16 @@ import { faSquareUpRight } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { computed, toRefs } from 'vue'
|
import { computed, toRefs } from 'vue'
|
||||||
import { playbackService } from '@/services'
|
import { playbackService } from '@/services'
|
||||||
import { queueStore } from '@/stores'
|
import { queueStore } from '@/stores'
|
||||||
import { secondsToHis } from '@/utils'
|
import { requireInjection, secondsToHis } from '@/utils'
|
||||||
import { useAuthorization } from '@/composables'
|
import { useAuthorization, useKoelPlus } from '@/composables'
|
||||||
import { useKoelPlus } from '@/composables'
|
import { SongListConfigKey } from '@/symbols'
|
||||||
|
|
||||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||||
import SoundBars from '@/components/ui/SoundBars.vue'
|
import SoundBars from '@/components/ui/SoundBars.vue'
|
||||||
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
import SongThumbnail from '@/components/song/SongThumbnail.vue'
|
||||||
|
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||||
|
|
||||||
|
const [config] = requireInjection<[Partial<SongListConfig>]>(SongListConfigKey, [{}])
|
||||||
|
|
||||||
const { currentUser } = useAuthorization()
|
const { currentUser } = useAuthorization()
|
||||||
const { isPlus } = useKoelPlus()
|
const { isPlus } = useKoelPlus()
|
||||||
|
@ -51,11 +60,15 @@ const { isPlus } = useKoelPlus()
|
||||||
const props = defineProps<{ item: SongRow }>()
|
const props = defineProps<{ item: SongRow }>()
|
||||||
const { item } = toRefs(props)
|
const { item } = toRefs(props)
|
||||||
|
|
||||||
const song = computed(() => item.value.song)
|
const song = computed<Song | CollaborativeSong>(() => item.value.song)
|
||||||
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!))
|
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!))
|
||||||
const external = computed(() => isPlus.value && song.value.owner_id !== currentUser.value?.id)
|
const external = computed(() => isPlus.value && song.value.owner_id !== currentUser.value?.id)
|
||||||
const fmtLength = secondsToHis(song.value.length)
|
const fmtLength = secondsToHis(song.value.length)
|
||||||
|
|
||||||
|
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(() => {
|
||||||
|
return (song.value as CollaborativeSong).collaboration.user;
|
||||||
|
})
|
||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
queueStore.queueIfNotQueued(song.value)
|
queueStore.queueIfNotQueued(song.value)
|
||||||
playbackService.play(song.value)
|
playbackService.play(song.value)
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
exports[`renders 1`] = `
|
exports[`renders 1`] = `
|
||||||
<div class="song-list-wrap" data-testid="song-list" tabindex="0">
|
<div class="song-list-wrap" data-testid="song-list" tabindex="0">
|
||||||
<div class="sortable song-list-header"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span><span class="time" data-testid="header-length" role="button" title="Sort by song duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc"></span></div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]">
|
<div class="sortable song-list-header"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span>
|
||||||
|
<!--v-if--><span class="time" data-testid="header-length" role="button" title="Sort by song duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc"></span>
|
||||||
|
</div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div data-v-a2b2e00f="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover"><img data-v-a2b2e00f="" alt="Test Album" src="https://example.com/cover.jpg" loading="lazy"><a data-v-a2b2e00f="" title="Pause" class="control" role="button"><br data-v-a2b2e00f="" data-testid="Icon" icon="[object Object]" class="text-highlight"></a></div></span><span class="title-artist"><span class="title text-primary"><!--v-if--> Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" type="button"><br data-testid="Icon" icon="[object Object]"></button></span></div>`;
|
exports[`renders 1`] = `
|
||||||
|
<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div data-v-a2b2e00f="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover"><img data-v-a2b2e00f="" alt="Test Album" src="https://example.com/cover.jpg" loading="lazy"><a data-v-a2b2e00f="" title="Pause" class="control" role="button"><br data-v-a2b2e00f="" data-testid="Icon" icon="[object Object]" class="text-highlight"></a></div></span><span class="title-artist"><span class="title text-primary"><!--v-if--> Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span>
|
||||||
|
<!--v-if--><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" type="button"><br data-testid="Icon" icon="[object Object]"></button></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
|
@ -29,27 +30,27 @@ new class extends UnitTestCase {
|
||||||
expect(toggleMock).toHaveBeenCalled()
|
expect(toggleMock).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each<[ScreenName, MethodOf<typeof songStore>]>([
|
it.each<[ScreenName, MethodOf<typeof songStore>, string|number]>([
|
||||||
['Album', 'fetchForAlbum'],
|
['Album', 'fetchForAlbum', 42],
|
||||||
['Artist', 'fetchForArtist'],
|
['Artist', 'fetchForArtist', 42],
|
||||||
['Playlist', 'fetchForPlaylist']
|
['Playlist', 'fetchForPlaylist', '71d8cd40-20d4-4b17-b460-d30fe5bb7b66']
|
||||||
])('initiates playback for %s screen', async (screenName, fetchMethod) => {
|
])('initiates playback for %s screen', async (screenName, fetchMethod, id) => {
|
||||||
commonStore.state.song_count = 10
|
commonStore.state.song_count = 10
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const fetchMock = this.mock(songStore, fetchMethod).mockResolvedValue(songs)
|
const fetchMock = this.mock(songStore, fetchMethod).mockResolvedValue(songs)
|
||||||
const playMock = this.mock(playbackService, 'queueAndPlay')
|
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
|
|
||||||
await this.router.activateRoute({
|
await this.router.activateRoute({
|
||||||
screen: screenName,
|
screen: screenName,
|
||||||
path: '_'
|
path: '_'
|
||||||
}, { id: '42' })
|
}, { id: String(id) })
|
||||||
|
|
||||||
this.renderComponent()
|
this.renderComponent()
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('button'))
|
await this.user.click(screen.getByRole('button'))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(42)
|
expect(fetchMock).toHaveBeenCalledWith(id)
|
||||||
expect(playMock).toHaveBeenCalledWith(songs)
|
expect(playMock).toHaveBeenCalledWith(songs)
|
||||||
expect(goMock).toHaveBeenCalledWith('queue')
|
expect(goMock).toHaveBeenCalledWith('queue')
|
||||||
})
|
})
|
||||||
|
@ -67,7 +68,7 @@ new class extends UnitTestCase {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const fetchMock = this.mock(store, fetchMethod).mockResolvedValue(songs)
|
const fetchMock = this.mock(store, fetchMethod).mockResolvedValue(songs)
|
||||||
const playMock = this.mock(playbackService, 'queueAndPlay')
|
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
|
|
||||||
await this.router.activateRoute({
|
await this.router.activateRoute({
|
||||||
screen: screenName,
|
screen: screenName,
|
||||||
|
@ -89,7 +90,7 @@ new class extends UnitTestCase {
|
||||||
const songs = factory<Song>('song', 3)
|
const songs = factory<Song>('song', 3)
|
||||||
const fetchMock = this.mock(queueStore, 'fetchRandom').mockResolvedValue(songs)
|
const fetchMock = this.mock(queueStore, 'fetchRandom').mockResolvedValue(songs)
|
||||||
const playMock = this.mock(playbackService, 'queueAndPlay')
|
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
|
|
||||||
await this.router.activateRoute({
|
await this.router.activateRoute({
|
||||||
screen: screenName,
|
screen: screenName,
|
||||||
|
@ -110,7 +111,7 @@ new class extends UnitTestCase {
|
||||||
commonStore.state.song_count = 0
|
commonStore.state.song_count = 0
|
||||||
|
|
||||||
const playMock = this.mock(playbackService, 'queueAndPlay')
|
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
|
|
||||||
await this.router.activateRoute({
|
await this.router.activateRoute({
|
||||||
screen: 'Songs',
|
screen: 'Songs',
|
||||||
|
|
|
@ -35,7 +35,7 @@ const initiatePlayback = async () => {
|
||||||
songs = await songStore.fetchForArtist(parseInt(getRouteParam('id')!))
|
songs = await songStore.fetchForArtist(parseInt(getRouteParam('id')!))
|
||||||
break
|
break
|
||||||
case 'Playlist':
|
case 'Playlist':
|
||||||
songs = await songStore.fetchForPlaylist(parseInt(getRouteParam('id')!))
|
songs = await songStore.fetchForPlaylist(getRouteParam('id')!)
|
||||||
break
|
break
|
||||||
case 'Favorites':
|
case 'Favorites':
|
||||||
songs = await favoriteStore.fetch()
|
songs = await favoriteStore.fetch()
|
||||||
|
|
|
@ -7,22 +7,19 @@
|
||||||
href="/#/profile"
|
href="/#/profile"
|
||||||
title="Profile and preferences"
|
title="Profile and preferences"
|
||||||
>
|
>
|
||||||
<img :alt="`Avatar of ${currentUser.name}`" :src="currentUser.avatar">
|
<UserAvatar :user="currentUser" width="40"/>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useAuthorization } from '@/composables'
|
import { useAuthorization } from '@/composables'
|
||||||
|
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||||
|
|
||||||
const { currentUser } = useAuthorization()
|
const { currentUser } = useAuthorization()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
img {
|
img {
|
||||||
display: block;
|
|
||||||
width: 39px;
|
|
||||||
aspect-ratio: 1/1;
|
|
||||||
border-radius: 50%;
|
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
border: 1px solid rgba(255, 255, 255, .1);
|
border: 1px solid rgba(255, 255, 255, .1);
|
||||||
transition: border .2s ease-in-out;
|
transition: border .2s ease-in-out;
|
||||||
|
|
|
@ -67,6 +67,7 @@ header.screen-header {
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
display: block;
|
display: block;
|
||||||
|
margin-top: .2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
|
@ -15,7 +16,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to search screen when search box is focused', async () => {
|
it('goes to search screen when search box is focused', async () => {
|
||||||
const mock = this.mock(this.router, 'go')
|
const mock = this.mock(Router, 'go')
|
||||||
this.render(SearchForm)
|
this.render(SearchForm)
|
||||||
|
|
||||||
await this.user.click(screen.getByRole('searchbox'))
|
await this.user.click(screen.getByRole('searchbox'))
|
||||||
|
@ -33,7 +34,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('goes to the search screen if the form is submitted', async () => {
|
it('goes to the search screen if the form is submitted', async () => {
|
||||||
const goMock = this.mock(this.router, 'go')
|
const goMock = this.mock(Router, 'go')
|
||||||
this.render(SearchForm)
|
this.render(SearchForm)
|
||||||
|
|
||||||
await this.type(screen.getByRole('searchbox'), 'hey')
|
await this.type(screen.getByRole('searchbox'), 'hey')
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
// Vitest Snapshot v1
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
exports[`renders 1`] = `<a data-v-663f2e50="" class="view-profile" data-testid="view-profile-link" href="/#/profile" title="Profile and preferences"><img data-v-663f2e50="" alt="Avatar of John Doe" src="https://example.com/avatar.jpg"></a>`;
|
exports[`renders 1`] = `<a data-v-663f2e50="" class="view-profile" data-testid="view-profile-link" href="/#/profile" title="Profile and preferences"><img data-v-f835091e="" data-v-663f2e50="" alt="Avatar of John Doe" src="https://example.com/avatar.jpg" title="John Doe" width="40"></a>`;
|
||||||
|
|
18
resources/assets/js/components/user/UserAvatar.vue
Normal file
18
resources/assets/js/components/user/UserAvatar.vue
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<img :alt="`Avatar of ${user.name}`" :src="user.avatar" :title="user.name">
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { toRefs } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ user: Pick<User, 'name' | 'avatar'> }>()
|
||||||
|
const { user } = toRefs(props)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
img {
|
||||||
|
border-radius: 50%;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Router from '@/router'
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
@ -5,8 +6,8 @@ import { screen } from '@testing-library/vue'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
import { userStore } from '@/stores'
|
import { userStore } from '@/stores'
|
||||||
import { DialogBoxStub } from '@/__tests__/stubs'
|
import { DialogBoxStub } from '@/__tests__/stubs'
|
||||||
import UserCard from './UserCard.vue'
|
|
||||||
import { invitationService } from '@/services'
|
import { invitationService } from '@/services'
|
||||||
|
import UserCard from './UserCard.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
private renderComponent (user: User) {
|
private renderComponent (user: User) {
|
||||||
|
@ -37,7 +38,7 @@ new class extends UnitTestCase {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('redirects to Profile screen if edit current user', async () => {
|
it('redirects to Profile screen if edit current user', async () => {
|
||||||
const mock = this.mock(this.router, 'go')
|
const mock = this.mock(Router, 'go')
|
||||||
const user = factory<User>('user')
|
const user = factory<User>('user')
|
||||||
this.be(user).renderComponent(user)
|
this.be(user).renderComponent(user)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<article :class="{ me: isCurrentUser }" class="user-card">
|
<article :class="{ me: isCurrentUser }" class="user-card">
|
||||||
<img :alt="`${user.name}'s avatar`" :src="user.avatar" height="80" width="80">
|
<UserAvatar :user="user" width="80" />
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>
|
<h1>
|
||||||
|
@ -41,6 +41,7 @@ import { useAuthorization, useDialogBox, useMessageToaster, useRouter } from '@/
|
||||||
import { eventBus, parseValidationError } from '@/utils'
|
import { eventBus, parseValidationError } from '@/utils'
|
||||||
|
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
|
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||||
|
|
||||||
const props = defineProps<{ user: User }>()
|
const props = defineProps<{ user: User }>()
|
||||||
const { user } = toRefs(props)
|
const { user } = toRefs(props)
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import { defineComponent, onMounted } from 'vue'
|
import { defineComponent, onMounted } from 'vue'
|
||||||
import { authService } from '@/services'
|
import { authService } from '@/services'
|
||||||
import { playlistFolderStore, playlistStore, userStore } from '@/stores'
|
import { playlistFolderStore, playlistStore } from '@/stores'
|
||||||
import { eventBus, forceReloadWindow } from '@/utils'
|
import { eventBus, forceReloadWindow } from '@/utils'
|
||||||
import { useDialogBox, useMessageToaster, useRouter } from '@/composables'
|
import { useDialogBox, useMessageToaster, useRouter } from '@/composables'
|
||||||
|
|
||||||
|
@ -33,8 +33,7 @@ export const GlobalEventListeners = defineComponent({
|
||||||
go('home')
|
go('home')
|
||||||
}
|
}
|
||||||
}).on('LOG_OUT', async () => {
|
}).on('LOG_OUT', async () => {
|
||||||
await userStore.logout()
|
await authService.logout()
|
||||||
authService.destroy()
|
|
||||||
forceReloadWindow()
|
forceReloadWindow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -108,7 +108,7 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
|
||||||
switch (getDragType(event)) {
|
switch (getDragType(event)) {
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
return playlistStore
|
return playlistStore
|
||||||
.byId(parseInt(event.dataTransfer!.getData('application/x-koel.playlist'))) as T | undefined
|
.byId(event.dataTransfer!.getData('application/x-koel.playlist')) as T | undefined
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -133,7 +133,7 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
|
||||||
const artist = await artistStore.resolve(<number>data)
|
const artist = await artistStore.resolve(<number>data)
|
||||||
return artist ? await songStore.fetchForArtist(artist) : <Song[]>[]
|
return artist ? await songStore.fetchForArtist(artist) : <Song[]>[]
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
const playlist = playlistStore.byId(<number>data)
|
const playlist = playlistStore.byId(<string>data)
|
||||||
return playlist ? await songStore.fetchForPlaylist(playlist) : <Song[]>[]
|
return playlist ? await songStore.fetchForPlaylist(playlist) : <Song[]>[]
|
||||||
case 'playlist-folder':
|
case 'playlist-folder':
|
||||||
const folder = playlistFolderStore.byId(<string>data)
|
const folder = playlistFolderStore.byId(<string>data)
|
||||||
|
|
|
@ -21,7 +21,7 @@ export const useRouter = () => {
|
||||||
getCurrentScreen,
|
getCurrentScreen,
|
||||||
isCurrentScreen,
|
isCurrentScreen,
|
||||||
onScreenActivated,
|
onScreenActivated,
|
||||||
go: router.go.bind(router),
|
go: Router.go,
|
||||||
onRouteChanged: router.onRouteChanged.bind(router),
|
onRouteChanged: router.onRouteChanged.bind(router),
|
||||||
resolveRoute: router.resolve.bind(router),
|
resolveRoute: router.resolve.bind(router),
|
||||||
triggerNotFound: router.triggerNotFound.bind(router)
|
triggerNotFound: router.triggerNotFound.bind(router)
|
||||||
|
|
|
@ -17,12 +17,11 @@ import {
|
||||||
|
|
||||||
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
|
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
|
||||||
import SongList from '@/components/song/SongList.vue'
|
import SongList from '@/components/song/SongList.vue'
|
||||||
import SongListControls from '@/components/song/SongListControls.vue'
|
|
||||||
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
||||||
|
|
||||||
export const useSongList = (
|
export const useSongList = (
|
||||||
songs: Ref<Song[]>,
|
songs: Ref<Song[]>,
|
||||||
config: Partial<SongListConfig> = { sortable: true, reorderable: true }
|
config: Partial<SongListConfig> = { sortable: true, reorderable: true, collaborative: false }
|
||||||
) => {
|
) => {
|
||||||
const filterKeywords = ref('')
|
const filterKeywords = ref('')
|
||||||
config = reactive(config)
|
config = reactive(config)
|
||||||
|
@ -124,10 +123,10 @@ export const useSongList = (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
SongList,
|
SongList,
|
||||||
SongListControls,
|
|
||||||
ControlsToggle,
|
ControlsToggle,
|
||||||
ThumbnailStack,
|
ThumbnailStack,
|
||||||
songs,
|
songs,
|
||||||
|
config,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
|
|
@ -1,28 +1,22 @@
|
||||||
import { useRouter } from '@/composables'
|
import { merge } from 'lodash'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
export const useSongListControls = () => {
|
import SongListControls from '@/components/song/SongListControls.vue'
|
||||||
const { isCurrentScreen } = useRouter()
|
|
||||||
|
|
||||||
const getSongListControlsConfig = () => {
|
export const useSongListControls = (
|
||||||
const config: SongListControlsConfig = {
|
screen: ScreenName,
|
||||||
play: true,
|
configOverrides: Partial<SongListControlsConfig> | (() => Partial<SongListControlsConfig>) = {}
|
||||||
addTo: {
|
) => {
|
||||||
queue: true,
|
const defaults: SongListControlsConfig = {
|
||||||
favorites: true
|
play: true,
|
||||||
},
|
addTo: {
|
||||||
clearQueue: false,
|
queue: screen !== 'Queue',
|
||||||
deletePlaylist: false,
|
favorites: screen !== 'Favorites',
|
||||||
refresh: false,
|
},
|
||||||
filter: false
|
clearQueue: screen === 'Queue',
|
||||||
}
|
deletePlaylist: screen === 'Playlist',
|
||||||
|
refresh: screen === 'Playlist',
|
||||||
config.clearQueue = isCurrentScreen('Queue')
|
filter: [
|
||||||
config.addTo.queue = !isCurrentScreen('Queue')
|
|
||||||
config.addTo.favorites = !isCurrentScreen('Favorites')
|
|
||||||
config.deletePlaylist = isCurrentScreen('Playlist')
|
|
||||||
config.refresh = isCurrentScreen('Playlist')
|
|
||||||
|
|
||||||
config.filter = isCurrentScreen(
|
|
||||||
'Queue',
|
'Queue',
|
||||||
'Artist',
|
'Artist',
|
||||||
'Album',
|
'Album',
|
||||||
|
@ -30,12 +24,13 @@ export const useSongListControls = () => {
|
||||||
'RecentlyPlayed',
|
'RecentlyPlayed',
|
||||||
'Playlist',
|
'Playlist',
|
||||||
'Search.Songs'
|
'Search.Songs'
|
||||||
)
|
].includes(screen)
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config = merge(defaults, typeof configOverrides === 'function' ? configOverrides() : configOverrides)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getSongListControlsConfig
|
SongListControls,
|
||||||
|
config: reactive(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
import { Route } from '@/router'
|
import { Route } from '@/router'
|
||||||
import { userStore } from '@/stores'
|
import { userStore } from '@/stores'
|
||||||
import { localStorageService } from '@/services'
|
import { localStorageService, playlistCollaborationService } from '@/services'
|
||||||
import { useUpload } from '@/composables'
|
import { useUpload } from '@/composables'
|
||||||
|
import { forceReloadWindow, logger } from '@/utils'
|
||||||
|
import Router from '@/router'
|
||||||
|
|
||||||
|
const UUID_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||||
|
|
||||||
export const routes: Route[] = [
|
export const routes: Route[] = [
|
||||||
{
|
{
|
||||||
|
@ -80,9 +84,23 @@ export const routes: Route[] = [
|
||||||
screen: 'Artist'
|
screen: 'Artist'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/playlist/(?<id>\\d+)',
|
path: `/playlist/(?<id>${UUID_REGEX})`,
|
||||||
screen: 'Playlist'
|
screen: 'Playlist'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: `/playlist/collaborate/(?<id>${UUID_REGEX})`,
|
||||||
|
screen: 'Blank',
|
||||||
|
onResolve: async params => {
|
||||||
|
try {
|
||||||
|
const playlist = await playlistCollaborationService.acceptInvite(params.id)
|
||||||
|
Router.go(`/playlist/${playlist.id}`, true)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/genres',
|
path: '/genres',
|
||||||
screen: 'Genres'
|
screen: 'Genres'
|
||||||
|
@ -96,7 +114,7 @@ export const routes: Route[] = [
|
||||||
screen: 'Visualizer'
|
screen: 'Visualizer'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
|
path: `/song/(?<id>${UUID_REGEX})`,
|
||||||
screen: 'Queue',
|
screen: 'Queue',
|
||||||
redirect: () => 'queue',
|
redirect: () => 'queue',
|
||||||
onResolve: params => {
|
onResolve: params => {
|
||||||
|
@ -105,7 +123,7 @@ export const routes: Route[] = [
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/invitation/accept/(?<token>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
|
path: `/invitation/accept/(?<token>${UUID_REGEX})`,
|
||||||
screen: 'Invitation.Accept'
|
screen: 'Invitation.Accept'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -132,7 +132,7 @@ const onUserLoggedIn = async () => {
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
userStore.init(await userStore.getProfile())
|
userStore.init(await authService.getProfile())
|
||||||
|
|
||||||
await socketService.init()
|
await socketService.init()
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { ref, Ref, watch } from 'vue'
|
import { ref, Ref, watch } from 'vue'
|
||||||
|
import { forceReloadWindow } from '@/utils'
|
||||||
|
|
||||||
type RouteParams = Record<string, string>
|
type RouteParams = Record<string, string>
|
||||||
type ResolveHook = (params: RouteParams) => boolean | void
|
type ResolveHook = (params: RouteParams) => Promise<boolean | void> | boolean | void
|
||||||
type RedirectHook = (params: RouteParams) => Route | string
|
type RedirectHook = (params: RouteParams) => Route | string
|
||||||
|
|
||||||
export type Route = {
|
export type Route = {
|
||||||
|
@ -44,7 +45,7 @@ export default class Router {
|
||||||
|
|
||||||
public async resolve () {
|
public async resolve () {
|
||||||
if (!location.hash || location.hash === '#/' || location.hash === '#!/') {
|
if (!location.hash || location.hash === '#/' || location.hash === '#!/') {
|
||||||
return this.go(this.homeRoute.path)
|
return Router.go(this.homeRoute.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = this.tryMatchRoute()
|
const matched = this.tryMatchRoute()
|
||||||
|
@ -54,13 +55,13 @@ export default class Router {
|
||||||
return this.triggerNotFound()
|
return this.triggerNotFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.onResolve?.(params) === false) {
|
if ((await route.onResolve?.(params)) === false) {
|
||||||
return this.triggerNotFound()
|
return this.triggerNotFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.redirect) {
|
if (route.redirect) {
|
||||||
const to = route.redirect(params)
|
const to = route.redirect(params)
|
||||||
return typeof to === 'string' ? this.go(to) : this.activateRoute(to, params)
|
return typeof to === 'string' ? Router.go(to) : this.activateRoute(to, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.activateRoute(route, params)
|
return this.activateRoute(route, params)
|
||||||
|
@ -96,7 +97,7 @@ export default class Router {
|
||||||
this.$currentRoute.value.params = params
|
this.$currentRoute.value.params = params
|
||||||
}
|
}
|
||||||
|
|
||||||
public go (path: string | number) {
|
public static go (path: string | number, reload = false) {
|
||||||
if (typeof path === 'number') {
|
if (typeof path === 'number') {
|
||||||
history.go(path)
|
history.go(path)
|
||||||
return
|
return
|
||||||
|
@ -112,5 +113,7 @@ export default class Router {
|
||||||
|
|
||||||
path = path.substring(1, path.length)
|
path = path.substring(1, path.length)
|
||||||
location.assign(`${location.origin}${location.pathname}${path}`)
|
location.assign(`${location.origin}${location.pathname}${path}`)
|
||||||
|
|
||||||
|
reload && forceReloadWindow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { localStorageService } from '@/services/localStorageService'
|
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import { authService } from './authService'
|
import { authService, http, localStorageService, UpdateCurrentProfileData } from '@/services'
|
||||||
|
import factory from '@/__tests__/factory'
|
||||||
|
import { userStore } from '@/stores'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
protected test () {
|
protected test () {
|
||||||
|
@ -27,5 +28,58 @@ new class extends UnitTestCase {
|
||||||
authService.destroy()
|
authService.destroy()
|
||||||
expect(mock).toHaveBeenCalledWith('api-token')
|
expect(mock).toHaveBeenCalledWith('api-token')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs in', async () => {
|
||||||
|
const postMock = this.mock(http, 'post').mockResolvedValue({
|
||||||
|
'audio-token': 'foo',
|
||||||
|
token: 'bar'
|
||||||
|
})
|
||||||
|
|
||||||
|
await authService.login('john@doe.com', 'curry-wurst')
|
||||||
|
|
||||||
|
expect(postMock).toHaveBeenCalledWith('me', { email: 'john@doe.com', password: 'curry-wurst' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs out', async () => {
|
||||||
|
const deleteMock = this.mock(http, 'delete')
|
||||||
|
await authService.logout()
|
||||||
|
|
||||||
|
expect(deleteMock).toHaveBeenCalledWith('me')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gets profile', async () => {
|
||||||
|
const getMock = this.mock(http, 'get')
|
||||||
|
await authService.getProfile()
|
||||||
|
|
||||||
|
expect(getMock).toHaveBeenCalledWith('me')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates profile', async () => {
|
||||||
|
userStore.state.current = factory<User>('user', {
|
||||||
|
id: 1,
|
||||||
|
name: 'John Doe',
|
||||||
|
email: 'john@doe.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
const updated = factory<User>('user', {
|
||||||
|
id: 1,
|
||||||
|
name: 'Jane Doe',
|
||||||
|
email: 'jane@doe.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
const putMock = this.mock(http, 'put').mockResolvedValue(updated)
|
||||||
|
|
||||||
|
const data: UpdateCurrentProfileData = {
|
||||||
|
current_password: 'curry-wurst',
|
||||||
|
name: 'Jane Doe',
|
||||||
|
email: 'jane@doe.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
await authService.updateProfile(data)
|
||||||
|
|
||||||
|
expect(putMock).toHaveBeenCalledWith('me', data)
|
||||||
|
expect(userStore.current.name).toBe('Jane Doe')
|
||||||
|
expect(userStore.current.email).toBe('jane@doe.com')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,42 @@
|
||||||
import { localStorageService } from '@/services'
|
import { merge } from 'lodash'
|
||||||
|
import { http, localStorageService } from '@/services'
|
||||||
|
import { userStore } from '@/stores'
|
||||||
|
|
||||||
|
export interface UpdateCurrentProfileData {
|
||||||
|
current_password: string | null
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
avatar?: string
|
||||||
|
new_password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompositeToken {
|
||||||
|
'audio-token': string
|
||||||
|
'token': string
|
||||||
|
}
|
||||||
|
|
||||||
const API_TOKEN_STORAGE_KEY = 'api-token'
|
const API_TOKEN_STORAGE_KEY = 'api-token'
|
||||||
const AUDIO_TOKEN_STORAGE_KEY = 'audio-token'
|
const AUDIO_TOKEN_STORAGE_KEY = 'audio-token'
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
|
async login (email: string, password: string) {
|
||||||
|
const token = await http.post<CompositeToken>('me', { email, password })
|
||||||
|
|
||||||
|
this.setAudioToken(token['audio-token'])
|
||||||
|
this.setApiToken(token.token)
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout () {
|
||||||
|
await http.delete('me')
|
||||||
|
this.destroy()
|
||||||
|
},
|
||||||
|
|
||||||
|
getProfile: async () => await http.get<User>('me'),
|
||||||
|
|
||||||
|
updateProfile: async (data: UpdateCurrentProfileData) => {
|
||||||
|
merge(userStore.current, (await http.put<User>('me', data)))
|
||||||
|
},
|
||||||
|
|
||||||
getApiToken: () => localStorageService.get(API_TOKEN_STORAGE_KEY),
|
getApiToken: () => localStorageService.get(API_TOKEN_STORAGE_KEY),
|
||||||
|
|
||||||
hasApiToken () {
|
hasApiToken () {
|
||||||
|
|
|
@ -29,7 +29,7 @@ class Http {
|
||||||
return (await this.request<T>('get', url)).data
|
return (await this.request<T>('get', url)).data
|
||||||
}
|
}
|
||||||
|
|
||||||
public async post<T> (url: string, data: Record<string, any>, onUploadProgress?: any) {
|
public async post<T> (url: string, data: Record<string, any> = {}, onUploadProgress?: any) {
|
||||||
return (await this.request<T>('post', url, data, onUploadProgress)).data
|
return (await this.request<T>('post', url, data, onUploadProgress)).data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,13 +61,11 @@ class Http {
|
||||||
this.silent || this.hideLoadingIndicator()
|
this.silent || this.hideLoadingIndicator()
|
||||||
this.silent = false
|
this.silent = false
|
||||||
|
|
||||||
// …get the tokens from the header or response data if exist, and save them.
|
// …get the tokens from the header if exist, and save them
|
||||||
const token = response.headers.authorization || response.data.token
|
// This occurs during user updating password.
|
||||||
|
const token = response.headers.authorization
|
||||||
token && authService.setApiToken(token)
|
token && authService.setApiToken(token)
|
||||||
|
|
||||||
const audioToken = response.data['audio-token']
|
|
||||||
audioToken && authService.setAudioToken(audioToken)
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}, error => {
|
}, error => {
|
||||||
this.silent || this.hideLoadingIndicator()
|
this.silent || this.hideLoadingIndicator()
|
||||||
|
|
|
@ -13,3 +13,4 @@ export * from './socketListener'
|
||||||
export * from './volumeManager'
|
export * from './volumeManager'
|
||||||
export * from './invitationService'
|
export * from './invitationService'
|
||||||
export * from './plusService'
|
export * from './plusService'
|
||||||
|
export * from './playlistCollaborationService'
|
||||||
|
|
|
@ -89,7 +89,7 @@ class PlaybackService {
|
||||||
await this.restart()
|
await this.restart()
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setNowPlayingMeta(song) {
|
private async setNowPlayingMeta(song: Song) {
|
||||||
document.title = `${song.title} ♫ Koel`
|
document.title = `${song.title} ♫ Koel`
|
||||||
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)
|
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)
|
||||||
|
|
||||||
|
|
16
resources/assets/js/services/playlistCollaborationService.ts
Normal file
16
resources/assets/js/services/playlistCollaborationService.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { http } from '@/services'
|
||||||
|
|
||||||
|
export const playlistCollaborationService = {
|
||||||
|
async createInviteLink (playlist: Playlist) {
|
||||||
|
if (playlist.is_smart) {
|
||||||
|
throw Error('Smart playlists are not collaborative.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = (await http.post<{ token: string }>(`playlists/${playlist.id}/collaborators/invite`)).token
|
||||||
|
return `${window.location.origin}/#/playlist/collaborate/${token}`
|
||||||
|
},
|
||||||
|
|
||||||
|
async acceptInvite (token: string) {
|
||||||
|
return http.post<Playlist>(`playlists/collaborators/accept`, { token })
|
||||||
|
}
|
||||||
|
}
|
|
@ -52,7 +52,7 @@ export const playlistStore = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
byId (id: number) {
|
byId (id: string) {
|
||||||
return this.state.playlists.find(playlist => playlist.id === id)
|
return this.state.playlists.find(playlist => playlist.id === id)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,8 @@ export const queueStore = {
|
||||||
songs: []
|
songs: []
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
i: 0,
|
||||||
|
|
||||||
init (savedState: QueueState) {
|
init (savedState: QueueState) {
|
||||||
// don't set this.all here, as it would trigger saving state
|
// don't set this.all here, as it would trigger saving state
|
||||||
this.state.songs = songStore.syncWithVault(savedState.songs)
|
this.state.songs = songStore.syncWithVault(savedState.songs)
|
||||||
|
|
|
@ -174,8 +174,8 @@ export const songStore = {
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchForPlaylist (playlist: Playlist | number, refresh = false) {
|
async fetchForPlaylist (playlist: Playlist | string, refresh = false) {
|
||||||
const id = typeof playlist === 'number' ? playlist : playlist.id
|
const id = typeof playlist === 'string' ? playlist : playlist.id
|
||||||
|
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
cache.remove(['playlist.songs', id])
|
cache.remove(['playlist.songs', id])
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { http } from '@/services'
|
import { http } from '@/services'
|
||||||
import { CreateUserData, UpdateCurrentProfileData, UpdateUserData, userStore } from '.'
|
import { CreateUserData, UpdateUserData, userStore } from '.'
|
||||||
|
|
||||||
const currentUser = factory<User>('user', {
|
const currentUser = factory<User>('user', {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
@ -50,49 +50,6 @@ new class extends UnitTestCase {
|
||||||
expect(userStore.byId(2)).toEqual(user)
|
expect(userStore.byId(2)).toEqual(user)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('logs in', async () => {
|
|
||||||
const postMock = this.mock(http, 'post')
|
|
||||||
await userStore.login('john@doe.com', 'curry-wurst')
|
|
||||||
|
|
||||||
expect(postMock).toHaveBeenCalledWith('me', { email: 'john@doe.com', password: 'curry-wurst' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('logs out', async () => {
|
|
||||||
const deleteMock = this.mock(http, 'delete')
|
|
||||||
await userStore.logout()
|
|
||||||
|
|
||||||
expect(deleteMock).toHaveBeenCalledWith('me')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('gets profile', async () => {
|
|
||||||
const getMock = this.mock(http, 'get')
|
|
||||||
await userStore.getProfile()
|
|
||||||
|
|
||||||
expect(getMock).toHaveBeenCalledWith('me')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates profile', async () => {
|
|
||||||
const updated = factory<User>('user', {
|
|
||||||
id: 1,
|
|
||||||
name: 'Jane Doe',
|
|
||||||
email: 'jane@doe.com'
|
|
||||||
})
|
|
||||||
|
|
||||||
const putMock = this.mock(http, 'put').mockResolvedValue(updated)
|
|
||||||
|
|
||||||
const data: UpdateCurrentProfileData = {
|
|
||||||
current_password: 'curry-wurst',
|
|
||||||
name: 'Jane Doe',
|
|
||||||
email: 'jane@doe.com'
|
|
||||||
}
|
|
||||||
|
|
||||||
await userStore.updateProfile(data)
|
|
||||||
|
|
||||||
expect(putMock).toHaveBeenCalledWith('me', data)
|
|
||||||
expect(userStore.current.name).toBe('Jane Doe')
|
|
||||||
expect(userStore.current.email).toBe('jane@doe.com')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a user', async () => {
|
it('creates a user', async () => {
|
||||||
const data: CreateUserData = {
|
const data: CreateUserData = {
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
|
|
|
@ -2,15 +2,6 @@ import { differenceBy, merge } from 'lodash'
|
||||||
import { http } from '@/services'
|
import { http } from '@/services'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { arrayify } from '@/utils'
|
import { arrayify } from '@/utils'
|
||||||
import { UnwrapNestedRefs } from '@vue/reactivity'
|
|
||||||
|
|
||||||
export interface UpdateCurrentProfileData {
|
|
||||||
current_password: string | null
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
avatar?: string
|
|
||||||
new_password?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserFormData {
|
interface UserFormData {
|
||||||
name: string
|
name: string
|
||||||
|
@ -27,7 +18,7 @@ export interface UpdateUserData extends UserFormData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userStore = {
|
export const userStore = {
|
||||||
vault: new Map<number, UnwrapNestedRefs<User>>(),
|
vault: new Map<number, User>(),
|
||||||
|
|
||||||
state: reactive({
|
state: reactive({
|
||||||
users: [] as User[],
|
users: [] as User[],
|
||||||
|
@ -61,14 +52,6 @@ export const userStore = {
|
||||||
return this.state.current
|
return this.state.current
|
||||||
},
|
},
|
||||||
|
|
||||||
login: async (email: string, password: string) => await http.post<User>('me', { email, password }),
|
|
||||||
logout: async () => await http.delete('me'),
|
|
||||||
getProfile: async () => await http.get<User>('me'),
|
|
||||||
|
|
||||||
async updateProfile (data: UpdateCurrentProfileData) {
|
|
||||||
merge(this.current, (await http.put<User>('me', data)))
|
|
||||||
},
|
|
||||||
|
|
||||||
async store (data: CreateUserData) {
|
async store (data: CreateUserData) {
|
||||||
const user = await http.post<User>('users', data)
|
const user = await http.post<User>('users', data)
|
||||||
this.add(user)
|
this.add(user)
|
||||||
|
|
16
resources/assets/js/types.d.ts
vendored
16
resources/assets/js/types.d.ts
vendored
|
@ -155,6 +155,14 @@ interface Song {
|
||||||
deleted?: boolean
|
deleted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CollaborativeSong extends Song {
|
||||||
|
collaboration: {
|
||||||
|
user: Pick<User, 'name' | 'avatar'>
|
||||||
|
added_at: string | null
|
||||||
|
fmt_added_at: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface QueueState {
|
interface QueueState {
|
||||||
type: 'queue-states'
|
type: 'queue-states'
|
||||||
songs: Song[]
|
songs: Song[]
|
||||||
|
@ -217,12 +225,14 @@ interface PlaylistFolder {
|
||||||
|
|
||||||
interface Playlist {
|
interface Playlist {
|
||||||
type: 'playlists'
|
type: 'playlists'
|
||||||
readonly id: number
|
readonly id: string
|
||||||
|
readonly user_id: User['id']
|
||||||
name: string
|
name: string
|
||||||
folder_id: PlaylistFolder['id'] | null
|
folder_id: PlaylistFolder['id'] | null
|
||||||
is_smart: boolean
|
is_smart: boolean
|
||||||
rules: SmartPlaylistRuleGroup[]
|
rules: SmartPlaylistRuleGroup[]
|
||||||
own_songs_only: boolean
|
own_songs_only: boolean
|
||||||
|
collaborators: User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList
|
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList
|
||||||
|
@ -302,7 +312,7 @@ interface EqualizerPreset {
|
||||||
declare type PlaybackState = 'Stopped' | 'Playing' | 'Paused'
|
declare type PlaybackState = 'Stopped' | 'Playing' | 'Paused'
|
||||||
declare type ScreenName =
|
declare type ScreenName =
|
||||||
| 'Home'
|
| 'Home'
|
||||||
| 'Default'
|
| 'Default' | 'Blank'
|
||||||
| 'Queue'
|
| 'Queue'
|
||||||
| 'Songs'
|
| 'Songs'
|
||||||
| 'Albums'
|
| 'Albums'
|
||||||
|
@ -333,7 +343,6 @@ interface AddToMenuConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SongListControlsConfig {
|
interface SongListControlsConfig {
|
||||||
play: boolean
|
|
||||||
addTo: AddToMenuConfig
|
addTo: AddToMenuConfig
|
||||||
clearQueue: boolean
|
clearQueue: boolean
|
||||||
deletePlaylist: boolean
|
deletePlaylist: boolean
|
||||||
|
@ -369,6 +378,7 @@ type RepeatMode = 'NO_REPEAT' | 'REPEAT_ALL' | 'REPEAT_ONE'
|
||||||
interface SongListConfig {
|
interface SongListConfig {
|
||||||
sortable: boolean
|
sortable: boolean
|
||||||
reorderable: boolean
|
reorderable: boolean
|
||||||
|
collaborative: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type SongListSortField = keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_name' | 'length' | 'artist_name' | 'created_at'>
|
type SongListSortField = keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_name' | 'length' | 'artist_name' | 'created_at'>
|
||||||
|
|
|
@ -17,19 +17,23 @@ export const forceReloadWindow = (): void => {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const copyText = (text: string): void => {
|
export const copyText = async (text: string) => {
|
||||||
let copyArea = document.querySelector<HTMLTextAreaElement>('#copyArea')
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch (e) {
|
||||||
|
let copyArea = document.querySelector<HTMLTextAreaElement>('#copyArea')
|
||||||
|
|
||||||
if (!copyArea) {
|
if (!copyArea) {
|
||||||
copyArea = document.createElement('textarea')
|
copyArea = document.createElement('textarea')
|
||||||
copyArea.id = 'copyArea'
|
copyArea.id = 'copyArea'
|
||||||
document.body.appendChild(copyArea)
|
document.body.appendChild(copyArea)
|
||||||
|
}
|
||||||
|
|
||||||
|
copyArea.style.top = `${window.scrollY || document.documentElement.scrollTop}px`
|
||||||
|
copyArea.value = text
|
||||||
|
select(copyArea)
|
||||||
|
document.execCommand('copy')
|
||||||
}
|
}
|
||||||
|
|
||||||
copyArea.style.top = `${window.scrollY || document.documentElement.scrollTop}px`
|
|
||||||
copyArea.value = text
|
|
||||||
select(copyArea)
|
|
||||||
document.execCommand('copy')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isDemo = () => {
|
export const isDemo = () => {
|
||||||
|
|
|
@ -24,6 +24,8 @@ use App\Http\Controllers\API\GenreController;
|
||||||
use App\Http\Controllers\API\GenreSongController;
|
use App\Http\Controllers\API\GenreSongController;
|
||||||
use App\Http\Controllers\API\LikeMultipleSongsController;
|
use App\Http\Controllers\API\LikeMultipleSongsController;
|
||||||
use App\Http\Controllers\API\ObjectStorage\S3\SongController as S3SongController;
|
use App\Http\Controllers\API\ObjectStorage\S3\SongController as S3SongController;
|
||||||
|
use App\Http\Controllers\API\PlaylistCollaboration\AcceptPlaylistCollaborationController;
|
||||||
|
use App\Http\Controllers\API\PlaylistCollaboration\CreatePlaylistCollaborationTokenController;
|
||||||
use App\Http\Controllers\API\PlaylistController;
|
use App\Http\Controllers\API\PlaylistController;
|
||||||
use App\Http\Controllers\API\PlaylistFolderController;
|
use App\Http\Controllers\API\PlaylistFolderController;
|
||||||
use App\Http\Controllers\API\PlaylistFolderPlaylistController;
|
use App\Http\Controllers\API\PlaylistFolderPlaylistController;
|
||||||
|
@ -163,7 +165,12 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
||||||
Route::put('songs/publicize', PublicizeSongsController::class);
|
Route::put('songs/publicize', PublicizeSongsController::class);
|
||||||
Route::put('songs/privatize', PrivatizeSongsController::class);
|
Route::put('songs/privatize', PrivatizeSongsController::class);
|
||||||
|
|
||||||
|
// License routes
|
||||||
Route::post('licenses/activate', ActivateLicenseController::class);
|
Route::post('licenses/activate', ActivateLicenseController::class);
|
||||||
|
|
||||||
|
// Playlist collaboration routes
|
||||||
|
Route::post('playlists/{playlist}/collaborators/invite', CreatePlaylistCollaborationTokenController::class);
|
||||||
|
Route::post('playlists/collaborators/accept', AcceptPlaylistCollaborationController::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Object-storage (S3) routes
|
// Object-storage (S3) routes
|
||||||
|
|
|
@ -129,7 +129,7 @@ class DownloadTest extends TestCase
|
||||||
/** @var Playlist $playlist */
|
/** @var Playlist $playlist */
|
||||||
$playlist = Playlist::factory()->for($user)->create();
|
$playlist = Playlist::factory()->for($user)->create();
|
||||||
|
|
||||||
$playlist->songs()->attach($songs);
|
$playlist->addSongs($songs);
|
||||||
|
|
||||||
$this->downloadService
|
$this->downloadService
|
||||||
->shouldReceive('getDownloadablePath')
|
->shouldReceive('getDownloadablePath')
|
||||||
|
|
|
@ -33,7 +33,7 @@ class InitialDataTest extends TestCase
|
||||||
'short_key',
|
'short_key',
|
||||||
'customer_name',
|
'customer_name',
|
||||||
'customer_email',
|
'customer_email',
|
||||||
'store_url',
|
'product_id',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue