From 228bb62df437f4ee580016337a69b59002b31314 Mon Sep 17 00:00:00 2001
From: Ztec <ztec@riper.fr>
Date: Thu, 13 Apr 2023 11:46:43 +0200
Subject: [PATCH] Add Media Player and resume to last playback position

In order to ease podcast listening, the player can be put on top of the feed entry as main content.
Use the `Use podcast player` option to enable that. It works on audio and video.

Also, when playing audio or video, progression will be saved in order to be able to resume listening later.
This position saving is done using the original attachement/enclosures player AND podcast player and do not rely on
the podcast player option ti be enabled.

Additionally, I made the player fill the width with the entry container to ease seeking and have a bigger video.

updateEnclosures now keep existing enclosures based on URL

When feeds get updated, enclosures entries are always wiped and re-created. This cause two issue
 - enclosure progression get lost in the process
 - enclosure ID changes

I used the URL as identifier of an enclosure. Not perfect but hopefully should work.
When an enclosure already exist, I simply do nothing and leave the entry as is in the database.
If anyone is listening/watching to this enclosure during the refresh, the id stay coherent and progression saving still works.

The updateEnclosures function got a bit more complex. I tried to make it the more clear I could.
Some optimisation are possible but would make the function harder to read in my opinion.

I'm not sure if this is often the case, but some feeds may include tracking or simply change the url each
time we update the feed. In those situation, enclosures ids and progression will be lost.

I have no idea how to handle this last situation. Use the size instead/alongside url to define the identity of an enclosure ?

Translation: english as placeholder for every language except French

Aside, I tested a video feed and fixed a few things for it. In fact, the MimeType was not working
at all on my side, and found a pretty old stackoverflow discussion that suggest to use an Apple non-standard MimeType for
m4v video format. I only did one substitution because I only have one feed to test. Any new video feed can make this go away
or evolve depending on the situation. Real video feeds does not tend to be easy to find and test extensively this.

Co-authored-by: toastal
---
 database/migrations.go                  |   8 ++
 locale/translations/de_DE.json          |   1 +
 locale/translations/el_EL.json          |   1 +
 locale/translations/en_US.json          |   1 +
 locale/translations/es_ES.json          |   1 +
 locale/translations/fi_FI.json          |   1 +
 locale/translations/fr_FR.json          |   1 +
 locale/translations/hi_IN.json          |   1 +
 locale/translations/id_ID.json          |   1 +
 locale/translations/it_IT.json          |   1 +
 locale/translations/ja_JP.json          |   1 +
 locale/translations/nl_NL.json          |   1 +
 locale/translations/pl_PL.json          |   1 +
 locale/translations/pt_BR.json          |   1 +
 locale/translations/ru_RU.json          |   1 +
 locale/translations/tr_TR.json          |   1 +
 locale/translations/uk_UA.json          |   1 +
 locale/translations/zh_CN.json          |   1 +
 locale/translations/zh_TW.json          |   1 +
 model/enclosure.go                      |  29 ++++--
 model/enclosure_test.go                 |  30 ++++++
 model/feed.go                           |   7 ++
 storage/enclosure.go                    | 132 ++++++++++++++++++++++--
 storage/entry_query_builder.go          |   2 +
 storage/feed.go                         |  12 ++-
 storage/feed_query_builder.go           |   2 +
 template/templates/views/edit_feed.html |   2 +
 template/templates/views/entry.html     |  51 +++++++--
 ui/entry_enclosure_save_position.go     |  48 +++++++++
 ui/feed_edit.go                         |   1 +
 ui/form/feed.go                         |   3 +
 ui/static/css/common.css                |   4 +
 ui/static/js/app.js                     |  20 ++++
 ui/static/js/bootstrap.js               |   8 ++
 ui/ui.go                                |   1 +
 35 files changed, 355 insertions(+), 23 deletions(-)
 create mode 100644 model/enclosure_test.go
 create mode 100644 ui/entry_enclosure_save_position.go

diff --git a/database/migrations.go b/database/migrations.go
index 20fe906d..edf926dc 100644
--- a/database/migrations.go
+++ b/database/migrations.go
@@ -660,4 +660,12 @@ var migrations = []func(tx *sql.Tx) error{
 		_, err = tx.Exec(sql)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `
+			ALTER TABLE feeds ADD COLUMN no_media_player boolean default 'f';
+			ALTER TABLE enclosures ADD COLUMN media_progression int default 0;
+		`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }
diff --git a/locale/translations/de_DE.json b/locale/translations/de_DE.json
index ac2b9c13..b71c2b70 100644
--- a/locale/translations/de_DE.json
+++ b/locale/translations/de_DE.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "Erlaube selbstsignierte oder ungültige Zertifikate",
     "form.feed.label.fetch_via_proxy": "Über Proxy abrufen",
     "form.feed.label.disabled": "Dieses Abonnement nicht aktualisieren",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
     "form.category.label.title": "Titel",
     "form.category.hide_globally": "Einträge in der globalen Ungelesen-Liste ausblenden",
diff --git a/locale/translations/el_EL.json b/locale/translations/el_EL.json
index 41e13e19..42b88c32 100644
--- a/locale/translations/el_EL.json
+++ b/locale/translations/el_EL.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "Να επιτρέπονται αυτο-υπογεγραμμένα ή μη έγκυρα πιστοποιητικά",
     "form.feed.label.fetch_via_proxy": "Λήψη μέσω διακομιστή μεσολάβησης",
     "form.feed.label.disabled": "Μη ανανέωση αυτής της ροής",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
     "form.category.label.title": "Τίτλος",
     "form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
diff --git a/locale/translations/en_US.json b/locale/translations/en_US.json
index c79a39ae..7beacf9a 100644
--- a/locale/translations/en_US.json
+++ b/locale/translations/en_US.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "Allow self-signed or invalid certificates",
     "form.feed.label.fetch_via_proxy": "Fetch via proxy",
     "form.feed.label.disabled": "Do not refresh this feed",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Hide entries in global unread list",
     "form.category.label.title": "Title",
     "form.category.hide_globally": "Hide entries in global unread list",
diff --git a/locale/translations/es_ES.json b/locale/translations/es_ES.json
index 6f01d235..d4e32ea9 100644
--- a/locale/translations/es_ES.json
+++ b/locale/translations/es_ES.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "Permitir certificados autofirmados o no válidos",
     "form.feed.label.fetch_via_proxy": "Buscar a través de proxy",
     "form.feed.label.disabled": "No actualice este feed",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Ocultar artículos en la lista global de no leídos",
     "form.category.label.title": "Título",
     "form.category.hide_globally": "Ocultar artículos en la lista global de no leídos",
diff --git a/locale/translations/fi_FI.json b/locale/translations/fi_FI.json
index 190d3715..a41f37bd 100644
--- a/locale/translations/fi_FI.json
+++ b/locale/translations/fi_FI.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "Salli itseallekirjoitetut tai virheelliset varmenteet",
     "form.feed.label.fetch_via_proxy": "Nouda välityspalvelimen kautta",
     "form.feed.label.disabled": "Älä päivitä tätä syötettä",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Piilota artikkelit lukemattomien listassa",
     "form.category.label.title": "Otsikko",
     "form.category.hide_globally": "Piilota artikkelit lukemattomien listassa",
diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json
index 604a7ff9..19d41ca8 100644
--- a/locale/translations/fr_FR.json
+++ b/locale/translations/fr_FR.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "Autoriser les certificats auto-signés ou non valides",
     "form.feed.label.fetch_via_proxy": "Récupérer via proxy",
     "form.feed.label.disabled": "Ne pas actualiser ce flux",
+    "form.feed.label.no_media_player": "Pas de lecteur multimedia (audio/vidéo)",
     "form.feed.label.hide_globally": "Masquer les entrées dans la liste globale non lue",
     "form.category.label.title": "Titre",
     "form.category.hide_globally": "Masquer les entrées dans la liste globale non lue",
diff --git a/locale/translations/hi_IN.json b/locale/translations/hi_IN.json
index fa997f0d..209f404f 100644
--- a/locale/translations/hi_IN.json
+++ b/locale/translations/hi_IN.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "स्व-हस्ताक्षरित या अमान्य प्रमाणपत्रों की अनुमति दें",
     "form.feed.label.fetch_via_proxy": "प्रॉक्सी के माध्यम से प्राप्त करें",
     "form.feed.label.disabled": "इस फ़ीड को रीफ़्रेश न करें",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
     "form.category.label.title": "शीर्षक",
     "form.category.hide_globally": "वैश्विक अपठित सूची में प्रविष्टियां छिपाएं",
diff --git a/locale/translations/id_ID.json b/locale/translations/id_ID.json
index 7880fcf5..17ed9d26 100644
--- a/locale/translations/id_ID.json
+++ b/locale/translations/id_ID.json
@@ -281,6 +281,7 @@
     "form.feed.label.allow_self_signed_certificates": "Perbolehkan sertifikat web tidak valid atau sertifikasi sendiri",
     "form.feed.label.fetch_via_proxy": "Ambil via Proksi",
     "form.feed.label.disabled": "Jangan perbarui umpan ini",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
     "form.category.label.title": "Judul",
     "form.category.hide_globally": "Sembunyikan entri di daftar belum dibaca global",
diff --git a/locale/translations/it_IT.json b/locale/translations/it_IT.json
index 246faa86..15481c7b 100644
--- a/locale/translations/it_IT.json
+++ b/locale/translations/it_IT.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "Consenti certificati autofirmati o non validi",
     "form.feed.label.fetch_via_proxy": "Recuperare tramite proxy",
     "form.feed.label.disabled": "Non aggiornare questo feed",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Nascondere le voci nella lista globale dei non letti",
     "form.category.label.title": "Titolo",
     "form.category.hide_globally": "Nascondere le voci nella lista globale dei non letti",
diff --git a/locale/translations/ja_JP.json b/locale/translations/ja_JP.json
index 6baa6a1e..bdbdad98 100644
--- a/locale/translations/ja_JP.json
+++ b/locale/translations/ja_JP.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "自己署名証明書または無効な証明書を許可する",
     "form.feed.label.fetch_via_proxy": "プロキシ経由で取得",
     "form.feed.label.disabled": "このフィードを更新しない",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "未読一覧に記事を表示しない",
     "form.category.label.title": "タイトル",
     "form.category.hide_globally": "未読一覧に記事を表示しない",
diff --git a/locale/translations/nl_NL.json b/locale/translations/nl_NL.json
index 11f83ab8..ddc96191 100644
--- a/locale/translations/nl_NL.json
+++ b/locale/translations/nl_NL.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "Sta zelfondertekende of ongeldige certificaten toe",
     "form.feed.label.fetch_via_proxy": "Ophalen via proxy",
     "form.feed.label.disabled": "Vernieuw deze feed niet",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Verberg items in de globale ongelezen lijst",
     "form.category.label.title": "Naam",
     "form.category.hide_globally": "Verberg items in de globale ongelezen lijst",
diff --git a/locale/translations/pl_PL.json b/locale/translations/pl_PL.json
index f162c550..82507496 100644
--- a/locale/translations/pl_PL.json
+++ b/locale/translations/pl_PL.json
@@ -286,6 +286,7 @@
     "form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
     "form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
     "form.feed.label.disabled": "Nie odświeżaj tego kanału",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
     "form.category.label.title": "Tytuł",
     "form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
diff --git a/locale/translations/pt_BR.json b/locale/translations/pt_BR.json
index e53bd100..12b0e7f0 100644
--- a/locale/translations/pt_BR.json
+++ b/locale/translations/pt_BR.json
@@ -283,6 +283,7 @@
     "form.feed.label.ignore_http_cache": "Ignorar cache HTTP",
     "form.feed.label.allow_self_signed_certificates": "Permitir certificados autoassinados ou inválidos",
     "form.feed.label.disabled": "Não atualizar esta fonte",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.fetch_via_proxy": "Buscar via proxy",
     "form.feed.label.hide_globally": "Ocultar entradas na lista global não lida",
     "form.category.label.title": "Título",
diff --git a/locale/translations/ru_RU.json b/locale/translations/ru_RU.json
index af09d58d..13b89a18 100644
--- a/locale/translations/ru_RU.json
+++ b/locale/translations/ru_RU.json
@@ -286,6 +286,7 @@
     "form.feed.label.allow_self_signed_certificates": "Разрешить самоподписанные или недействительные сертификаты",
     "form.feed.label.fetch_via_proxy": "Получить через прокси",
     "form.feed.label.disabled": "Не обновлять этот канал",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
     "form.category.label.title": "Название",
     "form.category.hide_globally": "Скрыть записи в глобальном списке непрочитанных",
diff --git a/locale/translations/tr_TR.json b/locale/translations/tr_TR.json
index 3c679c83..71929071 100644
--- a/locale/translations/tr_TR.json
+++ b/locale/translations/tr_TR.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "Kendinden imzalı veya geçersiz sertifikalara izin ver",
     "form.feed.label.fetch_via_proxy": "Proxy ile çek",
     "form.feed.label.disabled": "Bu beslemeyi yenileme",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
     "form.category.label.title": "Başlık",
     "form.category.hide_globally": "Genel okunmamış listesindeki girişleri gizle",
diff --git a/locale/translations/uk_UA.json b/locale/translations/uk_UA.json
index 38e80ef4..599cb6e7 100644
--- a/locale/translations/uk_UA.json
+++ b/locale/translations/uk_UA.json
@@ -283,6 +283,7 @@
   "form.feed.label.allow_self_signed_certificates": "Дозволити сертифікати з власним підписом або недійсні",
   "form.feed.label.fetch_via_proxy": "Використати проксі-сервер",
   "form.feed.label.disabled": "Не оновлювати цю стрічку",
+  "form.feed.label.no_media_player": "No media player (audio/video)",
   "form.feed.label.hide_globally": "Приховати записи в глобальному списку непрочитаного",
   "form.category.label.title": "Назва",
   "form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
diff --git a/locale/translations/zh_CN.json b/locale/translations/zh_CN.json
index 98a20d15..f52a9d85 100644
--- a/locale/translations/zh_CN.json
+++ b/locale/translations/zh_CN.json
@@ -282,6 +282,7 @@
     "form.feed.label.allow_self_signed_certificates": "允许自签名证书或无效证书",
     "form.feed.label.fetch_via_proxy": "通过代理获取",
     "form.feed.label.disabled": "请勿刷新此源",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "隐藏全局未读列表中的文章",
     "form.category.label.title": "标题",
     "form.category.hide_globally": "隐藏全局未读列表中的文章",
diff --git a/locale/translations/zh_TW.json b/locale/translations/zh_TW.json
index 7ed280a8..45422226 100644
--- a/locale/translations/zh_TW.json
+++ b/locale/translations/zh_TW.json
@@ -284,6 +284,7 @@
     "form.feed.label.allow_self_signed_certificates": "允許自簽章憑證或無效憑證",
     "form.feed.label.fetch_via_proxy": "透過代理獲取",
     "form.feed.label.disabled": "請勿重新整理此Feed",
+    "form.feed.label.no_media_player": "No media player (audio/video)",
     "form.feed.label.hide_globally": "隱藏全域性未讀列表中的文章",
     "form.category.label.title": "標題",
     "form.category.hide_globally": "隱藏全域性未讀列表中的文章",
diff --git a/model/enclosure.go b/model/enclosure.go
index 52388794..b0887ef4 100644
--- a/model/enclosure.go
+++ b/model/enclosure.go
@@ -3,15 +3,32 @@
 // license that can be found in the LICENSE file.
 
 package model // import "miniflux.app/model"
+import "strings"
 
 // Enclosure represents an attachment.
 type Enclosure struct {
-	ID       int64  `json:"id"`
-	UserID   int64  `json:"user_id"`
-	EntryID  int64  `json:"entry_id"`
-	URL      string `json:"url"`
-	MimeType string `json:"mime_type"`
-	Size     int64  `json:"size"`
+	ID               int64  `json:"id"`
+	UserID           int64  `json:"user_id"`
+	EntryID          int64  `json:"entry_id"`
+	URL              string `json:"url"`
+	MimeType         string `json:"mime_type"`
+	Size             int64  `json:"size"`
+	MediaProgression int64  `json:"media_progression"`
+}
+
+// Html5MimeType will modify the actual MimeType to allow direct playback from HTML5 player for some kind of MimeType
+func (e Enclosure) Html5MimeType() string {
+	if strings.HasPrefix(e.MimeType, "video") {
+		switch e.MimeType {
+		// Solution from this stackoverflow discussion:
+		// https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470
+		// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
+		// https://www.florenceporcel.com/podcast/lfhdu.xml
+		case "video/m4v":
+			return "video/x-m4v"
+		}
+	}
+	return e.MimeType
 }
 
 // EnclosureList represents a list of attachments.
diff --git a/model/enclosure_test.go b/model/enclosure_test.go
new file mode 100644
index 00000000..e1a3b6b4
--- /dev/null
+++ b/model/enclosure_test.go
@@ -0,0 +1,30 @@
+package model
+
+import (
+	"testing"
+)
+
+func TestEnclosure_Html5MimeTypeGivesOriginalMimeType(t *testing.T) {
+	enclosure := Enclosure{MimeType: "thing/thisMimeTypeIsNotExpectedToBeReplaced"}
+	if enclosure.Html5MimeType() != enclosure.MimeType {
+		t.Fatalf(
+			"HTML5 MimeType must provide original MimeType if not explicitly Replaced. Got %s ,expected '%s' ",
+			enclosure.Html5MimeType(),
+			enclosure.MimeType,
+		)
+	}
+}
+
+func TestEnclosure_Html5MimeTypeReplaceStandardM4vByAppleSpecificMimeType(t *testing.T) {
+	enclosure := Enclosure{MimeType: "video/m4v"}
+	if enclosure.Html5MimeType() != "video/x-m4v" {
+		// Solution from this stackoverflow discussion:
+		// https://stackoverflow.com/questions/15277147/m4v-mimetype-video-mp4-or-video-m4v/66945470#66945470
+		// tested at the time of this commit (06/2023) on latest Firefox & Vivaldi on this feed
+		// https://www.florenceporcel.com/podcast/lfhdu.xml
+		t.Fatalf(
+			"HTML5 MimeType must be replaced by 'video/x-m4v' when originally video/m4v to ensure playbacks in brownser. Got '%s'",
+			enclosure.Html5MimeType(),
+		)
+	}
+}
diff --git a/model/feed.go b/model/feed.go
index 7068404f..de93f2b6 100644
--- a/model/feed.go
+++ b/model/feed.go
@@ -46,6 +46,7 @@ type Feed struct {
 	Username                    string    `json:"username"`
 	Password                    string    `json:"password"`
 	Disabled                    bool      `json:"disabled"`
+	NoMediaPlayer               bool      `json:"no_media_player"`
 	IgnoreHTTPCache             bool      `json:"ignore_http_cache"`
 	AllowSelfSignedCertificates bool      `json:"allow_self_signed_certificates"`
 	FetchViaProxy               bool      `json:"fetch_via_proxy"`
@@ -134,6 +135,7 @@ type FeedCreationRequest struct {
 	Password                    string `json:"password"`
 	Crawler                     bool   `json:"crawler"`
 	Disabled                    bool   `json:"disabled"`
+	NoMediaPlayer               bool   `json:"no_media_player"`
 	IgnoreHTTPCache             bool   `json:"ignore_http_cache"`
 	AllowSelfSignedCertificates bool   `json:"allow_self_signed_certificates"`
 	FetchViaProxy               bool   `json:"fetch_via_proxy"`
@@ -162,6 +164,7 @@ type FeedModificationRequest struct {
 	Password                    *string `json:"password"`
 	CategoryID                  *int64  `json:"category_id"`
 	Disabled                    *bool   `json:"disabled"`
+	NoMediaPlayer               *bool   `json:"no_media_player"`
 	IgnoreHTTPCache             *bool   `json:"ignore_http_cache"`
 	AllowSelfSignedCertificates *bool   `json:"allow_self_signed_certificates"`
 	FetchViaProxy               *bool   `json:"fetch_via_proxy"`
@@ -230,6 +233,10 @@ func (f *FeedModificationRequest) Patch(feed *Feed) {
 		feed.Disabled = *f.Disabled
 	}
 
+	if f.NoMediaPlayer != nil {
+		feed.NoMediaPlayer = *f.NoMediaPlayer
+	}
+
 	if f.IgnoreHTTPCache != nil {
 		feed.IgnoreHTTPCache = *f.IgnoreHTTPCache
 	}
diff --git a/storage/enclosure.go b/storage/enclosure.go
index aa9f64ef..452804dc 100644
--- a/storage/enclosure.go
+++ b/storage/enclosure.go
@@ -13,6 +13,20 @@ import (
 
 // GetEnclosures returns all attachments for the given entry.
 func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
+	tx, err := s.db.Begin()
+	if err != nil {
+		return nil, fmt.Errorf(`store: unable to start transaction: %v`, err)
+	}
+	// As the transaction is only created to use the txGetEnclosures function, we can commit it and close it.
+	// to avoid leaving an open transaction as I don't have any idea if it will be closed automatically,
+	// I manually close it. I chose `commit` over `rollback` because I assumed it cost less on SGBD, but I'm no Database
+	// administrator so any better solution is welcome.
+	defer tx.Commit()
+	return s.txGetEnclosures(tx, entryID)
+}
+
+// GetEnclosures returns all attachments for the given entry within a Database transaction
+func (s *Storage) txGetEnclosures(tx *sql.Tx, entryID int64) (model.EnclosureList, error) {
 	query := `
 		SELECT
 			id,
@@ -20,7 +34,8 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
 			entry_id,
 			url,
 			size,
-			mime_type
+			mime_type,
+		    media_progression
 		FROM
 			enclosures
 		WHERE
@@ -28,7 +43,7 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
 		ORDER BY id ASC
 	`
 
-	rows, err := s.db.Query(query, entryID)
+	rows, err := tx.Query(query, entryID)
 	if err != nil {
 		return nil, fmt.Errorf(`store: unable to fetch enclosures: %v`, err)
 	}
@@ -44,6 +59,7 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
 			&enclosure.URL,
 			&enclosure.Size,
 			&enclosure.MimeType,
+			&enclosure.MediaProgression,
 		)
 
 		if err != nil {
@@ -56,6 +72,43 @@ func (s *Storage) GetEnclosures(entryID int64) (model.EnclosureList, error) {
 	return enclosures, nil
 }
 
+func (s *Storage) GetEnclosure(enclosureID int64) (*model.Enclosure, error) {
+	query := `
+		SELECT
+			id,
+			user_id,
+			entry_id,
+			url,
+			size,
+			mime_type,
+		    media_progression
+		FROM
+			enclosures
+		WHERE
+			id = $1
+		ORDER BY id ASC
+	`
+
+	row := s.db.QueryRow(query, enclosureID)
+
+	var enclosure model.Enclosure
+	err := row.Scan(
+		&enclosure.ID,
+		&enclosure.UserID,
+		&enclosure.EntryID,
+		&enclosure.URL,
+		&enclosure.Size,
+		&enclosure.MimeType,
+		&enclosure.MediaProgression,
+	)
+
+	if err != nil {
+		return nil, fmt.Errorf(`store: unable to fetch enclosure row: %v`, err)
+	}
+
+	return &enclosure, nil
+}
+
 func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error {
 	if enclosure.URL == "" {
 		return nil
@@ -63,9 +116,9 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error
 
 	query := `
 		INSERT INTO enclosures
-			(url, size, mime_type, entry_id, user_id)
+			(url, size, mime_type, entry_id, user_id, media_progression)
 		VALUES
-			($1, $2, $3, $4, $5)
+			($1, $2, $3, $4, $5, $6)
 		RETURNING
 			id
 	`
@@ -76,6 +129,7 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error
 		enclosure.MimeType,
 		enclosure.EntryID,
 		enclosure.UserID,
+		enclosure.MediaProgression,
 	).Scan(&enclosure.ID)
 
 	if err != nil {
@@ -86,12 +140,48 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error
 }
 
 func (s *Storage) updateEnclosures(tx *sql.Tx, userID, entryID int64, enclosures model.EnclosureList) error {
-	// We delete all attachments in the transaction to keep only the ones visible in the feeds.
-	if _, err := tx.Exec(`DELETE FROM enclosures WHERE user_id=$1 AND entry_id=$2`, userID, entryID); err != nil {
-		return err
+	originalEnclosures, err := s.txGetEnclosures(tx, entryID)
+	if err != nil {
+		return fmt.Errorf(`store: unable fetch enclosures for entry #%d : %v`, entryID, err)
 	}
 
+	// this map will allow to identify enclosure already in the database based on their URL.
+	originalEnclosuresByURL := map[string]*model.Enclosure{}
+	for _, enclosure := range originalEnclosures {
+		originalEnclosuresByURL[enclosure.URL] = enclosure
+	}
+
+	// in order to keep enclosure ID consistent I need to identify already existing one to keep them as is, and only
+	// add/delete enclosure that need to be
+	enclosuresToAdd := map[string]*model.Enclosure{}
+	enclosuresToDelete := map[string]*model.Enclosure{}
+	enclosuresToKeep := map[string]*model.Enclosure{}
+
 	for _, enclosure := range enclosures {
+		originalEnclosure, alreadyExist := originalEnclosuresByURL[enclosure.URL]
+		if alreadyExist {
+			enclosuresToKeep[originalEnclosure.URL] = originalEnclosure // we keep the original already in the database
+		} else {
+			enclosuresToAdd[enclosure.URL] = enclosure // we insert the new one
+		}
+	}
+
+	// we know what to keep, and add. We need to find what's in the database that need to be deleted
+	for _, enclosure := range originalEnclosures {
+		_, existToAdd := enclosuresToAdd[enclosure.URL]
+		_, existToKeep := enclosuresToKeep[enclosure.URL]
+		if !existToKeep && !existToAdd { // if it does not exist to keep or add this mean it has been deleted.
+			enclosuresToDelete[enclosure.URL] = enclosure
+		}
+	}
+
+	for _, enclosure := range enclosuresToDelete {
+		if _, err := tx.Exec(`DELETE FROM enclosures WHERE user_id=$1 AND entry_id=$2 and id=$3`, userID, entryID, enclosure.ID); err != nil {
+			return err
+		}
+	}
+
+	for _, enclosure := range enclosuresToAdd {
 		if err := s.createEnclosure(tx, enclosure); err != nil {
 			return err
 		}
@@ -99,3 +189,31 @@ func (s *Storage) updateEnclosures(tx *sql.Tx, userID, entryID int64, enclosures
 
 	return nil
 }
+func (s *Storage) UpdateEnclosure(enclosure *model.Enclosure) error {
+	query := `
+		UPDATE
+			enclosures
+		SET
+			url=$1,
+			size=$2,
+			mime_type=$3,
+			entry_id=$4, 
+			user_id=$5, 
+			media_progression=$6
+		WHERE
+			id=$7
+	`
+	_, err := s.db.Exec(query,
+		enclosure.URL,
+		enclosure.Size,
+		enclosure.MimeType,
+		enclosure.EntryID,
+		enclosure.UserID,
+		enclosure.MediaProgression,
+		enclosure.ID,
+	)
+	if err != nil {
+		return fmt.Errorf(`store: unable to update enclosure #%d : %v`, enclosure.ID, err)
+	}
+	return nil
+}
diff --git a/storage/entry_query_builder.go b/storage/entry_query_builder.go
index dae7bc1f..d47ca3af 100644
--- a/storage/entry_query_builder.go
+++ b/storage/entry_query_builder.go
@@ -272,6 +272,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 			f.crawler,
 			f.user_agent,
 			f.cookie,
+			f.no_media_player,
 			fi.icon_id,
 			u.timezone
 		FROM
@@ -336,6 +337,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
 			&entry.Feed.Crawler,
 			&entry.Feed.UserAgent,
 			&entry.Feed.Cookie,
+			&entry.Feed.NoMediaPlayer,
 			&iconID,
 			&tz,
 		)
diff --git a/storage/feed.go b/storage/feed.go
index 03d0ba1c..efa1b516 100644
--- a/storage/feed.go
+++ b/storage/feed.go
@@ -243,10 +243,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
 			allow_self_signed_certificates,
 			fetch_via_proxy,
 			hide_globally,
-			url_rewrite_rules
+			url_rewrite_rules,
+			no_media_player
 		)
 		VALUES
-			($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
+			($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)
 		RETURNING
 			id
 	`
@@ -274,6 +275,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
 		feed.FetchViaProxy,
 		feed.HideGlobally,
 		feed.UrlRewriteRules,
+		feed.NoMediaPlayer,
 	).Scan(&feed.ID)
 	if err != nil {
 		return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err)
@@ -333,9 +335,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
 			allow_self_signed_certificates=$22,
 			fetch_via_proxy=$23,
 			hide_globally=$24,
-			url_rewrite_rules=$25
+			url_rewrite_rules=$25,
+			no_media_player=$26
 		WHERE
-			id=$26 AND user_id=$27
+			id=$27 AND user_id=$28
 	`
 	_, err = s.db.Exec(query,
 		feed.FeedURL,
@@ -363,6 +366,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
 		feed.FetchViaProxy,
 		feed.HideGlobally,
 		feed.UrlRewriteRules,
+		feed.NoMediaPlayer,
 		feed.ID,
 		feed.UserID,
 	)
diff --git a/storage/feed_query_builder.go b/storage/feed_query_builder.go
index 4f3194db..1c310eb7 100644
--- a/storage/feed_query_builder.go
+++ b/storage/feed_query_builder.go
@@ -167,6 +167,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
 			f.allow_self_signed_certificates,
 			f.fetch_via_proxy,
 			f.disabled,
+			f.no_media_player,
 			f.hide_globally,
 			f.category_id,
 			c.title as category_title,
@@ -230,6 +231,7 @@ func (f *FeedQueryBuilder) GetFeeds() (model.Feeds, error) {
 			&feed.AllowSelfSignedCertificates,
 			&feed.FetchViaProxy,
 			&feed.Disabled,
+			&feed.NoMediaPlayer,
 			&feed.HideGlobally,
 			&feed.Category.ID,
 			&feed.Category.Title,
diff --git a/template/templates/views/edit_feed.html b/template/templates/views/edit_feed.html
index eb3df395..af4a5b2a 100644
--- a/template/templates/views/edit_feed.html
+++ b/template/templates/views/edit_feed.html
@@ -130,6 +130,8 @@
         {{ end }}
         <label><input type="checkbox" name="disabled" value="1" {{ if .form.Disabled }}checked{{ end }}> {{ t "form.feed.label.disabled" }}</label>
 
+        <label><input type="checkbox" name="no_media_player" {{ if .form.NoMediaPlayer }}checked{{ end }} value="1" >  {{ t "form.feed.label.no_media_player" }} </label>
+
         {{ if not .form.CategoryHidden }}
         <label><input type="checkbox" name="hide_globally" value="1"{{ if .form.HideGlobally }} checked{{ end }}> {{ t "form.feed.label.hide_globally" }}</label>
         {{ end }}
diff --git a/template/templates/views/entry.html b/template/templates/views/entry.html
index a0fec9b7..73273988 100644
--- a/template/templates/views/entry.html
+++ b/template/templates/views/entry.html
@@ -144,6 +144,39 @@
     {{ end }}
     {{ end }}
     <article role="article" class="entry-content gesture-nav-{{ $.user.GestureNav }}" dir="auto">
+        {{ if (and .entry.Enclosures (not .entry.Feed.NoMediaPlayer)) }}
+            {{ range .entry.Enclosures }}
+                {{ if ne .URL "" }}
+                    {{ if hasPrefix .MimeType "audio/" }}
+                    <div class="enclosure-audio" >
+                        <audio controls preload="metadata"
+                               data-last-position="{{ .MediaProgression }}"
+                               data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
+                        >
+                            {{ if (and $.user (mustBeProxyfied "audio")) }}
+                            <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
+                            {{ else }}
+                            <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
+                            {{ end }}
+                        </audio>
+                    </div>
+                    {{ else if hasPrefix .MimeType "video/" }}
+                    <div class="enclosure-video">
+                        <video controls preload="metadata"
+                               data-last-position="{{ .MediaProgression }}"
+                               data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
+                        >
+                            {{ if (and $.user (mustBeProxyfied "video")) }}
+                            <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
+                            {{ else }}
+                            <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
+                            {{ end }}
+                        </video>
+                    </div>
+                    {{ end }}
+                {{ end }}
+            {{ end }}
+        {{end}}
         {{ if .user }}
             {{ noescape (proxyFilter .entry.Content) }}
         {{ else }}
@@ -158,21 +191,27 @@
             <div class="entry-enclosure">
                 {{ if hasPrefix .MimeType "audio/" }}
                     <div class="enclosure-audio">
-                        <audio controls preload="metadata">
+                        <audio controls preload="metadata"
+                               data-last-position="{{ .MediaProgression }}"
+                               data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
+                        >
 				{{ if (and $.user (mustBeProxyfied "audio")) }}
-				    <source src="{{ proxyURL .URL }}" type="{{ .MimeType }}">
+				    <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
 				{{ else }}
-				    <source src="{{ .URL | safeURL }}" type="{{ .MimeType }}">
+				    <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
 				{{ end }}
                         </audio>
                     </div>
                 {{ else if hasPrefix .MimeType "video/" }}
                     <div class="enclosure-video">
-                        <video controls preload="metadata">
+                        <video controls preload="metadata"
+                               data-last-position="{{ .MediaProgression }}"
+                               data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"
+                        >
 				{{ if (and $.user (mustBeProxyfied "video")) }}
-				    <source src="{{ proxyURL .URL }}" type="{{ .MimeType }}">
+				    <source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
 				{{ else }}
-				    <source src="{{ .URL | safeURL }}" type="{{ .MimeType }}">
+				    <source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
 				{{ end }}
                         </video>
                     </div>
diff --git a/ui/entry_enclosure_save_position.go b/ui/entry_enclosure_save_position.go
new file mode 100644
index 00000000..729fb163
--- /dev/null
+++ b/ui/entry_enclosure_save_position.go
@@ -0,0 +1,48 @@
+// Copyright 2018 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
+// license that can be found in the LICENSE file.
+
+package ui // import "miniflux.app/ui"
+
+import (
+	json2 "encoding/json"
+	"io"
+	"net/http"
+
+	"miniflux.app/http/request"
+	"miniflux.app/http/response/json"
+)
+
+type enclosurePositionSaveRequest struct {
+	Progression int64 `json:"progression"`
+}
+
+func (h *handler) saveEnclosureProgression(w http.ResponseWriter, r *http.Request) {
+	enclosureID := request.RouteInt64Param(r, "enclosureID")
+	enclosure, err := h.store.GetEnclosure(enclosureID)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+	var postData enclosurePositionSaveRequest
+	body, err := io.ReadAll(r.Body)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+
+	json2.Unmarshal(body, &postData)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+	enclosure.MediaProgression = postData.Progression
+
+	err = h.store.UpdateEnclosure(enclosure)
+	if err != nil {
+		json.ServerError(w, r, err)
+		return
+	}
+
+	json.Created(w, r, map[string]string{"message": "saved"})
+}
diff --git a/ui/feed_edit.go b/ui/feed_edit.go
index ed9e5378..14cf6b9f 100644
--- a/ui/feed_edit.go
+++ b/ui/feed_edit.go
@@ -59,6 +59,7 @@ func (h *handler) showEditFeedPage(w http.ResponseWriter, r *http.Request) {
 		AllowSelfSignedCertificates: feed.AllowSelfSignedCertificates,
 		FetchViaProxy:               feed.FetchViaProxy,
 		Disabled:                    feed.Disabled,
+		NoMediaPlayer:               feed.NoMediaPlayer,
 		HideGlobally:                feed.HideGlobally,
 		CategoryHidden:              feed.Category.HideGlobally,
 	}
diff --git a/ui/form/feed.go b/ui/form/feed.go
index 53181a7f..f93937aa 100644
--- a/ui/form/feed.go
+++ b/ui/form/feed.go
@@ -31,6 +31,7 @@ type FeedForm struct {
 	AllowSelfSignedCertificates bool
 	FetchViaProxy               bool
 	Disabled                    bool
+	NoMediaPlayer               bool
 	HideGlobally                bool
 	CategoryHidden              bool // Category has "hide_globally"
 }
@@ -57,6 +58,7 @@ func (f FeedForm) Merge(feed *model.Feed) *model.Feed {
 	feed.AllowSelfSignedCertificates = f.AllowSelfSignedCertificates
 	feed.FetchViaProxy = f.FetchViaProxy
 	feed.Disabled = f.Disabled
+	feed.NoMediaPlayer = f.NoMediaPlayer
 	feed.HideGlobally = f.HideGlobally
 	return feed
 }
@@ -86,6 +88,7 @@ func NewFeedForm(r *http.Request) *FeedForm {
 		AllowSelfSignedCertificates: r.FormValue("allow_self_signed_certificates") == "1",
 		FetchViaProxy:               r.FormValue("fetch_via_proxy") == "1",
 		Disabled:                    r.FormValue("disabled") == "1",
+		NoMediaPlayer:               r.FormValue("no_media_player") == "1",
 		HideGlobally:                r.FormValue("hide_globally") == "1",
 	}
 }
diff --git a/ui/static/css/common.css b/ui/static/css/common.css
index 821d10c9..f61720cf 100644
--- a/ui/static/css/common.css
+++ b/ui/static/css/common.css
@@ -1081,3 +1081,7 @@ details.entry-enclosures {
 .disabled {
     opacity: 20%;
 }
+
+audio,video {
+    width: 100%;
+}
diff --git a/ui/static/js/app.js b/ui/static/js/app.js
index b8082fc8..308a22b7 100644
--- a/ui/static/js/app.js
+++ b/ui/static/js/app.js
@@ -618,3 +618,23 @@ function showToast(label, iconElement) {
 function goToAddSubscription() {
     window.location.href = document.body.dataset.addSubscriptionUrl;
 }
+
+/**
+ * save player position to allow to resume playback later
+ * @param {Element} playerElement
+ */
+function handlePlayerProgressionSave(playerElement) {
+    const currentPositionInSeconds = Math.floor(playerElement.currentTime); // we do not need a precise value
+    const lastKnownPositionInSeconds = parseInt(playerElement.dataset.lastPosition, 10);
+    const recordInterval = 10;
+
+    // we limit the number of update to only one by interval. Otherwise, we would have multiple update per seconds
+    if (currentPositionInSeconds >= (lastKnownPositionInSeconds + recordInterval) ||
+        currentPositionInSeconds <= (lastKnownPositionInSeconds - recordInterval)
+    ) {
+        playerElement.dataset.lastPosition = currentPositionInSeconds.toString();
+        let request = new RequestBuilder(playerElement.dataset.saveUrl);
+        request.withBody({progression: currentPositionInSeconds});
+        request.execute();
+    }
+}
diff --git a/ui/static/js/bootstrap.js b/ui/static/js/bootstrap.js
index b4c46017..5ff97734 100644
--- a/ui/static/js/bootstrap.js
+++ b/ui/static/js/bootstrap.js
@@ -112,4 +112,12 @@ document.addEventListener("DOMContentLoaded", function () {
             }
         }
     });
+
+   // enclosure media player position save & resume
+    const elements = document.querySelectorAll("audio[data-last-position],video[data-last-position]");
+    elements.forEach((element) => {
+        // we set the current time of media players
+        if (element.dataset.lastPosition){ element.currentTime = element.dataset.lastPosition; }
+        element.ontimeupdate = () => handlePlayerProgressionSave(element);
+    });
 });
diff --git a/ui/ui.go b/ui/ui.go
index 15f5da78..cf7b53c0 100644
--- a/ui/ui.go
+++ b/ui/ui.go
@@ -95,6 +95,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
 	// Entry pages.
 	uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost)
+	uiRouter.HandleFunc("/entry/enclosure/{enclosureID}/save-progression", handler.saveEnclosureProgression).Name("saveEnclosureProgression").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/entry/download/{entryID}", handler.fetchContent).Name("fetchContent").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/proxy/{encodedDigest}/{encodedURL}", handler.mediaProxy).Name("proxy").Methods(http.MethodGet)
 	uiRouter.HandleFunc("/entry/bookmark/{entryID}", handler.toggleBookmark).Name("toggleBookmark").Methods(http.MethodPost)