前言
我的博客底部有一个"每日一句"的板块,每天展示一句英文名言和对应的中文翻译。最近突发奇想,如果能直接播放这句英文的音频,对于学习英语的访客来说会是一个很棒的功能。于是就有了这篇文章,记录整个实现过程。
需求分析
在动手之前,先明确一下需求:
- 功能实现:点击播放按钮,获取并播放每日一句的英文音频
- 界面设计:播放按钮要与现有页面风格保持一致
- 响应式适配:PC端和移动端都要有良好的显示效果
- 兼容性:不能影响页面原有的任何功能
- 用户体验:需要有播放状态反馈,让用户知道当前是否在播放
技术方案
音频来源
有道词典有一个每日一句的API接口,返回的数据中包含音频URL:
https://dict.youdao.com/infoline/style/cardList?mode=publish&client=mobile&style=daily
跨域问题
直接从前端调用有道词典API会遇到CORS跨域限制。解决方案是在后端添加一个代理接口,前端调用后端接口,后端再去调用有道词典API。
实现步骤
- 后端添加代理接口
/api/daily-quote/voice - 前端添加播放按钮和音频播放逻辑
- 添加播放状态反馈(播放中/暂停)
- 响应式样式适配
后端实现
在 IndexController.java 中添加新的接口:
java
/**
* 获取每日一句音频URL(代理有道词典API,解决CORS问题)
*
* @return 音频URL JSON数据
*/
@GetMapping(value = "/api/daily-quote/voice", produces = "application/json;charset=UTF-8")
@ResponseBody
public Map<String, Object> getDailyQuoteVoice() {
Map<String, Object> result = new HashMap<>();
String apiUrl = "https://dict.youdao.com/infoline/style/cardList?mode=publish&client=mobile&style=daily";
try {
HttpHeaders headers = new HttpHeaders();
headers.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
headers.add("Accept", "application/json, text/plain, */*");
headers.add("Host", "dict.youdao.com");
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
ResponseEntity<String> responseEntity = restTemplate.exchange(
apiUrl,
HttpMethod.GET,
requestEntity,
String.class
);
if (responseEntity.getStatusCode() == HttpStatus.OK) {
String responseStr = responseEntity.getBody();
if (responseStr != null && !responseStr.isEmpty()) {
JsonNode rootNode = objectMapper.readTree(responseStr);
if (rootNode.isArray() && rootNode.size() > 0) {
for (JsonNode item : rootNode) {
if (item.has("voice") && !item.get("voice").asText().trim().isEmpty()) {
String voiceUrl = item.get("voice").asText().trim();
result.put("voiceUrl", voiceUrl);
result.put("success", true);
return result;
}
}
}
}
}
} catch (Exception e) {
log.error("获取每日一句音频URL失败", e);
}
result.put("voiceUrl", "");
result.put("success", false);
result.put("message", "获取音频URL失败");
return result;
}
前端实现
HTML结构
在每日一句卡片中添加播放按钮:
html
<div class="daily-quote" id="daily-quote">
<div class="daily-quote-content">
<div class="daily-quote-en" id="quote-en">Loading...</div>
<div class="daily-quote-cn" id="quote-cn">正在获取每日一句...</div>
</div>
<button class="quote-play-btn" id="quote-play-btn" title="播放英文音频" aria-label="播放英文音频">
<i class="fas fa-play" id="quote-play-icon"></i>
</button>
</div>
CSS样式
播放按钮的样式设计:
css
/* 播放按钮样式 */
.quote-play-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
border: none;
cursor: pointer;
color: white;
font-size: 1rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);
flex-shrink: 0;
position: relative;
z-index: 2;
}
.quote-play-btn:hover {
transform: scale(1.1);
box-shadow: 0 4px 16px rgba(14, 165, 233, 0.5);
}
/* 播放中状态 */
.quote-play-btn.playing {
background: linear-gradient(135deg, #ec4899 0%, #db2777 100%);
box-shadow: 0 2px 8px rgba(236, 72, 153, 0.3);
animation: pulsePlayBtn 1.5s ease-in-out infinite;
}
@keyframes pulsePlayBtn {
0%, 100% {
box-shadow: 0 2px 8px rgba(236, 72, 153, 0.3), 0 0 0 0 rgba(236, 72, 153, 0.4);
}
50% {
box-shadow: 0 2px 8px rgba(236, 72, 153, 0.3), 0 0 0 8px rgba(236, 72, 153, 0);
}
}
JavaScript逻辑
音频播放的核心逻辑:
javascript
// 音频播放相关变量
var currentAudioUrl = null;
var audioPlayer = null;
var isPlaying = false;
var isLoading = false;
// 更新播放按钮状态
function updatePlayButtonState(playing) {
isPlaying = playing;
if (playBtn && playIcon) {
if (playing) {
playBtn.classList.add('playing');
playIcon.className = 'fas fa-pause';
playBtn.title = '暂停播放';
} else {
playBtn.classList.remove('playing');
playIcon.className = 'fas fa-play';
playBtn.title = '播放英文音频';
}
}
}
// 播放音频
function playAudio(url) {
if (!url) return;
// 如果正在播放同一音频,则暂停
if (audioPlayer && currentAudioUrl === url && isPlaying) {
audioPlayer.pause();
updatePlayButtonState(false);
return;
}
// 如果已有音频实例,先停止
if (audioPlayer) {
audioPlayer.pause();
audioPlayer = null;
}
// 创建新的音频实例
audioPlayer = new Audio(url);
// 监听播放结束
audioPlayer.addEventListener('ended', function() {
updatePlayButtonState(false);
});
// 开始播放
audioPlayer.play().then(function() {
currentAudioUrl = url;
updatePlayButtonState(true);
isLoading = false;
}).catch(function(error) {
console.error('Audio play failed:', error);
updatePlayButtonState(false);
isLoading = false;
});
}
// 获取并播放音频
function fetchAndPlayAudio() {
if (isLoading) return;
isLoading = true;
// 调用后端代理接口
fetch('/api/daily-quote/voice')
.then(function(response) {
return response.json();
})
.then(function(data) {
if (data && data.success && data.voiceUrl) {
localStorage.setItem('dailyQuoteVoice', data.voiceUrl);
playAudio(data.voiceUrl);
}
})
.catch(function(error) {
console.error('Error fetching voice data:', error);
isLoading = false;
});
}
// 绑定播放按钮点击事件
playBtn.addEventListener('click', function(e) {
e.stopPropagation();
if (isPlaying && audioPlayer) {
audioPlayer.pause();
updatePlayButtonState(false);
} else {
fetchAndPlayAudio();
}
});
遇到的问题与解决方案
1. CORS跨域问题
问题:前端直接调用有道词典API时,浏览器报CORS错误。
解决:在后端添加代理接口,前端调用后端接口,后端再去调用有道词典API。
2. DOM加载时机问题
问题 :JavaScript代码执行时,DOM元素可能还未加载完成,导致 addEventListener 报错。
解决 :使用 DOMContentLoaded 事件确保DOM加载完成后再执行代码:
javascript
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupDailyQuote);
} else {
setupDailyQuote();
}
3. 移动端样式适配
问题:在移动端,播放按钮可能会超出卡片边界。
解决:调整卡片宽度与相邻的爱情计时器卡片保持一致(360px),并针对不同屏幕尺寸设置响应式样式。
最终效果
实现后的效果如下:
- 默认状态:蓝色圆形播放按钮,带悬浮放大效果
- 播放中状态:按钮变为粉色,带有脉冲动画,图标变为暂停
- 交互体验:点击播放,再次点击暂停,播放完成自动恢复默认状态
总结
通过这次开发,我学到了:
- 跨域问题的解决方案:后端代理是处理第三方API跨域问题的常用方法
- 音频播放API的使用:HTML5的Audio API简单易用,但要注意事件监听和状态管理
- 响应式设计的重要性:同一个功能需要在不同设备上都有良好的体验
- 用户体验细节:播放状态反馈、加载状态、错误处理等细节都会影响用户体验
这个功能虽然不大,但完整的实现过程涉及到了前后端开发、API调用、UI设计等多个方面,是一次很好的练手项目。