diff --git a/filter.php b/filter.php
index e5c3c44b9ee8b420f7cbf21238afe52ed7f9948e..edfbfadbfdebe5d7aa7e7e7e162367737dbe7293 100644
--- a/filter.php
+++ b/filter.php
@@ -20,39 +20,68 @@
  *  This filter will replace any links to opencast videos with the selected player from opencast.
  *
  * @package    filter_opencast
- * @copyright  2018 Tamara Gunkel
+ * @copyright  2024 Justus Dieckmann and Tamara Gunkel, University of Münster
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-
 use mod_opencast\local\paella_transform;
 
 /**
  * Automatic opencast videos filter class.
  *
  * @package    filter_opencast
- * @copyright  2018 Tamara Gunkel
+ * @copyright  2024 Justus Dieckmann and Tamara Gunkel, University of Münster
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class filter_opencast extends moodle_text_filter {
 
+    /**
+     * Get the content of the attribute $attributename from $tag.
+     * @param string $tag HTML-Tag.
+     * @param string $attributename Name of the attribute.
+     * @param string $attributecontentregex Regex of what the content of the attribute might be.
+     * @return string|null The content of the attribute or null, if it doesn't exist.
+     */
+    private static function get_attribute(string $tag, string $attributename, string $attributecontentregex = '.*'): string|null {
+        $pattern = "/$attributename=(['\"])($attributecontentregex)\\1/";
+        preg_match($pattern, $tag, $matches);
+        return $matches[2] ?? null;
+    }
+
+    /**
+     * Test whether $url matches one of the episodeurls.
+     * @param string $url The url to test.
+     * @param array $episodeurls array of [ocinstanceid, episoderegex, baseurl].
+     * @return array|null [ocinstanceid, episodeid] or null if there are no matches.
+     */
+    private static function test_url(string $url, array $episodeurls) : array|null {
+        foreach ($episodeurls as [$ocinstanceid, $episoderegex, $baseurl]) {
+            if (preg_match_all($episoderegex, $url, $matches)) {
+                return [$ocinstanceid, $matches[1][0]];
+            }
+        }
+        return null;
+    }
+
     /**
      * Replaces Opencast videos embedded in <video> tags by the paella player.
+     *
      * @param string $text
      * @param array $options
      * @return array|mixed|string|string[]|null
-     * @throws dml_exception
      */
     public function filter($text, array $options = []) {
-        global $PAGE, $OUTPUT;
-        $i = 0;
 
-        if (stripos($text, '</video>') === false) {
-            // Performance shortcut - if there is no </video> tag, nothing can match.
+        if (preg_match('</(a|video)>', $text) !== 1) {
+            // Performance shortcut - if there are no </video> or </a> tags, nothing can match.
             return $text;
         }
 
-        foreach (\tool_opencast\local\settings_api::get_ocinstances() as $ocinstance) {
+        // First section: (Relatively) quick check if there are episode urls in the text, and only look for these later.
+        // Improvable by combining all episode urls into one big regex if needed.
+        $ocinstances = \tool_opencast\local\settings_api::get_ocinstances();
+        $occurrences = [];
+        foreach ($ocinstances as $ocinstance) {
             $episodeurls = get_config('filter_opencast', 'episodeurl_' . $ocinstance->id);
 
             if (!$episodeurls) {
@@ -63,120 +92,174 @@ class filter_opencast extends moodle_text_filter {
                 $episodeurl = trim($episodeurl);
 
                 $urlparts = parse_url($episodeurl);
+                if (!isset($urlparts['scheme']) || !isset($urlparts['host'])) {
+                    continue;
+                }
                 $baseurl = $urlparts['scheme'] . '://' . $urlparts['host'];
                 if (isset($urlparts['port'])) {
                     $baseurl .= ':' . $urlparts['port'];
                 }
 
-                if (empty($episodeurl) || stripos($text, $baseurl) === false) {
-                    continue;
+                if (self::str_contains($text, $baseurl)) {
+                    $episoderegex = "/" . preg_quote($episodeurl, "/") . "/";
+                    $episoderegex = preg_replace('/\\\\\[EPISODEID\\\]/', '([0-9a-zA-Z\-]+)', $episoderegex);
+                    $occurrences[] = [$ocinstance->id, $episoderegex, $baseurl];
                 }
+            }
+        }
 
-                // Looking for tags.
-                $matches = preg_split('/(<[^>]*>)/i', $text, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
-                if ($matches) {
-                    $renderer = $PAGE->get_renderer('filter_opencast');
-                    $video = false;
-                    $width = false;
-                    $height = false;
-
-                    foreach ($matches as $match) {
-                        if (empty(trim($match))) {
-                            continue;
-                        }
-                        // Check if the match is a video tag.
-                        if (substr($match, 0, 6) === "<video") {
-                            $video = true;
-                            preg_match('/width="([0-9]+)"/', $match, $width);
-                            preg_match('/height="([0-9]+)"/', $match, $height);
-                            $width = $width ? $width[1] : $width;
-                            $height = $height ? $height[1] : $height;
-                        } else if ($video) {
-                            $video = false;
-                            if (substr($match, 0, 7) === "<source") {
-
-                                // Check if video is from opencast.
-                                if (strpos($match, $baseurl) === false) {
-                                    $width = $height = false;
-                                    continue;
-                                }
-
-                                // Extract url.
-                                preg_match_all('/<source[^>]+src=([\'"])(?<src>.+?)\1[^>]*>/i', $match, $result);
-
-                                // Change url for loading the (Paella) Player.
-                                $link = $result['src'][0];
-
-                                // Get episode id from link.
-                                $episoderegex = "/" . preg_quote($episodeurl, "/") . "/";
-                                $episoderegex = preg_replace('/\\\\\[EPISODEID\\\]/', '([0-9a-zA-Z\-]+)', $episoderegex);
-                                $nummatches = preg_match_all($episoderegex, $link, $episodeid);
-
-                                if (!$nummatches) {
-                                    $width = $height = false;
-                                    continue;
-                                }
-
-                                $data = paella_transform::get_paella_data_json($ocinstance->id, $episodeid[1][0]);
-
-                                if (!$data) {
-                                    continue;
-                                }
-
-                                // Collect the needed data being submitted to the template.
-                                $mustachedata = new stdClass();
-                                $mustachedata->playerid = 'ocplayer_' . $i++;
-                                $mustachedata->configurl = (new moodle_url(get_config('filter_opencast', 'configurl_' . $ocinstance->id)))->out(false);
-                                $mustachedata->themeurl = (new moodle_url(get_config('mod_opencast', 'themeurl_' . $ocinstance->id)))->out(false);
-                                if (strpos($mustachedata->configurl, 'http') === false) {
-                                    $mustachedata->configurl = (new moodle_url($mustachedata->configurl))->out();
-                                }
-
-                                $mustachedata->data = json_encode($data);
-                                $mustachedata->width = $width;
-                                $mustachedata->height = $height;
-                                $mustachedata->modplayerpath = (new moodle_url('/mod/opencast/player.html'))->out();
-
-                                if (isset($data['streams'])) {
-                                    if (count($data['streams']) === 1) {
-                                        $sources = $data['streams'][0]['sources'];
-                                        $res = $sources[array_key_first($sources)][0]['res'];
-                                        $resolution = $res['w'] . '/' . $res['h'];
-                                        $mustachedata->resolution = $resolution;
-
-                                        if ($width xor $height) {
-                                            if ($width) {
-                                                $mustachedata->height = $width * ($res['h'] / $res['w']);
-                                            } else if ($height) {
-                                                $mustachedata->width = $height * ($res['w'] / $res['h']);
-                                            }
-                                        }
-                                    } else {
-                                        if ($width && $height) {
-                                            $mustachedata->width = $width;
-                                            $mustachedata->height = $height;
-                                        }
-                                    }
-                                    $newtext = $renderer->render_player($mustachedata);
-                                } else {
-                                    $newtext = $OUTPUT->render(new \core\output\notification(
-                                            get_string('erroremptystreamsources', 'mod_opencast'),
-                                            \core\output\notification::NOTIFY_ERROR
-                                    ));
-                                }
-
-                                // Replace video tag.
-                                $text = preg_replace('/<video(?:(?!<\/video>).)*?' . preg_quote($match, '/') . '.*?<\/video>/s',
-                                    $newtext, $text, 1);
-                            }
-                            $width = $height = false;
+        if (empty($occurrences)) {
+            return $text;
+        }
+
+        // Second section: splitting the text into tags (and stuff between tags), and search for relevant urls in <a> and <video>.
+        $matches = preg_split('/(<[^>]*>)/i', $text, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
+        if (!$matches) {
+            return $text;
+        }
+
+        $i = 0;
+        $newtext = '';
+
+        $episode = null;
+        $currenttag = null;
+        $texttoreplace = '';
+        $width = null;
+        $height = null;
+
+        // We go through the complete text and transfer it match by match to $newtext.
+        // While we are going through interesting tags, $currenttag is set to 'video' or 'a' respectively.
+        // During that time, the matches are transferred to $texttoreplace instead. When we find the matching closing tag,
+        // ... we add either $texttoreplace to $newtext, or the html for the video player, if we found a matching opencast url.
+        foreach ($matches as $match) {
+            if ($currenttag) {
+                $texttoreplace .= $match;
+                if (self::str_starts_with($match, "</$currenttag")) {
+                    $replacement = null;
+                    if ($episode) {
+                        $replacement = self::render_player($episode[0], $episode[1], $i++, $width, $height);
+                    }
+                    if ($replacement) {
+                        $newtext .= $replacement;
+                    } else {
+                        $newtext .= $texttoreplace;
+                    }
+                    $episode = null;
+                    $width = null;
+                    $height = null;
+                    $texttoreplace = null;
+                    $currenttag = null;
+                } else if (!$episode && $currenttag === 'video' && self::str_starts_with($match, '<source ')) {
+                    $src = self::get_attribute($match, 'src');
+                    if ($src) {
+                        $episode = self::test_url($src, $occurrences);
+                    }
+                }
+            } else {
+                if (self::str_starts_with($match, '<video ')) {
+                    $currenttag = 'video';
+                    $width = self::get_attribute($match, 'width', '[0-9]+');
+                    $height = self::get_attribute($match, 'height', '[0-9]+');
+                    $src = self::get_attribute($match, 'src');
+                    if ($src) {
+                        $episode = self::test_url($src, $occurrences);
+                    }
+                } else if (self::str_starts_with($match, '<a ')) {
+                    $src = self::get_attribute($match, 'href');
+                    if ($src) {
+                        $episode = self::test_url($src, $occurrences);
+                        // Only set currenttag if there is a recognized url,
+                        // ... so that nested <a> or <video> tags can be matched otherwise.
+                        if ($episode) {
+                            $currenttag = 'a';
                         }
                     }
                 }
+                if ($currenttag) {
+                    $texttoreplace .= $match;
+                } else {
+                    $newtext .= $match;
+                }
+            }
+        }
+
+        return $newtext;
+    }
+
+    /**
+     * Render HTML for embedding video player.
+     * @param int $ocinstanceid Id of ocinstance.
+     * @param string $episodeid Id opencast episode.
+     * @param int $playerid Unique id to assign to player element.
+     * @param int|null $width Optionally width for player.
+     * @param int|null $height Optionally height for player.
+     * @return string|null
+     */
+    private static function render_player(int $ocinstanceid, string $episodeid, int $playerid,
+            $width = null, $height = null): string|null {
+        global $OUTPUT, $PAGE;
+
+        $data = paella_transform::get_paella_data_json($ocinstanceid, $episodeid);
+
+        if (!$data) {
+            return null;
+        }
+
+        // Collect the needed data being submitted to the template.
+        $mustachedata = new stdClass();
+        $mustachedata->playerid = 'ocplayer_' . $playerid;
+        $mustachedata->configurl =
+                (new moodle_url(get_config('filter_opencast', 'configurl_' . $ocinstanceid)))->out(false);
+        $mustachedata->themeurl =
+                (new moodle_url(get_config('mod_opencast', 'themeurl_' . $ocinstanceid)))->out(false);
+
+        $mustachedata->data = json_encode($data);
+        $mustachedata->width = $width;
+        $mustachedata->height = $height;
+        $mustachedata->modplayerpath = (new moodle_url('/mod/opencast/player.html'))->out(false);
+
+        if (isset($data['streams'])) {
+            if (count($data['streams']) === 1) {
+                $sources = $data['streams'][0]['sources'];
+                $res = $sources[array_key_first($sources)][0]['res'];
+                $resolution = $res['w'] . '/' . $res['h'];
+                $mustachedata->resolution = $resolution;
+
+                if ($width xor $height) {
+                    if ($width) {
+                        $mustachedata->height = $width * ($res['h'] / $res['w']);
+                    } else if ($height) {
+                        $mustachedata->width = $height * ($res['w'] / $res['h']);
+                    }
+                }
             }
+            $renderer = $PAGE->get_renderer('filter_opencast');
+            return $renderer->render_player($mustachedata);
+        } else {
+            return $OUTPUT->render(new \core\output\notification(
+                    get_string('erroremptystreamsources', 'mod_opencast'),
+                    \core\output\notification::NOTIFY_ERROR
+            ));
         }
+    }
 
-        // Return the same string except processed by the above.
-        return $text;
+    /**
+     * Polyfill for str_contains for PHP 7.
+     * @param string $haystack
+     * @param string $needle
+     * @return bool
+     */
+    private static function str_contains(string $haystack, string $needle): bool {
+        return strpos($haystack, $needle) !== false;
+    }
+
+    /**
+     * Polyfill for str_starts_with for PHP 7.
+     * @param string $haystack
+     * @param string $needle
+     * @return bool
+     */
+    private static function str_starts_with(string $haystack, string $needle): bool {
+        return strpos($haystack, $needle) === 0;
     }
 }