├── .env.example ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── launch.json ├── LICENSE ├── README.md ├── astro.config.mjs ├── netlify.toml ├── package.json ├── pnpm-lock.yaml ├── public ├── apple-touch-icon.png ├── favicon.ico ├── mask-icon.svg ├── pwa-192x192.png ├── pwa-512x512.png └── robots.txt ├── screenshot.png ├── shims.d.ts ├── src ├── app.d.ts ├── assets │ ├── ark.css │ ├── ark │ │ ├── base.css │ │ ├── dialog.css │ │ ├── menu.css │ │ ├── pin-input.css │ │ ├── popover.css │ │ ├── tabs.css │ │ ├── toast.css │ │ └── tooltip.css │ ├── dataset.ts │ └── main.css ├── components │ ├── common │ │ ├── AllSongList.tsx │ │ ├── Button.tsx │ │ ├── ConnectMessageDialog.tsx │ │ ├── DataLayer.tsx │ │ ├── Logo.tsx │ │ ├── LyricListView.tsx │ │ ├── SongListSidebarContent.tsx │ │ ├── StatusView.tsx │ │ ├── ToggleButton.tsx │ │ └── sidebarTab │ │ │ ├── SongList.tsx │ │ │ ├── UploadLyricPanel.tsx │ │ │ └── WebSearchList.tsx │ ├── controller │ │ ├── BottomControlBar.tsx │ │ ├── ControllerPeerLayer.tsx │ │ ├── CurrentPlayingBar.tsx │ │ ├── ExtraViewButtonView.tsx │ │ ├── ScreenControlView.tsx │ │ ├── SongListSidebar.tsx │ │ ├── TextControlView.tsx │ │ └── TimerInfoView.tsx │ ├── index │ │ └── WelcomeBox.tsx │ └── screen │ │ ├── ExtraViewScreenView.tsx │ │ ├── FloatControlBar.tsx │ │ ├── FloatControlBarMenuButton.tsx │ │ ├── FloatControlBarStatusBar.tsx │ │ ├── FloatControlBarTimerInfoView.tsx │ │ ├── FloatControllerMenuPanel.tsx │ │ ├── FullScreenControlArea.tsx │ │ ├── LyricScreenView.tsx │ │ ├── Page.tsx │ │ ├── ScreenPeerLayer.tsx │ │ └── SongCoverScreenView.tsx ├── composables │ ├── index.ts │ ├── useCoreState.ts │ └── useTimeServer.ts ├── env.d.ts ├── layouts │ └── Layout.astro ├── logic │ ├── connect.ts │ ├── data.ts │ ├── lyric.ts │ ├── singleTrack.ts │ └── time.ts ├── pages │ ├── controller.astro │ ├── index.astro │ └── screen.astro ├── pwa.ts ├── stores │ ├── connect.ts │ ├── coreState.ts │ ├── data.ts │ └── ui.ts └── types.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_STUN_HOST= 2 | PUBLIC_TURN_HOST= 3 | PUBLIC_TURN_USERNAME= 4 | PUBLIC_TURN_PASSWORD= 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | 23 | .netlify 24 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | strict-peer-dependencies=false 3 | auto-install-peers=true -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Diu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MayScreen 2 | 3 | Mayday teleprompter. 4 | 5 | ![MayScreen](./screenshot.png) 6 | 7 | [screen.mayday.blue](https://screen.mayday.blue) 8 | 9 | ## Features 10 | 11 | - PWA Support 12 | - WebRTC communication based on [PeerJS](https://peerjs.com/) 13 | - Powered by [Astro](https://astro.build/) & [SolidJS](https://www.solidjs.com/) 14 | 15 | ## License 16 | 17 | MIT -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config' 2 | import UnoCSS from 'unocss/astro' 3 | 4 | import { presetUno, presetIcons } from 'unocss' 5 | import transformerDirectives from '@unocss/transformer-directives' 6 | import AstroPWA from '@vite-pwa/astro' 7 | import solid from '@astrojs/solid-js' 8 | 9 | // https://astro.build/config 10 | export default defineConfig({ 11 | server: { 12 | port: 3000, 13 | }, 14 | integrations: [ 15 | solid(), 16 | UnoCSS({ 17 | presets: [ 18 | presetUno({ 19 | dark: 'class', 20 | }), 21 | presetIcons(), 22 | ], 23 | transformers: [ 24 | transformerDirectives(), 25 | ], 26 | shortcuts: [{ 27 | 'bg-base': 'bg-light-50 dark:bg-[#0A0A0A]', 28 | 'bg-base-100': 'bg-light-400 dark:bg-dark-500', 29 | 'bg-base-200': 'bg-light-600 dark:bg-dark-600', 30 | 'fg-base': 'text-neutral-700 dark:text-neutral-300', 31 | 'fg-lighter': 'text-neutral-400 dark:text-neutral-500', 32 | 'fg-lighter-200': 'text-neutral-400/50 dark:text-neutral-500/50', 33 | 'fg-emphasis': 'text-dark-900 dark:text-light-900', 34 | 'fg-primary': 'text-sky-700 dark:text-sky-300', 35 | 'bg-primary': 'bg-sky-500/15 dark:bg-sky-300/15', 36 | 'hv-base': 'transition-colors duration-300 cursor-pointer', 37 | 'border-base': 'border-light-900 dark:border-dark-200', 38 | 'bg-blur': 'bg-light-50/85 dark:bg-dark-800/85 backdrop-blur-xl backdrop-saturate-150', 39 | 'fcc': 'flex items-center justify-center', 40 | }], 41 | }), 42 | AstroPWA({ 43 | base: '/', 44 | scope: '/', 45 | includeAssets: [ 46 | 'favicon.ico', 47 | 'apple-touch-icon.png', 48 | 'mask-icon.svg', 49 | 'pwa-192x192.png', 50 | 'pwa-512x512.png', 51 | ], 52 | registerType: 'autoUpdate', 53 | manifest: { 54 | name: 'MayScreen', 55 | short_name: 'MayScreen', 56 | description: 'MayScreen', 57 | theme_color: '#000000', 58 | icons: [ 59 | { 60 | src: 'pwa-192x192.png', 61 | sizes: '192x192', 62 | type: 'image/png', 63 | }, 64 | { 65 | src: 'pwa-512x512.png', 66 | sizes: '512x512', 67 | type: 'image/png', 68 | purpose: 'any', 69 | }, 70 | { 71 | src: 'pwa-512x512.png', 72 | sizes: '512x512', 73 | type: 'image/png', 74 | purpose: 'maskable', 75 | }, 76 | ], 77 | workbox: { 78 | navigateFallback: '/404', 79 | globPatterns: ['**/*.{css,js,html,svg,png,ico,txt}'], 80 | }, 81 | devOptions: { 82 | enabled: true, 83 | navigateFallbackAllowlist: [/^\/404$/], 84 | }, 85 | }, 86 | }), 87 | ], 88 | vite: { 89 | build: { 90 | target: 'es2015', 91 | }, 92 | }, 93 | }) 94 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NETLIFY_USE_PNPM = "true" 3 | 4 | [[headers]] 5 | for = "/manifest.webmanifest" 6 | [headers.values] 7 | Content-Type = "application/manifest+json" 8 | 9 | [[headers]] 10 | for = "/assets/*" 11 | [headers.values] 12 | cache-control = ''' 13 | max-age=31536000, 14 | immutable 15 | ''' -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "may-screen", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev --host", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview --host", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@ark-ui/solid": "^4.10.1", 14 | "@astrojs/solid-js": "^5.0.4", 15 | "@nanostores/solid": "^0.5.0", 16 | "@solid-primitives/keyed": "^1.5.0", 17 | "@solid-primitives/scheduled": "^1.5.0", 18 | "@solid-primitives/upload": "^0.1.0", 19 | "@unocss/reset": "^65.4.3", 20 | "astro": "^5.2.5", 21 | "clsx": "^2.1.1", 22 | "limax": "^4.1.0", 23 | "lucide-solid": "^0.474.0", 24 | "nanostores": "^0.11.3", 25 | "peerjs": "^1.5.4", 26 | "solid-js": "^1.9.4", 27 | "solid-motionone": "^1.0.3", 28 | "workbox-window": "^7.3.0" 29 | }, 30 | "devDependencies": { 31 | "@iconify-json/ph": "^1.2.2", 32 | "@unocss/preset-icons": "^65.4.3", 33 | "@unocss/preset-web-fonts": "^65.4.3", 34 | "@unocss/transformer-directives": "^65.4.3", 35 | "@vite-pwa/astro": "^0.5.0", 36 | "unocss": "^65.4.3", 37 | "vite-plugin-pwa": "^0.21.1" 38 | }, 39 | "packageManager": "pnpm@10.2.0" 40 | } 41 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/may-today/screen/d5cc9131418fe9fd67252f08a177ab3b3a12da7a/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/may-today/screen/d5cc9131418fe9fd67252f08a177ab3b3a12da7a/public/favicon.ico -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/may-today/screen/d5cc9131418fe9fd67252f08a177ab3b3a12da7a/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/may-today/screen/d5cc9131418fe9fd67252f08a177ab3b3a12da7a/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/may-today/screen/d5cc9131418fe9fd67252f08a177ab3b3a12da7a/screenshot.png -------------------------------------------------------------------------------- /shims.d.ts: -------------------------------------------------------------------------------- 1 | import type { AttributifyAttributes } from '@unocss/preset-attributify' 2 | 3 | // declare module 'solid-js' { 4 | // namespace JSX { 5 | // interface HTMLAttributes extends AttributifyAttributes {} 6 | // } 7 | // } 8 | 9 | declare global { 10 | namespace astroHTML.JSX { 11 | interface HTMLAttributes extends AttributifyAttributes { } 12 | } 13 | namespace JSX { 14 | interface HTMLAttributes<> extends AttributifyAttributes {} 15 | } 16 | } -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DATE__: string 2 | 3 | declare module '*.yml' { 4 | const value: any 5 | export default value 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/ark.css: -------------------------------------------------------------------------------- 1 | @import 'ark/base.css'; 2 | @import 'ark/dialog.css'; 3 | @import 'ark/menu.css'; 4 | @import 'ark/pin-input.css'; 5 | @import 'ark/popover.css'; 6 | @import 'ark/tabs.css'; 7 | @import 'ark/toast.css'; 8 | @import 'ark/tooltip.css'; -------------------------------------------------------------------------------- /src/assets/ark/base.css: -------------------------------------------------------------------------------- 1 | @keyframes anim-scale-in { 2 | from { 3 | opacity: 0; 4 | transform: scale(0.9); 5 | } 6 | to { 7 | opacity: 1; 8 | transform: scale(1); 9 | } 10 | } 11 | 12 | @keyframes anim-scale-out { 13 | from { 14 | opacity: 1; 15 | transform: scale(1); 16 | } 17 | to { 18 | opacity: 0; 19 | transform: scale(0.9); 20 | } 21 | } 22 | 23 | @keyframes anim-bottom-in { 24 | from { 25 | opacity: 0; 26 | transform: translateY(10px); 27 | } 28 | to { 29 | opacity: 1; 30 | transform: translateY(0); 31 | } 32 | } 33 | 34 | @keyframes anim-bottom-out { 35 | from { 36 | opacity: 1; 37 | transform: translateY(0); 38 | } 39 | to { 40 | opacity: 0; 41 | transform: translateY(10px); 42 | } 43 | } -------------------------------------------------------------------------------- /src/assets/ark/dialog.css: -------------------------------------------------------------------------------- 1 | [data-scope='dialog'][data-part='backdrop'] { 2 | @apply fixed top-0 left-0 bottom-0 w-100vw z-20; 3 | --at-apply: 'bg-black/70'; 4 | } 5 | 6 | [data-scope='dialog'][data-part='positioner'] { 7 | @apply fixed fcc top-0 left-0 bottom-0 w-100vw z-20; 8 | } 9 | 10 | [data-scope='dialog'][data-part='content'] { 11 | @apply w-full max-w-md max-h-90vh mx-4 bg-base overflow-hidden; 12 | @apply rounded-xl border border-dark outline-0; 13 | } 14 | 15 | [data-scope='dialog'][data-part='content'].black { 16 | @apply bg-black; 17 | } 18 | 19 | [data-scope='dialog'][data-part='content'].sidebar { 20 | @apply fixed top-0 left-0 bottom-0 w-70vw max-w-300px max-h-100vh m-0; 21 | @apply flex flex-col border-0 border-r border-base bg-base rounded-0; 22 | @apply overflow-hidden transition-all; 23 | animation: none !important; 24 | } 25 | 26 | [data-scope='dialog'][data-part='content'][data-state='open'].sidebar { 27 | @apply translate-x-0; 28 | } 29 | 30 | [data-scope='dialog'][data-part='content'][data-state='closed'].sidebar { 31 | @apply -translate-x-full; 32 | } 33 | 34 | [data-scope='dialog'][data-part='content'][data-state='open'] { 35 | animation: anim-scale-in 200ms ease-out; 36 | } 37 | 38 | [data-scope='dialog'][data-part='content'][data-state='closed'] { 39 | animation: anim-scale-out 200ms ease-in; 40 | } 41 | 42 | [data-scope='dialog'][data-part='title'] { 43 | @apply font-semibold leading-none; 44 | } 45 | 46 | [data-scope='dialog'][data-part='description'] { 47 | @apply text-sm fg-lighter; 48 | } 49 | 50 | [data-scope='dialog'][data-part='trigger'] { 51 | @apply text-sm bg-transparent; 52 | } 53 | 54 | -------------------------------------------------------------------------------- /src/assets/ark/menu.css: -------------------------------------------------------------------------------- 1 | [data-scope='menu'][data-part='trigger'] { 2 | @apply bg-transparent; 3 | } 4 | 5 | [data-scope='menu'][data-part='content'] { 6 | @apply p-4 bg-base; 7 | @apply border border-base rounded-md; 8 | } 9 | 10 | [data-scope='menu'][data-part='content'].black { 11 | @apply bg-black; 12 | } 13 | 14 | [data-scope='menu'][data-part='content'] { 15 | @apply p-1 bg-base; 16 | @apply border border-base; 17 | } 18 | 19 | [data-scope='menu'][data-part='item'] { 20 | @apply px-3 py-2 bg-base; 21 | @apply hv-base rounded-md; 22 | } 23 | 24 | [data-scope='menu'][data-part='trigger'] { 25 | --at-apply: 'focus-visible:outline-0'; 26 | --at-apply: 'focus:outline-0 focus:border-white'; 27 | --at-apply: 'disabled:cursor-not-allowed disabled:opacity-50'; 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/assets/ark/pin-input.css: -------------------------------------------------------------------------------- 1 | 2 | [data-scope='pin-input'] { 3 | @apply z-30; 4 | } 5 | 6 | [data-scope='pin-input'][data-part='control'] { 7 | @apply fcc gap-1; 8 | } 9 | 10 | [data-scope='pin-input'][data-part='input'] { 11 | @apply w-10 h-10 rounded-md border border-dark bg-transparent text-center transition-colors ring-0; 12 | --at-apply: 'placeholder:fg-lighter-200'; 13 | --at-apply: 'focus-visible:outline-0'; 14 | --at-apply: 'focus:outline-0 focus:border-white'; 15 | --at-apply: 'disabled:cursor-not-allowed disabled:opacity-50'; 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/assets/ark/popover.css: -------------------------------------------------------------------------------- 1 | [data-scope='popover'] { 2 | @apply z-20; 3 | } 4 | 5 | [data-scope='popover'][data-part='trigger'] { 6 | @apply bg-transparent; 7 | } 8 | 9 | [data-scope='popover'][data-part='close-trigger'] { 10 | @apply bg-transparent; 11 | } 12 | 13 | [data-scope='popover'][data-part='content'] { 14 | @apply bg-base; 15 | @apply border border-base rounded-md overflow-hidden; 16 | } 17 | 18 | [data-scope='popover'][data-part='content'].bg-blur { 19 | @apply bg-blur; 20 | } 21 | 22 | [data-scope='popover'][data-part='content'].black { 23 | @apply bg-black; 24 | } 25 | 26 | [data-scope='popover'][data-part='content'].sm { 27 | @apply text-xs px-2 py-1 border border-dark max-w-sm; 28 | } 29 | 30 | [data-scope='popover'][data-part='content'][data-state='open'] { 31 | animation: anim-scale-in 200ms ease-out; 32 | } 33 | 34 | [data-scope='popover'][data-part='content'][data-state='closed'] { 35 | animation: anim-scale-out 200ms ease-in; 36 | } 37 | 38 | [data-scope='popover'][data-part='content'][data-state='open'].bottom-in { 39 | animation: anim-bottom-in 200ms ease-out; 40 | } 41 | 42 | [data-scope='popover'][data-part='content'][data-state='closed'].bottom-in { 43 | animation: anim-bottom-out 200ms ease-in; 44 | } 45 | 46 | -------------------------------------------------------------------------------- /src/assets/ark/tabs.css: -------------------------------------------------------------------------------- 1 | [data-scope='tabs'][data-part='list'] { 2 | @apply flex gap-2 text-sm; 3 | } 4 | 5 | [data-scope='tabs'][data-part='trigger'] { 6 | @apply bg-transparent op-50 px-1 py-1; 7 | } 8 | 9 | [data-scope='tabs'][data-part='trigger'][data-selected] { 10 | @apply op-100; 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/ark/toast.css: -------------------------------------------------------------------------------- 1 | [data-scope='toast'][data-part='root'] { 2 | @apply bg-base; 3 | @apply border border-base rounded-md overflow-hidden; 4 | @apply flex flex-col px-4 py-3; 5 | --at-apply: 'min-w-20em max-w-80vw sm:max-w-40em'; 6 | } 7 | 8 | [data-scope='toast'][data-part='root'][data-state='open'] { 9 | animation: anim-bottom-in 200ms ease-out; 10 | } 11 | 12 | [data-scope='toast'][data-part='root'][data-state='closed'] { 13 | animation: anim-bottom-out 200ms ease-in; 14 | } 15 | 16 | [data-scope='toast'][data-part='title'] { 17 | @apply text-sm font-semibold mb-1; 18 | } 19 | 20 | [data-scope='toast'][data-part='description'] { 21 | @apply text-sm fg-lighter; 22 | } 23 | 24 | [data-scope='toast'][data-part='close-trigger'] { 25 | @apply absolute top-1 right-1; 26 | @apply text-sm bg-transparent; 27 | } 28 | -------------------------------------------------------------------------------- /src/assets/ark/tooltip.css: -------------------------------------------------------------------------------- 1 | [data-scope='tooltip'] { 2 | @apply z-30; 3 | } 4 | 5 | [data-scope='tooltip'][data-part='trigger'] { 6 | @apply bg-transparent; 7 | } 8 | 9 | [data-scope='tooltip'][data-part='content'] { 10 | @apply text-xs text-white bg-black rounded-lg px-2 py-1 border border-dark max-w-sm; 11 | } 12 | 13 | [data-scope='tooltip'][data-part='content'][data-state='open'] { 14 | animation: anim-scale-in 200ms ease-out; 15 | } 16 | 17 | [data-scope='tooltip'][data-part='content'][data-state='closed'] { 18 | animation: anim-scale-out 200ms ease-in; 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/assets/dataset.ts: -------------------------------------------------------------------------------- 1 | export const datasetConfig = { 2 | mayday: { 3 | name: '五月天', 4 | downUrl: 'https://wx-static.ddiu.site/dataset/mayday-5525.json', 5 | }, 6 | jayzhou: { 7 | name: '周杰伦', 8 | downUrl: 'https://wx-static.ddiu.site/dataset/jayzhou.json', 9 | }, 10 | jjlin: { 11 | name: '林俊杰', 12 | downUrl: 'https://wx-static.ddiu.site/dataset/jjlin.json', 13 | }, 14 | fhcq: { 15 | name: '凤凰传奇', 16 | downUrl: 'https://wx-static.ddiu.site/dataset/fhcq.json', 17 | }, 18 | } as Record -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import 'ark.css'; 2 | 3 | :root { 4 | --c-scroll: #d9d9d9; 5 | --c-scroll-hover: #bbbbbb; 6 | --c-shadow: #00000008; 7 | --c-primary: #0369a120; 8 | } 9 | 10 | :focus { 11 | outline: none; 12 | } 13 | 14 | @media (prefers-color-scheme: dark) { 15 | :root { 16 | --c-scroll: #333333; 17 | --c-scroll-hover: #555555; 18 | --c-shadow: #ffffff08; 19 | --c-primary: #7dd3fc20; 20 | } 21 | } 22 | 23 | ::-webkit-scrollbar { 24 | width: 4px; 25 | height: 4px; 26 | } 27 | 28 | ::-webkit-scrollbar-thumb { 29 | background-color: var(--c-scroll); 30 | } 31 | 32 | ::-webkit-scrollbar-thumb:hover { 33 | background-color: var(--c-scroll-hover); 34 | } 35 | 36 | .anim-text-out { 37 | animation: text-out 400ms ease-out; 38 | animation-fill-mode: forwards; 39 | } 40 | 41 | .loading-anim::before { 42 | content: ' '; 43 | position: absolute; 44 | top: 0; 45 | bottom: 0; 46 | background-image: linear-gradient(90deg, #ffffff00 0%, #ffffff66 35%, #ffffff66 65%, #ffffff00 100%); 47 | width: 60%; 48 | animation-duration: 2s; 49 | animation-iteration-count: infinite; 50 | animation-name: progress-bar-loop; 51 | } 52 | 53 | @keyframes text-out { 54 | from { 55 | transform: translateX(0); 56 | opacity: 1; 57 | } 58 | to { 59 | transform: translateX(-6px); 60 | opacity: 0; 61 | } 62 | } 63 | 64 | @keyframes progress-bar-loop { 65 | from { 66 | left: -60%; 67 | } 68 | to { 69 | left: 110%; 70 | } 71 | } 72 | 73 | @media(hover: hover) and (pointer: fine) { 74 | .hv-base { 75 | @apply hover:bg-base-100; 76 | } 77 | } -------------------------------------------------------------------------------- /src/components/common/AllSongList.tsx: -------------------------------------------------------------------------------- 1 | import { For } from 'solid-js' 2 | import { useStore } from '@nanostores/solid' 3 | import clsx from 'clsx' 4 | import { $metaGroupList } from '@/stores/data' 5 | import { $currentSongId } from '@/stores/coreState' 6 | import { $coreState } from '@/composables' 7 | 8 | interface Props { 9 | class?: string 10 | onClick?: (songId: string) => void 11 | } 12 | 13 | export default (props: Props) => { 14 | const metaGroupList = useStore($metaGroupList) 15 | const currentSongId = useStore($currentSongId) 16 | 17 | let scrollDiv: HTMLDivElement 18 | 19 | $metaGroupList.listen(() => { 20 | scrollDiv.scrollTop = 0 21 | }) 22 | 23 | const handleSongClick = (songId: string) => { 24 | if (props.onClick) { 25 | props.onClick(songId) 26 | return 27 | } 28 | $coreState.triggerAction({ type: 'set_id', payload: songId }) 29 | } 30 | 31 | return ( 32 |
33 | 34 | {(group) => ( 35 |
36 |

{group.index}

37 |
38 | 39 | {song => ( 40 |
handleSongClick(song.slug)} 46 | > 47 | { song.title } 48 |
49 | )} 50 |
51 |
52 |
53 | )} 54 |
55 |
56 | ) 57 | } -------------------------------------------------------------------------------- /src/components/common/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js" 2 | 3 | interface Props { 4 | class?: string 5 | variant?: 'primary' | 'secondary' | 'outline' | 'ghost' 6 | size?: 'small' | 'medium' | 'large' 7 | icon?: string 8 | onClick?: () => void 9 | children?: JSXElement 10 | } 11 | 12 | export default (props: Props) => { 13 | const variantClass = () => { 14 | return { 15 | primary: 'fcc gap-1 rounded-md shrink-0 bg-white text-black hover:bg-light cursor-pointer', 16 | secondary: 'fcc gap-1 rounded-md shrink-0 bg-base-100 hover:bg-base-100 cursor-pointer', 17 | outline: 'fcc gap-1 rounded-md shrink-0 bg-transparent border border-white/20 hover:bg-base-100 cursor-pointer', 18 | ghost: 'fcc gap-1 rounded-md shrink-0 bg-transparent cursor-pointer', 19 | }[props.variant || 'primary'] 20 | } 21 | 22 | const sizeClass = () => { 23 | return { 24 | small: props.children ? 'px-1 py-1 text-xs' : 'w-6 h-6 text-xs', 25 | medium: props.children ? 'p-2 text-sm' : 'w-8 h-8 text-sm', 26 | large: props.children ? 'p-2 text-md' : 'w-10 h-10 text-md', 27 | }[props.size || 'medium'] 28 | } 29 | 30 | return ( 31 |
32 | { props.icon &&
} 33 | { props.children } 34 |
35 | ) 36 | } -------------------------------------------------------------------------------- /src/components/common/ConnectMessageDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '@nanostores/solid' 2 | import { Portal } from 'solid-js/web' 3 | import { Dialog } from '@ark-ui/solid' 4 | import { X } from 'lucide-solid' 5 | import { $connectErrorMessage } from '@/stores/connect' 6 | 7 | export default () => { 8 | const connectErrorMessage = useStore($connectErrorMessage) 9 | 10 | return ( 11 | $connectErrorMessage.set(null)} closeOnInteractOutside={false} trapFocus={false}> 12 | 13 | 14 | 15 | 16 |
17 | 连接失败 18 |
19 |
20 |

{connectErrorMessage()}

21 |
22 | 23 | 24 | 25 |
26 |
27 |
28 |
29 | ) 30 | } -------------------------------------------------------------------------------- /src/components/common/DataLayer.tsx: -------------------------------------------------------------------------------- 1 | import { onMount } from 'solid-js' 2 | import { loadStorageData, fetchAndUpdateData } from '@/logic/data' 3 | import { $dataset } from '@/stores/coreState' 4 | 5 | export default () => { 6 | const fetchedDatasets = [] as string[] 7 | onMount(() => { 8 | $dataset.subscribe(async (dataset) => { 9 | await loadStorageData(dataset) 10 | if (!fetchedDatasets.includes(dataset)) { 11 | await fetchAndUpdateData(dataset) && fetchedDatasets.push(dataset) 12 | } 13 | }) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/common/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { Portal } from 'solid-js/web' 2 | import { Dialog } from '@ark-ui/solid' 3 | import { X } from 'lucide-solid' 4 | import Button from './Button' 5 | 6 | export default () => { 7 | return ( 8 | 9 | 10 | MayScreen 11 | 12 | 13 | 14 | 15 | 16 |
17 | MayScreen 18 | 五迷创作的云端提词器 19 |
20 |
21 |

制作: Diu

22 |

源代码: may-today/screen

23 |
24 |
25 | 28 |
29 | 30 | 31 | 32 |
33 |
34 |
35 |
36 | ) 37 | } -------------------------------------------------------------------------------- /src/components/common/LyricListView.tsx: -------------------------------------------------------------------------------- 1 | import { For, Show } from 'solid-js' 2 | import { useStore } from '@nanostores/solid' 3 | import clsx from 'clsx' 4 | import { $coreState } from '@/composables' 5 | import { $currentTimelineData } from '@/stores/data' 6 | import { $currentLyricIndex } from '@/stores/coreState' 7 | import { parseTime } from '@/logic/time' 8 | 9 | export default () => { 10 | const { triggerAction } = $coreState 11 | const currentTimelineData = useStore($currentTimelineData) 12 | const currentLyricIndex = useStore($currentLyricIndex) 13 | 14 | let scrollDiv: HTMLDivElement 15 | 16 | $currentTimelineData.listen(() => { 17 | setTimeout(() => { 18 | scrollDiv.scrollTop = 0 19 | }, 0) 20 | }) 21 | 22 | return ( 23 |
24 | 25 | {(line, index) => ( 26 |
{ triggerAction({ type: 'set_lyric_index', payload: index() }) }} 34 | > 35 | = 0}> 36 |
40 | {parseTime(line.startTime)} 41 |
42 |
43 |
48 | {line.data.text} 49 |
50 |
51 | )} 52 |
53 |
54 | ) 55 | } -------------------------------------------------------------------------------- /src/components/common/SongListSidebarContent.tsx: -------------------------------------------------------------------------------- 1 | import { For, Show, createSignal } from 'solid-js' 2 | import { useStore } from '@nanostores/solid' 3 | import clsx from 'clsx' 4 | import { Tabs, Menu, Popover } from '@ark-ui/solid' 5 | import { ChevronsUpDown } from 'lucide-solid' 6 | import { $coreState } from '@/composables' 7 | import { $dataset } from '@/stores/coreState' 8 | import { datasetConfig } from '@/assets/dataset' 9 | import SongList from './sidebarTab/SongList' 10 | import WebSearchList from './sidebarTab/WebSearchList' 11 | import UploadLyricPanel from './sidebarTab/UploadLyricPanel' 12 | 13 | export default () => { 14 | const [currentTab, setCurrentTab] = createSignal('local_list') 15 | const dataset = useStore($dataset) 16 | const currentDatasetInfo = () => ( 17 | datasetConfig[dataset()] || null 18 | ) 19 | 20 | const handleSelectDataset = (key: string) => { 21 | $coreState.triggerAction({ type: 'set_dataset', payload: key }) 22 | } 23 | 24 | return ( 25 | setCurrentTab(e.value!)} 29 | > 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {currentDatasetInfo().name}曲库 45 | 46 | 47 | 48 | 49 | 50 | {(key) => ( 51 | handleSelectDataset(key)} 58 | > 59 | {datasetConfig[key].name}曲库 60 | 61 | )} 62 | 63 | 64 | 65 | 66 | 67 | 68 | 网络搜索 69 | 上传 70 | 71 | 72 | ) 73 | } -------------------------------------------------------------------------------- /src/components/common/StatusView.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '@nanostores/solid' 2 | import { $connectStatus } from '@/stores/connect' 3 | import { $statusText, $connectionDialogOpen } from '@/stores/ui' 4 | import Button from './Button' 5 | import { Show } from 'solid-js' 6 | 7 | export default () => { 8 | const connectStatus = useStore($connectStatus) 9 | const statusText = useStore($statusText) 10 | let noticeTextDom: HTMLDivElement 11 | 12 | $statusText.listen((v) => { 13 | noticeTextDom.classList.remove('anim-text-out') 14 | setTimeout(() => { 15 | noticeTextDom.classList.add('anim-text-out') 16 | }, 2000) 17 | }) 18 | 19 | const statusDotClass = () => ({ 20 | 'not-ready': 'bg-gray/40', 21 | ready: 'bg-gray', 22 | connecting: 'bg-yellow-500', 23 | connected: 'bg-green-700', 24 | error: 'bg-red', 25 | }[connectStatus()]) 26 | 27 | return ( 28 |
29 |
$connectionDialogOpen.set(true)}> 30 |
31 |
32 | 33 | 34 | 35 |
{statusText()}
36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/components/common/ToggleButton.tsx: -------------------------------------------------------------------------------- 1 | import type { JSXElement } from "solid-js" 2 | 3 | interface Props { 4 | class?: string 5 | toggle: boolean 6 | disabled?: boolean 7 | onClick?: () => void 8 | children?: JSXElement 9 | } 10 | 11 | export default (props: Props) => { 12 | const toggleClass = () => { 13 | let baseClass = '' 14 | if (props.toggle) { 15 | baseClass += 'bg-white text-black transition-colors duration-300' 16 | if (!props.disabled) { 17 | baseClass += ' hover:bg-light-600 cursor-pointer' 18 | } 19 | } else { 20 | if (!props.disabled) { 21 | baseClass += 'hv-base' 22 | } 23 | } 24 | return baseClass 25 | } 26 | 27 | const disabledClass = () => { 28 | if (props.disabled) { 29 | return 'op-50 cursor-not-allowed' 30 | } else { 31 | return '' 32 | } 33 | } 34 | 35 | return ( 36 |
37 | { props.children } 38 |
39 | ) 40 | } -------------------------------------------------------------------------------- /src/components/common/sidebarTab/SongList.tsx: -------------------------------------------------------------------------------- 1 | import { For, createSignal, Show } from 'solid-js' 2 | import { useStore } from '@nanostores/solid' 3 | import { Search } from 'lucide-solid' 4 | import { $allDataDict } from '@/stores/data' 5 | import { searchByString } from '@/logic/data' 6 | import { $sidebarOpen } from '@/stores/ui' 7 | import { $currentSongId } from '@/stores/coreState' 8 | import type { SearchItem } from '@/types' 9 | import { $coreState } from '@/composables' 10 | import AllSongList from '@/components/common/AllSongList' 11 | 12 | export default () => { 13 | const allDataDict = useStore($allDataDict) 14 | const currentSongId = useStore($currentSongId) 15 | let inputRef: HTMLInputElement 16 | const [inputText, setInputText] = createSignal('') 17 | const [filteredList, setFilteredList] = createSignal([]) 18 | 19 | const handleSongClick = (songId: string) => { 20 | $coreState.triggerAction({ type: 'set_id', payload: songId }) 21 | $sidebarOpen.set(false) 22 | clearInputState() 23 | } 24 | 25 | const handleInput = () => { 26 | const input = inputRef.value 27 | setInputText(input) 28 | const filtered = searchByString(input, Object.values(allDataDict())) 29 | setFilteredList(filtered) 30 | } 31 | 32 | const clearInputState = () => { 33 | inputRef.value = '' 34 | setInputText('') 35 | setFilteredList([]) 36 | } 37 | 38 | const SearchList = () => ( 39 |
40 | 41 | {song => ( 42 |
handleSongClick(song.data.slug)} 48 | > 49 |
{ song.data.title }
50 |
{ song.matchLines }
51 |
{ song.highlightLines }
52 |
53 | )} 54 |
55 |
56 | ) 57 | 58 | const SearchBox = () => ( 59 |
60 | 61 |
62 | { 69 | if (e.key === 'Escape') { 70 | clearInputState() 71 | } else if (e.key === 'Enter') { 72 | inputRef.blur() 73 | } 74 | }} 75 | /> 76 |
77 |
78 | ) 79 | 80 | return ( 81 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 |
90 | ) 91 | } -------------------------------------------------------------------------------- /src/components/common/sidebarTab/UploadLyricPanel.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js' 2 | import Button from '@/components/common/Button' 3 | import clsx from 'clsx' 4 | import { $coreState } from '@/composables' 5 | import { parseRawLRCFile } from '@/logic/lyric' 6 | import { $sidebarOpen } from '@/stores/ui' 7 | import type { SongDetail } from '@/types' 8 | 9 | export default () => { 10 | let inputRef: HTMLInputElement 11 | let textareaRef: HTMLTextAreaElement 12 | const [titleText, setTitleText] = createSignal('') 13 | const [contentText, setContentText] = createSignal('') 14 | 15 | const handleClear = () => { 16 | inputRef.value = '' 17 | textareaRef.value = '' 18 | setTitleText('') 19 | setContentText('') 20 | } 21 | 22 | const handlePushLyric = () => { 23 | const rawContent = contentText().trim() 24 | if (!rawContent) return 25 | const rawSongName = titleText() || '♫' 26 | const [songTitle, songArtist] = rawSongName.split('-', 2).map((str) => str.trim()) 27 | const singleTrack: SongDetail = { 28 | title: songTitle, 29 | slug: songTitle, 30 | index: '', 31 | meta: { 32 | artist: songArtist, 33 | }, 34 | detail: parseRawLRCFile(rawContent), 35 | } 36 | $coreState.triggerAction({ type: 'set_single_track', payload: singleTrack }) 37 | handleClear() 38 | $sidebarOpen.set(false) 39 | } 40 | 41 | return ( 42 |
43 |