Merge pull request #53 from L1uyQwQ/codex/mobile-responsive-controls

fix: improve mobile web responsive controls
This commit is contained in:
TIANYAO ZHANG authored and GitHub committed 2026-05-07 19:39:51 +08:00
commit b8c12e0291
4 files changed
+1185 -4

No files matched your search

+130 -4
View File
@@ -6,6 +6,7 @@
</main> </main>
<Player /> <Player />
<Toast /> <Toast />
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
<!-- Mobile mini player --> <!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')"> <div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
@@ -17,12 +18,38 @@
<div class="m-player-name">{{ currentSong.name }}</div> <div class="m-player-name">{{ currentSong.name }}</div>
<div class="m-player-artist">{{ currentSong.artist }}</div> <div class="m-player-artist">{{ currentSong.artist }}</div>
</div> </div>
<button class="m-player-btn" @click.stop="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()"> <div class="m-player-controls" @click.stop>
<button class="m-player-btn" @click="playerStore.prev()">
<Icon icon="mdi:skip-previous" />
</button>
<button class="m-player-btn" @click="playerStore.isPlaying ? playerStore.pause() : playerStore.resume()">
<Icon :icon="playerStore.isPlaying ? 'mdi:pause' : 'mdi:play'" /> <Icon :icon="playerStore.isPlaying ? 'mdi:pause' : 'mdi:play'" />
</button> </button>
<button class="m-player-btn" @click.stop="playerStore.next()"> <button class="m-player-btn" @click="playerStore.next()">
<Icon icon="mdi:skip-next" /> <Icon icon="mdi:skip-next" />
</button> </button>
<button class="m-player-btn" @click="cycleMobileMode">
<Icon :icon="mobileModeIcon" />
</button>
<button class="m-player-btn" @click="toggleMobileQueue">
<Icon icon="mdi:playlist-music" />
</button>
<button class="m-player-btn" @click="toggleMobileVolume">
<Icon icon="mdi:volume-high" />
</button>
</div>
<div v-if="mobileVolumeOpen" class="m-volume-popover" @click.stop>
<Icon icon="mdi:volume-high" class="m-volume-icon" />
<input
type="range"
min="0"
max="100"
:value="mobileVolume"
class="m-volume-slider"
@input="onMobileVolumeChange"
/>
<span class="m-volume-value">{{ mobileVolume }}</span>
</div>
</div> </div>
<!-- Mobile bottom tab bar --> <!-- Mobile bottom tab bar -->
@@ -57,6 +84,7 @@ import Navbar from './components/Navbar.vue';
import Player from './components/Player.vue'; import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue'; import CoverArt from './components/CoverArt.vue';
import Toast from './components/Toast.vue'; import Toast from './components/Toast.vue';
import Queue from './components/Queue.vue';
const playerStore = usePlayerStore(); const playerStore = usePlayerStore();
const theme = computed(() => playerStore.theme); const theme = computed(() => playerStore.theme);
@@ -64,6 +92,18 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const { connect } = useWebSocket(); const { connect } = useWebSocket();
const currentSong = computed(() => playerStore.currentSong); const currentSong = computed(() => playerStore.currentSong);
const mobileVolume = computed(() => playerStore.activeBot?.volume ?? 75);
const mobileMode = computed(() => playerStore.activeBot?.playMode ?? 'seq');
const mobileModeOrder = ['seq', 'loop', 'random', 'rloop'];
const mobileModeIcons: Record<string, string> = {
seq: 'mdi:arrow-right',
loop: 'mdi:repeat',
random: 'mdi:shuffle',
rloop: 'mdi:repeat-once',
};
const mobileModeIcon = computed(() => mobileModeIcons[mobileMode.value] ?? mobileModeIcons.seq);
const mobileVolumeOpen = ref(false);
const mobileQueueOpen = ref(false);
const mobileProgressPct = ref(0); const mobileProgressPct = ref(0);
let syncTimer: ReturnType<typeof setInterval> | null = null; let syncTimer: ReturnType<typeof setInterval> | null = null;
@@ -77,6 +117,29 @@ function updateMobileProgress() {
mobileRaf = requestAnimationFrame(updateMobileProgress); mobileRaf = requestAnimationFrame(updateMobileProgress);
} }
function onMobileVolumeChange(e: Event) {
const volume = Number((e.target as HTMLInputElement).value);
playerStore.setVolume(volume);
}
function toggleMobileVolume() {
mobileVolumeOpen.value = !mobileVolumeOpen.value;
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
}
function toggleMobileQueue() {
mobileQueueOpen.value = !mobileQueueOpen.value;
if (mobileQueueOpen.value) mobileVolumeOpen.value = false;
}
function cycleMobileMode() {
const currentIndex = mobileModeOrder.indexOf(mobileMode.value);
const nextMode = mobileModeOrder[(currentIndex + 1) % mobileModeOrder.length] ?? mobileModeOrder[0];
mobileVolumeOpen.value = false;
mobileQueueOpen.value = false;
playerStore.setMode(nextMode);
}
onMounted(() => { onMounted(() => {
playerStore.loadTheme(); playerStore.loadTheme();
connect(); connect();
@@ -132,6 +195,14 @@ onUnmounted(() => {
} }
} }
.mobile-queue {
display: none;
@media (max-width: 768px) {
display: flex;
}
}
.m-player-progress { .m-player-progress {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -151,6 +222,13 @@ onUnmounted(() => {
min-width: 0; min-width: 0;
} }
.m-player-controls {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.m-player-name { .m-player-name {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
@@ -168,16 +246,64 @@ onUnmounted(() => {
} }
.m-player-btn { .m-player-btn {
width: 32px; width: 28px;
height: 32px; height: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 22px; font-size: 20px;
opacity: 0.85; opacity: 0.85;
flex-shrink: 0; flex-shrink: 0;
} }
.m-volume-popover {
position: absolute;
right: 8px;
bottom: calc(100% + 8px);
display: flex;
align-items: center;
gap: 8px;
width: min(260px, calc(100vw - 32px));
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-dropdown);
cursor: default;
}
.m-volume-icon {
flex: 0 0 auto;
font-size: 18px;
color: var(--text-secondary);
}
.m-volume-slider {
flex: 1 1 auto;
min-width: 0;
height: 4px;
appearance: none;
background: var(--border-color);
border-radius: 2px;
outline: none;
&::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
background: var(--color-primary);
border-radius: 50%;
}
}
.m-volume-value {
flex: 0 0 30px;
font-size: 12px;
color: var(--text-secondary);
text-align: right;
font-variant-numeric: tabular-nums;
}
// Mobile bottom tab bar // Mobile bottom tab bar
.m-tabbar { .m-tabbar {
position: fixed; position: fixed;
+1
View File
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia';
import App from './App.vue'; import App from './App.vue';
import router from './router/index.js'; import router from './router/index.js';
import './styles/global.scss'; import './styles/global.scss';
import './styles/mobile.scss';
const app = createApp(App); const app = createApp(App);
app.use(createPinia()); app.use(createPinia());
+4
View File
@@ -376,11 +376,15 @@ export const usePlayerStore = defineStore('player', {
async setVolume(volume: number) { async setVolume(volume: number) {
if (!this.activeBotId) return; if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/volume`, { volume }); await axios.post(`/api/player/${this.activeBotId}/volume`, { volume });
const bot = this.bots.find((b) => b.id === this.activeBotId);
if (bot) bot.volume = volume;
}, },
async setMode(mode: string) { async setMode(mode: string) {
if (!this.activeBotId) return; if (!this.activeBotId) return;
await axios.post(`/api/player/${this.activeBotId}/mode`, { mode }); await axios.post(`/api/player/${this.activeBotId}/mode`, { mode });
const bot = this.bots.find((b) => b.id === this.activeBotId);
if (bot) bot.playMode = mode;
}, },
async fetchHomeData() { async fetchHomeData() {
File diff suppressed because it is too large. Load diff