Building a Course Detail Page with Video Playback in a Django-Vue3 E-Learning Platform

Backend Data Modeling and API Endpoints

To support course browsing and detailed viewing, the backend defines several interrelated models:

  • Course: Contains fields like name, level, price, sales, hours, total_jie (total sections), video_url (intro video), and question (FAQ).
  • Chapter: Linked to Course via foreign key; includes name, order, summary, and duration_seconds.
  • Section: Belongs to both Course and Chapter; stores name, order, video_url, duration_seconds, and is_free_trial.
  • Instructor: Holds name, avatar, introduce, role, and a reverse relation to courses taught.
  • Comment and Reply: Support user feedback with timestamps, ratings, and nested replies.

Key API views include:

# Retrieve full course details including instructor, chapters, and metadata
class CourseDetailView(APIView):
    def get(self, request, pk):
        cache_key = f"course_detail_{pk}"
        cached = cache.get(cache_key)
        if cached:
            return Response({"code": 200, "data": cached})

        course = get_object_or_404(CourseModel, id=pk)
        serializer = CourseDetailSerializer(course)
        data = serializer.data
        cache.set(cache_key, data, timeout=600)  # 10-minute TTL
        return Response({"code": 200, "data": data})

# Fetch all chapters for a given course
class ChapterListView(APIView):
    def get(self, request, course_id):
        chapters = ChapterModel.objects.filter(course_id=course_id).prefetch_related(
            'sections'
        ).order_by('order')
        serializer = ChapterListSerializer(chapters, many=True)
        return Response({"code": 200, "data": serializer.data})

# Fetch comments and associated replies for a course
class CommentThreadView(APIView):
    def get(self, request, course_id):
        comments = CommentModel.objects.filter(
            course_id=course_id,
            is_approved=True
        ).select_related('user').prefetch_related('replies__user')
        serializer = CommentThreadSerializer(comments, many=True)
        return Response({"code": 200, "data": serializer.data})

URL routing:

urlpatterns = [
    path('courses/<int:pk>/', CourseDetailView.as_view()),
    path('courses/<int:course_id>/chapters/', ChapterListView.as_view()),
    path('courses/<int:course_id>/comments/', CommentThreadView.as_view()),
]

Frontend Course Detail Implementation

The Vue 3 component Info.vue renders course information using reactive state and asynchronous data loading. It supports tabbed navigation between Overview, Chapters, Reveiws, and FAQ.

Video Player Integration

A responsive video player is embedded using vue-aliplayer-v2. The player dynamical loads video sources from the course's video_url field and displays a cover image:

<template>
  <AliPlayerV3
    ref="playerRef"
    class="w-full rounded-lg aspect-video md:aspect-[16/9]"
    :source="courseData.video_url"
    :cover="courseData.cover_image"
    :options="{
      autoplay: false,
      controls: true,
      width: '100%',
      height: '100%',
      useH5Prism: true
    }"
    @play="handlePlay"
    @pause="handlePause"
  />
</template>

Alternative native <video> implementation (fallback or lightweight usage):

<video class="w-full rounded-lg aspect-video" controls preload="metadata">
  <source :src="courseData.video_url" type="video/mp4" />
  Your browser does not support the video tag.
</video>

Tabbed Content Rendering

Tab switching uses a simple numeric index (tabIndex) bound to conditional rendering:

<div class="tab-content">
  <div v-if="tabIndex === 1" v-html="courseData.description"></div>
  
  <div v-if="tabIndex === 2">
    <h3 class="text-xl font-bold mb-4">Course Curriculum</h3>
    <p class="mb-3">{{ chapters.length }} chapters • {{ courseData.hours }} hours</p>
    <div v-for="chapter in chapters" :key="chapter.id" class="mb-6">
      <h4 class="font-medium flex items-center gap-2">
        <span class="text-blue-600">Ch. {{ chapter.order }}</span>
        {{ chapter.name }}
      </h4>
      <p v-if="chapter.summary" class="text-gray-600 mt-1" v-html="chapter.summary"></p>
      <ul class="mt-3 space-y-2">
        <li v-for="section in chapter.sections" :key="section.id" class="flex justify-between items-center p-2 rounded hover:bg-gray-50">
          <span class="flex items-center gap-2">
            <span>{{ chapter.order }}.{{ section.order }}</span>
            {{ section.name }}
            <span v-if="section.is_free_trial" class="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded">Free</span>
          </span>
          <span class="text-sm text-gray-500">{{ formatDuration(section.duration_seconds) }}</span>
        </li>
      </ul>
    </div>
  </div>

  <div v-if="tabIndex === 3">
    <h3 class="text-xl font-bold mb-4">Student Reviews</h3>
    <div v-for="comment in comments" :key="comment.id" class="border-b pb-4 mb-4 last:border-0 last:mb-0">
      <div class="flex items-center gap-3 mb-2">
        <img :src="comment.user.avatar" class="w-10 h-10 rounded-full" />
        <div>
          <p class="font-medium">{{ comment.user.username }}</p>
          <p class="text-sm text-gray-500">{{ comment.created_at | formatDate }}</p>
        </div>
      </div>
      <p v-html="comment.content"></p>
      <div v-if="comment.replies.length" class="mt-3 pl-6 border-l-2 border-gray-200">
        <div v-for="reply in comment.replies" :key="reply.id" class="mb-2">
          <strong>{{ reply.user.username }}:</strong> {{ reply.content }}
        </div>
      </div>
    </div>
  </div>

  <div v-if="tabIndex === 4" v-html="courseData.question"></div>
</div>

Relevant composition setup:

// src/composables/useCourse.js
import { ref, onMounted } from 'vue';
import http from '@/utils/http';

export function useCourse(id) {
  const courseData = ref({});
  const chapters = ref([]);
  const comments = ref([]);
  const tabIndex = ref(1);

  const loadCourse = async () => {
    const res = await http.get(`/api/courses/${id}/`);
    courseData.value = res.data.data;
  };

  const loadChapters = async () => {
    const res = await http.get(`/api/courses/${id}/chapters/`);
    chapters.value = res.data.data;
  };

  const loadComments = async () => {
    const res = await http.get(`/api/courses/${id}/comments/`);
    comments.value = res.data.data;
  };

  onMounted(async () => {
    await Promise.all([
      loadCourse(),
      loadChapters(),
      loadComments()
    ]);
  });

  return {
    courseData,
    chapters,
    comments,
    tabIndex
  };
}

Supporting Utilities

A helper function formats seconds into HH:MM:SS:

// src/utils/time.js
export function formatDuration(seconds) {
  const hrs = Math.floor(seconds / 3600);
  const mins = Math.floor((seconds % 3600) / 60);
  const secs = seconds % 60;
  return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}

Caching is implemented via Django’s built-in cache framework, reducing database load for frequently accessed course pages.

Tags: Django vue3 e-learning video-player api-integration

Posted on Thu, 14 May 2026 04:48:13 +0000 by kamasheto