{"id":3514,"date":"2026-10-15T18:26:41","date_gmt":"2026-10-15T23:26:41","guid":{"rendered":"https:\/\/xlinesoft.com\/blog\/?p=3514"},"modified":"2026-10-15T18:26:41","modified_gmt":"2026-10-15T23:26:41","slug":"track-employee-training-video-completion","status":"publish","type":"post","link":"https:\/\/xlinesoft.com\/blog\/2026\/10\/15\/track-employee-training-video-completion\/","title":{"rendered":"Track Employee Training Video Completion"},"content":{"rendered":"<p>Employees often need to complete onboarding, safety, or compliance videos. In this tutorial, we will build a training video tracker that resumes each video from the saved position, allows rewinding, prevents users from skipping unwatched sections, and records completion.<\/p>\n<p>The same approach works in PHPRunner and ASPRunner.NET. The browser-side code is the same in both products, while the server-side examples are provided separately in PHP and C#.<\/p>\n<p><a href=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr.png\"><img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr-1024x614.png\" alt=\"\" width=\"810\" height=\"486\" class=\"alignnone size-large wp-image-3521\" srcset=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr-1024x614.png 1024w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr-600x360.png 600w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr-768x461.png 768w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/10\/scr.png 1209w\" sizes=\"auto, (max-width: 810px) 100vw, 810px\" \/><\/a><br \/>\n<!--more--><\/p>\n<h3>1. Creating the required tables<\/h3>\n<p>This example uses the <strong>employees<\/strong> table for application login. Its key field is <strong>EmployeeID<\/strong>. The sample project uses <strong>FirstName<\/strong> as the username field and <strong>LastName<\/strong> as the password field. Create the three tables below.<\/p>\n<p><strong>MySQL<\/strong><\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"sql\" name=\"mshighlighter\" >\r\nCREATE TABLE employees (\r\n    EmployeeID INT NOT NULL AUTO_INCREMENT,\r\n    LastName VARCHAR(20) NOT NULL,\r\n    FirstName VARCHAR(10) NOT NULL,\r\n    Title VARCHAR(30) NULL,\r\n    TitleOfCourtesy VARCHAR(25) NULL,\r\n    BirthDate DATETIME NULL,\r\n    HireDate DATETIME NULL,\r\n    Address VARCHAR(60) NULL,\r\n    City VARCHAR(11) NULL,\r\n    Region VARCHAR(15) NULL,\r\n    PostalCode VARCHAR(10) NULL,\r\n    Country VARCHAR(15) NULL,\r\n    HomePhone VARCHAR(24) NULL,\r\n    Extension VARCHAR(4) NULL,\r\n    Photo LONGBLOB NULL,\r\n    Notes LONGTEXT NULL,\r\n    ReportsTo DATETIME NULL,\r\n    ownerid INT NULL,\r\n    email VARCHAR(250) NULL,\r\n    PRIMARY KEY (EmployeeID)\r\n);\r\n\r\nCREATE TABLE videos (\r\n    video_id INT NOT NULL AUTO_INCREMENT,\r\n    title VARCHAR(255) NOT NULL,\r\n    description LONGTEXT NULL,\r\n    video_file LONGTEXT NULL,\r\n    duration INT NULL,\r\n    PRIMARY KEY (video_id)\r\n);\r\n\r\nCREATE TABLE video_watch (\r\n    watch_id INT NOT NULL AUTO_INCREMENT,\r\n    employee_id INT NOT NULL,\r\n    video_id INT NOT NULL,\r\n    last_position DECIMAL(10,2) NOT NULL DEFAULT 0,\r\n    max_watched DECIMAL(10,2) NOT NULL DEFAULT 0,\r\n    completed TINYINT NOT NULL DEFAULT 0,\r\n    last_watched DATETIME NULL,\r\n    completed_at DATETIME NULL,\r\n    PRIMARY KEY (watch_id),\r\n    UNIQUE KEY uq_video_watch (employee_id, video_id)\r\n);<\/textarea><\/pre>\n<\/div>\n<p>The two position fields serve different purposes. <strong>last_position<\/strong> is where playback resumes. <strong>max_watched<\/strong> is the furthest point the employee has reached and therefore the furthest position to which the player may seek.<\/p>\n<p>Add both tables to the project. Configure <strong>employees<\/strong> as the login table and make sure <strong>EmployeeID<\/strong> is available through the current user data.<\/p>\n<h3>2. Configuring video upload and playback<\/h3>\n<p>Open the <strong>videos<\/strong> table and configure <strong>video_file<\/strong> as follows:<\/p>\n<p>1. On the Add and Edit pages, set &#8216;Edit as&#8217; to &#8216;Document upload&#8217;.<br \/>\n2. On the View page, set &#8216;View as&#8217; to &#8216;Video file&#8217;.<br \/>\n3. Set the video player size. The sample project uses 600 by 400 pixels.<br \/>\n4. Include <strong>title<\/strong>, <strong>description<\/strong>, and <strong>video_file<\/strong> on the View page.<br \/>\n5. Remove <strong>duration<\/strong> from the Add and Edit pages. The application will save it automatically when the video is played.<\/p>\n<p>The generated application uses the browser&#8217;s HTML5 video player, so no additional video library is required.<\/p>\n<h3>3. Processing progress requests on the server<\/h3>\n<p>The video page sends three AJAX requests: load the current progress, save the video duration, and save the current playback position.<\/p>\n<p>Open <strong>Events &#8211; Global events &#8211; After App Init<\/strong> and add the version for your product.<\/p>\n<p><strong>PHP<\/strong><\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"php\" name=\"mshighlighter\" >\r\nif (\r\n    $_SERVER[\"REQUEST_METHOD\"] === \"POST\" &&\r\n    in_array(\r\n        $_POST[\"action\"] ?? \"\",\r\n        array(\r\n            \"get_video_progress\",\r\n            \"save_video_progress\",\r\n            \"save_video_duration\"\r\n        ),\r\n        true\r\n    )\r\n) {\r\n    header(\"Content-Type: application\/json\");\r\n\r\n    function videoResponse($data, $status = 200) {\r\n        http_response_code($status);\r\n        echo json_encode($data);\r\n        exit;\r\n    }\r\n\r\n    if (!Security::isLoggedIn()) {\r\n        videoResponse(array(\"success\" => false), 401);\r\n    }\r\n\r\n    $user = Security::currentUserData();\r\n    $employeeId = (int)($user[\"EmployeeID\"] ?? 0);\r\n    $videoId = filter_var(\r\n        $_POST[\"video_id\"] ?? null,\r\n        FILTER_VALIDATE_INT\r\n    );\r\n\r\n    if ($employeeId <= 0 || !$videoId || $videoId <= 0) {\r\n        videoResponse(array(\"success\" => false), 400);\r\n    }\r\n\r\n    $video = DB::Select(\r\n        \"videos\",\r\n        array(\"video_id\" => $videoId)\r\n    )->fetchAssoc();\r\n\r\n    if (!$video) {\r\n        videoResponse(array(\"success\" => false), 404);\r\n    }\r\n\r\n    $keys = array(\r\n        \"employee_id\" => $employeeId,\r\n        \"video_id\" => $videoId\r\n    );\r\n\r\n    $watch = DB::Select(\"video_watch\", $keys)->fetchAssoc();\r\n\r\n    if (!$watch) {\r\n        DB::Insert(\"video_watch\", array(\r\n            \"employee_id\" => $employeeId,\r\n            \"video_id\" => $videoId,\r\n            \"last_position\" => 0,\r\n            \"max_watched\" => 0,\r\n            \"completed\" => 0\r\n        ));\r\n\r\n        $watch = DB::Select(\"video_watch\", $keys)->fetchAssoc();\r\n    }\r\n\r\n    $action = $_POST[\"action\"];\r\n\r\n    if ($action === \"get_video_progress\") {\r\n        if (empty($_SESSION[\"video_progress_token\"])) {\r\n            $_SESSION[\"video_progress_token\"] =\r\n                bin2hex(random_bytes(32));\r\n        }\r\n\r\n        videoResponse(array(\r\n            \"success\" => true,\r\n            \"last_position\" => (float)$watch[\"last_position\"],\r\n            \"max_watched\" => (float)$watch[\"max_watched\"],\r\n            \"completed\" => (int)$watch[\"completed\"],\r\n            \"token\" => $_SESSION[\"video_progress_token\"]\r\n        ));\r\n    }\r\n\r\n    $token = $_POST[\"token\"] ?? \"\";\r\n\r\n    if (\r\n        !is_string($token) ||\r\n        empty($_SESSION[\"video_progress_token\"]) ||\r\n        !hash_equals($_SESSION[\"video_progress_token\"], $token)\r\n    ) {\r\n        videoResponse(array(\"success\" => false), 403);\r\n    }\r\n\r\n    if ($action === \"save_video_duration\") {\r\n        $duration = filter_var(\r\n            $_POST[\"duration\"] ?? null,\r\n            FILTER_VALIDATE_INT\r\n        );\r\n\r\n        if (!$duration || $duration < 1 || $duration > 86400) {\r\n            videoResponse(array(\"success\" => false), 400);\r\n        }\r\n\r\n        if (empty($video[\"duration\"])) {\r\n            DB::Update(\r\n                \"videos\",\r\n                array(\"duration\" => $duration),\r\n                array(\"video_id\" => $videoId)\r\n            );\r\n        }\r\n\r\n        videoResponse(array(\"success\" => true));\r\n    }\r\n\r\n    $position = filter_var(\r\n        $_POST[\"position\"] ?? null,\r\n        FILTER_VALIDATE_FLOAT\r\n    );\r\n    $maxWatched = filter_var(\r\n        $_POST[\"max_watched\"] ?? null,\r\n        FILTER_VALIDATE_FLOAT\r\n    );\r\n\r\n    if (\r\n        $position === false || $position === null ||\r\n        $maxWatched === false || $maxWatched === null ||\r\n        $position < 0 || $maxWatched < 0 ||\r\n        $position > $maxWatched + 0.5\r\n    ) {\r\n        videoResponse(array(\"success\" => false), 400);\r\n    }\r\n\r\n    $duration = (float)($video[\"duration\"] ?? 0);\r\n\r\n    if (\r\n        $duration > 0 &&\r\n        ($position > $duration + 1 || $maxWatched > $duration + 1)\r\n    ) {\r\n        videoResponse(array(\"success\" => false), 400);\r\n    }\r\n\r\n    $maxWatched = max(\r\n        (float)$watch[\"max_watched\"],\r\n        $maxWatched\r\n    );\r\n\r\n    $values = array(\r\n        \"last_position\" => $position,\r\n        \"max_watched\" => $maxWatched,\r\n        \"last_watched\" => date(\"Y-m-d H:i:s\")\r\n    );\r\n\r\n    if (\r\n        $duration > 0 &&\r\n        $maxWatched >= $duration - 1.5 &&\r\n        ($_POST[\"ended\"] ?? \"\") === \"1\"\r\n    ) {\r\n        $values[\"completed\"] = 1;\r\n\r\n        if (empty($watch[\"completed\"])) {\r\n            $values[\"completed_at\"] = date(\"Y-m-d H:i:s\");\r\n        }\r\n    }\r\n\r\n    DB::Update(\"video_watch\", $values, $keys);\r\n    videoResponse(array(\"success\" => true));\r\n}<\/textarea><\/pre>\n<\/div>\n<p><strong>C#<\/strong><\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"\" name=\"mshighlighter\" >\r\nstring action = MVCFunctions.postvalue(\"action\").ToString();\r\n\r\nif (\r\n    HttpContext.Current.Request.HttpMethod == \"POST\" &&\r\n    (\r\n        action == \"get_video_progress\" ||\r\n        action == \"save_video_progress\" ||\r\n        action == \"save_video_duration\"\r\n    )\r\n)\r\n{\r\n    Action<XVar, int> videoResponse = (payload, status) =>\r\n    {\r\n        HttpContext.Current.Response.StatusCode = status;\r\n        HttpContext.Current.Response.ContentType = \"application\/json\";\r\n        MVCFunctions.Echo(MVCFunctions.runner_json_encode(payload));\r\n        HttpContext.Current.Response.End();\r\n        throw new RunnerInlineOutputException();\r\n    };\r\n\r\n    if (!XVar.Pack(Security.isLoggedIn()))\r\n    {\r\n        XVar response = XVar.Array();\r\n        response[\"success\"] = false;\r\n        videoResponse(response, 401);\r\n    }\r\n\r\n    XVar user = Security.currentUserData();\r\n    int employeeId = 0;\r\n    int videoId = 0;\r\n\r\n    Int32.TryParse(user[\"EmployeeID\"].ToString(), out employeeId);\r\n    Int32.TryParse(\r\n        MVCFunctions.postvalue(\"video_id\").ToString(),\r\n        out videoId\r\n    );\r\n\r\n    if (employeeId <= 0 || videoId <= 0)\r\n    {\r\n        XVar response = XVar.Array();\r\n        response[\"success\"] = false;\r\n        videoResponse(response, 400);\r\n    }\r\n\r\n    XVar videoKeys = XVar.Array();\r\n    videoKeys[\"video_id\"] = videoId;\r\n\r\n    dynamic video = DB.Select(\"videos\", videoKeys).fetchAssoc();\r\n\r\n    if (!XVar.Pack(video))\r\n    {\r\n        XVar response = XVar.Array();\r\n        response[\"success\"] = false;\r\n        videoResponse(response, 404);\r\n    }\r\n\r\n    XVar keys = XVar.Array();\r\n    keys[\"employee_id\"] = employeeId;\r\n    keys[\"video_id\"] = videoId;\r\n\r\n    dynamic watch = DB.Select(\"video_watch\", keys).fetchAssoc();\r\n\r\n    if (!XVar.Pack(watch))\r\n    {\r\n        XVar values = XVar.Array();\r\n        values[\"employee_id\"] = employeeId;\r\n        values[\"video_id\"] = videoId;\r\n        values[\"last_position\"] = 0;\r\n        values[\"max_watched\"] = 0;\r\n        values[\"completed\"] = 0;\r\n\r\n        DB.Insert(\"video_watch\", values);\r\n        watch = DB.Select(\"video_watch\", keys).fetchAssoc();\r\n    }\r\n\r\n    if (action == \"get_video_progress\")\r\n    {\r\n        string sessionToken =\r\n            XSession.Session[\"video_progress_token\"].ToString();\r\n\r\n        if (String.IsNullOrEmpty(sessionToken))\r\n        {\r\n            sessionToken = Guid.NewGuid().ToString(\"N\");\r\n            XSession.Session[\"video_progress_token\"] = sessionToken;\r\n        }\r\n\r\n        XVar response = XVar.Array();\r\n        response[\"success\"] = true;\r\n        response[\"last_position\"] =\r\n            Convert.ToDouble(watch[\"last_position\"]);\r\n        response[\"max_watched\"] =\r\n            Convert.ToDouble(watch[\"max_watched\"]);\r\n        response[\"completed\"] =\r\n            Convert.ToInt32(watch[\"completed\"]);\r\n        response[\"token\"] = sessionToken;\r\n\r\n        videoResponse(response, 200);\r\n    }\r\n\r\n    string savedToken =\r\n        XSession.Session[\"video_progress_token\"].ToString();\r\n    string suppliedToken =\r\n        MVCFunctions.postvalue(\"token\").ToString();\r\n\r\n    if (\r\n        String.IsNullOrEmpty(savedToken) ||\r\n        !String.Equals(\r\n            savedToken,\r\n            suppliedToken,\r\n            StringComparison.Ordinal\r\n        )\r\n    )\r\n    {\r\n        XVar response = XVar.Array();\r\n        response[\"success\"] = false;\r\n        videoResponse(response, 403);\r\n    }\r\n\r\n    if (action == \"save_video_duration\")\r\n    {\r\n        int durationValue = 0;\r\n        Int32.TryParse(\r\n            MVCFunctions.postvalue(\"duration\").ToString(),\r\n            out durationValue\r\n        );\r\n\r\n        if (durationValue < 1 || durationValue > 86400)\r\n        {\r\n            XVar response = XVar.Array();\r\n            response[\"success\"] = false;\r\n            videoResponse(response, 400);\r\n        }\r\n\r\n        if (\r\n            String.IsNullOrEmpty(video[\"duration\"].ToString()) ||\r\n            Convert.ToInt32(video[\"duration\"]) == 0\r\n        )\r\n        {\r\n            XVar values = XVar.Array();\r\n            values[\"duration\"] = durationValue;\r\n            DB.Update(\"videos\", values, videoKeys);\r\n        }\r\n\r\n        XVar response = XVar.Array();\r\n        response[\"success\"] = true;\r\n        videoResponse(response, 200);\r\n    }\r\n\r\n    double position = 0;\r\n    double maxWatched = 0;\r\n\r\n    bool validPosition = Double.TryParse(\r\n        MVCFunctions.postvalue(\"position\").ToString(),\r\n        System.Globalization.NumberStyles.Float,\r\n        System.Globalization.CultureInfo.InvariantCulture,\r\n        out position\r\n    );\r\n    bool validMaximum = Double.TryParse(\r\n        MVCFunctions.postvalue(\"max_watched\").ToString(),\r\n        System.Globalization.NumberStyles.Float,\r\n        System.Globalization.CultureInfo.InvariantCulture,\r\n        out maxWatched\r\n    );\r\n\r\n    if (\r\n        !validPosition || !validMaximum ||\r\n        position < 0 || maxWatched < 0 ||\r\n        position > maxWatched + 0.5\r\n    )\r\n    {\r\n        XVar response = XVar.Array();\r\n        response[\"success\"] = false;\r\n        videoResponse(response, 400);\r\n    }\r\n\r\n    double duration = 0;\r\n    Double.TryParse(video[\"duration\"].ToString(), out duration);\r\n\r\n    if (\r\n        duration > 0 &&\r\n        (position > duration + 1 || maxWatched > duration + 1)\r\n    )\r\n    {\r\n        XVar response = XVar.Array();\r\n        response[\"success\"] = false;\r\n        videoResponse(response, 400);\r\n    }\r\n\r\n    maxWatched = Math.Max(\r\n        Convert.ToDouble(watch[\"max_watched\"]),\r\n        maxWatched\r\n    );\r\n\r\n    XVar progress = XVar.Array();\r\n    progress[\"last_position\"] = position;\r\n    progress[\"max_watched\"] = maxWatched;\r\n    progress[\"last_watched\"] = DateTime.Now;\r\n\r\n    if (\r\n        duration > 0 &&\r\n        maxWatched >= duration - 1.5 &&\r\n        MVCFunctions.postvalue(\"ended\").ToString() == \"1\"\r\n    )\r\n    {\r\n        progress[\"completed\"] = 1;\r\n\r\n        if (Convert.ToInt32(watch[\"completed\"]) == 0)\r\n        {\r\n            progress[\"completed_at\"] = DateTime.Now;\r\n        }\r\n    }\r\n\r\n    DB.Update(\"video_watch\", progress, keys);\r\n\r\n    XVar successResponse = XVar.Array();\r\n    successResponse[\"success\"] = true;\r\n    videoResponse(successResponse, 200);\r\n}<\/textarea><\/pre>\n<\/div>\n<p>The handler always obtains <strong>employee_id<\/strong> from the logged-in user&#8217;s record. It never accepts an employee ID from the browser. A session token protects the update requests, and the server records completion only when the player reaches the end of the video.<\/p>\n<p>If your login table uses a different key field, replace <strong>EmployeeID<\/strong> in this code.<\/p>\n<h3>4. Tracking playback on the View page<\/h3>\n<p>Open the <strong>videos<\/strong> View page and add the following code to its <strong>JavaScript OnLoad<\/strong> event:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"javascript\" name=\"mshighlighter\" >\r\nconst video = document.querySelector(\"video\");\r\n\r\nif (video) {\r\n    const params = new URLSearchParams(window.location.search);\r\n    const videoId = params.get(\"editid1\");\r\n    const url = window.location.pathname + window.location.search;\r\n\r\n    let durationSaved = false;\r\n    let lastPosition = 0;\r\n    let maxWatched = 0;\r\n    let restoringPosition = false;\r\n    let initialized = false;\r\n    let completed = false;\r\n    let token = \"\";\r\n    let lastSave = 0;\r\n    let saving = false;\r\n    let pendingSave = false;\r\n\r\n    function request(action, data = {}) {\r\n        return $.ajax({\r\n            url: url,\r\n            type: \"POST\",\r\n            dataType: \"json\",\r\n            data: {\r\n                action: action,\r\n                video_id: videoId,\r\n                ...data\r\n            }\r\n        });\r\n    }\r\n\r\n    function loadProgress() {\r\n        video.pause();\r\n\r\n        request(\"get_video_progress\")\r\n            .done(function(result) {\r\n                if (!result.success) return;\r\n\r\n                lastPosition = Number(result.last_position) || 0;\r\n                maxWatched = Number(result.max_watched) || 0;\r\n                completed = Number(result.completed) === 1;\r\n                token = result.token || \"\";\r\n\r\n                if (completed && Number.isFinite(video.duration)) {\r\n                    maxWatched = video.duration;\r\n                }\r\n\r\n                restoringPosition = true;\r\n                video.currentTime = Math.min(\r\n                    lastPosition,\r\n                    Math.max(0, video.duration - 0.25)\r\n                );\r\n\r\n                video.addEventListener(\r\n                    \"seeked\",\r\n                    function finishRestore() {\r\n                        restoringPosition = false;\r\n                        video.removeEventListener(\r\n                            \"seeked\",\r\n                            finishRestore\r\n                        );\r\n                    }\r\n                );\r\n\r\n                initialized = true;\r\n            });\r\n    }\r\n\r\n    function saveVideoDuration() {\r\n        if (durationSaved || !videoId || !token) return;\r\n\r\n        const duration = Math.ceil(video.duration);\r\n\r\n        if (!Number.isFinite(duration) || duration <= 0) return;\r\n\r\n        durationSaved = true;\r\n\r\n        request(\"save_video_duration\", {\r\n            duration: duration,\r\n            token: token\r\n        }).fail(function() {\r\n            durationSaved = false;\r\n        });\r\n    }\r\n\r\n    function saveProgress(ended = false) {\r\n        if (!initialized || !token) return;\r\n\r\n        if (saving) {\r\n            pendingSave = true;\r\n            return;\r\n        }\r\n\r\n        saving = true;\r\n\r\n        const position = Math.max(\r\n            0,\r\n            Math.min(video.currentTime, maxWatched)\r\n        );\r\n\r\n        request(\"save_video_progress\", {\r\n            position: position.toFixed(2),\r\n            max_watched: maxWatched.toFixed(2),\r\n            ended: ended ? \"1\" : \"0\",\r\n            token: token\r\n        }).always(function() {\r\n            saving = false;\r\n\r\n            if (pendingSave) {\r\n                pendingSave = false;\r\n                saveProgress(video.ended);\r\n            }\r\n        });\r\n    }\r\n\r\n    function initialize() {\r\n        if (initialized) return;\r\n        if (!Number.isFinite(video.duration)) return;\r\n        loadProgress();\r\n    }\r\n\r\n    video.addEventListener(\"loadedmetadata\", initialize);\r\n\r\n    if (video.readyState >= 1) {\r\n        initialize();\r\n    }\r\n\r\n    video.addEventListener(\"play\", function() {\r\n        if (!initialized) {\r\n            video.pause();\r\n            return;\r\n        }\r\n\r\n        saveVideoDuration();\r\n    });\r\n\r\n    video.addEventListener(\"timeupdate\", function() {\r\n        if (!initialized || restoringPosition || video.seeking) {\r\n            return;\r\n        }\r\n\r\n        const current = video.currentTime;\r\n\r\n        if (\r\n            !video.paused &&\r\n            current > maxWatched &&\r\n            current <= maxWatched + 2\r\n        ) {\r\n            maxWatched = current;\r\n        }\r\n\r\n        const now = Date.now();\r\n\r\n        if (!video.paused &#038;&#038; now - lastSave >= 5000) {\r\n            lastSave = now;\r\n            saveProgress();\r\n        }\r\n    });\r\n\r\n    video.addEventListener(\"seeking\", function() {\r\n        if (!initialized || restoringPosition) return;\r\n\r\n        if (video.currentTime > maxWatched + 0.5) {\r\n            restoringPosition = true;\r\n            video.currentTime = maxWatched;\r\n        }\r\n    });\r\n\r\n    video.addEventListener(\"seeked\", function() {\r\n        if (restoringPosition) {\r\n            restoringPosition = false;\r\n        }\r\n    });\r\n\r\n    video.addEventListener(\"pause\", function() {\r\n        if (initialized && !video.ended) {\r\n            saveProgress();\r\n        }\r\n    });\r\n\r\n    video.addEventListener(\"ended\", function() {\r\n        maxWatched = Math.max(maxWatched, video.duration);\r\n        completed = true;\r\n        saveProgress(true);\r\n    });\r\n}<\/textarea><\/pre>\n<\/div>\n<p>Progress is saved every five seconds during playback, when the video is paused, and when it ends. A completed video can be played or searched normally. For an unfinished video, the user may rewind but cannot seek beyond <strong>max_watched<\/strong>.<\/p>\n<p>This browser-side restriction is suitable for ordinary training workflows. Like any client-side playback control, it should not be treated as tamper-proof compliance evidence against a user deliberately modifying browser requests.<\/p>\n<h3>5. Opening the video from the List page<\/h3>\n<p>Open the <strong>videos<\/strong> List page in Page Designer. Select the grid row and configure its click action to open the View page in the same browser window.<\/p>\n<p>Users can now click anywhere on a video row to open the player. This uses the built-in row click action and does not require additional JavaScript.<\/p>\n<h3>6. Showing progress with row colors<\/h3>\n<p>Open the <strong>videos<\/strong> List page and add the appropriate code to <strong>After record processed<\/strong>.<\/p>\n<p><strong>PHP<\/strong><\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"php\" name=\"mshighlighter\" >$user = Security::currentUserData();\r\n\r\n$keys = array(\r\n    \"employee_id\" => (int)$user[\"EmployeeID\"],\r\n    \"video_id\" => (int)$data[\"video_id\"]\r\n);\r\n\r\n$watch = DB::Select(\"video_watch\", $keys)->fetchAssoc();\r\n\r\nif (!$watch || (float)$watch[\"max_watched\"] &lt;= 0) {\r\n    $record[\"css\"] = \"background:#fce8e6;\";\r\n} elseif ((int)$watch[\"completed\"] === 1) {\r\n    $record[\"css\"] = \"background:#e6f4ea;\";\r\n} else {\r\n    $record[\"css\"] = \"background:#fff4cc;\";\r\n}<\/textarea><\/pre>\n<\/div>\n<p><strong>C#<\/strong><\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"\" name=\"mshighlighter\" >XVar user = Security.currentUserData();\r\n\r\nXVar keys = XVar.Array();\r\nkeys[\"employee_id\"] = Convert.ToInt32(user[\"EmployeeID\"]);\r\nkeys[\"video_id\"] = Convert.ToInt32(data[\"video_id\"]);\r\n\r\ndynamic watch = DB.Select(\"video_watch\", keys).fetchAssoc();\r\n\r\nif (\r\n    !XVar.Pack(watch) ||\r\n    Convert.ToDouble(watch[\"max_watched\"]) &lt;= 0\r\n)\r\n{\r\n    record[\"css\"] = \"background:#fce8e6;\";\r\n}\r\nelse if (Convert.ToInt32(watch[\"completed\"]) == 1)\r\n{\r\n    record[\"css\"] = \"background:#e6f4ea;\";\r\n}\r\nelse\r\n{\r\n    record[\"css\"] = \"background:#fff4cc;\";\r\n}<\/textarea><\/pre>\n<\/div>\n<p>Pink rows have not been started, yellow rows are in progress, and green rows are complete. The query uses the logged-in employee&#8217;s ID, so every employee sees their own progress.<\/p>\n<h3>7. Testing the completed application<\/h3>\n<p>Build the project and log in as an employee. Open a video, watch part of it, pause, and leave the page. When you return, playback should resume from the saved position. Rewinding should work, while attempts to seek beyond the furthest watched position should return the player to that position.<\/p>\n<p>After the video reaches the end, its List-page row should change to green. Log in as another employee to confirm that progress is stored separately for each user.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Build a training video tracker in PHPRunner or ASPRunner.NET that saves playback position, prevents skipping, records completion, and displays progress.<\/p>\n","protected":false},"author":3,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[16,1,8],"tags":[],"class_list":["post-3514","post","type-post","status-publish","format-standard","hentry","category-asp-net","category-php-category","category-tutorials"],"_links":{"self":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts\/3514","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/comments?post=3514"}],"version-history":[{"count":6,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts\/3514\/revisions"}],"predecessor-version":[{"id":3522,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts\/3514\/revisions\/3522"}],"wp:attachment":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/media?parent=3514"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/categories?post=3514"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/tags?post=3514"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}