为博客每日一句添加音频播放功能

  Java   18分钟   169浏览   0评论
AI 智能摘要
AI正在分析文章内容

前言

我的博客底部有一个"每日一句"的板块,每天展示一句英文名言和对应的中文翻译。最近突发奇想,如果能直接播放这句英文的音频,对于学习英语的访客来说会是一个很棒的功能。于是就有了这篇文章,记录整个实现过程。

需求分析

在动手之前,先明确一下需求:

  1. 功能实现:点击播放按钮,获取并播放每日一句的英文音频
  2. 界面设计:播放按钮要与现有页面风格保持一致
  3. 响应式适配:PC端和移动端都要有良好的显示效果
  4. 兼容性:不能影响页面原有的任何功能
  5. 用户体验:需要有播放状态反馈,让用户知道当前是否在播放

技术方案

音频来源

有道词典有一个每日一句的API接口,返回的数据中包含音频URL:

https://dict.youdao.com/infoline/style/cardList?mode=publish&client=mobile&style=daily

跨域问题

直接从前端调用有道词典API会遇到CORS跨域限制。解决方案是在后端添加一个代理接口,前端调用后端接口,后端再去调用有道词典API。

实现步骤

  1. 后端添加代理接口 /api/daily-quote/voice
  2. 前端添加播放按钮和音频播放逻辑
  3. 添加播放状态反馈(播放中/暂停)
  4. 响应式样式适配

后端实现

IndexController.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结构

在每日一句卡片中添加播放按钮:

<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样式

播放按钮的样式设计:

/* 播放按钮样式 */
.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逻辑

音频播放的核心逻辑:

// 音频播放相关变量
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加载完成后再执行代码:

if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', setupDailyQuote);
} else {
    setupDailyQuote();
}

3. 移动端样式适配

问题:在移动端,播放按钮可能会超出卡片边界。

解决:调整卡片宽度与相邻的爱情计时器卡片保持一致(360px),并针对不同屏幕尺寸设置响应式样式。

最终效果

实现后的效果如下:

  • 默认状态:蓝色圆形播放按钮,带悬浮放大效果
  • 播放中状态:按钮变为粉色,带有脉冲动画,图标变为暂停
  • 交互体验:点击播放,再次点击暂停,播放完成自动恢复默认状态

总结

通过这次开发,我学到了:

  1. 跨域问题的解决方案:后端代理是处理第三方API跨域问题的常用方法
  2. 音频播放API的使用:HTML5的Audio API简单易用,但要注意事件监听和状态管理
  3. 响应式设计的重要性:同一个功能需要在不同设备上都有良好的体验
  4. 用户体验细节:播放状态反馈、加载状态、错误处理等细节都会影响用户体验

这个功能虽然不大,但完整的实现过程涉及到了前后端开发、API调用、UI设计等多个方面,是一次很好的练手项目。

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论
AI助手
召田最帅boy的小助手
基于当前文章回答您的问题
🤖
我是召田最帅boy的小助手
我已经阅读了这篇文章,可以帮您:
理解文章内容 · 解答细节问题 · 分析核心观点