Compare commits

...

2 commits

Author SHA1 Message Date
fe98476154
feat: add mobile landscape navbar 2024-02-22 19:15:58 +01:00
818900edc1
fix: arrMoveMulti function
Beware of the Array.sort() footgun!
2024-02-22 01:09:36 +01:00
14 changed files with 316 additions and 75 deletions

View file

@ -8,7 +8,7 @@
%sveltekit.head% %sveltekit.head%
</head> </head>
<title>Tiraya</title> <title>Tiraya</title>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="tap">
<noscript>Please enable JavaScript to run the Tiraya application</noscript> <noscript>Please enable JavaScript to run the Tiraya application</noscript>
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>

View file

@ -1,7 +0,0 @@
import { describe, it, expect } from "vitest";
describe("sum test", () => {
it("adds 1 + 2 to equal 3", () => {
expect(1 + 2).toBe(3);
});
});

View file

@ -15,7 +15,7 @@
} from "$lib/util/types"; } from "$lib/util/types";
import { DBLCLICK_MS, LIST_PAGENAV_N } from "$lib/util/constants"; import { DBLCLICK_MS, LIST_PAGENAV_N } from "$lib/util/constants";
import { import {
arrMoveMulti2, arrMoveMulti,
clamp, clamp,
dateToNumber, dateToNumber,
findParentWithAttr, findParentWithAttr,
@ -598,24 +598,10 @@
function doMove(data: MoveData) { function doMove(data: MoveData) {
dispatch("moveTracks", data); dispatch("moveTracks", data);
// let os = 0; let offset = arrMoveMulti(tracks, data.positions, data.target);
// for (let i = 0; i < data.positions.length; i++) { tracks = tracks;
// if (data.positions[i] < data.target) {
// os--;
// }
// }
// for (let i = 0; i < data.positions.length; i++) { let toff = data.target + offset;
// selection[data.target + i + os] = true;
// }
// lastSelection = Math.max(
// data.target + data.positions.length + os - 1,
// tracks.length - 1
// );
let res = arrMoveMulti2(tracks, data.positions, data.target);
tracks = res.array;
let toff = data.target + res.offset;
selection = {}; selection = {};
selectRange([toff, toff + data.positions.length - 1]); selectRange([toff, toff + data.positions.length - 1]);
scrollTo(data.target); scrollTo(data.target);

View file

@ -15,7 +15,7 @@
import NavbarMobileItem from "./NavbarMobileItem.svelte"; import NavbarMobileItem from "./NavbarMobileItem.svelte";
</script> </script>
<div class="btm-nav btm-nav-sm bg-base-300 z-50"> <div class="btm-nav btm-nav-sm text-xs bg-base-300 z-50">
<NavbarMobileItem <NavbarMobileItem
title={$LL.home()} title={$LL.home()}
icon={mdiHomeOutline} icon={mdiHomeOutline}

View file

@ -0,0 +1,59 @@
<script>
import LL from "$i18n/i18n-svelte";
import {
mdiAccountMusic,
mdiAccountMusicOutline,
mdiAlbum,
mdiHome,
mdiHomeOutline,
mdiPlaylistMusic,
mdiPlaylistMusicOutline,
} from "@mdi/js";
import { iconAlbumOutline, iconSearch, iconSearchFilled } from "$lib/util/icons";
import NavbarItemLarge from "./NavbarItemLarge.svelte";
import PlayerbarMobileLandscape from "$lib/components/player/PlayerbarMobileLandscape.svelte";
</script>
<div class="flex flex-col h-full">
<ul class="menu menu-compact flex flex-col p-1">
<NavbarItemLarge
title={$LL.home()}
icon={mdiHomeOutline}
iconActive={mdiHome}
href="/"
/>
<NavbarItemLarge
title={$LL.search()}
icon={iconSearch}
iconActive={iconSearchFilled}
href="/search"
/>
<NavbarItemLarge
title={$LL.artists()}
icon={mdiAccountMusicOutline}
iconActive={mdiAccountMusic}
href="/artist"
/>
<NavbarItemLarge
title={$LL.albums()}
icon={iconAlbumOutline}
iconActive={mdiAlbum}
href="/album"
/>
<!-- <NavbarItemLarge
title={$LL.tracks()}
icon={mdiMusicNoteOutline}
iconActive={mdiMusicNote}
href="/tracks"
/> -->
<NavbarItemLarge
title={$LL.playlists()}
icon={mdiPlaylistMusicOutline}
iconActive={mdiPlaylistMusic}
href="/playlist"
/>
</ul>
<PlayerbarMobileLandscape />
</div>

View file

@ -49,8 +49,8 @@
<div <div
class="flex items-center justify-start" class="flex items-center justify-start"
aria-label={$LL.now_playing({ aria-label={$LL.now_playing({
title: "Across the Sea", title: track.name,
artist: "Leaves' Eyes", artist: track.artists[0].name,
})} })}
> >
<CurrentCover /> <CurrentCover />

View file

@ -29,9 +29,6 @@
// Enable color transition after first image load // Enable color transition after first image load
let colorTrans = false; let colorTrans = false;
let buttonsElm: Element;
let seekbarElm: Element;
function onImageLoad(e: Event) { function onImageLoad(e: Event) {
fastAverageColor(e.target as HTMLImageElement, (res) => { fastAverageColor(e.target as HTMLImageElement, (res) => {
imgColor = res; imgColor = res;
@ -75,7 +72,7 @@
class="shadow rounded-md z-50" class="shadow rounded-md z-50"
class:transition-colors={colorTrans} class:transition-colors={colorTrans}
style={`background-color: ${bgColorHex}`} style={`background-color: ${bgColorHex}`}
aria-label={$LL.now_playing({ title: "Across the Sea", artist: "Leaves' Eyes" })} aria-label={$LL.now_playing({ title: track.name, artist: track.artists[0].name })}
> >
<div class="inner"> <div class="inner">
<button class="relative" aria-label={$LL.fullscreen_player()}> <button class="relative" aria-label={$LL.fullscreen_player()}>
@ -107,7 +104,7 @@
<TrackNameMobile slot="next" track={tracks[ti + 1]} /> <TrackNameMobile slot="next" track={tracks[ti + 1]} />
</Swiper> </Swiper>
<div class="controls flex items-center" bind:this={buttonsElm}> <div class="controls flex items-center">
<LikeButton /> <LikeButton />
{#if !$mainPhone} {#if !$mainPhone}
<IconButton <IconButton
@ -136,7 +133,7 @@
</div> </div>
</div> </div>
<div class="seekbar" bind:this={seekbarElm}> <div class="seekbar">
<Slider ariaLabel={$LL.seek_bar()} /> <Slider ariaLabel={$LL.seek_bar()} />
</div> </div>
</div> </div>

View file

@ -0,0 +1,147 @@
<script lang="ts">
import LL from "$i18n/i18n-svelte";
import { mdiFullscreen, mdiPause, mdiPlay } from "@mdi/js";
import { Direction, type Color, type Track } from "$lib/util/types";
import { colorToHex, correctBgColor, fastAverageColor } from "$lib/util/colors";
import { textColor } from "$lib/stores/theme";
import { COLOR_B3, MIN_CONTRAST_BODY } from "$lib/util/constants";
import { tracks } from "$lib/util/testdata";
import { TRACK_SKIP, hub, registerHandler } from "$lib/util/events";
import Icon from "$lib/components/ui/Icon.svelte";
import IconToggle from "$lib/components/ui/IconToggle.svelte";
import LikeButton from "$lib/components/ui/LikeButton.svelte";
import Slider from "$lib/components/ui/Slider.svelte";
import Swiper from "$lib/components/ui/Swiper.svelte";
import TrackNameMobile from "./TrackNameMobile.svelte";
let imgColor: Color | null;
let bgColorHex: string;
// Enable color transition after first image load
let colorTrans = false;
function onImageLoad(e: Event) {
fastAverageColor(e.target as HTMLImageElement, (res) => {
imgColor = res;
});
}
$: if (imgColor) {
const corrected = correctBgColor($textColor, imgColor, MIN_CONTRAST_BODY);
bgColorHex = colorToHex(corrected);
window.setTimeout(() => (colorTrans = true), 300);
} else if (!bgColorHex) {
bgColorHex = COLOR_B3;
}
// Test code to simulate player behavior
let ti = 1;
let track: Track;
$: track = tracks[ti];
function nextTrack() {
ti++;
if (ti >= tracks.length) ti = 0;
}
function prevTrack() {
ti--;
if (ti < 0) ti = tracks.length - 1;
}
registerHandler(TRACK_SKIP, (direction) => {
if (direction === Direction.Forward) {
nextTrack();
} else if (direction == Direction.Backward) {
prevTrack();
}
});
</script>
<div
id="playerbar-mobile-landscape"
class="m-2 shadow rounded-md relative"
class:transition-colors={colorTrans}
style={`background-color: ${bgColorHex}`}
aria-label={$LL.now_playing({ title: track.name, artist: track.artists[0].name })}
>
<div class="inner">
<Swiper
cls="h-14 mx-2"
hasPrev={ti > 0}
hasNext={ti + 1 < tracks.length}
on:swiped={(e) => hub.emit(TRACK_SKIP, e.detail)}
>
<TrackNameMobile slot="prev" track={tracks[ti - 1]} />
<TrackNameMobile slot="curr" {track} />
<TrackNameMobile slot="next" track={tracks[ti + 1]} />
</Swiper>
<div class="seekbar">
<Slider ariaLabel={$LL.seek_bar()} marginX={false} />
</div>
<button class="relative" aria-label={$LL.fullscreen_player()}>
<div
class="absolute w-full h-full flex items-center justify-center bg-base-100/50
transition-opacity opacity-0 hover:opacity-100"
>
<Icon path={mdiFullscreen} size={2} />
</div>
<img
class="h-full rounded-bl-md"
src={track.album.imageUrl}
alt=""
crossorigin="anonymous"
height="54"
width="54"
draggable="false"
on:load={onImageLoad}
/>
</button>
<div class="controls flex items-center">
<LikeButton />
<IconToggle
iconOn={mdiPause}
iconOff={mdiPlay}
size="md"
iColorOn=""
ariaLabelOff={$LL.pause()}
ariaLabelOn={$LL.play()}
/>
</div>
</div>
</div>
<style lang="postcss">
#playerbar-mobile-landscape {
user-select: none;
}
.inner {
width: 100%;
height: 100%;
display: grid;
grid-template:
"trackname trackname" 1fr
"img controls" / 54px 1fr;
gap: 0 0.5rem;
}
button {
grid-area: img;
}
.controls {
grid-area: controls;
}
.seekbar {
position: absolute;
width: 100%;
bottom: 3rem;
z-index: 10;
}
</style>

View file

@ -11,6 +11,7 @@
export let max = 100; export let max = 100;
export let value = 0; export let value = 0;
export let ariaLabel: string | undefined = undefined; export let ariaLabel: string | undefined = undefined;
export let marginX = true;
/** /**
* Value to be displayed next to the slider * Value to be displayed next to the slider
@ -141,7 +142,7 @@
on:mouseup={onDragEnd} on:mouseup={onDragEnd}
on:resize={resizeSlider} on:resize={resizeSlider}
/> />
<div class="flex-1 mx-2"> <div class="flex-1" class:mx-2={marginX}>
<div <div
class="slider-wrapper" class="slider-wrapper"
class:holding class:holding

View file

@ -8,6 +8,7 @@
export let hasNext = true; export let hasNext = true;
/** Bind to get the current state */ /** Bind to get the current state */
export let swiping = false; export let swiping = false;
export let cls = "";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -44,7 +45,7 @@
</script> </script>
<div <div
class="carousel" class="carousel {cls}"
bind:clientWidth={width} bind:clientWidth={width}
use:swipeable={{ thresholdProvider: () => width / 3 }} use:swipeable={{ thresholdProvider: () => width / 3 }}
on:swipeStart={onSwipeStart} on:swipeStart={onSwipeStart}

View file

@ -0,0 +1,62 @@
import { test, expect } from "vitest";
import { arrMoveMulti } from "./functions";
const ARR = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
test.each([
{
name: "single fwd",
positions: [1],
target: 3,
result: [0, 2, 1, 3, 4, 5, 6, 7, 8, 9],
offset: -1,
},
{
name: "range fwd",
positions: [1, 2, 3],
target: 7,
result: [0, 4, 5, 6, 1, 2, 3, 7, 8, 9],
offset: -3,
},
{
name: "range+single fwd",
positions: [1, 2, 3, 7],
target: 9,
result: [0, 4, 5, 6, 8, 1, 2, 3, 7, 9],
offset: -4,
},
{
name: "single bck",
positions: [8],
target: 1,
result: [0, 8, 1, 2, 3, 4, 5, 6, 7, 9],
offset: 0,
},
{
name: "range bck",
positions: [6, 7, 8],
target: 1,
result: [0, 6, 7, 8, 1, 2, 3, 4, 5, 9],
offset: 0,
},
{
name: "range+single bck",
positions: [8, 3, 6, 7],
target: 1,
result: [0, 3, 6, 7, 8, 1, 2, 4, 5, 9],
offset: 0,
},
{
name: "range inside", // no items get moved
positions: [2, 3, 4, 5],
target: 4,
result: ARR,
offset: -2,
},
])("arrMoveMulti $name", ({ positions, target, result, offset }) => {
const arr = [...ARR];
const o = arrMoveMulti(arr, positions, target);
expect(arr).toStrictEqual(result);
expect(o).toBe(offset);
});

View file

@ -236,8 +236,12 @@ export function arrMove<T>(arr: T[], oldIndex: number, newIndex: number) {
arr.splice(newIndex > oldIndex ? newIndex - 1 : newIndex, 0, ...items); arr.splice(newIndex > oldIndex ? newIndex - 1 : newIndex, 0, ...items);
} }
export function arrMoveMulti<T>(arr: T[], positions: number[], target: number) { /** Move a list of items in an array to the target position.
positions.sort(); *
* Returns the offset of the move operation
* (the number of positions the items were shifted to the right) */
export function arrMoveMulti<T>(arr: T[], positions: number[], target: number): number {
positions.sort((a, b) => a - b);
const toInsert = []; const toInsert = [];
let offset = 0; let offset = 0;
@ -247,33 +251,8 @@ export function arrMoveMulti<T>(arr: T[], positions: number[], target: number) {
} }
toInsert.reverse(); toInsert.reverse();
arr.splice(target + offset, 0, ...toInsert); arr.splice(target + offset, 0, ...toInsert);
}
export function arrMoveMulti2<T>( return offset;
arr: T[],
positions: number[],
target: number
): { array: T[]; offset: number } {
const a1 = [],
spliced = [];
const pset = new Set(positions);
let offset = 0;
for (let i = positions.length - 1; i >= 0; i--) {
if (target > positions[i]) offset--;
}
for (let i = 0; i < arr.length; i++) {
if (pset.has(i)) {
spliced.push(arr[i]);
} else {
a1.push(arr[i]);
}
}
a1.splice(target + offset, 0, ...spliced);
return { array: a1, offset };
} }
export function setCookie(cName: string, cValue: string, expDays: number) { export function setCookie(cName: string, cValue: string, expDays: number) {

View file

@ -25,12 +25,13 @@
headerColor, headerColor,
} from "$lib/stores/layout"; } from "$lib/stores/layout";
import { clamp, getEventCoords } from "$lib/util/functions"; import { clamp, getEventCoords } from "$lib/util/functions";
import { WIDTH_SMALL } from "$lib/util/constants"; import { WIDTH_PHONE, WIDTH_SMALL } from "$lib/util/constants";
import Menubar from "$lib/components/nav/Navbar.svelte"; import Navbar from "$lib/components/nav/Navbar.svelte";
import Playerbar from "$lib/components/player/Playerbar.svelte"; import Playerbar from "$lib/components/player/Playerbar.svelte";
import DummyText from "$lib/components/ui/DummyText.svelte"; import DummyText from "$lib/components/ui/DummyText.svelte";
import NavbarMobile from "$lib/components/nav/NavbarMobile.svelte"; import NavbarMobile from "$lib/components/nav/NavbarMobile.svelte";
import NavbarMobileLandscape from "$lib/components/nav/NavbarMobileLandscape.svelte";
import PlayerbarMobile from "$lib/components/player/PlayerbarMobile.svelte"; import PlayerbarMobile from "$lib/components/player/PlayerbarMobile.svelte";
import FixedHeader from "$lib/components/header/FixedHeader.svelte"; import FixedHeader from "$lib/components/header/FixedHeader.svelte";
@ -53,8 +54,10 @@
let queuebarElm: HTMLElement; let queuebarElm: HTMLElement;
let queuebarResizing = false; let queuebarResizing = false;
let mobileLandscape = false;
$: mobileLandscape = $screenHeight < WIDTH_PHONE && $screenWidth > WIDTH_PHONE;
let showNavbar = false; let showNavbar = false;
$: showNavbar = $screenWidth >= WIDTH_SMALL; $: showNavbar = $screenWidth >= WIDTH_SMALL && !mobileLandscape;
let showQueue = false; let showQueue = false;
$: showQueue = showNavbar && $clientState.showQueue; $: showQueue = showNavbar && $clientState.showQueue;
@ -168,7 +171,8 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
id="app" id="app"
class:mobile={!showNavbar} class:mobile={!showNavbar && !mobileLandscape}
class:mobile-landscape={mobileLandscape}
bind:clientWidth={$screenWidth} bind:clientWidth={$screenWidth}
bind:clientHeight={$screenHeight} bind:clientHeight={$screenHeight}
on:dragend={onDragEnd} on:dragend={onDragEnd}
@ -186,7 +190,11 @@
on:touchstart|passive={initResizeNavbarTouch} on:touchstart|passive={initResizeNavbarTouch}
class:active={navbarResizing} class:active={navbarResizing}
/> />
<Menubar /> <Navbar />
</nav>
{:else if mobileLandscape}
<nav class="sidebar">
<NavbarMobileLandscape />
</nav> </nav>
{/if} {/if}
{#if showQueue} {#if showQueue}
@ -214,15 +222,15 @@
<div class="h-12" /> <div class="h-12" />
{/if} {/if}
<slot /> <slot />
{#if !showNavbar} {#if !showNavbar && !mobileLandscape}
<!-- Spacer to ensure enough bottom space to scroll beyond floating playerbara --> <!-- Spacer to ensure enough bottom space to scroll beyond floating playerbar -->
<div class="h-24" /> <div class="h-24" />
{/if} {/if}
</main> </main>
</div> </div>
{#if showNavbar} {#if showNavbar && !mobileLandscape}
<Playerbar /> <Playerbar />
{:else} {:else if !mobileLandscape}
<PlayerbarMobile /> <PlayerbarMobile />
<NavbarMobile /> <NavbarMobile />
{/if} {/if}
@ -242,6 +250,10 @@
"main-content" calc(100vh - 3rem) "main-content" calc(100vh - 3rem)
"player" 3rem / 1fr; "player" 3rem / 1fr;
} }
&.mobile-landscape {
grid-template: "main-content" 100vh;
}
} }
#main-content { #main-content {
@ -253,6 +265,10 @@
); );
} }
.mobile-landscape > #main-content {
grid-template: "sidebar main" 1fr/10rem 1fr;
}
main { main {
grid-area: main; grid-area: main;

View file

@ -3,7 +3,7 @@
import type { PageData } from "./$types"; import type { PageData } from "./$types";
import { searchTerm } from "$lib/stores/layout"; import { searchTerm } from "$lib/stores/layout";
import { userLink, arrMoveMulti, arrMoveMulti2 } from "$lib/util/functions"; import { userLink } from "$lib/util/functions";
import { ListView, type MoveData } from "$lib/util/types"; import { ListView, type MoveData } from "$lib/util/types";
import { testPlaylist } from "$lib/util/testdata"; import { testPlaylist } from "$lib/util/testdata";
// import testPlaylist from "$lib/util/testdata/playlist_5K_conv.json"; // import testPlaylist from "$lib/util/testdata/playlist_5K_conv.json";