diff --git a/libavformat/Makefile b/libavformat/Makefile
index 47bbbbfb2a..5f29e05618 100644
--- a/libavformat/Makefile
+++ b/libavformat/Makefile
@@ -576,6 +576,7 @@ OBJS-$(CONFIG_TRUEHD_DEMUXER)            += rawdec.o mlpdec.o
 OBJS-$(CONFIG_TRUEHD_MUXER)              += rawenc.o
 OBJS-$(CONFIG_TTA_DEMUXER)               += tta.o apetag.o img2.o
 OBJS-$(CONFIG_TTA_MUXER)                 += ttaenc.o apetag.o img2.o
+OBJS-$(CONFIG_TTML_DEMUXER)              += ttmldec.o
 OBJS-$(CONFIG_TTML_MUXER)                += ttmlenc.o
 OBJS-$(CONFIG_TTY_DEMUXER)               += tty.o sauce.o
 OBJS-$(CONFIG_TY_DEMUXER)                += ty.o
diff --git a/libavformat/allformats.c b/libavformat/allformats.c
index cb5b69e9cd..0280592f7b 100644
--- a/libavformat/allformats.c
+++ b/libavformat/allformats.c
@@ -459,6 +459,7 @@ extern const AVInputFormat  ff_truehd_demuxer;
 extern const FFOutputFormat ff_truehd_muxer;
 extern const AVInputFormat  ff_tta_demuxer;
 extern const FFOutputFormat ff_tta_muxer;
+extern const AVInputFormat  ff_ttml_demuxer;
 extern const FFOutputFormat ff_ttml_muxer;
 extern const AVInputFormat  ff_txd_demuxer;
 extern const AVInputFormat  ff_tty_demuxer;
diff --git a/libavformat/ttmldec.c b/libavformat/ttmldec.c
new file mode 100644
index 0000000000..e63a2d04c8
--- /dev/null
+++ b/libavformat/ttmldec.c
@@ -0,0 +1,432 @@
+/*
+ * TTML subtitle demuxer
+ * Copyright (c) 2023 Aidan Vaughan (TheDaChicken)
+ *
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+/**
+ * @file
+ * TTML subtitle demuxer
+ * @see https://www.w3.org/TR/ttml1/
+ * @see https://www.w3.org/TR/ttml2/
+ * @see https://www.w3.org/TR/ttml-imsc/rec
+ */
+
+#include <libxml/parser.h>
+#include "avformat.h"
+#include "ttmlenc.h"
+#include "internal.h"
+#include "subtitles.h"
+#include "libavutil/intreadwrite.h"
+#include "libavutil/opt.h"
+
+typedef struct {
+    const AVClass *class;
+    FFDemuxSubtitlesQueue q;
+    int kind;
+    const char* lang;
+    uint8_t* header;
+    int header_size;
+    int cea_608_drawing;
+} TTMLContext;
+
+/* https://www.w3.org/TR/2018/REC-ttml2-20181108/#timing-value-time-expression */
+static int64_t read_ts(const char *s)
+{
+    /* Clock-time format */
+    int hh, mm, ss, ms;
+    if (sscanf(s, "%u:%u:%u.%u", &hh, &mm, &ss, &ms) == 4) return (hh*3600LL + mm*60LL + ss) * 1000LL + ms;
+    if (sscanf(s, "%u:%u:%u:%u", &hh, &mm, &ss, &ms) == 4) return (hh*3600LL + mm*60LL + ss) * 1000LL + ms;
+    /* ^^^ may contain seconds not as a fraction (.) ???? */
+    if (sscanf(s, "%u:%u.%u",      &mm, &ss, &ms) == 3) return (            mm*60LL + ss) * 1000LL + ms;
+    return AV_NOPTS_VALUE;
+}
+
+static int dump_ns(AVBPrint* buf, xmlNsPtr cur, int another) {
+    if (cur->prefix != NULL) {
+        av_bprintf(buf, "xmlns:%s", cur->prefix);
+    }
+    else
+        av_bprintf(buf, "xmlns");
+    av_bprintf(buf, "=\"");
+    av_bprint_escape(buf, cur->href, NULL,
+                     AV_ESCAPE_MODE_XML,
+                     AV_ESCAPE_FLAG_XML_DOUBLE_QUOTES);
+    av_bprintf(buf, "\"");
+    if(another)
+        av_bprintf(buf, " ");
+    return 0;
+}
+
+static int dump_attr(AVBPrint* buf, xmlNsPtr ns, const char* name, const xmlChar *val, int another) {
+    if(!val)
+        return 0;
+
+    if ((ns != NULL) && (ns->prefix != NULL)) {
+        av_bprintf(buf, "%s:", ns->prefix);
+    }
+    av_bprintf(buf, "%s=\"", name);
+    av_bprint_escape(buf, val, NULL,
+                     AV_ESCAPE_MODE_XML,
+                     AV_ESCAPE_FLAG_XML_DOUBLE_QUOTES);
+    av_bprintf(buf, "\"");
+    if(another)
+        av_bprintf(buf, " ");
+    return 0;
+}
+
+static int parse_body(AVFormatContext *s, const char *url, xmlDoc *doc, xmlNodePtr body)
+{
+    int ret = 0;
+    TTMLContext *ttml = s->priv_data;
+    xmlNodePtr div = NULL;
+    xmlNodePtr p_node = NULL;
+    xmlAttrPtr body_attr = body->properties;
+    xmlChar* val = NULL;
+    xmlBufferPtr p_buf;
+    AVBPrint p_attr_buf;
+    AVBPrint body_attr_buf;
+
+    div = xmlFirstElementChild(body);
+    if(!av_stristr(div->name, "div")) /* div must exist for proper ttml */
+    {
+        av_log(s, AV_LOG_ERROR, "Unable to parse '%s' - wrong node in body name[%s]\n",
+               url, div->name);
+        return AVERROR_INVALIDDATA;
+    }
+
+    av_bprint_init(&p_attr_buf, 0, INT_MAX);
+    av_bprint_init(&body_attr_buf, 0, INT_MAX);
+
+    while (body_attr) {
+        val = xmlGetProp(body, body_attr->name);
+        dump_attr(&body_attr_buf, body_attr->ns, body_attr->name, val, body_attr->next != NULL);
+        body_attr = body_attr->next;
+        xmlFree(val);
+    }
+
+    p_buf = xmlBufferCreate();
+    p_node = xmlFirstElementChild(div);
+    while(p_node) {
+        AVPacket *sub;
+        xmlNodePtr child = NULL;
+        xmlAttrPtr p_attr = p_node->properties;
+        int64_t ts_start, ts_end = AV_NOPTS_VALUE, ts_dur = AV_NOPTS_VALUE;
+
+        if (strncmp(p_node->name, "p", 1) != 0) {
+            av_log(s, AV_LOG_ERROR, "Unable to parse '%s' - wrong subtitle node name[%s]\n",
+                   url, div->name);
+            ret = AVERROR_INVALIDDATA;
+            goto cleanup;
+        }
+
+        dump_attr(&p_attr_buf, p_attr->ns, "default", body_attr_buf.str, p_attr->next != NULL);
+        while (p_attr) {
+            val = xmlGetProp(p_node, p_attr->name);
+
+            if(!val)
+            {
+                av_log(s, AV_LOG_WARNING, "parse_body p_attr->name = %s val is NULL\n", p_attr->name);
+                p_attr = p_attr->next;
+                continue;
+            }
+
+            if (!strncmp(p_attr->name, "begin", 5)) {
+                ts_start = read_ts(val); /* doc: https://www.w3.org/TR/2018/REC-ttml2-20181108/#timing-attribute-begin */
+            } else if (!strncmp(p_attr->name, "end", 3)) {
+                ts_end = read_ts(val); /* doc: https://www.w3.org/TR/2018/REC-ttml2-20181108/#timing-attribute-end */
+            } else if (!strncmp(p_attr->name, "dur", 3)) {
+                ts_dur = read_ts(val); /* doc: https://www.w3.org/TR/2018/REC-ttml2-20181108/#timing-attribute-dur */
+            } else {
+                dump_attr(&p_attr_buf, p_attr->ns, p_attr->name, val, p_attr->next != NULL);
+            }
+
+            p_attr = p_attr->next;
+            xmlFree(val);
+        }
+
+        /* couldn't find pts in p tag */
+        if(ts_start == AV_NOPTS_VALUE || (ts_end == AV_NOPTS_VALUE && ts_dur == AV_NOPTS_VALUE)) {
+            av_log(s, AV_LOG_ERROR, "Unable to parse '%s' - no timestamp line[%d]\n",
+                   url, p_node->line);
+            ret = AVERROR_INVALIDDATA;
+            goto cleanup;
+        }
+
+        /* dump data to packet for decoding */
+        child = p_node->children;
+        while (child != NULL) {
+            if(!strncmp(child->name, "metadata", 8) && !ttml->cea_608_drawing)
+            { /* check if the "metadata" tag is meant to contain cea-608 rows/cols */
+                val = xmlGetProp(child, "ccrow");
+                if(val) {
+                    xmlFree(val);
+                    goto next;
+                }
+            }
+
+            ret = xmlNodeDump(p_buf, doc, child, 0, 0);
+            if (ret < 0) {
+                ret = AVERROR_INVALIDDATA;
+                goto cleanup;
+            }
+            next:
+            child = child->next;
+        }
+
+        /* create packet */
+        sub = ff_subtitles_queue_insert(&ttml->q, xmlBufferContent(p_buf), xmlBufferLength(p_buf), 0);
+        if (!sub) {
+            ret = AVERROR(ENOMEM);
+            goto cleanup;
+        }
+        sub->pts = ts_start;
+        if(ts_dur != AV_NOPTS_VALUE)
+            sub->duration = ts_dur;
+        else
+            sub->duration = ts_end - ts_start;
+
+        if (p_attr_buf.len)
+        {
+            /* Keep the properties of p styles */
+            uint8_t *side_data_buf = av_packet_new_side_data(sub, AV_PKT_DATA_WEBVTT_SETTINGS,
+                                                             p_attr_buf.len);
+            if(!side_data_buf)
+                goto cleanup;
+            memcpy(side_data_buf, p_attr_buf.str, p_attr_buf.len);
+        }
+
+        p_node = xmlNextElementSibling(p_node);
+        xmlBufferEmpty(p_buf);
+        av_bprint_clear(&p_attr_buf);
+    }
+
+    ff_subtitles_queue_finalize(s, &ttml->q);
+
+    cleanup:
+    av_bprint_finalize(&p_attr_buf, NULL);
+    av_bprint_finalize(&body_attr_buf, NULL);
+    xmlBufferFree(p_buf);
+    return ret;
+}
+
+static int parse_ttml(AVFormatContext *s, AVIOContext *in, const char *url) {
+    TTMLContext *ttml = s->priv_data;
+    int ret = 0;
+    AVBPrint buf;
+    xmlDoc *doc = NULL;
+    xmlNodePtr root_element = NULL;
+    xmlNodePtr child_node = NULL;
+    xmlAttrPtr attr_ptr = NULL;
+    xmlNsPtr ns_ptr = NULL;
+    xmlChar *val = NULL;
+
+    av_bprint_init(&buf, 0, INT_MAX);
+
+    ret = avio_read_to_bprint(in, &buf, SIZE_MAX);
+    if (ret < 0 || !avio_feof(in)) {
+        av_log(s, AV_LOG_ERROR, "Unable to parse '%s'\n", url);
+        if (ret == 0)
+            ret = AVERROR_INVALIDDATA;
+        goto cleanup;
+    }
+
+    LIBXML_TEST_VERSION
+
+    doc = xmlReadMemory(buf.str, buf.len, url, NULL, 0);
+    root_element = xmlDocGetRootElement(doc);
+
+    if (!root_element) {
+        ret = AVERROR_INVALIDDATA;
+        av_log(s, AV_LOG_ERROR, "Unable to parse '%s' - missing root node\n", url);
+        goto cleanup;
+    }
+
+    if (root_element->type != XML_ELEMENT_NODE || av_strcasecmp(root_element->name, "TT")) {
+        ret = AVERROR_INVALIDDATA;
+        av_log(s, AV_LOG_ERROR, "Unable to parse '%s' - wrong root node name[%s] type[%d]\n",
+               url, root_element->name, (int) root_element->type);
+        goto cleanup;
+    }
+
+    av_bprint_clear(&buf);
+
+    child_node = xmlFirstElementChild(root_element);
+    while(child_node) /* traverse through root nodes to get head, body */
+    {
+        if(!strncmp(child_node->name, "head", 4))
+        {
+            xmlBufferPtr header_buf = xmlBufferCreate();
+            if(!header_buf)
+                return AVERROR(ENOMEM);
+
+            /* read and create extra-data */
+            /* extra-data first contains namespace (attrs) of tt */
+            attr_ptr = root_element->properties;
+            while (attr_ptr) {
+                val = xmlGetProp(root_element, attr_ptr->name);
+                if (!strncmp(attr_ptr->name, "lang", 4)) {
+                    ttml->lang = av_strdup(val);
+                } else {
+                    dump_attr(&buf, attr_ptr->ns, attr_ptr->name, val, attr_ptr->next != NULL);
+                }
+                attr_ptr = attr_ptr->next;
+                xmlFree(val);
+            }
+
+            /* take definitions */
+            ns_ptr = root_element->nsDef;
+            if(ns_ptr)
+                av_bprintf(&buf, " ");
+            while (ns_ptr) {
+                dump_ns(&buf, ns_ptr, ns_ptr->next != NULL);
+                ns_ptr = ns_ptr->next;
+            }
+
+            av_bprint_chars(&buf, '\0', 1);
+
+            /* dump head element containing the styles */
+            xmlNodeDump(header_buf, doc, child_node, 0,0);
+
+            av_bprint_append_data(&buf, xmlBufferContent(header_buf), xmlBufferLength(header_buf));
+            av_bprint_chars(&buf, '\n', 1); /* new line due to head */
+            av_bprint_chars(&buf, '\0', 1);
+
+            ttml->header = av_malloc(buf.len);
+            if(!ttml->header)
+                return AVERROR(ENOMEM);
+
+            /* copy */
+            ttml->header_size = buf.len;
+            memcpy(ttml->header, buf.str, buf.len);
+
+            xmlBufferFree(header_buf);
+        }
+        else if(!strncmp(child_node->name, "body", 4) &&
+                (ret = parse_body(s, url, doc, child_node)) < 0)
+        {
+            av_log(s, AV_LOG_ERROR, "Unable to parse '%s' - body\n", url);
+            goto cleanup;
+        }
+        child_node = xmlNextElementSibling(child_node);
+    }
+
+
+    cleanup:
+    xmlFreeDoc(doc);
+    xmlCleanupParser();
+    av_bprint_finalize(&buf, NULL);
+    return ret;
+}
+
+static int ttml_probe(const AVProbeData *p)
+{
+    if (!av_stristr(p->buf, "<tt"))
+        return 0;
+
+    if (av_stristr(p->buf, "xmlns=\"http://www.w3.org/ns/ttml\"") /* current */ ||
+        av_stristr(p->buf, "xmlns=\"http://www.w3.org/2006/\"")) /* for unstable old urls */
+        return AVPROBE_SCORE_MAX;
+
+    return 0;
+}
+
+static int ttml_read_header(AVFormatContext *s)
+{
+    const size_t base_extradata_size = TTMLENC_EXTRADATA_SIGNATURE_SIZE + 1 +
+                                       AV_INPUT_BUFFER_PADDING_SIZE;
+    TTMLContext *ttml = s->priv_data;
+    AVStream *st;
+    int ret = 0;
+
+    if((ret = parse_ttml(s, s->pb, s->url)) < 0)
+        return ret;
+
+    st = avformat_new_stream(s, NULL);
+    if (!st)
+        return AVERROR(ENOMEM);
+    avpriv_set_pts_info(st, 64, 1, 1000);
+    st->codecpar->codec_type = AVMEDIA_TYPE_SUBTITLE;
+    st->codecpar->codec_id   = AV_CODEC_ID_TTML;
+    st->codecpar->extradata = av_malloc(ttml->header_size +
+                                        base_extradata_size);
+    if (!st->codecpar->extradata)
+        return AVERROR(ENOMEM);
+
+    memcpy(st->codecpar->extradata, TTMLENC_EXTRADATA_SIGNATURE,
+           TTMLENC_EXTRADATA_SIGNATURE_SIZE);
+    memcpy(st->codecpar->extradata + TTMLENC_EXTRADATA_SIGNATURE_SIZE,
+           ttml->header, ttml->header_size);
+    st->codecpar->extradata_size = ttml->header_size + base_extradata_size;
+
+    av_dict_set(&st->metadata, "language", ttml->lang, 0);
+
+    return ret;
+}
+
+static int ttml_read_packet(AVFormatContext *s, AVPacket *pkt)
+{
+    TTMLContext *ttml = s->priv_data;
+    return ff_subtitles_queue_read_packet(&ttml->q, pkt);
+}
+
+static int ttml_read_seek(AVFormatContext *s, int stream_index,
+                          int64_t min_ts, int64_t ts, int64_t max_ts, int flags)
+{
+    TTMLContext *ttml = s->priv_data;
+    return ff_subtitles_queue_seek(&ttml->q, s, stream_index,
+                                   min_ts, ts, max_ts, flags);
+}
+
+static int ttml_read_close(AVFormatContext *s)
+{
+    TTMLContext *ttml = s->priv_data;
+    ff_subtitles_queue_clean(&ttml->q);
+    av_free(ttml->header);
+    return 0;
+}
+
+#define OFFSET(x) offsetof(TTMLContext, x)
+#define KIND_FLAGS AV_OPT_FLAG_SUBTITLE_PARAM|AV_OPT_FLAG_DECODING_PARAM
+
+static const AVOption options[] = {
+        { "cea_608_drawing", "Enable unofficial ttml cea-608 support", OFFSET(kind), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, KIND_FLAGS},
+        { NULL }
+};
+
+static const AVClass ttml_demuxer_class = {
+        .class_name  = "TTML demuxer",
+        .item_name   = av_default_item_name,
+        .option      = options,
+        .version     = LIBAVUTIL_VERSION_INT,
+};
+
+const AVInputFormat ff_ttml_demuxer = {
+        .name           = "ttml",
+        .long_name      = NULL_IF_CONFIG_SMALL("TTML subtitle"),
+        .priv_data_size = sizeof(TTMLContext),
+        .priv_class     = &ttml_demuxer_class,
+        .flags_internal = FF_FMT_INIT_CLEANUP,
+        .read_probe     = ttml_probe,
+        .read_header    = ttml_read_header,
+        .read_packet    = ttml_read_packet,
+        .read_seek2     = ttml_read_seek,
+        .read_close     = ttml_read_close,
+        .extensions     = "ttml",
+        .mime_type      = "application/ttml+xml",
+};
diff --git a/libavformat/version.h b/libavformat/version.h
index af7d0a1024..e2634b85ae 100644
--- a/libavformat/version.h
+++ b/libavformat/version.h
@@ -31,7 +31,7 @@
 
 #include "version_major.h"
 
-#define LIBAVFORMAT_VERSION_MINOR   4
+#define LIBAVFORMAT_VERSION_MINOR   5
 #define LIBAVFORMAT_VERSION_MICRO 100
 
 #define LIBAVFORMAT_VERSION_INT AV_VERSION_INT(LIBAVFORMAT_VERSION_MAJOR, \
